17edb0e4f8
Unter dem Datum steht der Wochentag des Beginns, in der Hauptliste ausgeschrieben, in der Kompaktliste als Kürzel rechtsbündig neben dem Datum. Damit die Kompaktliste dabei ihre bisherigen Spaltenbreiten behält, nehmen BEOs und Objekte den freien Platz auf (w-1/2); die Datumsspalte bekommt eine feste Breite, Art steht auf whitespace-nowrap, weil eine Prozentbreite alle übrigen Spalten auf ihre Minimalbreite drückt und der Gruppenname dort sonst Wort für Wort umbricht. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
372 lines
16 KiB
TypeScript
372 lines
16 KiB
TypeScript
'use client';
|
|
|
|
import { useEffect, useRef, useState } from 'react';
|
|
import type { Kuppel, LogbuchEintrag } from '@/types/logbuch';
|
|
import { spendeLabel } from '@/types/logbuch';
|
|
|
|
interface Props {
|
|
kuppel: Kuppel;
|
|
refreshKey: number;
|
|
onEdit: (entry: LogbuchEintrag) => void;
|
|
currentUserKuerzel: string;
|
|
isAdmin?: boolean;
|
|
limit?: number;
|
|
compact?: boolean;
|
|
}
|
|
|
|
const pad = (n: number) => String(n).padStart(2, '0');
|
|
|
|
const MONATE = ['Januar','Februar','März','April','Mai','Juni','Juli','August','September','Oktober','November','Dezember'];
|
|
const WOCHENTAGE = ['Sonntag','Montag','Dienstag','Mittwoch','Donnerstag','Freitag','Samstag'];
|
|
|
|
function currentMonth() {
|
|
const d = new Date();
|
|
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}`;
|
|
}
|
|
|
|
function monthLabel(ym: string) {
|
|
const [y, m] = ym.split('-').map(Number);
|
|
return `${MONATE[m - 1]} ${y}`;
|
|
}
|
|
|
|
function prevMonth(ym: string) {
|
|
const [y, m] = ym.split('-').map(Number);
|
|
return m === 1 ? `${y - 1}-12` : `${y}-${pad(m - 1)}`;
|
|
}
|
|
|
|
function nextMonth(ym: string) {
|
|
const [y, m] = ym.split('-').map(Number);
|
|
return m === 12 ? `${y + 1}-01` : `${y}-${pad(m + 1)}`;
|
|
}
|
|
|
|
function formatDate(dt: string, short = false): string {
|
|
if (!dt) return '';
|
|
const d = new Date(dt);
|
|
if (isNaN(d.getTime())) return dt;
|
|
if (short) return `${pad(d.getDate())}.${pad(d.getMonth() + 1)}.`;
|
|
return `${pad(d.getDate())}.${pad(d.getMonth() + 1)}.${d.getFullYear()}`;
|
|
}
|
|
|
|
/** Wochentag zum Datum — in der Kompaktliste auf zwei Buchstaben gekürzt. */
|
|
function formatWeekday(dt: string, short = false): string {
|
|
if (!dt) return '';
|
|
const d = new Date(dt);
|
|
if (isNaN(d.getTime())) return '';
|
|
const name = WOCHENTAGE[d.getDay()];
|
|
return short ? name.slice(0, 2) : name;
|
|
}
|
|
|
|
function formatTime(dt: string): string {
|
|
if (!dt) return '';
|
|
const d = new Date(dt);
|
|
if (isNaN(d.getTime())) return dt;
|
|
return `${pad(d.getHours())}:${pad(d.getMinutes())}`;
|
|
}
|
|
|
|
export default function LogbuchList({ kuppel, refreshKey, onEdit, currentUserKuerzel, isAdmin = false, limit = 10, compact = false }: Props) {
|
|
const [entries, setEntries] = useState<LogbuchEintrag[]>([]);
|
|
const [total, setTotal] = useState(0);
|
|
const [month, setMonth] = useState(compact ? '' : currentMonth());
|
|
const [deleteId, setDeleteId] = useState<number | null>(null);
|
|
const [deleteErr, setDeleteErr] = useState('');
|
|
const [printEntries, setPrintEntries] = useState<LogbuchEintrag[] | null>(null);
|
|
const [search, setSearch] = useState('');
|
|
const [activeSearch, setActiveSearch] = useState('');
|
|
const printPending = useRef(false);
|
|
|
|
// Derived page: auto-resets to 0 when filter deps change — no separate setState-in-effect needed
|
|
const filterKey = `${kuppel}|${refreshKey}|${month}|${activeSearch}`;
|
|
const [pageState, setPageState] = useState({ page: 0, key: filterKey });
|
|
const page = pageState.key === filterKey ? pageState.page : 0;
|
|
|
|
// Fetch result tracked by paramsKey — loading/error are derived, not set synchronously in effect
|
|
const paramsKey = `${filterKey}|${limit}|${page}`;
|
|
const [fetchResult, setFetchResult] = useState<{ ok: boolean; forParams: string } | null>(null);
|
|
const fetchError = fetchResult?.forParams === paramsKey && !fetchResult.ok ? 'Fehler beim Laden.' : '';
|
|
const loading = !fetchError && fetchResult?.forParams !== paramsKey;
|
|
const error = fetchError || deleteErr;
|
|
|
|
useEffect(() => {
|
|
const t = setTimeout(() => setActiveSearch(search.trim()), 300);
|
|
return () => clearTimeout(t);
|
|
}, [search]);
|
|
|
|
useEffect(() => {
|
|
let cancelled = false;
|
|
const offset = page * limit;
|
|
const url = `/api/logbuch?kuppel=${encodeURIComponent(kuppel)}&limit=${limit}&offset=${offset}` +
|
|
(activeSearch ? `&search=${encodeURIComponent(activeSearch)}` : (month ? `&month=${encodeURIComponent(month)}` : ''));
|
|
fetch(url)
|
|
.then((r) => { if (!r.ok) throw new Error(); return r.json(); })
|
|
.then((data) => {
|
|
if (!cancelled) {
|
|
setEntries(data.entries);
|
|
setTotal(data.total);
|
|
setFetchResult({ ok: true, forParams: paramsKey });
|
|
setDeleteErr('');
|
|
}
|
|
})
|
|
.catch(() => { if (!cancelled) setFetchResult({ ok: false, forParams: paramsKey }); });
|
|
return () => { cancelled = true; };
|
|
}, [kuppel, refreshKey, limit, page, month, activeSearch]);
|
|
|
|
useEffect(() => {
|
|
function onAfterPrint() { setPrintEntries(null); }
|
|
window.addEventListener('afterprint', onAfterPrint);
|
|
return () => window.removeEventListener('afterprint', onAfterPrint);
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
if (printPending.current && printEntries !== null) {
|
|
printPending.current = false;
|
|
window.print();
|
|
}
|
|
}, [printEntries]);
|
|
|
|
async function handlePrint() {
|
|
const url = `/api/logbuch?kuppel=${encodeURIComponent(kuppel)}&limit=500&offset=0` +
|
|
(month ? `&month=${encodeURIComponent(month)}` : '') + '&order=asc';
|
|
const data = await fetch(url).then((r) => r.json());
|
|
printPending.current = true;
|
|
setPrintEntries(data.entries);
|
|
}
|
|
|
|
async function confirmDelete(id: number) {
|
|
try {
|
|
const res = await fetch(`/api/logbuch/${id}`, { method: 'DELETE' });
|
|
if (!res.ok) throw new Error();
|
|
setEntries((prev) => prev.filter((e) => e.ID !== id));
|
|
setTotal((t) => t - 1);
|
|
} catch {
|
|
setDeleteErr('Fehler beim Löschen.');
|
|
} finally {
|
|
setDeleteId(null);
|
|
}
|
|
}
|
|
|
|
const toolbar = !compact && (
|
|
<div className="flex items-center gap-2 mb-3 print:hidden">
|
|
<div
|
|
className="flex items-center gap-1 shrink-0"
|
|
style={{ visibility: activeSearch ? 'hidden' : 'visible' }}
|
|
>
|
|
<button
|
|
onClick={() => setMonth((m) => prevMonth(m))}
|
|
className="px-2 py-1 text-sm rounded-lg bg-[#85B7D7] hover:bg-[#6a9fc5]"
|
|
>←</button>
|
|
<input
|
|
type="month"
|
|
value={month}
|
|
max={currentMonth()}
|
|
onChange={(e) => setMonth(e.target.value > currentMonth() ? currentMonth() : e.target.value)}
|
|
className="border border-[#407BFF] rounded-lg px-2 py-1 text-sm text-gray-900 focus:outline-none focus:ring-2 focus:ring-inset focus:ring-[#235CC8]"
|
|
/>
|
|
<button
|
|
onClick={() => setMonth((m) => nextMonth(m))}
|
|
disabled={month >= currentMonth()}
|
|
className="px-2 py-1 text-sm rounded-lg bg-[#85B7D7] hover:bg-[#6a9fc5] disabled:opacity-40 disabled:cursor-not-allowed"
|
|
>→</button>
|
|
{month !== currentMonth() && (
|
|
<button
|
|
onClick={() => setMonth(currentMonth())}
|
|
className="text-sm text-blue-600 hover:underline ml-1"
|
|
>Aktueller Monat</button>
|
|
)}
|
|
</div>
|
|
<div className="relative flex-1 min-w-0 mx-3">
|
|
<input
|
|
type="text"
|
|
value={search}
|
|
onChange={(e) => setSearch(e.target.value)}
|
|
placeholder="Suche in Bemerkungen, Objekte, BEOs…"
|
|
className="w-full px-3 py-1.5 pr-8 border border-[#407BFF] rounded-lg text-sm text-gray-900 focus:outline-none focus:ring-2 focus:ring-inset focus:ring-[#235CC8]"
|
|
/>
|
|
{search ? (
|
|
<button
|
|
onClick={() => setSearch('')}
|
|
aria-label="Suche löschen"
|
|
className="absolute right-2 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-700 text-sm leading-none"
|
|
>✕</button>
|
|
) : (
|
|
<span className="absolute right-2 top-1/2 -translate-y-1/2 text-gray-400 pointer-events-none text-sm">🔍</span>
|
|
)}
|
|
</div>
|
|
<button
|
|
onClick={handlePrint}
|
|
className="text-sm px-3 py-1.5 bg-[#85B7D7] hover:bg-[#6a9fc5] text-black rounded-lg shrink-0"
|
|
>🖨 Drucken</button>
|
|
</div>
|
|
);
|
|
|
|
const printHeader = !compact && (
|
|
<div className="hidden print:block mb-3 text-sm font-semibold">
|
|
Monat: {monthLabel(month)}
|
|
</div>
|
|
);
|
|
|
|
const cell = compact
|
|
? 'px-1.5 py-1 border border-gray-200 text-xs text-gray-900'
|
|
: 'px-3 py-2 border border-gray-200 text-gray-900';
|
|
const head = compact
|
|
? 'px-1.5 py-1 border border-gray-300 text-xs font-semibold text-gray-900'
|
|
: 'px-3 py-2 border border-gray-300 text-gray-900';
|
|
|
|
const displayEntries = printEntries ?? entries;
|
|
|
|
return (
|
|
<div>
|
|
{toolbar}
|
|
{printHeader}
|
|
{loading && <div className="text-gray-500 text-sm py-4">Lade Einträge...</div>}
|
|
{error && <div className="text-red-600 text-sm py-4">{error}</div>}
|
|
{!loading && !error && <div className="overflow-x-auto">
|
|
<table className="w-full border-collapse" style={{ fontSize: compact ? '0.75rem' : '0.875rem' }}>
|
|
<thead>
|
|
<tr className="bg-gray-100 text-left">
|
|
{/* Im Kompaktmodus schmal halten — sonst zieht w-full die Spalte auf und der
|
|
rechtsbündige Wochentag rutscht weit vom Datum weg. */}
|
|
<th className={`${head} whitespace-nowrap ${compact ? 'w-20' : ''}`}>Datum</th>
|
|
{compact ? (
|
|
<>
|
|
<th className={`${head} whitespace-nowrap`}>Start</th>
|
|
<th className={`${head} whitespace-nowrap`}>Ende</th>
|
|
</>
|
|
) : (
|
|
<th className={`${head} whitespace-nowrap text-center`}>Zeit</th>
|
|
)}
|
|
<th className={`${head} ${compact ? 'whitespace-nowrap' : ''}`}>Art</th>
|
|
<th className={`${head} text-center w-10`}>Bes.</th>
|
|
{/* Im Kompaktmodus nehmen diese beiden den Restplatz auf. Sonst verteilt das
|
|
Auto-Layout ihn bei dünn gefüllten Zeilen über alle Spalten, und Art und
|
|
Zeiten werden unnötig breit. Die Prozentangabe drückt die übrigen Spalten
|
|
auf ihre Minimalbreite — deshalb steht Art dort auf whitespace-nowrap,
|
|
sonst bricht der Gruppenname dort Wort für Wort um. */}
|
|
<th className={`${head} ${compact ? 'w-1/2' : ''}`}>BEOs</th>
|
|
<th className={`${head} ${compact ? 'w-1/2' : ''}`}>Objekte</th>
|
|
{!compact && <th className={head}>Bemerkungen</th>}
|
|
{!compact && <th className={head}>Wetter</th>}
|
|
<th className={`${head} text-center print:hidden`}>Aktionen</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{displayEntries.length === 0 ? (
|
|
<tr>
|
|
<td colSpan={compact ? 7 : 10} className="px-3 py-4 text-gray-500 text-sm text-center">
|
|
{activeSearch ? `Keine Einträge für „${activeSearch}" gefunden.` : month ? `Keine Einträge für ${monthLabel(month)}.` : 'Keine Einträge vorhanden.'}
|
|
</td>
|
|
</tr>
|
|
) : displayEntries.map((e) => (
|
|
<tr key={e.ID} className="hover:bg-gray-50">
|
|
<td className={`${cell} whitespace-nowrap`}>
|
|
{compact ? (
|
|
<div className="flex items-baseline justify-between gap-2">
|
|
<span>{formatDate(e.Beginn, true)}</span>
|
|
<span className="text-gray-500">{formatWeekday(e.Beginn, true)}</span>
|
|
</div>
|
|
) : (
|
|
<>
|
|
<div>{formatDate(e.Beginn)}</div>
|
|
<div className="text-xs text-gray-500">{formatWeekday(e.Beginn)}</div>
|
|
</>
|
|
)}
|
|
</td>
|
|
{compact ? (
|
|
<>
|
|
<td className={`${cell} whitespace-nowrap`}>{formatTime(e.Beginn)}</td>
|
|
<td className={`${cell} whitespace-nowrap`}>{formatTime(e.Ende)}</td>
|
|
</>
|
|
) : (
|
|
<td className={`${cell} whitespace-nowrap text-center`}>
|
|
<div>{formatTime(e.Beginn)}</div>
|
|
<div className="text-gray-400 leading-none">—</div>
|
|
<div>{formatTime(e.Ende)}</div>
|
|
</td>
|
|
)}
|
|
<td className={`${cell} ${compact ? 'whitespace-nowrap' : ''}`}>
|
|
<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}>
|
|
{e.BEOs
|
|
? (() => {
|
|
const beos = e.BEOs.split(', ');
|
|
if (e.created_by_kuerzel) {
|
|
const idx = beos.indexOf(e.created_by_kuerzel);
|
|
if (idx > 0) beos.unshift(beos.splice(idx, 1)[0]);
|
|
}
|
|
return beos.map((k, i, arr) => (
|
|
<span key={k}>
|
|
{k === e.created_by_kuerzel ? <strong>{k}</strong> : k}
|
|
{i < arr.length - 1 ? ', ' : ''}
|
|
</span>
|
|
));
|
|
})()
|
|
: '—'}
|
|
</td>
|
|
<td className={cell}>{e.Objekte || '—'}</td>
|
|
{!compact && <td className={cell}>{e.Bemerkungen || ''}</td>}
|
|
{!compact && (
|
|
<td className={cell}>
|
|
{e.WetterTemp !== null && !(parseFloat(String(e.WetterTemp)) === 0 && parseFloat(String(e.WetterFeuchte ?? 0)) === 0 && parseFloat(String(e.WetterDruck ?? 0)) === 0) && (
|
|
<div className="text-xs whitespace-nowrap">
|
|
<div>{e.WetterTemp} °C</div>
|
|
<div>{Math.round(e.WetterFeuchte ?? 0)} %</div>
|
|
<div>{Math.round(e.WetterDruck ?? 0)} hPa</div>
|
|
</div>
|
|
)}
|
|
</td>
|
|
)}
|
|
<td className={`${cell} text-center whitespace-nowrap print:hidden`}>
|
|
{(isAdmin || e.BEOs?.split(', ').includes(currentUserKuerzel)) && (
|
|
<>
|
|
<button onClick={() => onEdit(e)} className="text-blue-600 hover:text-blue-800 mr-2 font-medium">✎</button>
|
|
<button onClick={() => setDeleteId(e.ID)} className="text-red-600 hover:text-red-800 font-medium">✕</button>
|
|
</>
|
|
)}
|
|
</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>}
|
|
|
|
{!compact && total > limit && (
|
|
<div className="flex items-center justify-center gap-3 mt-3 print:hidden">
|
|
<button
|
|
onClick={() => setPageState({ page: page - 1, key: filterKey })}
|
|
disabled={page === 0}
|
|
className="px-3 py-1.5 text-sm text-gray-900 rounded-lg bg-gray-200 hover:bg-gray-300 disabled:opacity-40 disabled:cursor-not-allowed"
|
|
>← Zurück</button>
|
|
<span className="text-sm text-gray-600">Seite {page + 1} von {Math.ceil(total / limit)}</span>
|
|
<button
|
|
onClick={() => setPageState({ page: page + 1, key: filterKey })}
|
|
disabled={(page + 1) * limit >= total}
|
|
className="px-3 py-1.5 text-sm text-gray-900 rounded-lg bg-gray-200 hover:bg-gray-300 disabled:opacity-40 disabled:cursor-not-allowed"
|
|
>Weiter →</button>
|
|
</div>
|
|
)}
|
|
|
|
{deleteId !== null && (
|
|
<div className="fixed inset-0 bg-black/40 flex items-center justify-center z-50">
|
|
<div className="bg-white rounded-xl shadow-xl p-6 max-w-sm w-full mx-4">
|
|
<h3 className="text-lg font-semibold mb-3">Eintrag löschen?</h3>
|
|
<p className="text-sm text-gray-600 mb-5">Dieser Eintrag wird unwiderruflich gelöscht.</p>
|
|
<div className="flex gap-3 justify-end">
|
|
<button onClick={() => setDeleteId(null)} className="px-4 py-2 bg-gray-200 hover:bg-gray-300 text-gray-700 rounded-lg text-sm">Abbrechen</button>
|
|
<button onClick={() => confirmDelete(deleteId)} className="px-4 py-2 bg-red-600 hover:bg-red-700 text-white rounded-lg text-sm">Löschen</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|