Layout improvements: - Labels now above inputs (not beside) - Inputs use full width for better readability - Better spacing and visual hierarchy Field changes: - Removed "Einheit" field (unused, confusing) - "Sortierung" renamed to "Anzeigereihenfolge" with help text - Added help text under inputs for clarity Conditional rendering: - Boolean features: hide Reset-Periode and Standard-Limit - Show info box explaining Boolean features - Count features: show all relevant fields Better UX: - Clear explanations what each field does - Visual feedback for different limit types - Cleaner, more focused interface Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
481 lines
17 KiB
JavaScript
481 lines
17 KiB
JavaScript
import { useState, useEffect } from 'react'
|
|
import { Save, Edit2, X, Info } from 'lucide-react'
|
|
import { api } from '../utils/api'
|
|
|
|
export default function AdminFeaturesPage() {
|
|
const [loading, setLoading] = useState(true)
|
|
const [error, setError] = useState('')
|
|
const [success, setSuccess] = useState('')
|
|
const [features, setFeatures] = useState([])
|
|
const [editingId, setEditingId] = useState(null)
|
|
const [formData, setFormData] = useState({
|
|
name: '',
|
|
category: 'data',
|
|
description: '',
|
|
limit_type: 'count',
|
|
default_limit: '',
|
|
reset_period: 'never',
|
|
visible_in_admin: true,
|
|
sort_order: 50,
|
|
active: true
|
|
})
|
|
|
|
useEffect(() => {
|
|
loadFeatures()
|
|
}, [])
|
|
|
|
async function loadFeatures() {
|
|
try {
|
|
setLoading(true)
|
|
const data = await api.listFeatures()
|
|
setFeatures(data)
|
|
setError('')
|
|
} catch (e) {
|
|
setError(e.message)
|
|
} finally {
|
|
setLoading(false)
|
|
}
|
|
}
|
|
|
|
function resetForm() {
|
|
setFormData({
|
|
name: '',
|
|
category: 'data',
|
|
description: '',
|
|
limit_type: 'count',
|
|
default_limit: '',
|
|
reset_period: 'never',
|
|
visible_in_admin: true,
|
|
sort_order: 50,
|
|
active: true
|
|
})
|
|
setEditingId(null)
|
|
}
|
|
|
|
function startEdit(feature) {
|
|
setFormData({
|
|
name: feature.name,
|
|
category: feature.category,
|
|
description: feature.description || '',
|
|
limit_type: feature.limit_type,
|
|
default_limit: feature.default_limit === null ? '' : feature.default_limit,
|
|
reset_period: feature.reset_period,
|
|
visible_in_admin: feature.visible_in_admin,
|
|
sort_order: feature.sort_order || 50,
|
|
active: feature.active
|
|
})
|
|
setEditingId(feature.id)
|
|
}
|
|
|
|
async function handleSave() {
|
|
try {
|
|
setError('')
|
|
setSuccess('')
|
|
|
|
if (!formData.name.trim()) {
|
|
setError('Name erforderlich')
|
|
return
|
|
}
|
|
|
|
const payload = {
|
|
name: formData.name.trim(),
|
|
category: formData.category,
|
|
description: formData.description.trim(),
|
|
limit_type: formData.limit_type,
|
|
default_limit: formData.default_limit === '' ? null : parseInt(formData.default_limit),
|
|
reset_period: formData.reset_period,
|
|
visible_in_admin: formData.visible_in_admin,
|
|
sort_order: formData.sort_order,
|
|
active: formData.active
|
|
}
|
|
|
|
await api.updateFeature(editingId, payload)
|
|
setSuccess('Feature aktualisiert')
|
|
await loadFeatures()
|
|
resetForm()
|
|
} catch (e) {
|
|
setError(e.message)
|
|
}
|
|
}
|
|
|
|
if (loading) return (
|
|
<div style={{ display: 'flex', justifyContent: 'center', padding: 40 }}>
|
|
<div className="spinner" />
|
|
</div>
|
|
)
|
|
|
|
const categoryOptions = [
|
|
{ value: 'data', label: 'Daten' },
|
|
{ value: 'ai', label: 'KI' },
|
|
{ value: 'export', label: 'Export' },
|
|
{ value: 'integration', label: 'Integrationen' }
|
|
]
|
|
|
|
const resetPeriodOptions = [
|
|
{ value: 'never', label: 'Nie (akkumuliert)' },
|
|
{ value: 'daily', label: 'Täglich' },
|
|
{ value: 'monthly', label: 'Monatlich' }
|
|
]
|
|
|
|
const limitTypeOptions = [
|
|
{ value: 'count', label: 'Anzahl (Count)' },
|
|
{ value: 'boolean', label: 'Ja/Nein (Boolean)' }
|
|
]
|
|
|
|
return (
|
|
<div style={{ paddingBottom: 80 }}>
|
|
{/* Header */}
|
|
<div style={{ marginBottom: 20 }}>
|
|
<div style={{ fontSize: 20, fontWeight: 700, color: 'var(--text1)' }}>
|
|
Feature-Konfiguration
|
|
</div>
|
|
<div style={{ fontSize: 13, color: 'var(--text3)', marginTop: 4 }}>
|
|
Limitierungs-Einstellungen für registrierte Features
|
|
</div>
|
|
</div>
|
|
|
|
{/* Info Box */}
|
|
<div style={{
|
|
padding: 12, background: 'var(--accent-light)', borderRadius: 8,
|
|
marginBottom: 16, fontSize: 12, color: 'var(--accent-dark)',
|
|
display: 'flex', gap: 8, alignItems: 'flex-start'
|
|
}}>
|
|
<Info size={16} style={{ marginTop: 2, flexShrink: 0 }} />
|
|
<div>
|
|
<strong>Hinweis:</strong> Features werden automatisch via Code registriert.
|
|
Hier können nur Basis-Einstellungen (Limit-Typ, Reset-Periode, Standards) angepasst werden.
|
|
Neue Features hinzuzufügen erfordert Code-Änderungen im Backend.
|
|
</div>
|
|
</div>
|
|
|
|
{/* Messages */}
|
|
{error && (
|
|
<div style={{
|
|
padding: 12, background: 'var(--danger)', color: 'white',
|
|
borderRadius: 8, marginBottom: 16, fontSize: 14
|
|
}}>
|
|
{error}
|
|
</div>
|
|
)}
|
|
{success && (
|
|
<div style={{
|
|
padding: 12, background: 'var(--accent)', color: 'white',
|
|
borderRadius: 8, marginBottom: 16, fontSize: 14
|
|
}}>
|
|
{success}
|
|
</div>
|
|
)}
|
|
|
|
{/* Edit Form */}
|
|
{editingId && (
|
|
<div className="card" style={{ padding: 20, marginBottom: 20 }}>
|
|
<div style={{
|
|
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
|
|
marginBottom: 16
|
|
}}>
|
|
<div style={{ fontSize: 16, fontWeight: 600 }}>
|
|
Feature konfigurieren
|
|
</div>
|
|
<button
|
|
className="btn btn-secondary"
|
|
onClick={resetForm}
|
|
style={{ padding: '6px 12px' }}
|
|
>
|
|
<X size={16} />
|
|
</button>
|
|
</div>
|
|
|
|
<div style={{ display: 'grid', gap: 16 }}>
|
|
{/* Feature ID (read-only) */}
|
|
<div>
|
|
<label className="form-label" style={{ display: 'block', marginBottom: 6 }}>
|
|
Feature ID
|
|
</label>
|
|
<input
|
|
className="form-input"
|
|
value={editingId}
|
|
disabled
|
|
style={{
|
|
width: '100%',
|
|
background: 'var(--surface2)',
|
|
color: 'var(--text3)',
|
|
cursor: 'not-allowed',
|
|
fontFamily: 'monospace'
|
|
}}
|
|
/>
|
|
</div>
|
|
|
|
{/* Name */}
|
|
<div>
|
|
<label className="form-label" style={{ display: 'block', marginBottom: 6 }}>
|
|
Name *
|
|
</label>
|
|
<input
|
|
className="form-input"
|
|
style={{ width: '100%' }}
|
|
value={formData.name}
|
|
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
|
placeholder="z.B. Gewichtseinträge"
|
|
/>
|
|
</div>
|
|
|
|
{/* Description */}
|
|
<div>
|
|
<label className="form-label" style={{ display: 'block', marginBottom: 6 }}>
|
|
Beschreibung (optional)
|
|
</label>
|
|
<input
|
|
className="form-input"
|
|
style={{ width: '100%' }}
|
|
value={formData.description}
|
|
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
|
|
placeholder="Kurze Erklärung was dieses Feature limitiert"
|
|
/>
|
|
</div>
|
|
|
|
{/* Category + Limit Type */}
|
|
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12 }}>
|
|
<div>
|
|
<label className="form-label" style={{ display: 'block', marginBottom: 6 }}>
|
|
Kategorie
|
|
</label>
|
|
<select
|
|
className="form-input"
|
|
style={{ width: '100%' }}
|
|
value={formData.category}
|
|
onChange={(e) => setFormData({ ...formData, category: e.target.value })}
|
|
>
|
|
{categoryOptions.map(o => (
|
|
<option key={o.value} value={o.value}>{o.label}</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
|
|
<div>
|
|
<label className="form-label" style={{ display: 'block', marginBottom: 6 }}>
|
|
Limit-Typ
|
|
</label>
|
|
<select
|
|
className="form-input"
|
|
style={{ width: '100%' }}
|
|
value={formData.limit_type}
|
|
onChange={(e) => setFormData({ ...formData, limit_type: e.target.value })}
|
|
>
|
|
{limitTypeOptions.map(o => (
|
|
<option key={o.value} value={o.value}>{o.label}</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Count-specific fields (only for limit_type='count') */}
|
|
{formData.limit_type === 'count' && (
|
|
<>
|
|
{/* Reset Period */}
|
|
<div>
|
|
<label className="form-label" style={{ display: 'block', marginBottom: 6 }}>
|
|
Reset-Periode
|
|
</label>
|
|
<select
|
|
className="form-input"
|
|
style={{ width: '100%' }}
|
|
value={formData.reset_period}
|
|
onChange={(e) => setFormData({ ...formData, reset_period: e.target.value })}
|
|
>
|
|
{resetPeriodOptions.map(o => (
|
|
<option key={o.value} value={o.value}>{o.label}</option>
|
|
))}
|
|
</select>
|
|
<div style={{ fontSize: 11, color: 'var(--text3)', marginTop: 4 }}>
|
|
Wann wird der Nutzungszähler zurückgesetzt?
|
|
</div>
|
|
</div>
|
|
|
|
{/* Default Limit */}
|
|
<div>
|
|
<label className="form-label" style={{ display: 'block', marginBottom: 6 }}>
|
|
Standard-Limit
|
|
</label>
|
|
<input
|
|
className="form-input"
|
|
style={{ width: '100%' }}
|
|
type="number"
|
|
value={formData.default_limit}
|
|
onChange={(e) => setFormData({ ...formData, default_limit: e.target.value })}
|
|
placeholder="Leer = unbegrenzt"
|
|
/>
|
|
<div style={{ fontSize: 11, color: 'var(--text3)', marginTop: 4 }}>
|
|
Fallback-Wert wenn kein Tier-spezifisches Limit gesetzt ist
|
|
</div>
|
|
</div>
|
|
</>
|
|
)}
|
|
|
|
{/* Boolean info */}
|
|
{formData.limit_type === 'boolean' && (
|
|
<div style={{
|
|
padding: 12, background: 'var(--accent-light)', borderRadius: 8,
|
|
fontSize: 12, color: 'var(--accent-dark)'
|
|
}}>
|
|
<strong>Boolean-Feature:</strong> Ist entweder verfügbar (AN) oder nicht verfügbar (AUS).
|
|
Keine Zähler oder Reset-Perioden notwendig.
|
|
</div>
|
|
)}
|
|
|
|
{/* Sort Order */}
|
|
<div>
|
|
<label className="form-label" style={{ display: 'block', marginBottom: 6 }}>
|
|
Anzeigereihenfolge
|
|
</label>
|
|
<input
|
|
className="form-input"
|
|
style={{ width: '100%' }}
|
|
type="number"
|
|
value={formData.sort_order}
|
|
onChange={(e) => setFormData({ ...formData, sort_order: parseInt(e.target.value) || 50 })}
|
|
/>
|
|
<div style={{ fontSize: 11, color: 'var(--text3)', marginTop: 4 }}>
|
|
Niedrigere Werte erscheinen weiter oben in Listen (Standard: 50)
|
|
</div>
|
|
</div>
|
|
|
|
{/* Checkboxes */}
|
|
<div style={{ display: 'flex', gap: 16, paddingTop: 8 }}>
|
|
<label style={{ display: 'flex', alignItems: 'center', gap: 6, fontSize: 13 }}>
|
|
<input
|
|
type="checkbox"
|
|
checked={formData.visible_in_admin}
|
|
onChange={(e) => setFormData({ ...formData, visible_in_admin: e.target.checked })}
|
|
/>
|
|
Im Admin sichtbar
|
|
</label>
|
|
<label style={{ display: 'flex', alignItems: 'center', gap: 6, fontSize: 13 }}>
|
|
<input
|
|
type="checkbox"
|
|
checked={formData.active}
|
|
onChange={(e) => setFormData({ ...formData, active: e.target.checked })}
|
|
/>
|
|
Feature aktiviert
|
|
</label>
|
|
</div>
|
|
|
|
{/* Actions */}
|
|
<div style={{ display: 'flex', gap: 8, marginTop: 8 }}>
|
|
<button className="btn btn-primary" onClick={handleSave}>
|
|
<Save size={14} /> Speichern
|
|
</button>
|
|
<button className="btn btn-secondary" onClick={resetForm}>
|
|
Abbrechen
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Features List */}
|
|
<div className="card" style={{ padding: 0, overflow: 'auto' }}>
|
|
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 13 }}>
|
|
<thead>
|
|
<tr style={{ background: 'var(--surface2)' }}>
|
|
<th style={{ textAlign: 'left', padding: '12px 16px', fontWeight: 600 }}>Feature</th>
|
|
<th style={{ textAlign: 'left', padding: '12px 16px', fontWeight: 600 }}>Kategorie</th>
|
|
<th style={{ textAlign: 'center', padding: '12px 16px', fontWeight: 600 }}>Limit-Typ</th>
|
|
<th style={{ textAlign: 'center', padding: '12px 16px', fontWeight: 600 }}>Reset</th>
|
|
<th style={{ textAlign: 'center', padding: '12px 16px', fontWeight: 600 }}>Standard</th>
|
|
<th style={{ textAlign: 'center', padding: '12px 16px', fontWeight: 600 }}>Status</th>
|
|
<th style={{ textAlign: 'right', padding: '12px 16px', fontWeight: 600 }}>Aktion</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{features.length === 0 && (
|
|
<tr>
|
|
<td colSpan={7} style={{ textAlign: 'center', padding: 40, color: 'var(--text3)' }}>
|
|
Keine Features registriert
|
|
</td>
|
|
</tr>
|
|
)}
|
|
{features.map((feature, idx) => (
|
|
<tr
|
|
key={feature.id}
|
|
style={{
|
|
borderBottom: idx === features.length - 1 ? 'none' : '1px solid var(--border)',
|
|
background: feature.active ? 'transparent' : 'var(--surface)',
|
|
opacity: feature.active ? 1 : 0.6
|
|
}}
|
|
>
|
|
<td style={{ padding: '12px 16px' }}>
|
|
<div style={{ fontWeight: 500 }}>{feature.name}</div>
|
|
<div style={{ fontSize: 11, color: 'var(--text3)', marginTop: 2, fontFamily: 'monospace' }}>
|
|
{feature.id}
|
|
</div>
|
|
{feature.description && (
|
|
<div style={{ fontSize: 11, color: 'var(--text3)', marginTop: 2 }}>
|
|
{feature.description}
|
|
</div>
|
|
)}
|
|
</td>
|
|
<td style={{ padding: '12px 16px' }}>
|
|
<span style={{
|
|
padding: '2px 8px', borderRadius: 4, fontSize: 11,
|
|
background: 'var(--accent-light)', color: 'var(--accent-dark)', fontWeight: 600
|
|
}}>
|
|
{feature.category}
|
|
</span>
|
|
</td>
|
|
<td style={{ padding: '12px 16px', textAlign: 'center' }}>
|
|
<span style={{
|
|
padding: '2px 8px', borderRadius: 4, fontSize: 11,
|
|
background: feature.limit_type === 'boolean' ? 'var(--surface2)' : 'var(--surface2)',
|
|
fontWeight: 500
|
|
}}>
|
|
{feature.limit_type === 'boolean' ? '✓/✗' : '123'}
|
|
</span>
|
|
</td>
|
|
<td style={{ padding: '12px 16px', textAlign: 'center', fontSize: 11, color: 'var(--text3)' }}>
|
|
{feature.reset_period === 'never' ? '∞' : feature.reset_period === 'daily' ? '1d' : '1m'}
|
|
</td>
|
|
<td style={{ padding: '12px 16px', textAlign: 'center', fontWeight: 500 }}>
|
|
{feature.default_limit === null ? '∞' : feature.default_limit}
|
|
</td>
|
|
<td style={{ padding: '12px 16px', textAlign: 'center' }}>
|
|
{feature.active ? (
|
|
<span style={{ color: 'var(--accent)', fontWeight: 600 }}>✓ Aktiv</span>
|
|
) : (
|
|
<span style={{ color: 'var(--text3)' }}>✗ Inaktiv</span>
|
|
)}
|
|
</td>
|
|
<td style={{ padding: '12px 16px', textAlign: 'right' }}>
|
|
<button
|
|
className="btn btn-secondary"
|
|
onClick={() => startEdit(feature)}
|
|
style={{ padding: '6px 12px', fontSize: 12 }}
|
|
>
|
|
<Edit2 size={14} /> Konfigurieren
|
|
</button>
|
|
</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
|
|
{/* Legend */}
|
|
<div style={{
|
|
marginTop: 16, padding: 12, background: 'var(--surface2)',
|
|
borderRadius: 8, fontSize: 12, color: 'var(--text3)'
|
|
}}>
|
|
<strong>Limit-Typ:</strong>
|
|
<div style={{ marginTop: 4 }}>
|
|
<strong>Boolean (✓/✗):</strong> Feature ist entweder verfügbar oder nicht (z.B. "KI aktiviert")
|
|
</div>
|
|
<div style={{ marginTop: 2 }}>
|
|
<strong>Count (123):</strong> Feature hat ein Nutzungs-Limit (z.B. "max. 50 Einträge")
|
|
</div>
|
|
<div style={{ marginTop: 8 }}>
|
|
<strong>Reset-Periode:</strong> ∞ = nie, 1d = täglich, 1m = monatlich
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|