Files
logbuch/components/Fuehrungen.tsx
T
admin b3fbc391b1 feat: Tab „Führungen" für ausgefallene und verschobene Sonderführungen — Version 1.14.0
Fällt eine Sonderführung aus oder wird sie verschoben, gibt es dafür keinen
Logbuch-Eintrag. Der neue Tab listet die kommenden zugesagten Führungen aus
SoFue2 und bietet je Zeile „Ausgefallen" und „Verschoben auf …" — offen für
jeden angemeldeten BEO.

Absage setzt status=3 und lässt den Termin stehen. Beim Verschieben wandert
der bisherige wtermin in die bis dahin ungenutzte Spalte 'verlegt'; beide
Aktionen hängen eine datierte Zeile mit dem Kürzel an 'bemerkung' an, statt
sie zu ersetzen. Damit ist die Terminhistorie erstmals nachvollziehbar.

Die Anleitung wird nun aus ANLEITUNG.md erzeugt (scripts/build-anleitung.mjs,
Design in scripts/anleitung.template.html), automatisch vor jedem Build.
public/anleitung.html ist deshalb nicht mehr in git.

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

223 lines
8.5 KiB
TypeScript

'use client';
import { useCallback, useEffect, useState } from 'react';
import TimeInput from './TimeInput';
interface SofueTermin {
id: number;
wtermin: string;
verein: string;
name: string;
vorname: string;
anzahl: number;
mitarbeiter: string | null;
bemerkung: string | null;
}
type Aktion = 'absage' | 'verschiebung';
function gruppe(t: SofueTermin): string {
if (t.verein.trim()) return t.verein.trim();
return [t.vorname, t.name].map((s) => s.trim()).filter(Boolean).join(' ') || '—';
}
function datumDe(wtermin: string): string {
const [j, m, t] = wtermin.split(' ')[0].split('-');
return `${t}.${m}.${j}`;
}
function zeitDe(wtermin: string): string {
return (wtermin.split(' ')[1] ?? '').slice(0, 5);
}
export default function Fuehrungen() {
const [termine, setTermine] = useState<SofueTermin[] | null>(null);
const [ladefehler, setLadefehler] = useState(false);
// Offener Bestätigungsbereich: welche Führung, welche Aktion
const [offen, setOffen] = useState<{ id: number; aktion: Aktion } | null>(null);
const [grund, setGrund] = useState('');
const [neuesDatum, setNeuesDatum] = useState('');
const [neueZeit, setNeueZeit] = useState('20:00');
const [speichert, setSpeichert] = useState(false);
const [meldung, setMeldung] = useState<{ art: 'ok' | 'fehler'; text: string } | null>(null);
const laden = useCallback(() => {
fetch('/api/sofue')
.then((r) => { if (!r.ok) throw new Error(); return r.json(); })
.then((d: SofueTermin[]) => { setTermine(d); setLadefehler(false); })
.catch(() => setLadefehler(true));
}, []);
useEffect(() => { laden(); }, [laden]);
function oeffnen(t: SofueTermin, aktion: Aktion) {
setOffen({ id: t.id, aktion });
setGrund('');
setNeuesDatum(t.wtermin.slice(0, 10));
setNeueZeit(zeitDe(t.wtermin) || '20:00');
setMeldung(null);
}
async function ausfuehren(t: SofueTermin, aktion: Aktion) {
if (aktion === 'verschiebung' && !neuesDatum) {
setMeldung({ art: 'fehler', text: 'Bitte ein neues Datum wählen.' });
return;
}
setSpeichert(true);
try {
const res = await fetch(`/api/sofue/${t.id}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
aktion,
grund,
termin: aktion === 'verschiebung' ? `${neuesDatum} ${neueZeit}:00` : undefined,
}),
});
if (!res.ok) throw new Error();
setMeldung({
art: 'ok',
text: aktion === 'absage'
? `Führung „${gruppe(t)}" vom ${datumDe(t.wtermin)} als ausgefallen vermerkt.`
: `Führung „${gruppe(t)}" verschoben auf den ${datumDe(neuesDatum)} um ${neueZeit} Uhr.`,
});
setOffen(null);
laden();
} catch {
setMeldung({ art: 'fehler', text: 'Die Änderung konnte nicht gespeichert werden.' });
} finally {
setSpeichert(false);
}
}
const thCls = 'px-3 py-2 border border-gray-300 text-xs font-semibold bg-gray-100 text-left whitespace-nowrap text-gray-900';
const tdCls = 'px-3 py-2 border border-gray-200 text-sm text-gray-900';
const btnCls = 'px-2 py-1 text-xs font-medium rounded-lg border-2 transition-colors whitespace-nowrap';
const inputCls = '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';
return (
<div className="space-y-3">
<p className="text-sm text-gray-600">
Kommende zugesagte Sonderführungen. Fällt eine Führung aus oder wird sie verschoben,
kann das hier vermerkt werden ein Logbuch-Eintrag ist dafür nicht nötig.
</p>
{meldung && (
<div
className={
'text-sm rounded-lg px-3 py-2 border ' +
(meldung.art === 'ok'
? 'text-green-700 bg-green-50 border-green-300'
: 'text-red-700 bg-red-50 border-red-300')
}
>
{meldung.text}
</div>
)}
{ladefehler && (
<div className="text-sm text-red-600">Die Termine konnten nicht geladen werden.</div>
)}
{!termine && !ladefehler && <div className="text-gray-500 text-sm py-4">Lade Termine...</div>}
{termine && termine.length === 0 && (
<div className="text-gray-500 text-sm py-4">Keine kommenden Sonderführungen.</div>
)}
{termine && termine.length > 0 && (
<div className="overflow-x-auto">
<table className="w-full border-collapse">
<thead>
<tr>
<th className={thCls}>Termin</th>
<th className={thCls}>Gruppe</th>
<th className={`${thCls} text-center`}>Pers.</th>
<th className={thCls}>BEO</th>
<th className={`${thCls} text-center`}>Aktion</th>
</tr>
</thead>
<tbody>
{termine.map((t) => (
<tr key={t.id} className="hover:bg-gray-50 align-top">
<td className={`${tdCls} whitespace-nowrap`}>
<div>{datumDe(t.wtermin)}</div>
<div className="text-xs text-gray-500">{zeitDe(t.wtermin)} Uhr</div>
</td>
<td className={tdCls}>{gruppe(t)}</td>
<td className={`${tdCls} text-center tabular-nums`}>{t.anzahl}</td>
<td className={tdCls}>{t.mitarbeiter || '—'}</td>
<td className={tdCls}>
{offen?.id === t.id ? (
<div className="space-y-2">
{offen.aktion === 'verschiebung' && (
<div className="flex flex-wrap items-center gap-2">
<label className="text-xs text-gray-600">Neuer Termin</label>
<input
type="date"
value={neuesDatum}
onChange={(e) => setNeuesDatum(e.target.value)}
className={inputCls}
/>
<TimeInput value={neueZeit} onChange={setNeueZeit} className="w-24" />
</div>
)}
<input
type="text"
value={grund}
onChange={(e) => setGrund(e.target.value.slice(0, 150))}
maxLength={150}
placeholder="Grund (optional)"
className={`${inputCls} w-full`}
/>
<div className="flex gap-2">
<button
type="button"
disabled={speichert}
onClick={() => ausfuehren(t, offen.aktion)}
className={`${btnCls} border-[#85B7D7] bg-[#85B7D7] text-black hover:bg-[#6a9fc5] disabled:opacity-50`}
>
{speichert
? 'Speichern...'
: offen.aktion === 'absage'
? 'Wirklich absagen'
: 'Verschieben'}
</button>
<button
type="button"
onClick={() => setOffen(null)}
className={`${btnCls} border-gray-300 bg-gray-100 text-gray-700 hover:bg-gray-200`}
>
Abbrechen
</button>
</div>
</div>
) : (
<div className="flex gap-2 justify-center">
<button
type="button"
onClick={() => oeffnen(t, 'absage')}
className={`${btnCls} border-red-300 text-red-700 hover:bg-red-50`}
>
Ausgefallen
</button>
<button
type="button"
onClick={() => oeffnen(t, 'verschiebung')}
className={`${btnCls} border-blue-300 text-blue-700 hover:bg-blue-50`}
>
Verschoben
</button>
</div>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
);
}