kennzeichen/frontend/src/app/components/RecognitionsTable.tsx
2025-11-10 07:12:06 +01:00

681 lines
24 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

'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<ExportFieldKey, string> =
Object.fromEntries(EXPORT_FIELDS.map(f => [f.key, f.label])) as Record<ExportFieldKey, string>;
function applyCoupling(keys: ExportFieldKey[]): ExportFieldKey[] {
const s = new Set<ExportFieldKey>(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<Recognition[]>([]);
const [searchTerm, setSearchTerm] = useState(initialSearch);
const [currentPage, setCurrentPage] = useState(initialPage);
const [totalPages, setTotalPages] = useState(1);
const [newestId, setNewestId] = useState<number | null>(null);
const [dateRange, setDateRange] = useState<{ from: Date | null; to: Date | null }>({
from: null,
to: null
});
const [directionFilter, setDirectionFilter] = useState<string>('');
const [cameraFilter, setCameraFilter] = useState<string>(''); // '' = alle
const [selectedIds, setSelectedIds] = useState<Set<number>>(new Set());
const [exportRunning, setExportRunning] = useState(false);
const [exportStage, setExportStage] = useState<string>('');
const [exportCounts, setExportCounts] = useState<{done:number,total:number}|null>(null);
const [exportProgress, setExportProgress] = useState<number>(0);
const [exportError, setExportError] = useState<string | null>(null);
// controlled/uncontrolled selection
const [internalSelected, setInternalSelected] = useState<Recognition | null>(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<ExportFieldKey[]>(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<Set<number>>(new Set());
const [totalMatching, setTotalMatching] = useState<number>(0);
const masterRef = useRef<HTMLInputElement>(null);
const { onNewRecognition, onExportProgress } = useSSE();
const exportJobIdRef = useRef<string | null>(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 = (
<Image src="/assets/img/csv.svg" alt="CSV" width={32} height={32} className="h-8 w-8 object-contain" />
);
const JsonImg = (
<Image src="/assets/img/json.svg" alt="JSON" width={32} height={32} className="h-8 w-8 object-contain" />
);
const PdfImg = (
<Image src="/assets/img/pdf.svg" alt="PDF" width={32} height={32} className="h-8 w-8 object-contain" />
);
// 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 (
<div className="space-y-4">
{/* Linke Spalte: Filter/Toolbar/Tabelle/Pagination */}
<div className="min-w-0 flex flex-col">
<RecognitionsTableFilters
searchTerm={searchTerm}
directionFilter={directionFilter}
selectedCamera={cameraFilter}
setSearchTerm={setSearchTerm}
setDirectionFilter={setDirectionFilter}
setDateRange={setDateRange}
setCurrentPage={setCurrentPage}
setSelectedCamera={setCameraFilter}
dateRange={dateRange}
/>
{/* Toolbar */}
<div className="mt-2 mb-2 flex flex-wrap items-center gap-2">
<span className="text-sm text-gray-500">
{pageStart}{pageEnd} von {totalMatching.toLocaleString('de-DE')} Einträgen
</span>
{(!allMatchingSelected || selectedCount < totalMatching) ? (
<Button
size="small"
variant="ghost"
onClick={() => {
setAllMatchingSelected(true);
setSelectedIds(new Set());
setDeselectedIds(new Set());
}}
>
<span className="inline-flex items-center gap-1.5">
<svg className="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={1.5} strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<path d="M12 21a9 9 0 1 0 0-18 9 9 0 0 0 0 18Z" />
<path d="M8.8 12.2l2.2 2.2 4.2-4.2" />
</svg>
Alle Einträge auswählen
</span>
</Button>
) : (
<Button
size="small"
variant="ghost"
onClick={() => {
setAllMatchingSelected(false);
setSelectedIds(new Set());
setDeselectedIds(new Set());
}}
>
<span className="inline-flex items-center gap-1.5">
<svg className="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={1.5} strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<path d="M12 21a9 9 0 1 0 0-18 9 9 0 0 0 0 18Z" />
<path d="M15 9l-6 6M9 9l6 6" />
</svg>
Auswahl leeren
</span>
</Button>
)}
{(selectedCount > 0 || allMatchingSelected) && (
<>
<span className="ml-auto text-sm text-gray-500">
{selectedCount.toLocaleString('de-DE')} {selectedCount === 1 ? 'Eintrag' : 'Einträge'} ausgewählt
</span>
<div className="flex gap-2">
<Button
size="small"
variant="ghost"
onClick={() => {
setAllMatchingSelected(false);
setSelectedIds(new Set());
setDeselectedIds(new Set());
}}
>
<span className="inline-flex items-center gap-1.5">
<svg className="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={1.5} strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<path d="M12 21a9 9 0 1 0 0-18 9 9 0 0 0 0 18Z" />
<path d="M15 9l-6 6M9 9l6 6" />
</svg>
Auswahl aufheben
</span>
</Button>
<Button
size="small"
variant="ghost"
onClick={() => setExportOpen(true)}
>
<span className="inline-flex items-center gap-1.5">
<svg className="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={1.5} strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<path d="M12 3v12" />
<path d="M8.25 11.25 12 15l3.75-3.75" />
<path d="M3 16.5V19a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-2.5" />
</svg>
Exportieren
</span>
</Button>
</div>
</>
)}
</div>
{/* Tabelle */}
<Table>
<Table.Head>
<Table.Row>
<Table.Cell header align="center">
<Checkbox
checked={allOnPageSelected}
indeterminate={!allOnPageSelected && someOnPageSelected}
onChange={(e) => toggleAllOnPage(e.currentTarget.checked)}
label={<span className="sr-only">Alle auf dieser Seite auswählen</span>}
containerClassName="justify-center"
/>
</Table.Cell>
<Table.Cell>Kennzeichen</Table.Cell>
<Table.Cell>Land</Table.Cell>
<Table.Cell>Marke</Table.Cell>
<Table.Cell>Modell</Table.Cell>
<Table.Cell>Treffsicherheit</Table.Cell>
<Table.Cell colSpan={2}>Richtung</Table.Cell>
<Table.Cell>Zeit</Table.Cell>
<Table.Cell>Kamera</Table.Cell>
</Table.Row>
</Table.Head>
<Table.Body>
{data.map((entry) => (
<RecognitionRow
key={entry.id}
entry={entry}
isSelected={selectedRow?.id === entry.id}
isNew={newestId === entry.id}
onClick={() =>
setSelectedRow(selectedRow?.id === entry.id ? null : entry)
}
checked={isSelected(entry.id)}
onToggle={() => toggleRow(entry.id)}
/>
))}
{data.length === 0 && (
<tr>
<td colSpan={10} className="p-5 text-center text-gray-500 dark:text-neutral-400">
Keine Daten gefunden.
</td>
</tr>
)}
</Table.Body>
</Table>
<Pagination currentPage={currentPage} totalPages={totalPages} onPageChange={goToPage} />
</div>
{/* Export-Modal */}
<Modal
open={exportOpen}
onClose={() => {
if (exportRunning) return;
setExportOpen(false);
setExportError(null);
}}
title="Ausgewählte Einträge exportieren"
saveButton
onSave={handleExport}
maxWidth="max-w-xl"
busy={exportRunning}
>
<div className="space-y-6">
{exportError && (
<div
role="alert"
aria-live="polite"
className="rounded-md border border-red-300 bg-red-50 p-3 text-sm text-red-800"
>
<div className="font-medium mb-1">Export fehlgeschlagen</div>
<pre className="whitespace-pre-wrap break-words text-xs m-0">{exportError}</pre>
</div>
)}
<section className="rounded-lg border border-gray-200 dark:border-neutral-700 p-4">
<div className="flex items-center justify-between gap-3 mb-3">
<h3 className="font-medium text-base text-gray-900 dark:text-neutral-100">
Welche Informationen sollen exportiert werden?
</h3>
<div className="flex gap-1">
<Button size="small" variant="ghost" onClick={selectAllFields} disabled={exportRunning}>Alle</Button>
<Button size="small" variant="ghost" onClick={selectDefault} disabled={exportRunning}>Standard</Button>
</div>
</div>
<div className="mb-3 flex flex-wrap gap-2">
{exportFields.length === 0 ? (
<span className="text-xs text-gray-500 dark:text-neutral-400">Keine Felder ausgewählt</span>
) : (
exportFields.map(k => (
<span
key={k}
className="text-xs px-2 py-1 rounded border border-gray-300 dark:border-neutral-600 bg-white dark:bg-neutral-800"
>
{LABELS[k] ?? k}
</span>
))
)}
</div>
<fieldset className="grid grid-cols-1 sm:grid-cols-2 gap-2">
{EXPORT_FIELDS
.filter(f => !HIDDEN_FROM_GRID.includes(f.key))
.map(f => (
<Checkbox
key={f.key}
checked={exportFields.includes(f.key)}
onChange={e => onToggleFieldCoupled(f.key, e.currentTarget.checked)}
label={f.label}
disabled={exportRunning}
/>
))}
</fieldset>
</section>
<section className="rounded-lg border border-gray-200 dark:border-neutral-700 p-4">
<h3 className="mb-3 font-medium text-base text-gray-900 dark:text-neutral-100">
Format wählen
</h3>
<div className="grid sm:grid-cols-3 gap-2 max-w-none">
<CheckboxGroup
multiple={false}
orientation="vertical"
value={[exportFormat]}
onChange={(vals) => 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}
/>
</div>
</section>
</div>
</Modal>
</div>
);
}