fix: improve AdminTierLimitsPage UX with responsive design
All checks were successful
Deploy Development / deploy (push) Successful in 57s
Build Test / lint-backend (push) Successful in 0s
Build Test / build-frontend (push) Successful in 13s

- Fix input bug: cells now editable after deletion (temp value tracking)
- Add responsive design: mobile card view, desktop table view
- Mobile: accordion-style FeatureMobileCard with fixed bottom bar
- Desktop: enhanced table with better visual feedback
- Maintains PWA compatibility (no media query conflicts)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Lars 2026-03-20 06:17:52 +01:00
parent 9438b5d617
commit 759d5e5162

View File

@ -1,4 +1,5 @@
import { useState, useEffect } from 'react'
import { Save, RotateCcw, ChevronDown, ChevronUp } from 'lucide-react'
import { api } from '../utils/api'
export default function AdminTierLimitsPage() {
@ -8,9 +9,13 @@ export default function AdminTierLimitsPage() {
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() {
@ -31,9 +36,16 @@ export default function AdminTierLimitsPage() {
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 === '' || value === 'unlimited' || value === '∞') {
if (value === 'unlimited' || value === '∞') {
parsedValue = null // unlimited
} else if (value === '0' || value === 'disabled') {
parsedValue = 0 // disabled
@ -42,16 +54,19 @@ export default function AdminTierLimitsPage() {
if (!isNaN(num) && num >= 0) {
parsedValue = num
} else {
return // invalid input
return // invalid input, ignore
}
}
newChanges[key] = { tierId, featureId, value: parsedValue }
newChanges[key] = { tierId, featureId, value: parsedValue, tempValue: value }
setChanges(newChanges)
}
async function saveChanges() {
if (Object.keys(changes).length === 0) {
// Filter out empty temporary values
const validChanges = Object.values(changes).filter(c => c.value !== '')
if (validChanges.length === 0) {
setSuccess('Keine Änderungen')
return
}
@ -61,7 +76,7 @@ export default function AdminTierLimitsPage() {
setError('')
setSuccess('')
const updates = Object.values(changes).map(c => ({
const updates = validChanges.map(c => ({
tier_id: c.tierId,
feature_id: c.featureId,
limit_value: c.value
@ -80,14 +95,16 @@ export default function AdminTierLimitsPage() {
function getCurrentValue(tierId, featureId) {
const key = `${tierId}:${featureId}`
if (key in changes) {
return changes[key].value
// 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 === null) return '∞'
if (val === 0) return '❌'
if (val === '' || val === null || val === undefined) return ''
if (val === '∞' || val === 'unlimited') return '∞'
if (val === 0 || val === '0') return '0'
return val.toString()
}
@ -106,11 +123,100 @@ export default function AdminTierLimitsPage() {
</div>
)
const hasChanges = Object.keys(changes).length > 0
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 */}
@ -123,7 +229,7 @@ export default function AdminTierLimitsPage() {
Tier Limits Matrix
</div>
<div style={{ fontSize: 13, color: 'var(--text3)', marginTop: 4 }}>
Konfiguriere Feature-Limits pro Tier ( = unbegrenzt, = deaktiviert)
Feature-Limits pro Tier (leer = unbegrenzt, 0 = deaktiviert)
</div>
</div>
<div style={{ display: 'flex', gap: 8 }}>
@ -133,7 +239,7 @@ export default function AdminTierLimitsPage() {
onClick={() => { setChanges({}); setSuccess(''); setError('') }}
disabled={saving}
>
Zurücksetzen
<RotateCcw size={14}/> Zurücksetzen
</button>
)}
<button
@ -141,7 +247,7 @@ export default function AdminTierLimitsPage() {
onClick={saveChanges}
disabled={saving || !hasChanges}
>
{saving ? 'Speichern...' : hasChanges ? `${Object.keys(changes).length} Änderungen speichern` : 'Speichern'}
{saving ? 'Speichern...' : hasChanges ? `${Object.keys(changes).filter(k=>changes[k].value!=='').length} Änderungen speichern` : <><Save size={14}/> Speichern</>}
</button>
</div>
</div>
@ -184,7 +290,10 @@ export default function AdminTierLimitsPage() {
textAlign: 'center', padding: '12px 16px', fontWeight: 600,
minWidth: 100
}}>
{tier.name}
<div>{tier.name}</div>
<div style={{ fontSize: 10, fontWeight: 400, color: 'var(--text3)', marginTop: 2 }}>
{tier.id}
</div>
</th>
))}
</tr>
@ -215,12 +324,12 @@ export default function AdminTierLimitsPage() {
}}>
<div>{feature.name}</div>
<div style={{ fontSize: 11, color: 'var(--text3)', marginTop: 2 }}>
{feature.limit_type === 'boolean' ? '(boolean)' : `(count, reset: ${feature.reset_period})`}
{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
const isChanged = `${tier.id}:${feature.id}` in changes && changes[`${tier.id}:${feature.id}`].value !== ''
return (
<td key={`${tier.id}-${feature.id}`} style={{
@ -231,19 +340,19 @@ export default function AdminTierLimitsPage() {
type="text"
value={formatValue(currentValue)}
onChange={(e) => handleChange(tier.id, feature.id, e.target.value)}
placeholder="∞"
style={{
width: '80px',
padding: '4px 8px',
border: `1px solid ${isChanged ? 'var(--accent)' : 'var(--border)'}`,
borderRadius: 4,
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 ? 'var(--danger)' :
currentValue === null ? 'var(--accent)' : 'var(--text1)'
color: currentValue === 0 || currentValue === '0' ? 'var(--danger)' :
currentValue === null || currentValue === '' || currentValue === '∞' ? 'var(--accent)' : 'var(--text1)'
}}
placeholder={feature.limit_type === 'boolean' ? '0/1' : '0-999'}
/>
</td>
)
@ -261,14 +370,72 @@ export default function AdminTierLimitsPage() {
marginTop: 16, padding: 12, background: 'var(--surface2)',
borderRadius: 8, fontSize: 12, color: 'var(--text3)'
}}>
<strong>Legende:</strong>
<strong>Eingabe:</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>
<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 !== ''
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>
)
}