mitai-jinkendo/frontend/src/pages/AdminTiersPage.jsx
Lars 07a802dff6
All checks were successful
Deploy Development / deploy (push) Successful in 56s
Build Test / lint-backend (push) Successful in 0s
Build Test / build-frontend (push) Successful in 13s
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>
2026-03-20 06:35:13 +01:00

393 lines
13 KiB
JavaScript

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