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:
2026-05-21 21:53:45 +02:00
parent 74082cd333
commit e6c86a97fc
4 changed files with 408 additions and 4 deletions

View 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>
);
}