Membership-System und Bug Fixing (inkl. Nutrition) #8
|
|
@ -20,6 +20,7 @@ import ActivityPage from './pages/ActivityPage'
|
|||
import Analysis from './pages/Analysis'
|
||||
import SettingsPage from './pages/SettingsPage'
|
||||
import GuidePage from './pages/GuidePage'
|
||||
import AdminTierLimitsPage from './pages/AdminTierLimitsPage'
|
||||
import './app.css'
|
||||
|
||||
function Nav() {
|
||||
|
|
@ -115,6 +116,7 @@ function AppShell() {
|
|||
<Route path="/analysis" element={<Analysis/>}/>
|
||||
<Route path="/settings" element={<SettingsPage/>}/>
|
||||
<Route path="/guide" element={<GuidePage/>}/>
|
||||
<Route path="/admin/tier-limits" element={<AdminTierLimitsPage/>}/>
|
||||
</Routes>
|
||||
</main>
|
||||
<Nav/>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { useState, useEffect } from 'react'
|
||||
import { Plus, Trash2, Pencil, Check, X, Shield, Key } from 'lucide-react'
|
||||
import { Plus, Trash2, Pencil, Check, X, Shield, Key, Settings } from 'lucide-react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { useAuth } from '../context/AuthContext'
|
||||
import { api } from '../utils/api'
|
||||
|
||||
|
|
@ -397,6 +398,21 @@ export default function AdminPanel() {
|
|||
|
||||
{/* Email Settings */}
|
||||
<EmailSettings/>
|
||||
|
||||
{/* v9c Subscription Management */}
|
||||
<div className="card section-gap" style={{marginTop:16}}>
|
||||
<div style={{fontWeight:700,fontSize:14,marginBottom:12,display:'flex',alignItems:'center',gap:6}}>
|
||||
<Settings size={16} color="var(--accent)"/> Subscription-System (v9c)
|
||||
</div>
|
||||
<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>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
274
frontend/src/pages/AdminTierLimitsPage.jsx
Normal file
274
frontend/src/pages/AdminTierLimitsPage.jsx
Normal file
|
|
@ -0,0 +1,274 @@
|
|||
import { useState, useEffect } from 'react'
|
||||
import { api } from '../utils/api'
|
||||
|
||||
export default function AdminTierLimitsPage() {
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState('')
|
||||
const [success, setSuccess] = useState('')
|
||||
const [matrix, setMatrix] = useState({ tiers: [], features: [], limits: {} })
|
||||
const [changes, setChanges] = useState({})
|
||||
const [saving, setSaving] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
loadMatrix()
|
||||
}, [])
|
||||
|
||||
async function loadMatrix() {
|
||||
try {
|
||||
setLoading(true)
|
||||
const data = await api.getTierLimitsMatrix()
|
||||
setMatrix(data)
|
||||
setChanges({})
|
||||
setError('')
|
||||
} catch (e) {
|
||||
setError(e.message)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
function handleChange(tierId, featureId, value) {
|
||||
const key = `${tierId}:${featureId}`
|
||||
const newChanges = { ...changes }
|
||||
|
||||
// Parse value
|
||||
let parsedValue = null
|
||||
if (value === '' || value === 'unlimited' || value === '∞') {
|
||||
parsedValue = null // unlimited
|
||||
} else if (value === '0' || value === 'disabled') {
|
||||
parsedValue = 0 // disabled
|
||||
} else {
|
||||
const num = parseInt(value)
|
||||
if (!isNaN(num) && num >= 0) {
|
||||
parsedValue = num
|
||||
} else {
|
||||
return // invalid input
|
||||
}
|
||||
}
|
||||
|
||||
newChanges[key] = { tierId, featureId, value: parsedValue }
|
||||
setChanges(newChanges)
|
||||
}
|
||||
|
||||
async function saveChanges() {
|
||||
if (Object.keys(changes).length === 0) {
|
||||
setSuccess('Keine Änderungen')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
setSaving(true)
|
||||
setError('')
|
||||
setSuccess('')
|
||||
|
||||
const updates = Object.values(changes).map(c => ({
|
||||
tier_id: c.tierId,
|
||||
feature_id: c.featureId,
|
||||
limit_value: c.value
|
||||
}))
|
||||
|
||||
await api.updateTierLimitsBatch(updates)
|
||||
setSuccess(`${updates.length} Limits gespeichert`)
|
||||
await loadMatrix()
|
||||
} catch (e) {
|
||||
setError(e.message)
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
function getCurrentValue(tierId, featureId) {
|
||||
const key = `${tierId}:${featureId}`
|
||||
if (key in changes) {
|
||||
return changes[key].value
|
||||
}
|
||||
return matrix.limits[key] ?? null
|
||||
}
|
||||
|
||||
function formatValue(val) {
|
||||
if (val === null) return '∞'
|
||||
if (val === 0) return '❌'
|
||||
return val.toString()
|
||||
}
|
||||
|
||||
function groupFeaturesByCategory() {
|
||||
const groups = {}
|
||||
matrix.features.forEach(f => {
|
||||
if (!groups[f.category]) groups[f.category] = []
|
||||
groups[f.category].push(f)
|
||||
})
|
||||
return groups
|
||||
}
|
||||
|
||||
if (loading) return (
|
||||
<div style={{ display: 'flex', justifyContent: 'center', padding: 40 }}>
|
||||
<div className="spinner" />
|
||||
</div>
|
||||
)
|
||||
|
||||
const hasChanges = Object.keys(changes).length > 0
|
||||
const categoryGroups = groupFeaturesByCategory()
|
||||
const categoryIcons = { data: '📊', ai: '🤖', export: '📤', integration: '🔗' }
|
||||
const categoryNames = { data: 'DATEN', ai: 'KI', export: 'EXPORT', integration: 'INTEGRATIONEN' }
|
||||
|
||||
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 Limits Matrix
|
||||
</div>
|
||||
<div style={{ fontSize: 13, color: 'var(--text3)', marginTop: 4 }}>
|
||||
Konfiguriere Feature-Limits pro Tier (∞ = unbegrenzt, ❌ = deaktiviert)
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
{hasChanges && (
|
||||
<button
|
||||
className="btn btn-secondary"
|
||||
onClick={() => { setChanges({}); setSuccess(''); setError('') }}
|
||||
disabled={saving}
|
||||
>
|
||||
Zurücksetzen
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
className="btn btn-primary"
|
||||
onClick={saveChanges}
|
||||
disabled={saving || !hasChanges}
|
||||
>
|
||||
{saving ? 'Speichern...' : hasChanges ? `${Object.keys(changes).length} Änderungen speichern` : 'Speichern'}
|
||||
</button>
|
||||
</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>
|
||||
)}
|
||||
|
||||
{/* Matrix Table */}
|
||||
<div className="card" style={{ padding: 0, overflow: 'auto' }}>
|
||||
<table style={{
|
||||
width: '100%', borderCollapse: 'collapse', fontSize: 13,
|
||||
minWidth: 800
|
||||
}}>
|
||||
<thead>
|
||||
<tr style={{ background: 'var(--surface2)' }}>
|
||||
<th style={{
|
||||
textAlign: 'left', padding: '12px 16px', fontWeight: 600,
|
||||
position: 'sticky', left: 0, background: 'var(--surface2)', zIndex: 10,
|
||||
borderRight: '1px solid var(--border)'
|
||||
}}>
|
||||
Feature
|
||||
</th>
|
||||
{matrix.tiers.map(tier => (
|
||||
<th key={tier.id} style={{
|
||||
textAlign: 'center', padding: '12px 16px', fontWeight: 600,
|
||||
minWidth: 100
|
||||
}}>
|
||||
{tier.name}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{Object.entries(categoryGroups).map(([category, features]) => (
|
||||
<>
|
||||
{/* Category Header */}
|
||||
<tr key={`cat-${category}`} style={{ background: 'var(--accent-light)' }}>
|
||||
<td colSpan={matrix.tiers.length + 1} style={{
|
||||
padding: '8px 16px', fontWeight: 600, fontSize: 11,
|
||||
textTransform: 'uppercase', letterSpacing: '0.5px',
|
||||
color: 'var(--accent-dark)'
|
||||
}}>
|
||||
{categoryIcons[category]} {categoryNames[category] || category}
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
{/* Feature Rows */}
|
||||
{features.map((feature, idx) => (
|
||||
<tr key={feature.id} style={{
|
||||
borderBottom: idx === features.length - 1 ? '2px solid var(--border)' : '1px solid var(--border)'
|
||||
}}>
|
||||
<td style={{
|
||||
padding: '8px 16px', fontWeight: 500,
|
||||
position: 'sticky', left: 0, background: 'var(--bg)', zIndex: 5,
|
||||
borderRight: '1px solid var(--border)'
|
||||
}}>
|
||||
<div>{feature.name}</div>
|
||||
<div style={{ fontSize: 11, color: 'var(--text3)', marginTop: 2 }}>
|
||||
{feature.limit_type === 'boolean' ? '(boolean)' : `(count, reset: ${feature.reset_period})`}
|
||||
</div>
|
||||
</td>
|
||||
{matrix.tiers.map(tier => {
|
||||
const currentValue = getCurrentValue(tier.id, feature.id)
|
||||
const isChanged = `${tier.id}:${feature.id}` in changes
|
||||
|
||||
return (
|
||||
<td key={`${tier.id}-${feature.id}`} style={{
|
||||
textAlign: 'center', padding: 8,
|
||||
background: isChanged ? 'var(--accent-light)' : 'transparent'
|
||||
}}>
|
||||
<input
|
||||
type="text"
|
||||
value={formatValue(currentValue)}
|
||||
onChange={(e) => handleChange(tier.id, feature.id, e.target.value)}
|
||||
style={{
|
||||
width: '80px',
|
||||
padding: '4px 8px',
|
||||
border: `1px solid ${isChanged ? 'var(--accent)' : 'var(--border)'}`,
|
||||
borderRadius: 4,
|
||||
textAlign: 'center',
|
||||
fontSize: 13,
|
||||
fontWeight: isChanged ? 600 : 400,
|
||||
background: 'var(--bg)',
|
||||
color: currentValue === 0 ? 'var(--danger)' :
|
||||
currentValue === null ? 'var(--accent)' : 'var(--text1)'
|
||||
}}
|
||||
placeholder={feature.limit_type === 'boolean' ? '0/1' : '0-999'}
|
||||
/>
|
||||
</td>
|
||||
)
|
||||
})}
|
||||
</tr>
|
||||
))}
|
||||
</>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* Legend */}
|
||||
<div style={{
|
||||
marginTop: 16, padding: 12, background: 'var(--surface2)',
|
||||
borderRadius: 8, fontSize: 12, color: 'var(--text3)'
|
||||
}}>
|
||||
<strong>Legende:</strong>
|
||||
<div style={{ marginTop: 8, display: 'flex', gap: 24, flexWrap: 'wrap' }}>
|
||||
<span><strong style={{ color: 'var(--accent)' }}>∞</strong> = Unbegrenzt (NULL)</span>
|
||||
<span><strong style={{ color: 'var(--danger)' }}>❌</strong> = Deaktiviert (0)</span>
|
||||
<span><strong>1-999</strong> = Limit-Wert</span>
|
||||
<span>Boolean: 0 = deaktiviert, 1 = aktiviert</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -137,4 +137,47 @@ export const api = {
|
|||
adminDeleteProfile: (id) => req(`/admin/profiles/${id}`,{method:'DELETE'}),
|
||||
adminSetPermissions: (id,d) => req(`/admin/profiles/${id}/permissions`,jput(d)),
|
||||
changePin: (pin) => req('/auth/pin',json({pin})),
|
||||
|
||||
// v9c Subscription System
|
||||
// User-facing
|
||||
getMySubscription: () => req('/subscription/me'),
|
||||
getMyUsage: () => req('/subscription/usage'),
|
||||
getMyLimits: () => req('/subscription/limits'),
|
||||
redeemCoupon: (code) => req('/coupons/redeem',json({code})),
|
||||
|
||||
// Admin: Features
|
||||
listFeatures: () => req('/features'),
|
||||
createFeature: (d) => req('/features',json(d)),
|
||||
updateFeature: (id,d) => req(`/features/${id}`,jput(d)),
|
||||
deleteFeature: (id) => req(`/features/${id}`,{method:'DELETE'}),
|
||||
|
||||
// Admin: Tiers
|
||||
listTiers: () => req('/tiers'),
|
||||
createTier: (d) => req('/tiers',json(d)),
|
||||
updateTier: (id,d) => req(`/tiers/${id}`,jput(d)),
|
||||
deleteTier: (id) => req(`/tiers/${id}`,{method:'DELETE'}),
|
||||
|
||||
// Admin: Tier Limits (Matrix)
|
||||
getTierLimitsMatrix: () => req('/tier-limits'),
|
||||
updateTierLimit: (d) => req('/tier-limits',jput(d)),
|
||||
updateTierLimitsBatch:(updates) => req('/tier-limits/batch',jput({updates})),
|
||||
|
||||
// Admin: User Restrictions
|
||||
listUserRestrictions: (pid) => req(`/user-restrictions${pid?'?profile_id='+pid:''}`),
|
||||
createUserRestriction:(d) => req('/user-restrictions',json(d)),
|
||||
updateUserRestriction:(id,d) => req(`/user-restrictions/${id}`,jput(d)),
|
||||
deleteUserRestriction:(id) => req(`/user-restrictions/${id}`,{method:'DELETE'}),
|
||||
|
||||
// Admin: Coupons
|
||||
listCoupons: () => req('/coupons'),
|
||||
createCoupon: (d) => req('/coupons',json(d)),
|
||||
updateCoupon: (id,d) => req(`/coupons/${id}`,jput(d)),
|
||||
deleteCoupon: (id) => req(`/coupons/${id}`,{method:'DELETE'}),
|
||||
getCouponRedemptions: (id) => req(`/coupons/${id}/redemptions`),
|
||||
|
||||
// Admin: Access Grants
|
||||
listAccessGrants: (pid,active)=> req(`/access-grants${pid?'?profile_id='+pid:''}${active?'&active_only=true':''}`),
|
||||
createAccessGrant: (d) => req('/access-grants',json(d)),
|
||||
updateAccessGrant: (id,d) => req(`/access-grants/${id}`,jput(d)),
|
||||
revokeAccessGrant: (id) => req(`/access-grants/${id}`,{method:'DELETE'}),
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user