feat: add admin pages for Features and Tiers management
AdminFeaturesPage: - Full CRUD for features registry - Add/edit features with all properties - Category, limit type, reset period configuration - Default limits and sorting AdminTiersPage: - Full CRUD for subscription tiers - Pricing configuration (monthly/yearly in cents) - Active/inactive state management - Card-based layout with edit/delete actions Both pages: - Form validation - Success/error messaging - Clean table/card layouts - Integrated in AdminPanel navigation Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
7d6d9dabf2
commit
07a802dff6
|
|
@ -21,6 +21,8 @@ import Analysis from './pages/Analysis'
|
|||
import SettingsPage from './pages/SettingsPage'
|
||||
import GuidePage from './pages/GuidePage'
|
||||
import AdminTierLimitsPage from './pages/AdminTierLimitsPage'
|
||||
import AdminFeaturesPage from './pages/AdminFeaturesPage'
|
||||
import AdminTiersPage from './pages/AdminTiersPage'
|
||||
import './app.css'
|
||||
|
||||
function Nav() {
|
||||
|
|
@ -117,6 +119,8 @@ function AppShell() {
|
|||
<Route path="/settings" element={<SettingsPage/>}/>
|
||||
<Route path="/guide" element={<GuidePage/>}/>
|
||||
<Route path="/admin/tier-limits" element={<AdminTierLimitsPage/>}/>
|
||||
<Route path="/admin/features" element={<AdminFeaturesPage/>}/>
|
||||
<Route path="/admin/tiers" element={<AdminTiersPage/>}/>
|
||||
</Routes>
|
||||
</main>
|
||||
<Nav/>
|
||||
|
|
|
|||
462
frontend/src/pages/AdminFeaturesPage.jsx
Normal file
462
frontend/src/pages/AdminFeaturesPage.jsx
Normal file
|
|
@ -0,0 +1,462 @@
|
|||
import { useState, useEffect } from 'react'
|
||||
import { Save, Plus, Edit2, Trash2, X } 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 [showAddForm, setShowAddForm] = useState(false)
|
||||
const [formData, setFormData] = useState({
|
||||
id: '',
|
||||
name: '',
|
||||
category: 'data',
|
||||
description: '',
|
||||
unit: 'count',
|
||||
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({
|
||||
id: '',
|
||||
name: '',
|
||||
category: 'data',
|
||||
description: '',
|
||||
unit: 'count',
|
||||
limit_type: 'count',
|
||||
default_limit: '',
|
||||
reset_period: 'never',
|
||||
visible_in_admin: true,
|
||||
sort_order: 50,
|
||||
active: true
|
||||
})
|
||||
setEditingId(null)
|
||||
setShowAddForm(false)
|
||||
}
|
||||
|
||||
function startEdit(feature) {
|
||||
setFormData({
|
||||
id: feature.id,
|
||||
name: feature.name,
|
||||
category: feature.category,
|
||||
description: feature.description || '',
|
||||
unit: feature.unit || 'count',
|
||||
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)
|
||||
setShowAddForm(false)
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
try {
|
||||
setError('')
|
||||
setSuccess('')
|
||||
|
||||
// Validation
|
||||
if (!formData.name.trim()) {
|
||||
setError('Name erforderlich')
|
||||
return
|
||||
}
|
||||
|
||||
const payload = {
|
||||
name: formData.name.trim(),
|
||||
category: formData.category,
|
||||
description: formData.description.trim(),
|
||||
unit: formData.unit,
|
||||
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
|
||||
}
|
||||
|
||||
if (editingId) {
|
||||
// Update existing
|
||||
await api.updateFeature(editingId, payload)
|
||||
setSuccess('Feature aktualisiert')
|
||||
} else {
|
||||
// Create new
|
||||
if (!formData.id.trim()) {
|
||||
setError('ID erforderlich')
|
||||
return
|
||||
}
|
||||
payload.id = formData.id.trim()
|
||||
await api.createFeature(payload)
|
||||
setSuccess('Feature erstellt')
|
||||
}
|
||||
|
||||
await loadFeatures()
|
||||
resetForm()
|
||||
} catch (e) {
|
||||
setError(e.message)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDelete(featureId) {
|
||||
if (!confirm('Feature wirklich löschen?')) return
|
||||
try {
|
||||
setError('')
|
||||
await api.deleteFeature(featureId)
|
||||
setSuccess('Feature gelöscht')
|
||||
await loadFeatures()
|
||||
} 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' },
|
||||
{ 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={{
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
|
||||
marginBottom: 20
|
||||
}}>
|
||||
<div>
|
||||
<div style={{ fontSize: 20, fontWeight: 700, color: 'var(--text1)' }}>
|
||||
Feature-Registry
|
||||
</div>
|
||||
<div style={{ fontSize: 13, color: 'var(--text3)', marginTop: 4 }}>
|
||||
Verfügbare Features verwalten
|
||||
</div>
|
||||
</div>
|
||||
{!showAddForm && !editingId && (
|
||||
<button
|
||||
className="btn btn-primary"
|
||||
onClick={() => setShowAddForm(true)}
|
||||
>
|
||||
<Plus size={16} /> Neues Feature
|
||||
</button>
|
||||
)}
|
||||
</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>
|
||||
)}
|
||||
|
||||
{/* Add/Edit Form */}
|
||||
{(showAddForm || 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 }}>
|
||||
{editingId ? 'Feature bearbeiten' : 'Neues Feature erstellen'}
|
||||
</div>
|
||||
<button
|
||||
className="btn btn-secondary"
|
||||
onClick={resetForm}
|
||||
style={{ padding: '6px 12px' }}
|
||||
>
|
||||
<X size={16} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'grid', gap: 12 }}>
|
||||
{/* ID (nur bei Neuanlage) */}
|
||||
{!editingId && (
|
||||
<div className="form-row">
|
||||
<label className="form-label">ID (Slug) *</label>
|
||||
<input
|
||||
className="form-input"
|
||||
value={formData.id}
|
||||
onChange={(e) => setFormData({ ...formData, id: e.target.value })}
|
||||
placeholder="z.B. weight_entries"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Name */}
|
||||
<div className="form-row">
|
||||
<label className="form-label">Name *</label>
|
||||
<input
|
||||
className="form-input"
|
||||
value={formData.name}
|
||||
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
||||
placeholder="z.B. Gewichtseinträge"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Category + Limit Type */}
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12 }}>
|
||||
<div className="form-row">
|
||||
<label className="form-label">Kategorie</label>
|
||||
<select
|
||||
className="form-input"
|
||||
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 className="form-row">
|
||||
<label className="form-label">Typ</label>
|
||||
<select
|
||||
className="form-input"
|
||||
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>
|
||||
|
||||
{/* Description */}
|
||||
<div className="form-row">
|
||||
<label className="form-label">Beschreibung</label>
|
||||
<input
|
||||
className="form-input"
|
||||
value={formData.description}
|
||||
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
|
||||
placeholder="Optionale Beschreibung"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Unit + Reset Period */}
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12 }}>
|
||||
<div className="form-row">
|
||||
<label className="form-label">Einheit</label>
|
||||
<input
|
||||
className="form-input"
|
||||
value={formData.unit}
|
||||
onChange={(e) => setFormData({ ...formData, unit: e.target.value })}
|
||||
placeholder="z.B. count, calls"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-row">
|
||||
<label className="form-label">Reset-Periode</label>
|
||||
<select
|
||||
className="form-input"
|
||||
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>
|
||||
</div>
|
||||
|
||||
{/* Default Limit + Sort Order */}
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12 }}>
|
||||
<div className="form-row">
|
||||
<label className="form-label">Standard-Limit</label>
|
||||
<input
|
||||
className="form-input"
|
||||
type="number"
|
||||
value={formData.default_limit}
|
||||
onChange={(e) => setFormData({ ...formData, default_limit: e.target.value })}
|
||||
placeholder="Leer = unbegrenzt"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-row">
|
||||
<label className="form-label">Sortierung</label>
|
||||
<input
|
||||
className="form-input"
|
||||
type="number"
|
||||
value={formData.sort_order}
|
||||
onChange={(e) => setFormData({ ...formData, sort_order: parseInt(e.target.value) || 50 })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Checkboxes */}
|
||||
<div style={{ display: 'flex', gap: 16 }}>
|
||||
<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 })}
|
||||
/>
|
||||
Aktiv
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div style={{ display: 'flex', gap: 8, marginTop: 8 }}>
|
||||
<button className="btn btn-primary" onClick={handleSave}>
|
||||
<Save size={14} /> {editingId ? 'Aktualisieren' : 'Erstellen'}
|
||||
</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 }}>Name</th>
|
||||
<th style={{ textAlign: 'left', padding: '12px 16px', fontWeight: 600 }}>ID</th>
|
||||
<th style={{ textAlign: 'left', padding: '12px 16px', fontWeight: 600 }}>Kategorie</th>
|
||||
<th style={{ textAlign: 'center', padding: '12px 16px', fontWeight: 600 }}>Typ</th>
|
||||
<th style={{ textAlign: 'center', padding: '12px 16px', fontWeight: 600 }}>Reset</th>
|
||||
<th style={{ textAlign: 'center', padding: '12px 16px', fontWeight: 600 }}>Std-Limit</th>
|
||||
<th style={{ textAlign: 'center', padding: '12px 16px', fontWeight: 600 }}>Aktiv</th>
|
||||
<th style={{ textAlign: 'right', padding: '12px 16px', fontWeight: 600 }}>Aktionen</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{features.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={8} style={{ textAlign: 'center', padding: 40, color: 'var(--text3)' }}>
|
||||
Keine Features vorhanden
|
||||
</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)'
|
||||
}}
|
||||
>
|
||||
<td style={{ padding: '12px 16px', fontWeight: 500 }}>
|
||||
{feature.name}
|
||||
{feature.description && (
|
||||
<div style={{ fontSize: 11, color: 'var(--text3)', marginTop: 2 }}>
|
||||
{feature.description}
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
<td style={{ padding: '12px 16px', fontSize: 11, color: 'var(--text3)', fontFamily: 'monospace' }}>
|
||||
{feature.id}
|
||||
</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', fontSize: 11 }}>
|
||||
{feature.limit_type === 'boolean' ? '✓/✗' : '123'}
|
||||
</td>
|
||||
<td style={{ padding: '12px 16px', textAlign: 'center', fontSize: 11, color: 'var(--text3)' }}>
|
||||
{feature.reset_period}
|
||||
</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)' }}>✓</span>
|
||||
) : (
|
||||
<span style={{ color: 'var(--text3)' }}>✗</span>
|
||||
)}
|
||||
</td>
|
||||
<td style={{ padding: '12px 16px', textAlign: 'right' }}>
|
||||
<div style={{ display: 'flex', gap: 4, justifyContent: 'flex-end' }}>
|
||||
<button
|
||||
className="btn btn-secondary"
|
||||
onClick={() => startEdit(feature)}
|
||||
style={{ padding: '4px 8px', fontSize: 11 }}
|
||||
>
|
||||
<Edit2 size={12} />
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-secondary"
|
||||
onClick={() => handleDelete(feature.id)}
|
||||
style={{ padding: '4px 8px', fontSize: 11, color: 'var(--danger)' }}
|
||||
>
|
||||
<Trash2 size={12} />
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -407,11 +407,23 @@ export default function AdminPanel() {
|
|||
<div style={{fontSize:12,color:'var(--text3)',marginBottom:12,lineHeight:1.5}}>
|
||||
Verwalte Tiers, Features und Limits für das neue Freemium-System.
|
||||
</div>
|
||||
<Link to="/admin/tier-limits">
|
||||
<button className="btn btn-secondary btn-full">
|
||||
📊 Tier Limits Matrix bearbeiten
|
||||
</button>
|
||||
</Link>
|
||||
<div style={{display:'grid',gap:8}}>
|
||||
<Link to="/admin/tiers">
|
||||
<button className="btn btn-secondary btn-full">
|
||||
🎯 Tiers verwalten
|
||||
</button>
|
||||
</Link>
|
||||
<Link to="/admin/features">
|
||||
<button className="btn btn-secondary btn-full">
|
||||
🔧 Feature-Registry verwalten
|
||||
</button>
|
||||
</Link>
|
||||
<Link to="/admin/tier-limits">
|
||||
<button className="btn btn-secondary btn-full">
|
||||
📊 Tier Limits Matrix bearbeiten
|
||||
</button>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
|
|
|||
392
frontend/src/pages/AdminTiersPage.jsx
Normal file
392
frontend/src/pages/AdminTiersPage.jsx
Normal file
|
|
@ -0,0 +1,392 @@
|
|||
import { useState, useEffect } from 'react'
|
||||
import { Save, Plus, Edit2, Trash2, X } from 'lucide-react'
|
||||
import { api } from '../utils/api'
|
||||
|
||||
export default function AdminTiersPage() {
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState('')
|
||||
const [success, setSuccess] = useState('')
|
||||
const [tiers, setTiers] = useState([])
|
||||
const [editingId, setEditingId] = useState(null)
|
||||
const [showAddForm, setShowAddForm] = useState(false)
|
||||
const [formData, setFormData] = useState({
|
||||
id: '',
|
||||
name: '',
|
||||
description: '',
|
||||
price_monthly_cents: '',
|
||||
price_yearly_cents: '',
|
||||
sort_order: 50,
|
||||
active: true
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
loadTiers()
|
||||
}, [])
|
||||
|
||||
async function loadTiers() {
|
||||
try {
|
||||
setLoading(true)
|
||||
const data = await api.listTiers()
|
||||
setTiers(data)
|
||||
setError('')
|
||||
} catch (e) {
|
||||
setError(e.message)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
function resetForm() {
|
||||
setFormData({
|
||||
id: '',
|
||||
name: '',
|
||||
description: '',
|
||||
price_monthly_cents: '',
|
||||
price_yearly_cents: '',
|
||||
sort_order: 50,
|
||||
active: true
|
||||
})
|
||||
setEditingId(null)
|
||||
setShowAddForm(false)
|
||||
}
|
||||
|
||||
function startEdit(tier) {
|
||||
setFormData({
|
||||
id: tier.id,
|
||||
name: tier.name,
|
||||
description: tier.description || '',
|
||||
price_monthly_cents: tier.price_monthly_cents === null ? '' : tier.price_monthly_cents,
|
||||
price_yearly_cents: tier.price_yearly_cents === null ? '' : tier.price_yearly_cents,
|
||||
sort_order: tier.sort_order || 50,
|
||||
active: tier.active
|
||||
})
|
||||
setEditingId(tier.id)
|
||||
setShowAddForm(false)
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
try {
|
||||
setError('')
|
||||
setSuccess('')
|
||||
|
||||
// Validation
|
||||
if (!formData.name.trim()) {
|
||||
setError('Name erforderlich')
|
||||
return
|
||||
}
|
||||
|
||||
const payload = {
|
||||
name: formData.name.trim(),
|
||||
description: formData.description.trim(),
|
||||
price_monthly_cents: formData.price_monthly_cents === '' ? null : parseInt(formData.price_monthly_cents),
|
||||
price_yearly_cents: formData.price_yearly_cents === '' ? null : parseInt(formData.price_yearly_cents),
|
||||
sort_order: formData.sort_order,
|
||||
active: formData.active
|
||||
}
|
||||
|
||||
if (editingId) {
|
||||
// Update existing
|
||||
await api.updateTier(editingId, payload)
|
||||
setSuccess('Tier aktualisiert')
|
||||
} else {
|
||||
// Create new
|
||||
if (!formData.id.trim()) {
|
||||
setError('ID erforderlich')
|
||||
return
|
||||
}
|
||||
payload.id = formData.id.trim()
|
||||
await api.createTier(payload)
|
||||
setSuccess('Tier erstellt')
|
||||
}
|
||||
|
||||
await loadTiers()
|
||||
resetForm()
|
||||
} catch (e) {
|
||||
setError(e.message)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDelete(tierId) {
|
||||
if (!confirm('Tier wirklich deaktivieren?')) return
|
||||
try {
|
||||
setError('')
|
||||
await api.deleteTier(tierId)
|
||||
setSuccess('Tier deaktiviert')
|
||||
await loadTiers()
|
||||
} catch (e) {
|
||||
setError(e.message)
|
||||
}
|
||||
}
|
||||
|
||||
function formatPrice(cents) {
|
||||
if (cents === null || cents === undefined) return 'Kostenlos'
|
||||
return `${(cents / 100).toFixed(2)} €`
|
||||
}
|
||||
|
||||
if (loading) return (
|
||||
<div style={{ display: 'flex', justifyContent: 'center', padding: 40 }}>
|
||||
<div className="spinner" />
|
||||
</div>
|
||||
)
|
||||
|
||||
return (
|
||||
<div style={{ paddingBottom: 80 }}>
|
||||
{/* Header */}
|
||||
<div style={{
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
|
||||
marginBottom: 20
|
||||
}}>
|
||||
<div>
|
||||
<div style={{ fontSize: 20, fontWeight: 700, color: 'var(--text1)' }}>
|
||||
Tier-Verwaltung
|
||||
</div>
|
||||
<div style={{ fontSize: 13, color: 'var(--text3)', marginTop: 4 }}>
|
||||
Subscription-Tiers konfigurieren
|
||||
</div>
|
||||
</div>
|
||||
{!showAddForm && !editingId && (
|
||||
<button
|
||||
className="btn btn-primary"
|
||||
onClick={() => setShowAddForm(true)}
|
||||
>
|
||||
<Plus size={16} /> Neuer Tier
|
||||
</button>
|
||||
)}
|
||||
</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>
|
||||
)}
|
||||
|
||||
{/* Add/Edit Form */}
|
||||
{(showAddForm || 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 }}>
|
||||
{editingId ? 'Tier bearbeiten' : 'Neuen Tier erstellen'}
|
||||
</div>
|
||||
<button
|
||||
className="btn btn-secondary"
|
||||
onClick={resetForm}
|
||||
style={{ padding: '6px 12px' }}
|
||||
>
|
||||
<X size={16} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'grid', gap: 12 }}>
|
||||
{/* ID (nur bei Neuanlage) */}
|
||||
{!editingId && (
|
||||
<div className="form-row">
|
||||
<label className="form-label">ID (Slug) *</label>
|
||||
<input
|
||||
className="form-input"
|
||||
value={formData.id}
|
||||
onChange={(e) => setFormData({ ...formData, id: e.target.value })}
|
||||
placeholder="z.B. enterprise"
|
||||
/>
|
||||
<span className="form-unit" style={{ fontSize: 11, color: 'var(--text3)' }}>
|
||||
Kleinbuchstaben, keine Leerzeichen
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Name */}
|
||||
<div className="form-row">
|
||||
<label className="form-label">Name *</label>
|
||||
<input
|
||||
className="form-input"
|
||||
value={formData.name}
|
||||
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
||||
placeholder="z.B. Enterprise"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
<div className="form-row">
|
||||
<label className="form-label">Beschreibung</label>
|
||||
<input
|
||||
className="form-input"
|
||||
value={formData.description}
|
||||
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
|
||||
placeholder="z.B. Für Teams und Unternehmen"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Pricing */}
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12 }}>
|
||||
<div className="form-row">
|
||||
<label className="form-label">Monatspreis (Cent)</label>
|
||||
<input
|
||||
className="form-input"
|
||||
type="number"
|
||||
value={formData.price_monthly_cents}
|
||||
onChange={(e) => setFormData({ ...formData, price_monthly_cents: e.target.value })}
|
||||
placeholder="Leer = kostenlos"
|
||||
/>
|
||||
<span className="form-unit" style={{ fontSize: 11 }}>
|
||||
{formData.price_monthly_cents ? formatPrice(parseInt(formData.price_monthly_cents)) : '-'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="form-row">
|
||||
<label className="form-label">Jahrespreis (Cent)</label>
|
||||
<input
|
||||
className="form-input"
|
||||
type="number"
|
||||
value={formData.price_yearly_cents}
|
||||
onChange={(e) => setFormData({ ...formData, price_yearly_cents: e.target.value })}
|
||||
placeholder="Leer = kostenlos"
|
||||
/>
|
||||
<span className="form-unit" style={{ fontSize: 11 }}>
|
||||
{formData.price_yearly_cents ? formatPrice(parseInt(formData.price_yearly_cents)) : '-'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Sort Order + Active */}
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr auto', gap: 12, alignItems: 'end' }}>
|
||||
<div className="form-row">
|
||||
<label className="form-label">Sortierung</label>
|
||||
<input
|
||||
className="form-input"
|
||||
type="number"
|
||||
value={formData.sort_order}
|
||||
onChange={(e) => setFormData({ ...formData, sort_order: parseInt(e.target.value) || 50 })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<label style={{ display: 'flex', alignItems: 'center', gap: 6, fontSize: 13, paddingBottom: 8 }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={formData.active}
|
||||
onChange={(e) => setFormData({ ...formData, active: e.target.checked })}
|
||||
/>
|
||||
Aktiv
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div style={{ display: 'flex', gap: 8, marginTop: 8 }}>
|
||||
<button className="btn btn-primary" onClick={handleSave}>
|
||||
<Save size={14} /> {editingId ? 'Aktualisieren' : 'Erstellen'}
|
||||
</button>
|
||||
<button className="btn btn-secondary" onClick={resetForm}>
|
||||
Abbrechen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Tiers List */}
|
||||
<div style={{ display: 'grid', gap: 12 }}>
|
||||
{tiers.length === 0 && (
|
||||
<div className="card" style={{ padding: 40, textAlign: 'center', color: 'var(--text3)' }}>
|
||||
Keine Tiers vorhanden
|
||||
</div>
|
||||
)}
|
||||
{tiers.map(tier => (
|
||||
<div
|
||||
key={tier.id}
|
||||
className="card"
|
||||
style={{
|
||||
padding: 16,
|
||||
opacity: tier.active ? 1 : 0.6,
|
||||
border: tier.active ? '1px solid var(--border)' : '1px dashed var(--border)'
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between' }}>
|
||||
<div style={{ flex: 1 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 4 }}>
|
||||
<div style={{ fontSize: 16, fontWeight: 700, color: 'var(--text1)' }}>
|
||||
{tier.name}
|
||||
</div>
|
||||
<span style={{
|
||||
padding: '2px 8px', borderRadius: 4, fontSize: 10,
|
||||
background: 'var(--surface2)', color: 'var(--text3)', fontFamily: 'monospace'
|
||||
}}>
|
||||
{tier.id}
|
||||
</span>
|
||||
{!tier.active && (
|
||||
<span style={{
|
||||
padding: '2px 8px', borderRadius: 4, fontSize: 10,
|
||||
background: 'var(--danger)', color: 'white', fontWeight: 600
|
||||
}}>
|
||||
INAKTIV
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{tier.description && (
|
||||
<div style={{ fontSize: 13, color: 'var(--text2)', marginBottom: 8 }}>
|
||||
{tier.description}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={{ display: 'flex', gap: 16, fontSize: 12, color: 'var(--text3)' }}>
|
||||
<div>
|
||||
<strong>Monatlich:</strong> {formatPrice(tier.price_monthly_cents)}
|
||||
</div>
|
||||
<div>
|
||||
<strong>Jährlich:</strong> {formatPrice(tier.price_yearly_cents)}
|
||||
</div>
|
||||
<div>
|
||||
<strong>Sortierung:</strong> {tier.sort_order}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', gap: 4 }}>
|
||||
<button
|
||||
className="btn btn-secondary"
|
||||
onClick={() => startEdit(tier)}
|
||||
style={{ padding: '6px 12px', fontSize: 12 }}
|
||||
>
|
||||
<Edit2 size={14} />
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-secondary"
|
||||
onClick={() => handleDelete(tier.id)}
|
||||
style={{ padding: '6px 12px', fontSize: 12, color: 'var(--danger)' }}
|
||||
disabled={!tier.active}
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Info */}
|
||||
<div style={{
|
||||
marginTop: 16, padding: 12, background: 'var(--surface2)',
|
||||
borderRadius: 8, fontSize: 12, color: 'var(--text3)'
|
||||
}}>
|
||||
<strong>Hinweis:</strong> Limits für jeden Tier können in der{' '}
|
||||
<a href="/admin/tier-limits" style={{ color: 'var(--accent)', textDecoration: 'none' }}>
|
||||
Tier Limits Matrix
|
||||
</a>{' '}
|
||||
konfiguriert werden.
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user