fix: complete rewrite of AdminUserRestrictionsPage
All checks were successful
Deploy Development / deploy (push) Successful in 58s
Build Test / lint-backend (push) Successful in 0s
Build Test / build-frontend (push) Successful in 13s

Fixed all reported bugs:
1. Initial values now correct (empty = no override, not defaults)
2. Save/Reset buttons always visible (fixed bottom bar)
3. Toggle buttons work correctly (can be toggled multiple times)
4. Simplified table columns (removed confusing Tier-Limit/Aktiv/Aktion)

New logic:
- Empty input = no override (user uses tier standard)
- Value entered = override set
- Change tracking with 3 actions: set, remove, toggle
- Clear status display: "Override aktiv" vs "Tier-Standard"

Simplified table structure:
- Feature (name + type)
- Override-Wert (input/toggle)
- Status (has override yes/no)

Better UX:
- Placeholder text explains empty = tier standard
- Status badge shows if override is active
- Fixed bottom bar always present
- Buttons disabled only when no changes
- Legend explains all input options

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Lars 2026-03-20 08:08:02 +01:00
parent 72d8dd8df7
commit 365fe3d068

View File

@ -1,5 +1,5 @@
import { useState, useEffect } from 'react' import { useState, useEffect } from 'react'
import { Save, User, AlertCircle, X } from 'lucide-react' import { Save, AlertCircle, X, RotateCcw } from 'lucide-react'
import { api } from '../utils/api' import { api } from '../utils/api'
export default function AdminUserRestrictionsPage() { export default function AdminUserRestrictionsPage() {
@ -21,6 +21,10 @@ export default function AdminUserRestrictionsPage() {
useEffect(() => { useEffect(() => {
if (selectedUserId) { if (selectedUserId) {
loadUserData(selectedUserId) loadUserData(selectedUserId)
} else {
setSelectedUser(null)
setRestrictions([])
setChanges({})
} }
}, [selectedUserId]) }, [selectedUserId])
@ -51,40 +55,43 @@ export default function AdminUserRestrictionsPage() {
setRestrictions(restrictionsData) setRestrictions(restrictionsData)
setChanges({}) setChanges({})
setError('') setError('')
setSuccess('')
} catch (e) { } catch (e) {
setError(e.message) setError(e.message)
} }
} }
function handleChange(featureId, value, enabled) { function handleChange(featureId, value) {
const key = featureId
const newChanges = { ...changes } const newChanges = { ...changes }
// Parse value // Empty string means: remove override (if exists) or do nothing
let parsedValue = null if (value === '') {
if (value === '' || value === 'unlimited' || value === '∞') { newChanges[featureId] = { action: 'remove' }
parsedValue = null // unlimited
} else if (value === '0' || value === 'disabled') {
parsedValue = 0 // disabled
} else { } else {
const num = parseInt(value) // Parse value
if (!isNaN(num) && num >= 0) { let parsedValue = null
parsedValue = num if (value === 'unlimited' || value === '∞') {
parsedValue = null
} else if (value === '0') {
parsedValue = 0
} else { } else {
return // invalid input const num = parseInt(value)
if (!isNaN(num) && num >= 0) {
parsedValue = num
} else {
return // invalid
}
} }
newChanges[featureId] = { action: 'set', value: parsedValue }
} }
newChanges[key] = { featureId, value: parsedValue, enabled }
setChanges(newChanges) setChanges(newChanges)
} }
function toggleEnabled(featureId) { function handleToggle(featureId, currentEnabled) {
const restriction = restrictions.find(r => r.feature_id === featureId) const newChanges = { ...changes }
const currentEnabled = restriction ? restriction.enabled : true newChanges[featureId] = { action: 'toggle', enabled: !currentEnabled }
const currentValue = restriction ? restriction.limit_value : null setChanges(newChanges)
handleChange(featureId, currentValue === null ? '' : currentValue, !currentEnabled)
} }
async function handleSave() { async function handleSave() {
@ -95,29 +102,56 @@ export default function AdminUserRestrictionsPage() {
setError('') setError('')
setSuccess('') setSuccess('')
// Process changes let changeCount = 0
for (const [featureId, change] of Object.entries(changes)) { for (const [featureId, change] of Object.entries(changes)) {
const existingRestriction = restrictions.find(r => r.feature_id === featureId) const existingRestriction = restrictions.find(r => r.feature_id === featureId)
if (existingRestriction) { if (change.action === 'remove') {
// Update existing restriction // Remove restriction if exists
await api.updateUserRestriction(existingRestriction.id, { if (existingRestriction) {
limit_value: change.value, await api.deleteUserRestriction(existingRestriction.id)
enabled: change.enabled changeCount++
}) }
} else { } else if (change.action === 'set') {
// Create new restriction // Create or update
await api.createUserRestriction({ if (existingRestriction) {
profile_id: selectedUserId, await api.updateUserRestriction(existingRestriction.id, {
feature_id: featureId, limit_value: change.value,
limit_value: change.value, enabled: true
enabled: change.enabled, })
reason: 'Admin override' } else {
}) await api.createUserRestriction({
profile_id: selectedUserId,
feature_id: featureId,
limit_value: change.value,
enabled: true,
reason: 'Admin override'
})
}
changeCount++
} else if (change.action === 'toggle') {
// Toggle enabled state
if (existingRestriction) {
await api.updateUserRestriction(existingRestriction.id, {
enabled: change.enabled
})
changeCount++
} else {
// Create new restriction with toggle state
await api.createUserRestriction({
profile_id: selectedUserId,
feature_id: featureId,
limit_value: change.enabled ? 1 : 0,
enabled: true,
reason: 'Admin override'
})
changeCount++
}
} }
} }
setSuccess(`${Object.keys(changes).length} Overrides gespeichert`) setSuccess(`${changeCount} Änderung(en) gespeichert`)
await loadUserData(selectedUserId) await loadUserData(selectedUserId)
} catch (e) { } catch (e) {
setError(e.message) setError(e.message)
@ -126,52 +160,41 @@ export default function AdminUserRestrictionsPage() {
} }
} }
async function handleRemoveRestriction(featureId) { function getDisplayValue(featureId) {
if (!confirm('Override wirklich entfernen? User erhält dann das Standard-Tier-Limit.')) return // Check pending changes first
if (featureId in changes) {
try { const change = changes[featureId]
const restriction = restrictions.find(r => r.feature_id === featureId) if (change.action === 'remove') return ''
if (restriction) { if (change.action === 'set') return change.value === null ? '' : change.value
await api.deleteUserRestriction(restriction.id) if (change.action === 'toggle') return change.enabled ? 1 : 0
setSuccess('Override entfernt')
await loadUserData(selectedUserId)
// Remove from changes if exists
const newChanges = { ...changes }
delete newChanges[featureId]
setChanges(newChanges)
}
} catch (e) {
setError(e.message)
} }
// Show existing restriction value (or empty if no restriction)
const restriction = restrictions.find(r => r.feature_id === featureId)
if (!restriction) return '' // No override = empty input
return restriction.limit_value === null ? '' : restriction.limit_value
} }
function getCurrentValue(featureId) { function getToggleState(featureId) {
// Check if there's a pending change // Check pending changes first
if (featureId in changes) { if (featureId in changes && changes[featureId].action === 'toggle') {
return changes[featureId].value return changes[featureId].enabled
} }
// Check existing restriction // Check existing restriction
const restriction = restrictions.find(r => r.feature_id === featureId) const restriction = restrictions.find(r => r.feature_id === featureId)
return restriction ? restriction.limit_value : null if (!restriction) return true // Default: enabled
// For boolean features: limit_value determines state
return restriction.limit_value !== 0
} }
function isEnabled(featureId) { function hasOverride(featureId) {
if (featureId in changes) {
return changes[featureId].enabled
}
const restriction = restrictions.find(r => r.feature_id === featureId)
return restriction ? restriction.enabled : true
}
function hasRestriction(featureId) {
return restrictions.some(r => r.feature_id === featureId) return restrictions.some(r => r.feature_id === featureId)
} }
function formatValue(val) { function isChanged(featureId) {
if (val === null || val === undefined) return '' return featureId in changes
return val.toString()
} }
if (loading) return ( if (loading) return (
@ -210,8 +233,9 @@ export default function AdminUserRestrictionsPage() {
}}> }}>
<AlertCircle size={16} style={{ marginTop: 2, flexShrink: 0 }} /> <AlertCircle size={16} style={{ marginTop: 2, flexShrink: 0 }} />
<div> <div>
<strong>Hinweis:</strong> User-Overrides haben höchste Priorität und überschreiben Tier-Limits. <strong>Hinweis:</strong> User-Overrides überschreiben Tier-Limits.
Nutze dies sparsam für Sonderfälle (z.B. Beta-Tester, Support-Anfragen). Leere Felder = kein Override (User nutzt Tier-Standard).
Wert eingeben = Override setzen.
</div> </div>
</div> </div>
@ -283,16 +307,20 @@ export default function AdminUserRestrictionsPage() {
</div> </div>
</div> </div>
{/* Features List */} {/* Features Table */}
<div className="card" style={{ padding: 0, overflow: 'auto', marginBottom: 16 }}> <div className="card" style={{ padding: 0, overflow: 'auto', marginBottom: 80 }}>
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 13 }}> <table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 13 }}>
<thead> <thead>
<tr style={{ background: 'var(--surface2)' }}> <tr style={{ background: 'var(--surface2)' }}>
<th style={{ textAlign: 'left', padding: '12px 16px', fontWeight: 600 }}>Feature</th> <th style={{ textAlign: 'left', padding: '12px 16px', fontWeight: 600, width: '40%' }}>
<th style={{ textAlign: 'center', padding: '12px 16px', fontWeight: 600 }}>Tier-Limit</th> Feature
<th style={{ textAlign: 'center', padding: '12px 16px', fontWeight: 600 }}>Override</th> </th>
<th style={{ textAlign: 'center', padding: '12px 16px', fontWeight: 600 }}>Aktiv</th> <th style={{ textAlign: 'center', padding: '12px 16px', fontWeight: 600, width: '30%' }}>
<th style={{ textAlign: 'right', padding: '12px 16px', fontWeight: 600 }}>Aktion</th> Override-Wert
</th>
<th style={{ textAlign: 'right', padding: '12px 16px', fontWeight: 600, width: '30%' }}>
Status
</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@ -300,7 +328,7 @@ export default function AdminUserRestrictionsPage() {
<> <>
{/* Category Header */} {/* Category Header */}
<tr key={`cat-${category}`} style={{ background: 'var(--accent-light)' }}> <tr key={`cat-${category}`} style={{ background: 'var(--accent-light)' }}>
<td colSpan={5} style={{ <td colSpan={3} style={{
padding: '8px 16px', fontWeight: 600, fontSize: 11, padding: '8px 16px', fontWeight: 600, fontSize: 11,
textTransform: 'uppercase', letterSpacing: '0.5px', textTransform: 'uppercase', letterSpacing: '0.5px',
color: 'var(--accent-dark)' color: 'var(--accent-dark)'
@ -311,82 +339,79 @@ export default function AdminUserRestrictionsPage() {
{/* Feature Rows */} {/* Feature Rows */}
{categoryFeatures.map(feature => { {categoryFeatures.map(feature => {
const currentValue = getCurrentValue(feature.id) const displayValue = getDisplayValue(feature.id)
const enabled = isEnabled(feature.id) const toggleState = getToggleState(feature.id)
const hasOverride = hasRestriction(feature.id) const override = hasOverride(feature.id)
const isChanged = feature.id in changes const changed = isChanged(feature.id)
return ( return (
<tr key={feature.id} style={{ <tr key={feature.id} style={{
borderBottom: '1px solid var(--border)', borderBottom: '1px solid var(--border)',
background: isChanged ? 'var(--accent-light)' : hasOverride ? 'var(--surface)' : 'transparent' background: changed ? 'var(--accent-light)' : 'transparent'
}}> }}>
<td style={{ padding: '8px 16px' }}> {/* Feature Name */}
<td style={{ padding: '12px 16px' }}>
<div style={{ fontWeight: 500 }}>{feature.name}</div> <div style={{ fontWeight: 500 }}>{feature.name}</div>
<div style={{ fontSize: 11, color: 'var(--text3)', marginTop: 2 }}> <div style={{ fontSize: 11, color: 'var(--text3)', marginTop: 2 }}>
{feature.limit_type === 'boolean' ? '(ja/nein)' : `(${feature.reset_period})`} {feature.limit_type === 'boolean' ? '(ja/nein)' : `(${feature.reset_period})`}
</div> </div>
</td> </td>
<td style={{ padding: '8px 16px', textAlign: 'center', color: 'var(--text3)' }}>
{/* TODO: Show actual tier limit - for now just show "Standard" */} {/* Override Input */}
Standard <td style={{ padding: '12px 16px', textAlign: 'center' }}>
</td>
<td style={{ padding: '8px 16px', textAlign: 'center' }}>
{feature.limit_type === 'boolean' ? ( {feature.limit_type === 'boolean' ? (
<button <button
onClick={() => toggleEnabled(feature.id)} onClick={() => handleToggle(feature.id, toggleState)}
style={{ style={{
padding: '6px 16px', padding: '6px 16px',
border: `2px solid ${enabled ? 'var(--accent)' : 'var(--border)'}`, border: `2px solid ${toggleState ? 'var(--accent)' : 'var(--border)'}`,
borderRadius: 20, borderRadius: 20,
background: enabled ? 'var(--accent)' : 'var(--surface)', background: toggleState ? 'var(--accent)' : 'var(--surface)',
color: enabled ? 'white' : 'var(--text3)', color: toggleState ? 'white' : 'var(--text3)',
fontSize: 12, fontSize: 12,
fontWeight: 600, fontWeight: 600,
cursor: 'pointer', cursor: 'pointer',
transition: 'all 0.2s', transition: 'all 0.2s',
minWidth: 70 minWidth: 80
}} }}
> >
{enabled ? '✓ AN' : '✗ AUS'} {toggleState ? '✓ AN' : '✗ AUS'}
</button> </button>
) : ( ) : (
<input <input
type="text" type="text"
value={formatValue(currentValue)} value={displayValue}
onChange={(e) => handleChange(feature.id, e.target.value, enabled)} onChange={(e) => handleChange(feature.id, e.target.value)}
placeholder="" placeholder="leer = Tier-Standard"
style={{ style={{
width: '80px', width: '140px',
padding: '6px 8px', padding: '6px 8px',
border: `1.5px solid ${isChanged ? 'var(--accent)' : hasOverride ? 'var(--border)' : 'var(--border)'}`, border: `1.5px solid ${changed ? 'var(--accent)' : 'var(--border)'}`,
borderRadius: 6, borderRadius: 6,
textAlign: 'center', textAlign: 'center',
fontSize: 13, fontSize: 13,
fontWeight: isChanged ? 600 : 400, fontWeight: changed ? 600 : 400,
background: hasOverride ? 'var(--surface2)' : 'var(--bg)', background: 'var(--bg)',
color: currentValue === 0 ? 'var(--danger)' : color: displayValue === 0 || displayValue === '0' ? 'var(--danger)' : 'var(--text1)'
currentValue === null ? 'var(--accent)' : 'var(--text1)'
}} }}
/> />
)} )}
</td> </td>
<td style={{ padding: '8px 16px', textAlign: 'center' }}>
{hasOverride ? ( {/* Status */}
<span style={{ color: 'var(--accent)', fontWeight: 600 }}></span> <td style={{ padding: '12px 16px', textAlign: 'right' }}>
{override ? (
<span style={{
padding: '4px 8px', borderRadius: 4, fontSize: 11,
background: 'var(--accent-light)', color: 'var(--accent-dark)',
fontWeight: 600
}}>
Override aktiv
</span>
) : ( ) : (
<span style={{ color: 'var(--text3)' }}>-</span> <span style={{ fontSize: 11, color: 'var(--text3)' }}>
)} Tier-Standard
</td> </span>
<td style={{ padding: '8px 16px', textAlign: 'right' }}>
{hasOverride && (
<button
className="btn btn-secondary"
onClick={() => handleRemoveRestriction(feature.id)}
style={{ padding: '4px 8px', fontSize: 11, color: 'var(--danger)' }}
>
<X size={12} /> Entfernen
</button>
)} )}
</td> </td>
</tr> </tr>
@ -398,47 +423,43 @@ export default function AdminUserRestrictionsPage() {
</table> </table>
</div> </div>
{/* Save Button */} {/* Fixed Bottom Bar */}
{hasChanges && ( <div style={{
<div style={{ position: 'fixed', bottom: 0, left: 0, right: 0,
position: 'fixed', bottom: 0, left: 0, right: 0, background: 'var(--bg)', borderTop: '1px solid var(--border)',
background: 'var(--bg)', borderTop: '1px solid var(--border)', padding: 16, display: 'flex', gap: 8, zIndex: 100,
padding: 16, display: 'flex', gap: 8, zIndex: 100, boxShadow: '0 -2px 10px rgba(0,0,0,0.1)'
boxShadow: '0 -2px 10px rgba(0,0,0,0.1)' }}>
}}> <button
<button className="btn btn-secondary"
className="btn btn-secondary" onClick={() => setChanges({})}
onClick={() => setChanges({})} disabled={!hasChanges || saving}
disabled={saving} style={{ flex: 1 }}
style={{ flex: 1 }} >
> <RotateCcw size={14} /> Zurücksetzen
Zurücksetzen </button>
</button> <button
<button className="btn btn-primary"
className="btn btn-primary" onClick={handleSave}
onClick={handleSave} disabled={!hasChanges || saving}
disabled={saving} style={{ flex: 2 }}
style={{ flex: 2 }} >
> {saving ? 'Speichern...' : hasChanges ? `${Object.keys(changes).length} Änderung(en) speichern` : 'Speichern'}
{saving ? '...' : `${Object.keys(changes).length} Overrides speichern`} </button>
</button> </div>
</div>
)}
{/* Legend */} {/* Legend */}
<div style={{ <div style={{
marginTop: 16, padding: 12, background: 'var(--surface2)', marginTop: 16, padding: 12, background: 'var(--surface2)',
borderRadius: 8, fontSize: 12, color: 'var(--text3)' borderRadius: 8, fontSize: 12, color: 'var(--text3)', marginBottom: 100
}}> }}>
<strong>Eingabe:</strong> <strong>Eingabe:</strong>
<div style={{ marginTop: 8, display: 'flex', gap: 24, flexWrap: 'wrap' }}> <div style={{ marginTop: 8, display: 'flex', gap: 24, flexWrap: 'wrap' }}>
<span><strong style={{ color: 'var(--accent)' }}>leer oder </strong> = Unbegrenzt</span> <span><strong>Leer</strong> = Tier-Standard nutzen (kein Override)</span>
<span><strong style={{ color: 'var(--danger)' }}>0</strong> = Deaktiviert</span> <span><strong style={{ color: 'var(--danger)' }}>0</strong> = Feature deaktiviert</span>
<span><strong> oder leer</strong> = Unbegrenzt (bei Count-Features)</span>
<span><strong>1-999999</strong> = Limit-Wert</span> <span><strong>1-999999</strong> = Limit-Wert</span>
</div> </div>
<div style={{ marginTop: 8 }}>
<strong> Aktiv</strong> = Override gesetzt, überschreibt Tier-Limit
</div>
</div> </div>
</> </>
)} )}