'use client'; import { useEffect, useState, useRef } from 'react'; import { usePathname, useSearchParams, useRouter } from 'next/navigation'; import { Recognition } from '../../types/plates'; import { Pagination } from './Pagination'; import RecognitionRow from './RecognitionRow'; import Table from './Table'; import RecognitionsTableFilters from './RecognitionsTableFilters'; import { useSSE } from './SSEContext'; import Checkbox from './Checkbox'; import { Button } from './Button'; import Modal from './Modal'; import CheckboxGroup from './CheckboxGroup'; import Image from 'next/image'; type Props = { initialSearch: string; initialPage: number; resetNewMarkers?: boolean; /** Optional: von außen gesteuerte Auswahl */ selected?: Recognition | null; onSelect?: (rec: Recognition | null) => void; }; // Welche Keys sind exportierbar: type ExportFieldKey = | 'id' | 'license' | 'licenseFormatted' | 'country' | 'brand' | 'model' | 'confidence' | 'timestampLocal' | 'cameraName' | 'direction' | 'directionDegrees'; const EXPORT_FIELDS: { key: ExportFieldKey; label: string }[] = [ { key: 'licenseFormatted', label: 'Kennzeichen (formatiert)' }, { key: 'license', label: 'Kennzeichen (roh)' }, { key: 'country', label: 'Land' }, { key: 'brand', label: 'Marke' }, { key: 'model', label: 'Modell' }, { key: 'confidence', label: 'Treffsicherheit' }, { key: 'timestampLocal', label: 'Zeit' }, { key: 'cameraName', label: 'Kamera' }, { key: 'direction', label: 'Richtung' }, { key: 'directionDegrees', label: 'Richtung (°)' }, { key: 'id', label: 'ID' }, ]; const HIDDEN_FROM_GRID: ExportFieldKey[] = ['directionDegrees']; const LABELS: Record = Object.fromEntries(EXPORT_FIELDS.map(f => [f.key, f.label])) as Record; function applyCoupling(keys: ExportFieldKey[]): ExportFieldKey[] { const s = new Set(keys); if (s.has('direction')) s.add('directionDegrees'); else s.delete('directionDegrees'); return Array.from(s); } export default function RecognitionsTable({ resetNewMarkers, initialPage, initialSearch, selected, onSelect, }: Props) { const [data, setData] = useState([]); const [searchTerm, setSearchTerm] = useState(initialSearch); const [currentPage, setCurrentPage] = useState(initialPage); const [totalPages, setTotalPages] = useState(1); const [newestId, setNewestId] = useState(null); const [dateRange, setDateRange] = useState<{ from: Date | null; to: Date | null }>({ from: null, to: null }); const [directionFilter, setDirectionFilter] = useState(''); const [cameraFilter, setCameraFilter] = useState(''); // '' = alle const [selectedIds, setSelectedIds] = useState>(new Set()); const [exportRunning, setExportRunning] = useState(false); const [exportStage, setExportStage] = useState(''); const [exportCounts, setExportCounts] = useState<{done:number,total:number}|null>(null); const [exportProgress, setExportProgress] = useState(0); const [exportError, setExportError] = useState(null); // controlled/uncontrolled selection const [internalSelected, setInternalSelected] = useState(null); const controlled = typeof onSelect === 'function' || selected !== undefined; const selectedRow = controlled ? (selected ?? null) : internalSelected; const setSelectedRow = controlled ? (onSelect as (r: Recognition | null) => void) : setInternalSelected; const selectAllFields = () => setExportFields(applyCoupling(EXPORT_FIELDS.map(f => f.key))); const DEFAULT_EXPORT_FIELDS: ExportFieldKey[] = [ 'licenseFormatted','country','brand','model', 'confidence','timestampLocal','cameraName','direction' ]; const selectDefault = () => setExportFields(applyCoupling(DEFAULT_EXPORT_FIELDS)); const [exportOpen, setExportOpen] = useState(false); const [exportFormat, setExportFormat] = useState<'csv' | 'json' | 'pdf'>('csv'); const [exportFields, setExportFields] = useState(DEFAULT_EXPORT_FIELDS); const onToggleFieldCoupled = (key: ExportFieldKey, checked: boolean) => { setExportFields(prev => { const next = new Set(prev); if (checked) next.add(key); else next.delete(key); return applyCoupling(Array.from(next)); }); }; const [allMatchingSelected, setAllMatchingSelected] = useState(false); const [deselectedIds, setDeselectedIds] = useState>(new Set()); const [totalMatching, setTotalMatching] = useState(0); const masterRef = useRef(null); const { onNewRecognition, onExportProgress } = useSSE(); const exportJobIdRef = useRef(null); const router = useRouter(); const pathname = usePathname(); const searchParams = useSearchParams(); const itemsPerPage = 10; const pageStart = totalMatching === 0 ? 0 : (currentPage - 1) * itemsPerPage + 1; const pageEnd = Math.min(currentPage * itemsPerPage, totalMatching); /** Icons */ const CsvImg = ( CSV ); const JsonImg = ( JSON ); const PdfImg = ( PDF ); // Auswahl bei Filterwechsel leeren (optional) useEffect(() => { setSelectedIds(new Set()); }, [searchTerm, dateRange, directionFilter]); // SSE-Listener für Export-Fortschritt useEffect(() => { if (!onExportProgress) return; const off = onExportProgress((msg: { jobId?: string; stage?: string; done?: number; total?: number; progress?: number }) => { if (!msg || msg.jobId !== exportJobIdRef.current) return; setExportCounts({ done: msg.done ?? 0, total: msg.total ?? 0 }); if (msg.stage) setExportStage(msg.stage); if (typeof msg.progress === 'number') { setExportProgress(Math.max(1, Math.min(99, Math.round(msg.progress)))); } else if ((msg.done ?? 0) > 0 && (msg.total ?? 0) > 0) { const p = Math.round(((msg.done as number) / (msg.total as number)) * 98); setExportProgress(Math.max(1, Math.min(99, p))); } }); return off; }, [onExportProgress]); // Query-Parameter einlesen useEffect(() => { const pageParam = searchParams.get('page'); const searchParam = searchParams.get('search'); const from = searchParams.get('timestampFrom'); const to = searchParams.get('timestampTo'); const directionParam= searchParams.get('direction'); const cameraParam = searchParams.get('camera'); if (pageParam) setCurrentPage(parseInt(pageParam)); if (searchParam) setSearchTerm(searchParam); if (from || to) { setDateRange({ from: from ? new Date(from) : null, to : to ? new Date(to) : null, }); } if (directionParam) setDirectionFilter(directionParam); if (cameraParam !== null) setCameraFilter(cameraParam); // '' oder Name }, [searchParams]); // URL aktuell halten useEffect(() => { const params = new URLSearchParams(); if (searchTerm) params.set('search', searchTerm); if (currentPage > 1) params.set('page', String(currentPage)); if (dateRange.from) params.set('timestampFrom', dateRange.from.toISOString()); if (dateRange.to) params.set('timestampTo', dateRange.to.toISOString()); if (directionFilter) params.set('direction', directionFilter); if (cameraFilter) params.set('camera', cameraFilter); router.replace(`${pathname}?${params.toString()}`); }, [searchTerm, currentPage, dateRange, directionFilter, cameraFilter, pathname, router]); useEffect(() => { if (resetNewMarkers) { setNewestId(null); } }, [resetNewMarkers]); useEffect(() => { const query = new URLSearchParams({ page: String(currentPage), limit: String(itemsPerPage), search: searchTerm, direction: directionFilter, }); if (dateRange.from) query.set('timestampFrom', dateRange.from.toISOString()); if (dateRange.to) query.set('timestampTo', dateRange.to.toISOString()); if (directionFilter) query.set('direction', directionFilter); if (cameraFilter) query.set('camera', cameraFilter); fetch(`/api/recognitions?${query}`, { credentials: "include", method: "GET" }) .then((res) => res.json()) .then((json) => { if (Array.isArray(json.data)) { setData(json.data); setTotalPages(json.totalPages || 1); setTotalMatching(json.totalCount || 0); } else { setData([]); setTotalPages(1); setTotalMatching(0); } }) .catch((err) => { console.error('❌ API-Aufruf fehlgeschlagen:', err); setData([]); setTotalPages(1); }); }, [currentPage, searchTerm, dateRange, directionFilter, cameraFilter]); useEffect(() => { let timeoutId: number | null = null; const off = onNewRecognition((newEntry: Recognition) => { if (currentPage !== 1) return; setData(prev => { if (prev.some(entry => entry.id === newEntry.id)) return prev; return [newEntry, ...prev].slice(0, itemsPerPage); }); fetch(`/api/recognitions/count`, { credentials: 'include' }) .then(res => res.json()) .then(({ count }) => { const pages = Math.max(1, Math.ceil(count / itemsPerPage)); setTotalPages(pages); }) .catch(console.error); setNewestId(newEntry.id); if (timeoutId) window.clearTimeout(timeoutId); timeoutId = window.setTimeout(() => { setNewestId(prevId => (prevId === newEntry.id ? null : prevId)); }, 2000); }); return () => { off(); if (timeoutId) clearTimeout(timeoutId); }; }, [currentPage, onNewRecognition]); const goToPage = (page: number) => { setCurrentPage(Math.max(1, Math.min(page, totalPages))); }; // Hilfsfunktionen const isSelected = (id: number) => allMatchingSelected ? !deselectedIds.has(id) : selectedIds.has(id); const toggleRow = (id: number) => { if (allMatchingSelected) { setDeselectedIds(prev => { const next = new Set(prev); if (next.has(id)) next.delete(id); else next.add(id); return next; }); } else { setSelectedIds(prev => { const next = new Set(prev); if (next.has(id)) next.delete(id); else next.add(id); return next; }); } }; const allOnPageSelected = data.length > 0 && data.every(r => isSelected(r.id)); const someOnPageSelected = data.some(r => isSelected(r.id)) && !allOnPageSelected; const toggleAllOnPage = (checked: boolean) => { if (allMatchingSelected) { setDeselectedIds(prev => { const next = new Set(prev); for (const row of data) { if (checked) next.delete(row.id); // Seite vollständig auswählen else next.add(row.id); // Seite vollständig abwählen } return next; }); } else { setSelectedIds(prev => { const next = new Set(prev); for (const row of data) { if (checked) next.add(row.id); else next.delete(row.id); } return next; }); } }; const handleExport = async () => { if (selectedCount === 0) { alert('Keine Einträge ausgewählt.'); return; } setExportError(null); const filters = { search: searchTerm, direction: directionFilter, timestampFrom: dateRange.from?.toISOString() ?? null, timestampTo: dateRange.to?.toISOString() ?? null, camera: cameraFilter || '', }; const selection = allMatchingSelected ? ({ mode: 'selected-all-except', exceptIds: Array.from(deselectedIds) } as const) : ({ mode: 'selected', ids: Array.from(selectedIds) } as const); const jobId = (typeof crypto !== 'undefined' && 'randomUUID' in crypto) ? crypto.randomUUID() : `job_${Date.now()}_${Math.random().toString(36).slice(2)}`; exportJobIdRef.current = jobId; const filenameFromDisposition = (h?: string | null, fallback = `recognitions.${exportFormat}`) => { if (!h) return fallback; const m = /filename\*?=(?:UTF-8''|")?([^\";]+)/i.exec(h); if (!m) return fallback; try { return decodeURIComponent(m[1].replace(/"/g, '')); } catch { return m[1].replace(/"/g, '') || fallback; } }; try { setExportRunning(true); setExportProgress(1); setExportStage(''); setExportCounts(null); const res = await fetch('/api/recognitions/export', { method: 'POST', credentials: 'include', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ format: exportFormat, filters, selection, fields: applyCoupling(exportFields), clientJobId: jobId, }), }); const blob = await res.blob(); const disp = res.headers.get('Content-Disposition'); const fileName = filenameFromDisposition(disp); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = fileName || `recognitions.${exportFormat}`; document.body.appendChild(a); a.click(); a.remove(); URL.revokeObjectURL(url); setExportProgress(100); setExportRunning(false); setExportStage(''); setExportCounts(null); exportJobIdRef.current = null; setExportOpen(false); } catch (err) { console.error('❌ Export:', err); const msg = String(err instanceof Error ? err.message : err); setExportError(msg); setExportRunning(false); setExportProgress(0); setExportStage(''); setExportCounts(null); exportJobIdRef.current = null; } }; const selectedCount = allMatchingSelected ? Math.max(0, (totalMatching || 0) - deselectedIds.size) : selectedIds.size; useEffect(() => { if (masterRef.current) { masterRef.current.indeterminate = !allOnPageSelected && someOnPageSelected; } }, [allOnPageSelected, someOnPageSelected]); return (
{/* Linke Spalte: Filter/Toolbar/Tabelle/Pagination */}
{/* Toolbar */}
{pageStart}–{pageEnd} von {totalMatching.toLocaleString('de-DE')} Einträgen {(!allMatchingSelected || selectedCount < totalMatching) ? ( ) : ( )} {(selectedCount > 0 || allMatchingSelected) && ( <> {selectedCount.toLocaleString('de-DE')} {selectedCount === 1 ? 'Eintrag' : 'Einträge'} ausgewählt
)}
{/* Tabelle */} toggleAllOnPage(e.currentTarget.checked)} label={Alle auf dieser Seite auswählen} containerClassName="justify-center" /> Kennzeichen Land Marke Modell Treffsicherheit Richtung Zeit Kamera {data.map((entry) => ( setSelectedRow(selectedRow?.id === entry.id ? null : entry) } checked={isSelected(entry.id)} onToggle={() => toggleRow(entry.id)} /> ))} {data.length === 0 && ( )}
Keine Daten gefunden.
{/* Export-Modal */} { if (exportRunning) return; setExportOpen(false); setExportError(null); }} title="Ausgewählte Einträge exportieren" saveButton onSave={handleExport} maxWidth="max-w-xl" busy={exportRunning} >
{exportError && (
Export fehlgeschlagen
{exportError}
)}

Welche Informationen sollen exportiert werden?

{exportFields.length === 0 ? ( Keine Felder ausgewählt ) : ( exportFields.map(k => ( {LABELS[k] ?? k} )) )}
{EXPORT_FIELDS .filter(f => !HIDDEN_FROM_GRID.includes(f.key)) .map(f => ( onToggleFieldCoupled(f.key, e.currentTarget.checked)} label={f.label} disabled={exportRunning} /> ))}

Format wählen

setExportFormat((vals[0] as 'csv' | 'json' | 'pdf') ?? 'csv')} options={[ { value: 'csv', label: 'CSV', description: 'Trennzeichen: Semikolon (;)', icon: CsvImg }, { value: 'pdf', label: 'PDF', description: exportRunning && exportCounts ? `${exportCounts.done}/${exportCounts.total} – ${exportStage || 'erzeuge…'}` : `${selectedCount} ${selectedCount===1?'Seite':'Seiten'}`, icon: PdfImg }, { value: 'json', label: 'JSON', icon: JsonImg }, ]} className="sm:col-span-3 max-w-full" itemClassName="w-full" progressByValue={exportRunning ? { [exportFormat]: exportProgress } : undefined} disabled={exportRunning} />
); }