Files
snakkimo-cmt/src/components/RecordModal.jsx
admin e6c86a97fc 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>
2026-05-21 21:53:45 +02:00

327 lines
12 KiB
JavaScript
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.
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>
);
}