Add RecordModal: row-click detail popup with inline editing
- Click any table row to open a full-detail popup - Editable fields (text, textarea, select, number) with PATCH save - Read-only display for IDs, timestamps, arrays - Pictures table fetches words via /pictures/:id/words in modal - Stop propagation on linked-field chips so they don't trigger modal - apiPatch + apiFetchOne helpers in api.js - editableFields + fetchRelated config in tables.js Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
326
src/components/RecordModal.jsx
Normal file
326
src/components/RecordModal.jsx
Normal file
@@ -0,0 +1,326 @@
|
|||||||
|
import { useEffect, useState, useCallback } from 'react';
|
||||||
|
import { apiPatch, apiFetchOne } from '../lib/api';
|
||||||
|
import { STATUS_COLORS } from '../lib/tables';
|
||||||
|
|
||||||
|
const READ_ONLY_FIELDS = new Set(['id', 'created_at', 'updated_at']);
|
||||||
|
const TIMESTAMP_RE = /(_at|_timestamp)$/;
|
||||||
|
|
||||||
|
function isReadOnly(key) {
|
||||||
|
return READ_ONLY_FIELDS.has(key) || TIMESTAMP_RE.test(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
function FieldLabel({ name }) {
|
||||||
|
return (
|
||||||
|
<span className="text-xs font-semibold text-slate-400 uppercase tracking-wide">
|
||||||
|
{name.replace(/_/g, ' ')}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ReadOnlyValue({ fieldKey, value }) {
|
||||||
|
if (value == null || value === '') return <span className="text-slate-300 text-sm">—</span>;
|
||||||
|
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
if (value.length === 0) return <span className="text-slate-400 text-sm">[]</span>;
|
||||||
|
return (
|
||||||
|
<div className="flex flex-wrap gap-1">
|
||||||
|
{value.map((v, i) => (
|
||||||
|
<span key={i} className="text-xs font-mono bg-slate-100 text-slate-600 rounded px-1.5 py-0.5">
|
||||||
|
{String(v).slice(0, 12)}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (TIMESTAMP_RE.test(fieldKey)) {
|
||||||
|
return (
|
||||||
|
<span className="text-sm text-slate-600">
|
||||||
|
{new Date(value).toLocaleString('de-DE')}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fieldKey === 'status') {
|
||||||
|
const cls = STATUS_COLORS[value] || 'bg-slate-100 text-slate-600';
|
||||||
|
return <span className={`text-xs font-medium px-2 py-0.5 rounded-full ${cls}`}>{value}</span>;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fieldKey === 'id') {
|
||||||
|
return <span className="text-sm font-mono text-slate-500">{value}</span>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return <span className="text-sm text-slate-700 break-words">{String(value)}</span>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function EditableField({ fieldKey, value, fieldDef, onChange }) {
|
||||||
|
const { type, options, min, max } = fieldDef;
|
||||||
|
|
||||||
|
if (type === 'select') {
|
||||||
|
return (
|
||||||
|
<select
|
||||||
|
value={value ?? ''}
|
||||||
|
onChange={e => onChange(e.target.value)}
|
||||||
|
className="w-full border border-slate-300 rounded-lg px-3 py-1.5 text-sm focus:outline-none focus:ring-2 focus:ring-indigo-400 bg-white"
|
||||||
|
>
|
||||||
|
<option value="">— wählen —</option>
|
||||||
|
{options.map(o => <option key={o} value={o}>{o}</option>)}
|
||||||
|
</select>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (type === 'textarea') {
|
||||||
|
return (
|
||||||
|
<textarea
|
||||||
|
value={value ?? ''}
|
||||||
|
onChange={e => onChange(e.target.value)}
|
||||||
|
rows={3}
|
||||||
|
className="w-full border border-slate-300 rounded-lg px-3 py-1.5 text-sm focus:outline-none focus:ring-2 focus:ring-indigo-400 bg-white resize-y"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (type === 'number') {
|
||||||
|
return (
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={value ?? ''}
|
||||||
|
min={min}
|
||||||
|
max={max}
|
||||||
|
onChange={e => onChange(e.target.value === '' ? null : Number(e.target.value))}
|
||||||
|
className="w-full border border-slate-300 rounded-lg px-3 py-1.5 text-sm focus:outline-none focus:ring-2 focus:ring-indigo-400 bg-white"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// default: text
|
||||||
|
return (
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={value ?? ''}
|
||||||
|
onChange={e => onChange(e.target.value)}
|
||||||
|
className="w-full border border-slate-300 rounded-lg px-3 py-1.5 text-sm focus:outline-none focus:ring-2 focus:ring-indigo-400 bg-white"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function RecordModal({ record, meta, onClose, onSaved }) {
|
||||||
|
const [values, setValues] = useState({});
|
||||||
|
const [dirty, setDirty] = useState({});
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [saveError, setSaveError] = useState('');
|
||||||
|
const [related, setRelated] = useState({});
|
||||||
|
|
||||||
|
// Init values from record
|
||||||
|
useEffect(() => {
|
||||||
|
if (!record) return;
|
||||||
|
setValues({ ...record });
|
||||||
|
setDirty({});
|
||||||
|
setSaveError('');
|
||||||
|
setRelated({});
|
||||||
|
}, [record]);
|
||||||
|
|
||||||
|
// Fetch related data (e.g. pictures → words)
|
||||||
|
useEffect(() => {
|
||||||
|
if (!record || !meta.fetchRelated?.length) return;
|
||||||
|
meta.fetchRelated.forEach(rel => {
|
||||||
|
apiFetchOne(rel.endpoint(record.id))
|
||||||
|
.then(data => {
|
||||||
|
const arr = Array.isArray(data) ? data : [];
|
||||||
|
setRelated(prev => ({ ...prev, [rel.key]: arr }));
|
||||||
|
})
|
||||||
|
.catch(() => setRelated(prev => ({ ...prev, [rel.key]: null })));
|
||||||
|
});
|
||||||
|
}, [record, meta]);
|
||||||
|
|
||||||
|
const handleChange = useCallback((key, val) => {
|
||||||
|
setValues(prev => ({ ...prev, [key]: val }));
|
||||||
|
setDirty(prev => ({ ...prev, [key]: true }));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
const patch = {};
|
||||||
|
Object.keys(dirty).forEach(k => { patch[k] = values[k]; });
|
||||||
|
if (Object.keys(patch).length === 0) { onClose(); return; }
|
||||||
|
|
||||||
|
setSaving(true);
|
||||||
|
setSaveError('');
|
||||||
|
try {
|
||||||
|
const updated = await apiPatch(meta.endpoint, record.id, patch);
|
||||||
|
onSaved(updated || { ...record, ...patch });
|
||||||
|
onClose();
|
||||||
|
} catch (err) {
|
||||||
|
setSaveError(err.message);
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!record) return null;
|
||||||
|
|
||||||
|
// All field keys from the record
|
||||||
|
const allKeys = Object.keys(record);
|
||||||
|
// Split into editable and read-only
|
||||||
|
const editableKeys = allKeys.filter(k => !isReadOnly(k) && meta.editableFields?.[k]);
|
||||||
|
const extraKeys = allKeys.filter(k => !isReadOnly(k) && !meta.editableFields?.[k] && !Array.isArray(record[k]));
|
||||||
|
const arrayKeys = allKeys.filter(k => Array.isArray(record[k]));
|
||||||
|
const roKeys = allKeys.filter(k => isReadOnly(k));
|
||||||
|
|
||||||
|
const hasDirty = Object.keys(dirty).length > 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-start justify-center bg-black/40 backdrop-blur-sm p-4 overflow-y-auto">
|
||||||
|
<div className="bg-white rounded-2xl shadow-2xl w-full max-w-2xl my-8 flex flex-col">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between px-6 py-4 border-b border-slate-200">
|
||||||
|
<div>
|
||||||
|
<span className="text-xl mr-2">{meta.icon}</span>
|
||||||
|
<span className="font-semibold text-slate-800">{meta.label}</span>
|
||||||
|
<span className="ml-2 font-mono text-xs text-slate-400">{record.id?.slice(0, 8)}…</span>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="text-slate-400 hover:text-slate-600 text-2xl leading-none transition-colors"
|
||||||
|
aria-label="Schließen"
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Body */}
|
||||||
|
<div className="px-6 py-5 space-y-5 overflow-y-auto">
|
||||||
|
|
||||||
|
{/* Editable fields */}
|
||||||
|
{editableKeys.length > 0 && (
|
||||||
|
<section>
|
||||||
|
<h3 className="text-xs font-bold text-slate-400 uppercase tracking-widest mb-3">Bearbeiten</h3>
|
||||||
|
<div className="space-y-3">
|
||||||
|
{editableKeys.map(key => (
|
||||||
|
<div key={key}>
|
||||||
|
<FieldLabel name={key} />
|
||||||
|
<div className="mt-1">
|
||||||
|
<EditableField
|
||||||
|
fieldKey={key}
|
||||||
|
value={values[key]}
|
||||||
|
fieldDef={meta.editableFields[key]}
|
||||||
|
onChange={val => handleChange(key, val)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{dirty[key] && (
|
||||||
|
<span className="text-xs text-indigo-500 mt-0.5 block">geändert</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Extra non-editable non-array fields */}
|
||||||
|
{extraKeys.length > 0 && (
|
||||||
|
<section>
|
||||||
|
<h3 className="text-xs font-bold text-slate-400 uppercase tracking-widest mb-3">Weitere Felder</h3>
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||||
|
{extraKeys.map(key => (
|
||||||
|
<div key={key} className="bg-slate-50 rounded-lg p-3">
|
||||||
|
<FieldLabel name={key} />
|
||||||
|
<div className="mt-1">
|
||||||
|
<ReadOnlyValue fieldKey={key} value={values[key]} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Array / linked fields */}
|
||||||
|
{arrayKeys.length > 0 && (
|
||||||
|
<section>
|
||||||
|
<h3 className="text-xs font-bold text-slate-400 uppercase tracking-widest mb-3">Verknüpfte IDs</h3>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{arrayKeys.map(key => (
|
||||||
|
<div key={key} className="bg-slate-50 rounded-lg p-3">
|
||||||
|
<FieldLabel name={key} />
|
||||||
|
<div className="mt-1">
|
||||||
|
<ReadOnlyValue fieldKey={key} value={values[key]} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Related data (fetched separately) */}
|
||||||
|
{meta.fetchRelated?.map(rel => (
|
||||||
|
<section key={rel.key}>
|
||||||
|
<h3 className="text-xs font-bold text-slate-400 uppercase tracking-widest mb-3">
|
||||||
|
{rel.label}
|
||||||
|
</h3>
|
||||||
|
<div className="bg-slate-50 rounded-lg p-3">
|
||||||
|
{related[rel.key] === undefined && (
|
||||||
|
<div className="flex gap-1">
|
||||||
|
{[1,2,3].map(i => (
|
||||||
|
<div key={i} className="h-6 w-16 bg-slate-200 rounded animate-pulse" />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{related[rel.key] === null && (
|
||||||
|
<span className="text-xs text-red-500">Fehler beim Laden</span>
|
||||||
|
)}
|
||||||
|
{Array.isArray(related[rel.key]) && related[rel.key].length === 0 && (
|
||||||
|
<span className="text-sm text-slate-400">Keine Einträge</span>
|
||||||
|
)}
|
||||||
|
{Array.isArray(related[rel.key]) && related[rel.key].length > 0 && (
|
||||||
|
<div className="flex flex-wrap gap-1.5">
|
||||||
|
{related[rel.key].map((item, i) => (
|
||||||
|
<span key={item.id || i} className="text-xs bg-indigo-50 text-indigo-700 rounded-full px-2.5 py-1 font-medium">
|
||||||
|
{rel.display(item)}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* Read-only metadata */}
|
||||||
|
<section>
|
||||||
|
<h3 className="text-xs font-bold text-slate-400 uppercase tracking-widest mb-3">Metadaten</h3>
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||||
|
{roKeys.map(key => (
|
||||||
|
<div key={key} className="bg-slate-50 rounded-lg p-3">
|
||||||
|
<FieldLabel name={key} />
|
||||||
|
<div className="mt-1">
|
||||||
|
<ReadOnlyValue fieldKey={key} value={values[key]} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Footer */}
|
||||||
|
<div className="px-6 py-4 border-t border-slate-200 flex items-center justify-between gap-3">
|
||||||
|
{saveError && (
|
||||||
|
<span className="text-sm text-red-600 flex-1">{saveError}</span>
|
||||||
|
)}
|
||||||
|
{!saveError && <span className="flex-1" />}
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="px-4 py-2 text-sm text-slate-600 hover:text-slate-800 border border-slate-300 rounded-lg transition-colors"
|
||||||
|
>
|
||||||
|
Abbrechen
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleSave}
|
||||||
|
disabled={saving || !hasDirty}
|
||||||
|
className="px-4 py-2 text-sm bg-indigo-600 hover:bg-indigo-700 disabled:opacity-40 text-white font-medium rounded-lg transition-colors"
|
||||||
|
>
|
||||||
|
{saving ? 'Speichern…' : hasDirty ? 'Speichern' : 'Gespeichert'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -54,3 +54,14 @@ export async function apiFetch(path, options = {}) {
|
|||||||
export async function fetchAll(endpoint) {
|
export async function fetchAll(endpoint) {
|
||||||
return apiFetch(`${endpoint}?limit=500&offset=0`);
|
return apiFetch(`${endpoint}?limit=500&offset=0`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function apiPatch(endpoint, id, body) {
|
||||||
|
return apiFetch(`${endpoint}/${id}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function apiFetchOne(path) {
|
||||||
|
return apiFetch(path);
|
||||||
|
}
|
||||||
|
|||||||
@@ -7,6 +7,13 @@ export const TABLES = {
|
|||||||
primaryLabel: 'titel_de',
|
primaryLabel: 'titel_de',
|
||||||
columns: ['titel_de', 'titel_en', 'status', 'difficulty_level', 'created_at'],
|
columns: ['titel_de', 'titel_en', 'status', 'difficulty_level', 'created_at'],
|
||||||
linkedFields: { picture_ids: 'pictures', category_ids: 'categories' },
|
linkedFields: { picture_ids: 'pictures', category_ids: 'categories' },
|
||||||
|
editableFields: {
|
||||||
|
titel_de: { type: 'text' },
|
||||||
|
titel_en: { type: 'text' },
|
||||||
|
status: { type: 'select', options: ['published', 'blocked', 'draft', 'requested', 'translated'] },
|
||||||
|
difficulty_level:{ type: 'number', min: 1, max: 10 },
|
||||||
|
},
|
||||||
|
fetchRelated: [],
|
||||||
},
|
},
|
||||||
pictures: {
|
pictures: {
|
||||||
label: 'Bilder',
|
label: 'Bilder',
|
||||||
@@ -16,6 +23,15 @@ export const TABLES = {
|
|||||||
primaryLabel: 'design',
|
primaryLabel: 'design',
|
||||||
columns: ['design', 'status', 'picture_link', 'blurhash', 'created_at'],
|
columns: ['design', 'status', 'picture_link', 'blurhash', 'created_at'],
|
||||||
linkedFields: {},
|
linkedFields: {},
|
||||||
|
editableFields: {
|
||||||
|
design: { type: 'text' },
|
||||||
|
status: { type: 'select', options: ['published', 'blocked', 'uploaded', 'requested', 'generated'] },
|
||||||
|
picture_link: { type: 'text' },
|
||||||
|
blurhash: { type: 'text' },
|
||||||
|
},
|
||||||
|
fetchRelated: [
|
||||||
|
{ key: 'words', label: 'Wörter', endpoint: id => `/pictures/${id}/words`, display: w => w.titel_de || w.id },
|
||||||
|
],
|
||||||
},
|
},
|
||||||
objects: {
|
objects: {
|
||||||
label: 'Objekte',
|
label: 'Objekte',
|
||||||
@@ -25,6 +41,11 @@ export const TABLES = {
|
|||||||
primaryLabel: 'notes',
|
primaryLabel: 'notes',
|
||||||
columns: ['id', 'status', 'notes', 'created_at'],
|
columns: ['id', 'status', 'notes', 'created_at'],
|
||||||
linkedFields: { word_ids: 'words', picture_ids: 'pictures', pair_ids: 'pairs' },
|
linkedFields: { word_ids: 'words', picture_ids: 'pictures', pair_ids: 'pairs' },
|
||||||
|
editableFields: {
|
||||||
|
notes: { type: 'textarea' },
|
||||||
|
status: { type: 'select', options: ['published', 'blocked', 'draft'] },
|
||||||
|
},
|
||||||
|
fetchRelated: [],
|
||||||
},
|
},
|
||||||
pairs: {
|
pairs: {
|
||||||
label: 'Pairs',
|
label: 'Pairs',
|
||||||
@@ -33,7 +54,17 @@ export const TABLES = {
|
|||||||
statusField: 'status',
|
statusField: 'status',
|
||||||
primaryLabel: 'id',
|
primaryLabel: 'id',
|
||||||
columns: ['id', 'answer_type', 'difficulty_level', 'status', 'question_id', 'positive_statement_id', 'created_at'],
|
columns: ['id', 'answer_type', 'difficulty_level', 'status', 'question_id', 'positive_statement_id', 'created_at'],
|
||||||
linkedFields: { question_id: 'questions', positive_statement_id: 'statements', negative_statement_id: 'statements' },
|
linkedFields: {
|
||||||
|
question_id: 'questions',
|
||||||
|
positive_statement_id: 'statements',
|
||||||
|
negative_statement_id: 'statements',
|
||||||
|
},
|
||||||
|
editableFields: {
|
||||||
|
answer_type: { type: 'select', options: ['word', 'sentence'] },
|
||||||
|
difficulty_level:{ type: 'number', min: 1, max: 10 },
|
||||||
|
status: { type: 'select', options: ['published', 'blocked', 'draft'] },
|
||||||
|
},
|
||||||
|
fetchRelated: [],
|
||||||
},
|
},
|
||||||
questions: {
|
questions: {
|
||||||
label: 'Fragen',
|
label: 'Fragen',
|
||||||
@@ -43,6 +74,12 @@ export const TABLES = {
|
|||||||
primaryLabel: 'sentence_de',
|
primaryLabel: 'sentence_de',
|
||||||
columns: ['sentence_de', 'sentence_en', 'status', 'created_at'],
|
columns: ['sentence_de', 'sentence_en', 'status', 'created_at'],
|
||||||
linkedFields: {},
|
linkedFields: {},
|
||||||
|
editableFields: {
|
||||||
|
sentence_de: { type: 'textarea' },
|
||||||
|
sentence_en: { type: 'textarea' },
|
||||||
|
status: { type: 'select', options: ['published', 'blocked', 'draft'] },
|
||||||
|
},
|
||||||
|
fetchRelated: [],
|
||||||
},
|
},
|
||||||
statements: {
|
statements: {
|
||||||
label: 'Statements',
|
label: 'Statements',
|
||||||
@@ -52,6 +89,14 @@ export const TABLES = {
|
|||||||
primaryLabel: 'positive_sentence_de',
|
primaryLabel: 'positive_sentence_de',
|
||||||
columns: ['positive_sentence_de', 'negative_sentence_de', 'status', 'created_at'],
|
columns: ['positive_sentence_de', 'negative_sentence_de', 'status', 'created_at'],
|
||||||
linkedFields: {},
|
linkedFields: {},
|
||||||
|
editableFields: {
|
||||||
|
positive_sentence_de: { type: 'textarea' },
|
||||||
|
negative_sentence_de: { type: 'textarea' },
|
||||||
|
positive_sentence_en: { type: 'textarea' },
|
||||||
|
negative_sentence_en: { type: 'textarea' },
|
||||||
|
status: { type: 'select', options: ['published', 'blocked', 'draft'] },
|
||||||
|
},
|
||||||
|
fetchRelated: [],
|
||||||
},
|
},
|
||||||
categories: {
|
categories: {
|
||||||
label: 'Kategorien',
|
label: 'Kategorien',
|
||||||
@@ -61,6 +106,12 @@ export const TABLES = {
|
|||||||
primaryLabel: 'name_de',
|
primaryLabel: 'name_de',
|
||||||
columns: ['name_de', 'name_en', 'name_sv', 'created_at'],
|
columns: ['name_de', 'name_en', 'name_sv', 'created_at'],
|
||||||
linkedFields: {},
|
linkedFields: {},
|
||||||
|
editableFields: {
|
||||||
|
name_de: { type: 'text' },
|
||||||
|
name_en: { type: 'text' },
|
||||||
|
name_sv: { type: 'text' },
|
||||||
|
},
|
||||||
|
fetchRelated: [],
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useEffect, useState, useMemo } from 'react';
|
import { useEffect, useState, useMemo } from 'react';
|
||||||
import { useParams, useNavigate } from 'react-router-dom';
|
import { useParams, useNavigate } from 'react-router-dom';
|
||||||
import Layout from '../components/Layout';
|
import Layout from '../components/Layout';
|
||||||
|
import RecordModal from '../components/RecordModal';
|
||||||
import { fetchAll } from '../lib/api';
|
import { fetchAll } from '../lib/api';
|
||||||
import { TABLES, STATUS_COLORS } from '../lib/tables';
|
import { TABLES, STATUS_COLORS } from '../lib/tables';
|
||||||
|
|
||||||
@@ -22,7 +23,7 @@ function CellValue({ col, value, linkedFields, navigate }) {
|
|||||||
{value.slice(0, 3).map(id => (
|
{value.slice(0, 3).map(id => (
|
||||||
<button
|
<button
|
||||||
key={id}
|
key={id}
|
||||||
onClick={() => targetTable && navigate(`/db/${targetTable}?id=${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"
|
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)}
|
title={String(id)}
|
||||||
>
|
>
|
||||||
@@ -41,7 +42,7 @@ function CellValue({ col, value, linkedFields, navigate }) {
|
|||||||
const targetTable = linkedFields[col];
|
const targetTable = linkedFields[col];
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
onClick={() => navigate(`/db/${targetTable}?id=${value}`)}
|
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"
|
className="text-xs bg-indigo-50 text-indigo-600 rounded px-1.5 py-0.5 hover:bg-indigo-100 font-mono"
|
||||||
title={value}
|
title={value}
|
||||||
>
|
>
|
||||||
@@ -84,6 +85,7 @@ export default function TableView() {
|
|||||||
const [statusFilter, setStatusFilter] = useState('');
|
const [statusFilter, setStatusFilter] = useState('');
|
||||||
const [textFilter, setTextFilter] = useState('');
|
const [textFilter, setTextFilter] = useState('');
|
||||||
const [highlightId, setHighlightId] = useState(null);
|
const [highlightId, setHighlightId] = useState(null);
|
||||||
|
const [modalRecord, setModalRecord] = useState(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const params = new URLSearchParams(window.location.search);
|
const params = new URLSearchParams(window.location.search);
|
||||||
@@ -118,6 +120,10 @@ export default function TableView() {
|
|||||||
});
|
});
|
||||||
}, [rows, statusFilter, textFilter]);
|
}, [rows, statusFilter, textFilter]);
|
||||||
|
|
||||||
|
function handleRecordSaved(updated) {
|
||||||
|
setRows(prev => prev.map(r => (r.id === updated.id ? { ...r, ...updated } : r)));
|
||||||
|
}
|
||||||
|
|
||||||
if (!meta) return <Layout back="/db"><p className="text-red-500">Unbekannte Tabelle: {tableKey}</p></Layout>;
|
if (!meta) return <Layout back="/db"><p className="text-red-500">Unbekannte Tabelle: {tableKey}</p></Layout>;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -191,7 +197,8 @@ export default function TableView() {
|
|||||||
{filtered.map((row, i) => (
|
{filtered.map((row, i) => (
|
||||||
<tr
|
<tr
|
||||||
key={row.id || i}
|
key={row.id || i}
|
||||||
className={`border-b border-slate-100 hover:bg-slate-50 transition-colors
|
onClick={() => setModalRecord(row)}
|
||||||
|
className={`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' : ''}`}
|
${highlightId && row.id === highlightId ? 'bg-indigo-50 ring-1 ring-indigo-300' : ''}`}
|
||||||
>
|
>
|
||||||
{meta.columns.map(col => (
|
{meta.columns.map(col => (
|
||||||
@@ -210,6 +217,15 @@ export default function TableView() {
|
|||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{modalRecord && (
|
||||||
|
<RecordModal
|
||||||
|
record={modalRecord}
|
||||||
|
meta={meta}
|
||||||
|
onClose={() => setModalRecord(null)}
|
||||||
|
onSaved={handleRecordSaved}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</Layout>
|
</Layout>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user