b3fbc391b1
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>
223 lines
8.5 KiB
TypeScript
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>
|
|
);
|
|
}
|