Files
snakkimo-cmt/src/components/RecordModal.jsx
2026-05-21 22:50:53 +02:00

414 lines
15 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, apiFetch, apiLink, apiUnlink } 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"
/>
);
}
function RelationManager({ recordId, rel }) {
const [items, setItems] = useState(undefined); // undefined = loading
const [search, setSearch] = useState('');
const [searchResults, setSearchResults] = useState(null);
const [searching, setSearching] = useState(false);
const [showSearch, setShowSearch] = useState(false);
const [error, setError] = useState('');
// Load linked items
useEffect(() => {
setItems(undefined);
apiFetchOne(rel.endpoint(recordId))
.then(data => setItems(Array.isArray(data) ? data : []))
.catch(() => setItems(null));
}, [recordId, rel]);
// Search available items
useEffect(() => {
if (!showSearch) return;
if (!search.trim()) { setSearchResults([]); return; }
const timer = setTimeout(() => {
setSearching(true);
const q = encodeURIComponent(search.trim());
apiFetch(`${rel.searchEndpoint}?search=${q}&limit=20&offset=0`)
.then(data => {
const arr = Array.isArray(data) ? data : [];
const linked = new Set((items || []).map(i => i.id));
setSearchResults(arr.filter(r => !linked.has(r.id)));
})
.catch(() => setSearchResults([]))
.finally(() => setSearching(false));
}, 300);
return () => clearTimeout(timer);
}, [search, showSearch, items, rel]);
async function handleLink(targetId) {
try {
await apiLink(rel.linkEndpoint(recordId, targetId));
// refresh
const data = await apiFetchOne(rel.endpoint(recordId));
setItems(Array.isArray(data) ? data : []);
setSearch('');
setSearchResults([]);
setShowSearch(false);
} catch (e) { setError(e.message); }
}
async function handleUnlink(targetId) {
try {
await apiUnlink(rel.linkEndpoint(recordId, targetId));
setItems(prev => (prev || []).filter(i => i.id !== targetId));
} catch (e) { setError(e.message); }
}
return (
<section>
<div className="flex items-center justify-between mb-2">
<h3 className="text-xs font-bold text-slate-400 uppercase tracking-widest">{rel.label}</h3>
<button
onClick={() => { setShowSearch(s => !s); setSearch(''); setSearchResults([]); }}
className="text-xs text-indigo-600 hover:text-indigo-800 font-medium flex items-center gap-1"
>
{showSearch ? '✕ Schließen' : '+ Verknüpfen'}
</button>
</div>
{/* Search box */}
{showSearch && (
<div className="mb-2">
<input
autoFocus
type="text"
placeholder={`${rel.label} suchen…`}
value={search}
onChange={e => setSearch(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"
/>
{searching && <p className="text-xs text-slate-400 mt-1">Suche</p>}
{searchResults && searchResults.length === 0 && search && !searching && (
<p className="text-xs text-slate-400 mt-1">Keine Ergebnisse</p>
)}
{searchResults && searchResults.length > 0 && (
<div className="mt-1 border border-slate-200 rounded-lg overflow-hidden max-h-36 overflow-y-auto">
{searchResults.map(r => (
<button
key={r.id}
onClick={() => handleLink(r.id)}
className="w-full text-left px-3 py-1.5 text-sm hover:bg-indigo-50 hover:text-indigo-700 transition-colors border-b border-slate-100 last:border-0"
>
{rel.searchLabel(r)}
</button>
))}
</div>
)}
</div>
)}
{/* Linked items */}
<div className="bg-slate-50 rounded-lg p-3 min-h-[40px]">
{items === 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>
)}
{items === null && <span className="text-xs text-red-500">Fehler beim Laden</span>}
{Array.isArray(items) && items.length === 0 && (
<span className="text-sm text-slate-400">Keine verknüpften Einträge</span>
)}
{Array.isArray(items) && items.length > 0 && (
<div className="flex flex-wrap gap-1.5">
{items.map(item => (
<span key={item.id} className="group flex items-center gap-1 text-xs bg-indigo-50 text-indigo-700 rounded-full pl-2.5 pr-1 py-1 font-medium">
{rel.display(item)}
<button
onClick={() => handleUnlink(item.id)}
className="text-indigo-400 hover:text-red-500 hover:bg-red-50 rounded-full w-4 h-4 flex items-center justify-center transition-colors"
title="Verknüpfung entfernen"
>
×
</button>
</span>
))}
</div>
)}
{error && <p className="text-xs text-red-500 mt-1">{error}</p>}
</div>
</section>
);
}
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('');
// Init values from record
useEffect(() => {
if (!record) return;
setValues({ ...record });
setDirty({});
setSaveError('');
}, [record]);
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 with link/unlink */}
{meta.fetchRelated?.map(rel => (
<RelationManager key={rel.key} recordId={record.id} rel={rel} />
))}
{/* 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>
);
}