feat: vollständiges DB-Admin-Tool mit Spalten-Toggle, Sortierung und FK-Auflösung

- Audios-Tabelle in TABLES-Config ergänzt
- Spalten dynamisch aus geladenen Daten abgeleitet; Sichtbarkeit per localStorage persistiert
- Spalten-Toggle-Dropdown mit Checkboxen und Reset
- Sortierung per Klick auf Spaltenheader (▲/▼)
- FK-Felder zeigen aufgelöste Labels statt rohe UUIDs

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-06-01 13:24:46 +02:00
parent 7564f23ef1
commit 232ba1ece5
2 changed files with 241 additions and 37 deletions

View File

@@ -1,4 +1,4 @@
import { useEffect, useState, useMemo, useCallback } from 'react';
import { useEffect, useState, useMemo, useCallback, useRef } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import Layout from '../components/Layout';
import RecordModal from '../components/RecordModal';
@@ -12,25 +12,29 @@ function truncate(str, n = 60) {
return s.length > n ? s.slice(0, n) + '…' : s;
}
function CellValue({ col, value, linkedFields, navigate }) {
function CellValue({ col, value, linkedFields, fkLookup, navigate }) {
if (value == null || value === '') return <span className="text-slate-300"></span>;
// Array of IDs (e.g. picture_ids, word_ids)
if (Array.isArray(value)) {
if (value.length === 0) return <span className="text-slate-300">[]</span>;
const targetTable = linkedFields[col];
const targetTableKey = linkedFields[col];
const lookup = targetTableKey && fkLookup[targetTableKey];
return (
<div className="flex flex-wrap gap-1">
{value.slice(0, 3).map(id => (
<button
key={id}
onClick={e => { e.stopPropagation(); targetTable && navigate(`/db/${targetTable}?id=${id}`); }}
className="text-xs bg-indigo-50 text-indigo-600 rounded px-1.5 py-0.5 hover:bg-indigo-100 font-mono"
title={String(id)}
>
{String(id).slice(0, 8)}
</button>
))}
{value.slice(0, 3).map(id => {
const label = lookup?.[id];
return (
<button
key={id}
onClick={e => { e.stopPropagation(); targetTableKey && navigate(`/db/${targetTableKey}?id=${id}`); }}
className="text-xs bg-indigo-50 text-indigo-600 rounded px-1.5 py-0.5 hover:bg-indigo-100 font-mono"
title={String(id)}
>
{label ? truncate(label, 20) : String(id).slice(0, 8)}
</button>
);
})}
{value.length > 3 && (
<span className="text-xs text-slate-400">+{value.length - 3}</span>
)}
@@ -40,14 +44,20 @@ function CellValue({ col, value, linkedFields, navigate }) {
// Single UUID FK (e.g. question_id)
if (typeof value === 'string' && linkedFields[col]) {
const targetTable = linkedFields[col];
const targetTableKey = linkedFields[col];
const lookup = fkLookup[targetTableKey];
const label = lookup?.[value];
return (
<button
onClick={e => { e.stopPropagation(); navigate(`/db/${targetTable}?id=${value}`); }}
className="text-xs bg-indigo-50 text-indigo-600 rounded px-1.5 py-0.5 hover:bg-indigo-100 font-mono"
onClick={e => { e.stopPropagation(); navigate(`/db/${targetTableKey}?id=${value}`); }}
className="text-xs bg-indigo-50 text-indigo-600 rounded px-1.5 py-0.5 hover:bg-indigo-100 max-w-[200px] text-left"
title={value}
>
{value.slice(0, 8)}
{label ? (
<span>{truncate(label, 24)} <span className="opacity-50 font-mono">{value.slice(0, 6)}</span></span>
) : (
<span className="font-mono">{value.slice(0, 8)}</span>
)}
</button>
);
}
@@ -63,18 +73,68 @@ function CellValue({ col, value, linkedFields, navigate }) {
return <span className="text-xs text-slate-500">{new Date(value).toLocaleDateString('de-DE')}</span>;
}
// Picture link — show as thumbnail + link
if (col === 'picture_link') {
// Picture/audio link
if (col === 'picture_link' || col === 'audio_link') {
return (
<a href={value} target="_blank" rel="noreferrer" className="text-xs text-indigo-600 underline">
Bild öffnen
{col === 'audio_link' ? 'Audio öffnen' : 'Bild öffnen'}
</a>
);
}
// Boolean
if (value === true || value === false) {
return <span className={`text-xs font-mono ${value ? 'text-emerald-600' : 'text-slate-400'}`}>{String(value)}</span>;
}
return <span className="text-sm text-slate-700">{truncate(value)}</span>;
}
function ColPanel({ allColumns, visibleSet, onToggle, onReset, anchorRef, onClose }) {
const panelRef = useRef(null);
useEffect(() => {
function handleClick(e) {
if (panelRef.current && !panelRef.current.contains(e.target) &&
anchorRef.current && !anchorRef.current.contains(e.target)) {
onClose();
}
}
document.addEventListener('mousedown', handleClick);
return () => document.removeEventListener('mousedown', handleClick);
}, [onClose, anchorRef]);
return (
<div
ref={panelRef}
className="absolute right-0 top-full mt-1 z-30 bg-white border border-slate-200 rounded-xl shadow-lg w-56 max-h-80 overflow-y-auto"
>
<div className="px-3 py-2 border-b border-slate-100 flex items-center justify-between">
<span className="text-xs font-bold text-slate-400 uppercase tracking-wide">Spalten</span>
<button
onClick={onReset}
className="text-xs text-indigo-500 hover:text-indigo-700 font-medium"
>
Zurücksetzen
</button>
</div>
<div className="py-1">
{allColumns.map(col => (
<label key={col} className="flex items-center gap-2.5 px-3 py-1.5 hover:bg-slate-50 cursor-pointer">
<input
type="checkbox"
checked={visibleSet.has(col)}
onChange={() => onToggle(col)}
className="accent-indigo-600"
/>
<span className="text-sm text-slate-700 font-mono">{col}</span>
</label>
))}
</div>
</div>
);
}
export default function TableView() {
const { tableKey } = useParams();
const navigate = useNavigate();
@@ -90,6 +150,23 @@ export default function TableView() {
const [showCreate, setShowCreate] = useState(false);
const [confirmDeleteId, setConfirmDeleteId] = useState(null);
const [deleting, setDeleting] = useState(false);
const [showColPanel, setShowColPanel] = useState(false);
const [sortCol, setSortCol] = useState(null);
const [sortDir, setSortDir] = useState('asc');
const [fkLookup, setFkLookup] = useState({});
const colBtnRef = useRef(null);
const COLS_KEY = `cols_${tableKey}`;
const [visibleCols, setVisibleCols] = useState(null);
// Load column prefs from localStorage whenever tableKey changes
useEffect(() => {
try {
const saved = localStorage.getItem(`cols_${tableKey}`);
setVisibleCols(saved ? JSON.parse(saved) : null);
} catch { setVisibleCols(null); }
}, [tableKey]);
useEffect(() => {
const params = new URLSearchParams(window.location.search);
@@ -100,12 +177,69 @@ export default function TableView() {
useEffect(() => {
if (!meta) return;
setLoading(true);
setRows([]);
setSortCol(null);
setFkLookup({});
fetchAll(meta.endpoint)
.then(data => setRows(Array.isArray(data) ? data : []))
.catch(err => setError(err.message))
.finally(() => setLoading(false));
}, [meta]);
// Derive all available columns from first loaded row
const allColumns = useMemo(() => {
if (rows.length === 0) return meta?.columns ?? [];
return Object.keys(rows[0]);
}, [rows, meta]);
// Effective visible columns
const visibleSet = useMemo(() => {
return new Set(visibleCols ?? meta?.columns ?? []);
}, [visibleCols, meta]);
const effectiveCols = useMemo(() => {
return allColumns.filter(c => visibleSet.has(c));
}, [allColumns, visibleSet]);
function toggleCol(col) {
const current = visibleCols ?? meta.columns;
const next = current.includes(col)
? current.filter(c => c !== col)
: [...current, col];
setVisibleCols(next);
localStorage.setItem(COLS_KEY, JSON.stringify(next));
}
function resetCols() {
setVisibleCols(null);
localStorage.removeItem(COLS_KEY);
setShowColPanel(false);
}
// Load FK lookup tables when rows are available
useEffect(() => {
if (!meta || rows.length === 0) return;
const targets = new Set(
Object.values(meta.linkedFields)
.map(v => typeof v === 'string' ? v : null)
.filter(Boolean)
);
targets.forEach(async targetKey => {
if (fkLookup[targetKey]) return;
const targetMeta = TABLES[targetKey];
if (!targetMeta) return;
try {
const data = await fetchAll(targetMeta.endpoint);
setFkLookup(prev => ({
...prev,
[targetKey]: Object.fromEntries(
data.map(r => [r.id, r[targetMeta.primaryLabel] || r.id])
),
}));
} catch {}
});
}, [rows, meta]); // eslint-disable-line react-hooks/exhaustive-deps
const statuses = useMemo(() => {
const set = new Set(rows.map(r => r.status).filter(Boolean));
return [...set].sort();
@@ -124,6 +258,23 @@ export default function TableView() {
});
}, [rows, statusFilter, textFilter]);
const sortedFiltered = useMemo(() => {
if (!sortCol) return filtered;
return [...filtered].sort((a, b) => {
const av = a[sortCol], bv = b[sortCol];
if (av == null && bv == null) return 0;
if (av == null) return 1;
if (bv == null) return -1;
const cmp = String(av).localeCompare(String(bv), 'de', { numeric: true });
return sortDir === 'asc' ? cmp : -cmp;
});
}, [filtered, sortCol, sortDir]);
function handleSort(col) {
if (sortCol === col) setSortDir(d => d === 'asc' ? 'desc' : 'asc');
else { setSortCol(col); setSortDir('asc'); }
}
function handleRecordSaved(updated) {
setRows(prev => prev.map(r => (r.id === updated.id ? { ...r, ...updated } : r)));
}
@@ -151,21 +302,44 @@ export default function TableView() {
return (
<Layout back="/db">
<div className="flex items-center justify-between mb-4">
<div className="flex items-center justify-between mb-4 gap-2 flex-wrap">
<h2 className="text-xl font-semibold text-slate-700">
{meta.icon} {meta.label}
<span className="ml-2 text-base font-normal text-slate-400">
({filtered.length}{rows.length === 500 ? '+' : ''} von {rows.length}{rows.length === 500 ? '+' : ''})
({sortedFiltered.length}{rows.length === 500 ? '+' : ''} von {rows.length}{rows.length === 500 ? '+' : ''})
</span>
</h2>
{hasCreateForm(tableKey) && (
<button
onClick={() => setShowCreate(true)}
className="flex items-center gap-1.5 bg-indigo-600 hover:bg-indigo-700 text-white text-sm font-medium px-3 py-1.5 rounded-lg transition-colors"
>
<span className="text-lg leading-none">+</span> Neu
</button>
)}
<div className="flex items-center gap-2">
{/* Column toggle */}
<div className="relative">
<button
ref={colBtnRef}
onClick={() => setShowColPanel(p => !p)}
className="flex items-center gap-1.5 border border-slate-300 hover:border-slate-400 text-slate-600 hover:text-slate-800 text-sm px-3 py-1.5 rounded-lg transition-colors bg-white"
>
Spalten {showColPanel ? '▲' : '▼'}
</button>
{showColPanel && (
<ColPanel
allColumns={allColumns}
visibleSet={visibleSet}
onToggle={toggleCol}
onReset={resetCols}
anchorRef={colBtnRef}
onClose={() => setShowColPanel(false)}
/>
)}
</div>
{hasCreateForm(tableKey) && (
<button
onClick={() => setShowCreate(true)}
className="flex items-center gap-1.5 bg-indigo-600 hover:bg-indigo-700 text-white text-sm font-medium px-3 py-1.5 rounded-lg transition-colors"
>
<span className="text-lg leading-none">+</span> Neu
</button>
)}
</div>
</div>
{/* Filters */}
@@ -210,35 +384,47 @@ export default function TableView() {
<table className="w-full text-sm">
<thead>
<tr className="bg-slate-50 border-b border-slate-200">
{meta.columns.map(col => (
<th key={col} className="text-left px-4 py-2.5 text-xs font-semibold text-slate-500 uppercase tracking-wide whitespace-nowrap">
{col}
{effectiveCols.map(col => (
<th
key={col}
onClick={() => handleSort(col)}
className="text-left px-4 py-2.5 text-xs font-semibold text-slate-500 uppercase tracking-wide whitespace-nowrap cursor-pointer hover:bg-slate-100 select-none transition-colors"
>
<span className="flex items-center gap-1">
{col}
{sortCol === col ? (
<span className="text-indigo-500">{sortDir === 'asc' ? '▲' : '▼'}</span>
) : (
<span className="text-slate-300 opacity-0 group-hover:opacity-100"></span>
)}
</span>
</th>
))}
<th className="px-3 py-2.5 w-10" />
</tr>
</thead>
<tbody>
{filtered.length === 0 && (
{sortedFiltered.length === 0 && (
<tr>
<td colSpan={meta.columns.length + 1} className="text-center py-10 text-slate-400">
<td colSpan={effectiveCols.length + 1} className="text-center py-10 text-slate-400">
Keine Einträge gefunden
</td>
</tr>
)}
{filtered.map((row, i) => (
{sortedFiltered.map((row, i) => (
<tr
key={row.id || i}
onClick={() => setModalRecord(row)}
className={`group border-b border-slate-100 hover:bg-slate-50 transition-colors cursor-pointer
${highlightId && row.id === highlightId ? 'bg-indigo-50 ring-1 ring-indigo-300' : ''}`}
>
{meta.columns.map(col => (
{effectiveCols.map(col => (
<td key={col} className="px-4 py-2.5 align-top max-w-[280px]">
<CellValue
col={col}
value={row[col]}
linkedFields={meta.linkedFields}
fkLookup={fkLookup}
navigate={navigate}
/>
</td>