681 lines
24 KiB
TypeScript
681 lines
24 KiB
TypeScript
'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>
|
||
);
|
||
}
|