- Boolean features now show as visual toggle buttons (AN/AUS) - Desktop: compact toggle (✓ AN / ✗ AUS) - Mobile: full-width toggle (✓ Aktiviert / ✗ Deaktiviert) - Prevents invalid values for boolean features - Green when enabled, gray when disabled Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
500 lines
18 KiB
JavaScript
500 lines
18 KiB
JavaScript
import { useState, useEffect } from 'react'
|
|
import { Save, RotateCcw, ChevronDown, ChevronUp } from 'lucide-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)
|
|
const [isMobile, setIsMobile] = useState(window.innerWidth < 768)
|
|
|
|
useEffect(() => {
|
|
loadMatrix()
|
|
const handleResize = () => setIsMobile(window.innerWidth < 768)
|
|
window.addEventListener('resize', handleResize)
|
|
return () => window.removeEventListener('resize', handleResize)
|
|
}, [])
|
|
|
|
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 }
|
|
|
|
// Allow temporary empty input for better UX
|
|
if (value === '') {
|
|
newChanges[key] = { tierId, featureId, value: '', tempValue: '' }
|
|
setChanges(newChanges)
|
|
return
|
|
}
|
|
|
|
// Parse value
|
|
let parsedValue = null
|
|
if (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, ignore
|
|
}
|
|
}
|
|
|
|
newChanges[key] = { tierId, featureId, value: parsedValue, tempValue: value }
|
|
setChanges(newChanges)
|
|
}
|
|
|
|
async function saveChanges() {
|
|
// Filter out empty temporary values
|
|
const validChanges = Object.values(changes).filter(c => c.value !== '')
|
|
|
|
if (validChanges.length === 0) {
|
|
setSuccess('Keine Änderungen')
|
|
return
|
|
}
|
|
|
|
try {
|
|
setSaving(true)
|
|
setError('')
|
|
setSuccess('')
|
|
|
|
const updates = validChanges.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 temp value for display
|
|
return changes[key].tempValue !== undefined ? changes[key].tempValue : changes[key].value
|
|
}
|
|
return matrix.limits[key] ?? null
|
|
}
|
|
|
|
function formatValue(val) {
|
|
if (val === '' || val === null || val === undefined) return ''
|
|
if (val === '∞' || val === 'unlimited') return '∞'
|
|
if (val === 0 || val === '0') return '0'
|
|
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).filter(k => changes[k].value !== '').length > 0
|
|
const categoryGroups = groupFeaturesByCategory()
|
|
const categoryIcons = { data: '📊', ai: '🤖', export: '📤', integration: '🔗' }
|
|
const categoryNames = { data: 'DATEN', ai: 'KI', export: 'EXPORT', integration: 'INTEGRATIONEN' }
|
|
|
|
// Mobile: Card-based view
|
|
if (isMobile) {
|
|
return (
|
|
<div style={{ paddingBottom: 100 }}>
|
|
{/* Header */}
|
|
<div style={{ marginBottom: 20 }}>
|
|
<div style={{ fontSize: 20, fontWeight: 700, color: 'var(--text1)' }}>
|
|
Tier Limits
|
|
</div>
|
|
<div style={{ fontSize: 13, color: 'var(--text3)', marginTop: 4 }}>
|
|
Limits pro Tier konfigurieren
|
|
</div>
|
|
</div>
|
|
|
|
{/* Messages */}
|
|
{error && (
|
|
<div style={{
|
|
padding: 12, background: 'var(--danger)', color: 'white',
|
|
borderRadius: 8, marginBottom: 16, fontSize: 13
|
|
}}>
|
|
{error}
|
|
</div>
|
|
)}
|
|
{success && (
|
|
<div style={{
|
|
padding: 12, background: 'var(--accent)', color: 'white',
|
|
borderRadius: 8, marginBottom: 16, fontSize: 13
|
|
}}>
|
|
{success}
|
|
</div>
|
|
)}
|
|
|
|
{/* Mobile: Feature Cards */}
|
|
{Object.entries(categoryGroups).map(([category, features]) => (
|
|
<div key={category} style={{ marginBottom: 20 }}>
|
|
{/* Category Header */}
|
|
<div style={{
|
|
padding: '6px 12px', background: 'var(--accent-light)', borderRadius: 8,
|
|
fontWeight: 600, fontSize: 11, textTransform: 'uppercase',
|
|
color: 'var(--accent-dark)', marginBottom: 8
|
|
}}>
|
|
{categoryIcons[category]} {categoryNames[category] || category}
|
|
</div>
|
|
|
|
{/* Features */}
|
|
{features.map(feature => (
|
|
<FeatureMobileCard
|
|
key={feature.id}
|
|
feature={feature}
|
|
tiers={matrix.tiers}
|
|
getCurrentValue={getCurrentValue}
|
|
handleChange={handleChange}
|
|
changes={changes}
|
|
/>
|
|
))}
|
|
</div>
|
|
))}
|
|
|
|
{/* Fixed Bottom Bar */}
|
|
<div style={{
|
|
position: 'fixed', bottom: 0, left: 0, right: 0,
|
|
background: 'var(--bg)', borderTop: '1px solid var(--border)',
|
|
padding: 16, display: 'flex', gap: 8, zIndex: 100,
|
|
boxShadow: '0 -2px 10px rgba(0,0,0,0.1)'
|
|
}}>
|
|
{hasChanges && (
|
|
<button
|
|
className="btn btn-secondary"
|
|
onClick={() => { setChanges({}); setSuccess(''); setError('') }}
|
|
disabled={saving}
|
|
style={{ flex: 1 }}
|
|
>
|
|
<RotateCcw size={14}/> Zurück
|
|
</button>
|
|
)}
|
|
<button
|
|
className="btn btn-primary"
|
|
onClick={saveChanges}
|
|
disabled={saving || !hasChanges}
|
|
style={{ flex: hasChanges ? 2 : 1 }}
|
|
>
|
|
{saving ? '...' : hasChanges ? <><Save size={14}/> {Object.keys(changes).filter(k=>changes[k].value!=='').length} Speichern</> : 'Keine Änderungen'}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
// Desktop: Table view
|
|
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 }}>
|
|
Feature-Limits pro Tier (leer = unbegrenzt, 0 = deaktiviert)
|
|
</div>
|
|
</div>
|
|
<div style={{ display: 'flex', gap: 8 }}>
|
|
{hasChanges && (
|
|
<button
|
|
className="btn btn-secondary"
|
|
onClick={() => { setChanges({}); setSuccess(''); setError('') }}
|
|
disabled={saving}
|
|
>
|
|
<RotateCcw size={14}/> Zurücksetzen
|
|
</button>
|
|
)}
|
|
<button
|
|
className="btn btn-primary"
|
|
onClick={saveChanges}
|
|
disabled={saving || !hasChanges}
|
|
>
|
|
{saving ? 'Speichern...' : hasChanges ? `${Object.keys(changes).filter(k=>changes[k].value!=='').length} Änderungen speichern` : <><Save size={14}/> 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
|
|
}}>
|
|
<div>{tier.name}</div>
|
|
<div style={{ fontSize: 10, fontWeight: 400, color: 'var(--text3)', marginTop: 2 }}>
|
|
{tier.id}
|
|
</div>
|
|
</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' ? '(ja/nein)' : `(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 && changes[`${tier.id}:${feature.id}`].value !== ''
|
|
|
|
// Boolean features: Toggle button
|
|
if (feature.limit_type === 'boolean') {
|
|
const isEnabled = currentValue !== 0 && currentValue !== '0'
|
|
return (
|
|
<td key={`${tier.id}-${feature.id}`} style={{
|
|
textAlign: 'center', padding: 8,
|
|
background: isChanged ? 'var(--accent-light)' : 'transparent'
|
|
}}>
|
|
<button
|
|
onClick={() => handleChange(tier.id, feature.id, isEnabled ? '0' : '1')}
|
|
style={{
|
|
padding: '6px 16px',
|
|
border: `2px solid ${isEnabled ? 'var(--accent)' : 'var(--border)'}`,
|
|
borderRadius: 20,
|
|
background: isEnabled ? 'var(--accent)' : 'var(--surface)',
|
|
color: isEnabled ? 'white' : 'var(--text3)',
|
|
fontSize: 12,
|
|
fontWeight: 600,
|
|
cursor: 'pointer',
|
|
transition: 'all 0.2s',
|
|
minWidth: 70
|
|
}}
|
|
>
|
|
{isEnabled ? '✓ AN' : '✗ AUS'}
|
|
</button>
|
|
</td>
|
|
)
|
|
}
|
|
|
|
// Count features: Text input
|
|
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)}
|
|
placeholder="∞"
|
|
style={{
|
|
width: '80px',
|
|
padding: '6px 8px',
|
|
border: `1.5px solid ${isChanged ? 'var(--accent)' : 'var(--border)'}`,
|
|
borderRadius: 6,
|
|
textAlign: 'center',
|
|
fontSize: 13,
|
|
fontWeight: isChanged ? 600 : 400,
|
|
background: 'var(--bg)',
|
|
color: currentValue === 0 || currentValue === '0' ? 'var(--danger)' :
|
|
currentValue === null || currentValue === '' || currentValue === '∞' ? 'var(--accent)' : 'var(--text1)'
|
|
}}
|
|
/>
|
|
</td>
|
|
)
|
|
})}
|
|
</tr>
|
|
))}
|
|
</>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
|
|
{/* Legend */}
|
|
<div style={{
|
|
marginTop: 16, padding: 12, background: 'var(--surface2)',
|
|
borderRadius: 8, fontSize: 12, color: 'var(--text3)'
|
|
}}>
|
|
<strong>Eingabe:</strong>
|
|
<div style={{ marginTop: 8, display: 'flex', gap: 24, flexWrap: 'wrap' }}>
|
|
<span><strong style={{ color: 'var(--accent)' }}>leer oder ∞</strong> = Unbegrenzt</span>
|
|
<span><strong style={{ color: 'var(--danger)' }}>0</strong> = Deaktiviert</span>
|
|
<span><strong>1-999999</strong> = Limit-Wert</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
// Mobile Card Component
|
|
function FeatureMobileCard({ feature, tiers, getCurrentValue, handleChange, changes }) {
|
|
const [expanded, setExpanded] = useState(false)
|
|
|
|
return (
|
|
<div className="card" style={{ marginBottom: 8, padding: 12 }}>
|
|
{/* Feature Header */}
|
|
<div
|
|
onClick={() => setExpanded(!expanded)}
|
|
style={{
|
|
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
|
|
cursor: 'pointer', padding: '4px 0'
|
|
}}
|
|
>
|
|
<div>
|
|
<div style={{ fontWeight: 600, fontSize: 13 }}>{feature.name}</div>
|
|
<div style={{ fontSize: 11, color: 'var(--text3)' }}>
|
|
{feature.limit_type === 'boolean' ? '(ja/nein)' : `(${feature.reset_period})`}
|
|
</div>
|
|
</div>
|
|
{expanded ? <ChevronUp size={16}/> : <ChevronDown size={16}/>}
|
|
</div>
|
|
|
|
{/* Tier Inputs (Expanded) */}
|
|
{expanded && (
|
|
<div style={{ marginTop: 12, paddingTop: 12, borderTop: '1px solid var(--border)' }}>
|
|
{tiers.map(tier => {
|
|
const currentValue = getCurrentValue(tier.id, feature.id)
|
|
const isChanged = `${tier.id}:${feature.id}` in changes && changes[`${tier.id}:${feature.id}`].value !== ''
|
|
|
|
// Boolean features: Toggle button
|
|
if (feature.limit_type === 'boolean') {
|
|
const isEnabled = currentValue !== 0 && currentValue !== '0'
|
|
return (
|
|
<div key={tier.id} className="form-row" style={{ marginBottom: 8, alignItems: 'center' }}>
|
|
<label className="form-label" style={{ fontSize: 12 }}>{tier.name}</label>
|
|
<button
|
|
onClick={() => handleChange(tier.id, feature.id, isEnabled ? '0' : '1')}
|
|
style={{
|
|
flex: 1,
|
|
padding: '8px 16px',
|
|
border: `2px solid ${isEnabled ? 'var(--accent)' : 'var(--border)'}`,
|
|
borderRadius: 20,
|
|
background: isEnabled ? 'var(--accent)' : 'var(--surface)',
|
|
color: isEnabled ? 'white' : 'var(--text3)',
|
|
fontSize: 13,
|
|
fontWeight: 600,
|
|
cursor: 'pointer',
|
|
transition: 'all 0.2s'
|
|
}}
|
|
>
|
|
{isEnabled ? '✓ Aktiviert' : '✗ Deaktiviert'}
|
|
</button>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
// Count features: Text input
|
|
return (
|
|
<div key={tier.id} className="form-row" style={{ marginBottom: 8 }}>
|
|
<label className="form-label" style={{ fontSize: 12 }}>{tier.name}</label>
|
|
<input
|
|
type="text"
|
|
className="form-input"
|
|
value={currentValue === null || currentValue === undefined ? '' : currentValue.toString()}
|
|
onChange={(e) => handleChange(tier.id, feature.id, e.target.value)}
|
|
placeholder="∞"
|
|
style={{
|
|
border: `1.5px solid ${isChanged ? 'var(--accent)' : 'var(--border)'}`,
|
|
background: isChanged ? 'var(--accent-light)' : 'var(--bg)',
|
|
color: currentValue === 0 ? 'var(--danger)' :
|
|
currentValue === null || currentValue === '' ? 'var(--accent)' : 'var(--text1)',
|
|
fontWeight: isChanged ? 600 : 400
|
|
}}
|
|
/>
|
|
<span className="form-unit" style={{ fontSize: 11 }}>
|
|
{currentValue === null || currentValue === '' ? '∞' : currentValue === 0 ? '❌' : '✓'}
|
|
</span>
|
|
</div>
|
|
)
|
|
})}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|