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>
This commit is contained in:
@@ -0,0 +1,222 @@
|
||||
'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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user