cf038ad3be
- LogbuchForm: Temperatur als String-State (tempRaw) – Minus-Vorzeichen tippbar - LogbuchList: input[type=month] mit max=currentMonth(), kein Zukunftsmonat wählbar - .gitignore: DB_BACKUP/ ergänzt Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
293 lines
12 KiB
TypeScript
293 lines
12 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;
|
|
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, limit = 10, compact = false }: Props) {
|
|
const [entries, setEntries] = useState<LogbuchEintrag[]>([]);
|
|
const [total, setTotal] = useState(0);
|
|
const [page, setPage] = useState(0);
|
|
const [month, setMonth] = useState(compact ? '' : currentMonth());
|
|
const [loading, setLoading] = useState(true);
|
|
const [deleteId, setDeleteId] = useState<number | null>(null);
|
|
const [error, setError] = useState('');
|
|
const [printEntries, setPrintEntries] = useState<LogbuchEintrag[] | null>(null);
|
|
const printPending = useRef(false);
|
|
|
|
useEffect(() => { setPage(0); }, [kuppel, refreshKey, month]);
|
|
|
|
useEffect(() => {
|
|
setLoading(true);
|
|
const offset = page * limit;
|
|
const url = `/api/logbuch?kuppel=${encodeURIComponent(kuppel)}&limit=${limit}&offset=${offset}` +
|
|
(month ? `&month=${encodeURIComponent(month)}` : '');
|
|
fetch(url)
|
|
.then((r) => { if (!r.ok) throw new Error(); return r.json(); })
|
|
.then((data) => { setEntries(data.entries); setTotal(data.total); setLoading(false); })
|
|
.catch(() => { setError('Fehler beim Laden.'); setLoading(false); });
|
|
}, [kuppel, refreshKey, limit, page, month]);
|
|
|
|
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 {
|
|
setError('Fehler beim Löschen.');
|
|
} finally {
|
|
setDeleteId(null);
|
|
}
|
|
}
|
|
|
|
const monthNav = !compact && (
|
|
<div className="flex items-center gap-2 mb-3 print:hidden">
|
|
<button
|
|
onClick={() => setMonth((m) => prevMonth(m))}
|
|
className="px-2 py-1 text-sm rounded-lg bg-gray-200 hover:bg-gray-300"
|
|
>←</button>
|
|
<input
|
|
type="month"
|
|
value={month}
|
|
max={currentMonth()}
|
|
onChange={(e) => setMonth(e.target.value > currentMonth() ? currentMonth() : e.target.value)}
|
|
className="border border-gray-300 rounded-lg px-2 py-1 text-sm"
|
|
/>
|
|
<button
|
|
onClick={() => setMonth((m) => nextMonth(m))}
|
|
disabled={month >= currentMonth()}
|
|
className="px-2 py-1 text-sm rounded-lg bg-gray-200 hover:bg-gray-300 disabled:opacity-40 disabled:cursor-not-allowed"
|
|
>→</button>
|
|
{month !== currentMonth() && (
|
|
<button
|
|
onClick={() => setMonth(currentMonth())}
|
|
className="text-sm text-blue-600 hover:underline"
|
|
>
|
|
Aktueller Monat
|
|
</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';
|
|
|
|
if (loading) return <>{monthNav}<div className="text-gray-500 text-sm py-4">Lade Einträge...</div></>;
|
|
if (error) return <>{monthNav}<div className="text-red-600 text-sm py-4">{error}</div></>;
|
|
|
|
const displayEntries = printEntries ?? entries;
|
|
|
|
return (
|
|
<div>
|
|
{!compact && (
|
|
<div className="flex justify-between items-center mb-2 print:hidden">
|
|
<span className="text-sm font-semibold text-gray-600">Einträge {kuppel}-Kuppel</span>
|
|
<button
|
|
onClick={handlePrint}
|
|
className="text-sm px-3 py-1.5 bg-gray-200 hover:bg-gray-300 text-gray-700 rounded-lg"
|
|
>
|
|
🖨 Drucken
|
|
</button>
|
|
</div>
|
|
)}
|
|
{monthNav}
|
|
{printHeader}
|
|
<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">
|
|
Keine Einträge für {monthLabel(month)}.
|
|
</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 && (
|
|
<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`}>
|
|
<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={() => setPage((p) => p - 1)}
|
|
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={() => setPage((p) => p + 1)}
|
|
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>
|
|
);
|
|
}
|