Files
logbuch/components/LogbuchList.tsx
T

335 lines
14 KiB
TypeScript

'use client';
import { useEffect, useRef, useState } from 'react';
import type { Kuppel, LogbuchEintrag } 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'];
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()}`;
}
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 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 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'
: 'px-3 py-2 border border-gray-200';
const head = compact
? 'px-1.5 py-1 border border-gray-300 text-xs font-semibold'
: 'px-3 py-2 border border-gray-300';
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">
<th className={`${head} whitespace-nowrap`}>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}>Art</th>
<th className={`${head} text-center w-10`}>Bes.</th>
<th className={head}>BEOs</th>
<th className={head}>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`}>{formatDate(e.Beginn, compact)}</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}>
<div>{e.ArtFuehrung}</div>
{e.SonderName && <div className="text-xs text-gray-500">{e.SonderName}</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 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 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>
);
}