feat: add feature usage UI components (Phase 3)
- Add api.getFeatureUsage() endpoint call - Create UsageBadge component (inline indicators) - Create FeatureUsageOverview component (Settings table) - Add "Kontingente" section to Settings page - Color-coded status (green/yellow/red) - Grouped by category - Shows reset period and next reset date Phase 3: Frontend Display Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
d10f605d66
commit
405abc1973
163
frontend/src/components/FeatureUsageOverview.css
Normal file
163
frontend/src/components/FeatureUsageOverview.css
Normal file
|
|
@ -0,0 +1,163 @@
|
||||||
|
/**
|
||||||
|
* FeatureUsageOverview Styles
|
||||||
|
* Phase 3: Frontend Display
|
||||||
|
*/
|
||||||
|
|
||||||
|
.feature-usage-overview {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feature-usage-loading,
|
||||||
|
.feature-usage-error,
|
||||||
|
.feature-usage-empty {
|
||||||
|
padding: 20px;
|
||||||
|
text-align: center;
|
||||||
|
color: var(--text2);
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feature-usage-loading {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feature-usage-error {
|
||||||
|
background: rgba(216, 90, 48, 0.1);
|
||||||
|
color: var(--danger);
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Category grouping */
|
||||||
|
.feature-category {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feature-category-label {
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text3);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feature-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Feature item */
|
||||||
|
.feature-item {
|
||||||
|
padding: 12px;
|
||||||
|
background: var(--surface2);
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feature-item--exceeded {
|
||||||
|
border-color: var(--danger);
|
||||||
|
background: rgba(216, 90, 48, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.feature-item--warning {
|
||||||
|
border-color: #d97706;
|
||||||
|
background: rgba(217, 119, 6, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.feature-item--ok {
|
||||||
|
border-color: var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.feature-main {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feature-name {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.feature-usage {
|
||||||
|
font-size: 14px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.usage-unlimited {
|
||||||
|
color: var(--accent);
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.usage-boolean {
|
||||||
|
font-weight: 500;
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.usage-boolean.enabled {
|
||||||
|
color: var(--accent);
|
||||||
|
background: rgba(29, 158, 117, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.usage-boolean.disabled {
|
||||||
|
color: var(--text3);
|
||||||
|
background: rgba(136, 135, 128, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.usage-count {
|
||||||
|
color: var(--text1);
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
.usage-count strong {
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feature-item--exceeded .usage-count {
|
||||||
|
color: var(--danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
.feature-item--warning .usage-count {
|
||||||
|
color: #d97706;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Meta info */
|
||||||
|
.feature-meta {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 8px;
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.meta-reset,
|
||||||
|
.meta-next-reset {
|
||||||
|
padding: 2px 6px;
|
||||||
|
background: var(--surface);
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Responsive */
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.feature-main {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feature-usage {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
142
frontend/src/components/FeatureUsageOverview.jsx
Normal file
142
frontend/src/components/FeatureUsageOverview.jsx
Normal file
|
|
@ -0,0 +1,142 @@
|
||||||
|
/**
|
||||||
|
* FeatureUsageOverview - Full feature usage table for Settings page
|
||||||
|
*
|
||||||
|
* Shows all features with usage, limits, reset period, and next reset date
|
||||||
|
* Phase 3: Frontend Display
|
||||||
|
*/
|
||||||
|
import { useState, useEffect } from 'react'
|
||||||
|
import { api } from '../utils/api'
|
||||||
|
import './FeatureUsageOverview.css'
|
||||||
|
|
||||||
|
const RESET_PERIOD_LABELS = {
|
||||||
|
'never': 'Niemals',
|
||||||
|
'daily': 'Täglich',
|
||||||
|
'monthly': 'Monatlich'
|
||||||
|
}
|
||||||
|
|
||||||
|
const CATEGORY_LABELS = {
|
||||||
|
'data': 'Daten',
|
||||||
|
'ai': 'KI',
|
||||||
|
'export': 'Export',
|
||||||
|
'import': 'Import',
|
||||||
|
'integration': 'Integration'
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function FeatureUsageOverview() {
|
||||||
|
const [features, setFeatures] = useState([])
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [error, setError] = useState(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadFeatures()
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const loadFeatures = async () => {
|
||||||
|
try {
|
||||||
|
setLoading(true)
|
||||||
|
const data = await api.getFeatureUsage()
|
||||||
|
setFeatures(data)
|
||||||
|
setError(null)
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to load feature usage:', err)
|
||||||
|
setError('Fehler beim Laden der Kontingente')
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatResetDate = (resetAt) => {
|
||||||
|
if (!resetAt) return '—'
|
||||||
|
try {
|
||||||
|
const date = new Date(resetAt)
|
||||||
|
return date.toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric' })
|
||||||
|
} catch {
|
||||||
|
return '—'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const getStatusClass = (feature) => {
|
||||||
|
if (!feature.allowed || feature.remaining < 0) return 'exceeded'
|
||||||
|
if (feature.limit && feature.remaining <= Math.ceil(feature.limit * 0.2)) return 'warning'
|
||||||
|
return 'ok'
|
||||||
|
}
|
||||||
|
|
||||||
|
// Group by category
|
||||||
|
const byCategory = features.reduce((acc, f) => {
|
||||||
|
const cat = f.category || 'other'
|
||||||
|
if (!acc[cat]) acc[cat] = []
|
||||||
|
acc[cat].push(f)
|
||||||
|
return acc
|
||||||
|
}, {})
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="feature-usage-loading">
|
||||||
|
<div className="spinner" />
|
||||||
|
Lade Kontingente...
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<div className="feature-usage-error">
|
||||||
|
⚠️ {error}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (features.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="feature-usage-empty">
|
||||||
|
Keine Features gefunden
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="feature-usage-overview">
|
||||||
|
{Object.entries(byCategory).map(([category, items]) => (
|
||||||
|
<div key={category} className="feature-category">
|
||||||
|
<div className="feature-category-label">
|
||||||
|
{CATEGORY_LABELS[category] || category}
|
||||||
|
</div>
|
||||||
|
<div className="feature-list">
|
||||||
|
{items.map(feature => (
|
||||||
|
<div key={feature.feature_id} className={`feature-item feature-item--${getStatusClass(feature)}`}>
|
||||||
|
<div className="feature-main">
|
||||||
|
<div className="feature-name">{feature.name}</div>
|
||||||
|
<div className="feature-usage">
|
||||||
|
{feature.limit === null ? (
|
||||||
|
<span className="usage-unlimited">Unbegrenzt</span>
|
||||||
|
) : feature.limit_type === 'boolean' ? (
|
||||||
|
<span className={`usage-boolean ${feature.allowed ? 'enabled' : 'disabled'}`}>
|
||||||
|
{feature.allowed ? '✓ Aktiviert' : '✗ Deaktiviert'}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="usage-count">
|
||||||
|
<strong>{feature.used}</strong> / {feature.limit}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{feature.limit_type === 'count' && feature.limit !== null && (
|
||||||
|
<div className="feature-meta">
|
||||||
|
<span className="meta-reset">
|
||||||
|
Reset: {RESET_PERIOD_LABELS[feature.reset_period] || feature.reset_period}
|
||||||
|
</span>
|
||||||
|
{feature.reset_at && (
|
||||||
|
<span className="meta-next-reset">
|
||||||
|
Nächster Reset: {formatResetDate(feature.reset_at)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
43
frontend/src/components/UsageBadge.css
Normal file
43
frontend/src/components/UsageBadge.css
Normal file
|
|
@ -0,0 +1,43 @@
|
||||||
|
/**
|
||||||
|
* UsageBadge Styles
|
||||||
|
*
|
||||||
|
* Small, visually distinct inline badge for usage indicators
|
||||||
|
* Phase 3: Frontend Display
|
||||||
|
*/
|
||||||
|
|
||||||
|
.usage-badge {
|
||||||
|
display: inline-block;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 500;
|
||||||
|
padding: 2px 6px;
|
||||||
|
border-radius: 4px;
|
||||||
|
margin-left: 6px;
|
||||||
|
opacity: 0.8;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.usage-badge--ok {
|
||||||
|
color: var(--accent, #1D9E75);
|
||||||
|
background: rgba(29, 158, 117, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.usage-badge--warning {
|
||||||
|
color: #d97706;
|
||||||
|
background: rgba(217, 119, 6, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.usage-badge--exceeded {
|
||||||
|
color: var(--danger, #D85A30);
|
||||||
|
background: rgba(216, 90, 48, 0.1);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Responsive: Even smaller on mobile */
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.usage-badge {
|
||||||
|
font-size: 0.7rem;
|
||||||
|
padding: 1px 4px;
|
||||||
|
margin-left: 4px;
|
||||||
|
}
|
||||||
|
}
|
||||||
31
frontend/src/components/UsageBadge.jsx
Normal file
31
frontend/src/components/UsageBadge.jsx
Normal file
|
|
@ -0,0 +1,31 @@
|
||||||
|
/**
|
||||||
|
* UsageBadge - Small inline usage indicator
|
||||||
|
*
|
||||||
|
* Shows usage quota in format: (used/limit)
|
||||||
|
* Color-coded: green (ok), yellow (warning), red (exceeded)
|
||||||
|
*
|
||||||
|
* Phase 3: Frontend Display
|
||||||
|
*/
|
||||||
|
import './UsageBadge.css'
|
||||||
|
|
||||||
|
export default function UsageBadge({ used, limit, remaining, allowed }) {
|
||||||
|
// Don't show badge if unlimited
|
||||||
|
if (limit === null || limit === undefined) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
// Determine status for color coding
|
||||||
|
let status = 'ok'
|
||||||
|
if (!allowed || remaining < 0) {
|
||||||
|
status = 'exceeded'
|
||||||
|
} else if (limit > 0 && remaining <= Math.ceil(limit * 0.2)) {
|
||||||
|
// Warning at 20% or less remaining
|
||||||
|
status = 'warning'
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span className={`usage-badge usage-badge--${status}`}>
|
||||||
|
({used}/{limit})
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -1,10 +1,11 @@
|
||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { Save, Download, Upload, Trash2, Plus, Check, Pencil, X, LogOut, Shield, Key } from 'lucide-react'
|
import { Save, Download, Upload, Trash2, Plus, Check, Pencil, X, LogOut, Shield, Key, BarChart3 } from 'lucide-react'
|
||||||
import { useProfile } from '../context/ProfileContext'
|
import { useProfile } from '../context/ProfileContext'
|
||||||
import { useAuth } from '../context/AuthContext'
|
import { useAuth } from '../context/AuthContext'
|
||||||
import { Avatar } from './ProfileSelect'
|
import { Avatar } from './ProfileSelect'
|
||||||
import { api } from '../utils/api'
|
import { api } from '../utils/api'
|
||||||
import AdminPanel from './AdminPanel'
|
import AdminPanel from './AdminPanel'
|
||||||
|
import FeatureUsageOverview from '../components/FeatureUsageOverview'
|
||||||
|
|
||||||
const COLORS = ['#1D9E75','#378ADD','#D85A30','#EF9F27','#7F77DD','#D4537E','#639922','#888780']
|
const COLORS = ['#1D9E75','#378ADD','#D85A30','#EF9F27','#7F77DD','#D4537E','#639922','#888780']
|
||||||
|
|
||||||
|
|
@ -326,6 +327,17 @@ export default function SettingsPage() {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Feature Usage Overview (Phase 3) */}
|
||||||
|
<div className="card section-gap">
|
||||||
|
<div className="card-title" style={{display:'flex',alignItems:'center',gap:6}}>
|
||||||
|
<BarChart3 size={15} color="var(--accent)"/> Kontingente
|
||||||
|
</div>
|
||||||
|
<p style={{fontSize:13,color:'var(--text2)',marginBottom:12,lineHeight:1.6}}>
|
||||||
|
Übersicht über deine Feature-Nutzung und verfügbare Kontingente.
|
||||||
|
</p>
|
||||||
|
<FeatureUsageOverview />
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Admin Panel */}
|
{/* Admin Panel */}
|
||||||
{isAdmin && (
|
{isAdmin && (
|
||||||
<div className="card section-gap">
|
<div className="card section-gap">
|
||||||
|
|
|
||||||
|
|
@ -144,6 +144,7 @@ export const api = {
|
||||||
getMyUsage: () => req('/subscription/usage'),
|
getMyUsage: () => req('/subscription/usage'),
|
||||||
getMyLimits: () => req('/subscription/limits'),
|
getMyLimits: () => req('/subscription/limits'),
|
||||||
redeemCoupon: (code) => req('/coupons/redeem',json({code})),
|
redeemCoupon: (code) => req('/coupons/redeem',json({code})),
|
||||||
|
getFeatureUsage: () => req('/features/usage'), // Phase 3: Usage overview
|
||||||
|
|
||||||
// Admin: Features
|
// Admin: Features
|
||||||
listFeatures: () => req('/features'),
|
listFeatures: () => req('/features'),
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user