Membership-System und Bug Fixing (inkl. Nutrition) #8
2
.gitignore
vendored
2
.gitignore
vendored
|
|
@ -61,4 +61,4 @@ tmp/
|
|||
|
||||
#.claude Konfiguration
|
||||
.claude/
|
||||
.claude/settings.local.json
|
||||
.claude/settings.local.jsonfrontend/package-lock.json
|
||||
|
|
|
|||
|
|
@ -60,6 +60,9 @@ def apply_migration():
|
|||
if not migration_needed(conn):
|
||||
print("[v9c Migration] Already applied, skipping.")
|
||||
conn.close()
|
||||
|
||||
# Even if main migration is done, check cleanup
|
||||
apply_cleanup_migration()
|
||||
return
|
||||
|
||||
print("[v9c Migration] Applying subscription system migration...")
|
||||
|
|
@ -83,6 +86,26 @@ def apply_migration():
|
|||
|
||||
print("[v9c Migration] ✅ Migration completed successfully!")
|
||||
|
||||
# Apply fix migration if exists
|
||||
fix_migration_path = os.path.join(
|
||||
os.path.dirname(__file__),
|
||||
"migrations",
|
||||
"v9c_fix_features.sql"
|
||||
)
|
||||
|
||||
if os.path.exists(fix_migration_path):
|
||||
print("[v9c Migration] Applying feature fixes...")
|
||||
with open(fix_migration_path, 'r', encoding='utf-8') as f:
|
||||
fix_sql = f.read()
|
||||
|
||||
conn = get_db_connection()
|
||||
cur = conn.cursor()
|
||||
cur.execute(fix_sql)
|
||||
conn.commit()
|
||||
cur.close()
|
||||
conn.close()
|
||||
print("[v9c Migration] ✅ Feature fixes applied!")
|
||||
|
||||
# Verify tables created
|
||||
conn = get_db_connection()
|
||||
cur = conn.cursor()
|
||||
|
|
@ -108,10 +131,123 @@ def apply_migration():
|
|||
cur.close()
|
||||
conn.close()
|
||||
|
||||
# After successful migration, apply cleanup
|
||||
apply_cleanup_migration()
|
||||
|
||||
except Exception as e:
|
||||
print(f"[v9c Migration] ❌ Error: {e}")
|
||||
raise
|
||||
|
||||
|
||||
def cleanup_features_needed(conn):
|
||||
"""Check if feature cleanup migration is needed."""
|
||||
cur = conn.cursor()
|
||||
|
||||
# Check if old export features still exist
|
||||
cur.execute("""
|
||||
SELECT COUNT(*) as count FROM features
|
||||
WHERE id IN ('export_csv', 'export_json', 'export_zip')
|
||||
""")
|
||||
old_exports = cur.fetchone()['count']
|
||||
|
||||
# Check if csv_import needs to be renamed
|
||||
cur.execute("""
|
||||
SELECT COUNT(*) as count FROM features
|
||||
WHERE id = 'csv_import'
|
||||
""")
|
||||
old_import = cur.fetchone()['count']
|
||||
|
||||
cur.close()
|
||||
|
||||
# Cleanup needed if old features exist
|
||||
return old_exports > 0 or old_import > 0
|
||||
|
||||
|
||||
def apply_cleanup_migration():
|
||||
"""Apply v9c feature cleanup migration."""
|
||||
print("[v9c Cleanup] Checking if cleanup migration is needed...")
|
||||
|
||||
try:
|
||||
conn = get_db_connection()
|
||||
|
||||
if not cleanup_features_needed(conn):
|
||||
print("[v9c Cleanup] Already applied, skipping.")
|
||||
conn.close()
|
||||
return
|
||||
|
||||
print("[v9c Cleanup] Applying feature consolidation...")
|
||||
|
||||
# Show BEFORE state
|
||||
cur = conn.cursor()
|
||||
cur.execute("SELECT id, name FROM features ORDER BY category, id")
|
||||
features_before = [f"{r['id']} ({r['name']})" for r in cur.fetchall()]
|
||||
print(f"[v9c Cleanup] Features BEFORE: {len(features_before)} features")
|
||||
for f in features_before:
|
||||
print(f" - {f}")
|
||||
cur.close()
|
||||
|
||||
# Read cleanup migration SQL
|
||||
cleanup_path = os.path.join(
|
||||
os.path.dirname(__file__),
|
||||
"migrations",
|
||||
"v9c_cleanup_features.sql"
|
||||
)
|
||||
|
||||
if not os.path.exists(cleanup_path):
|
||||
print(f"[v9c Cleanup] ⚠️ Cleanup migration file not found: {cleanup_path}")
|
||||
conn.close()
|
||||
return
|
||||
|
||||
with open(cleanup_path, 'r', encoding='utf-8') as f:
|
||||
cleanup_sql = f.read()
|
||||
|
||||
# Execute cleanup migration
|
||||
cur = conn.cursor()
|
||||
cur.execute(cleanup_sql)
|
||||
conn.commit()
|
||||
cur.close()
|
||||
|
||||
# Show AFTER state
|
||||
cur = conn.cursor()
|
||||
cur.execute("SELECT id, name, category FROM features ORDER BY category, id")
|
||||
features_after = cur.fetchall()
|
||||
print(f"[v9c Cleanup] Features AFTER: {len(features_after)} features")
|
||||
|
||||
# Group by category
|
||||
categories = {}
|
||||
for f in features_after:
|
||||
cat = f['category'] or 'other'
|
||||
if cat not in categories:
|
||||
categories[cat] = []
|
||||
categories[cat].append(f"{f['id']} ({f['name']})")
|
||||
|
||||
for cat, feats in sorted(categories.items()):
|
||||
print(f" {cat.upper()}:")
|
||||
for f in feats:
|
||||
print(f" - {f}")
|
||||
|
||||
# Verify tier_limits updated
|
||||
cur.execute("""
|
||||
SELECT tier_id, feature_id, limit_value
|
||||
FROM tier_limits
|
||||
WHERE feature_id IN ('data_export', 'data_import')
|
||||
ORDER BY tier_id, feature_id
|
||||
""")
|
||||
limits = cur.fetchall()
|
||||
print(f"[v9c Cleanup] Tier limits for data_export/data_import:")
|
||||
for lim in limits:
|
||||
limit_str = 'unlimited' if lim['limit_value'] is None else lim['limit_value']
|
||||
print(f" {lim['tier_id']}.{lim['feature_id']} = {limit_str}")
|
||||
|
||||
cur.close()
|
||||
conn.close()
|
||||
|
||||
print("[v9c Cleanup] ✅ Feature cleanup completed successfully!")
|
||||
|
||||
except Exception as e:
|
||||
print(f"[v9c Cleanup] ❌ Error: {e}")
|
||||
raise
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
apply_migration()
|
||||
|
|
|
|||
227
backend/auth.py
227
backend/auth.py
|
|
@ -121,17 +121,22 @@ def require_admin(x_auth_token: Optional[str] = Header(default=None)):
|
|||
# Feature Access Control (v9c)
|
||||
# ============================================================================
|
||||
|
||||
def get_effective_tier(profile_id: str) -> str:
|
||||
def get_effective_tier(profile_id: str, conn=None) -> str:
|
||||
"""
|
||||
Get the effective tier for a profile.
|
||||
|
||||
Checks for active access_grants first (from coupons, trials, etc.),
|
||||
then falls back to profile.tier.
|
||||
|
||||
Args:
|
||||
profile_id: User profile ID
|
||||
conn: Optional existing DB connection (to avoid pool exhaustion)
|
||||
|
||||
Returns:
|
||||
tier_id (str): 'free', 'basic', 'premium', or 'selfhosted'
|
||||
"""
|
||||
with get_db() as conn:
|
||||
# Use existing connection if provided, otherwise open new one
|
||||
if conn:
|
||||
cur = get_cursor(conn)
|
||||
|
||||
# Check for active access grants (highest priority)
|
||||
|
|
@ -154,9 +159,13 @@ def get_effective_tier(profile_id: str) -> str:
|
|||
cur.execute("SELECT tier FROM profiles WHERE id = %s", (profile_id,))
|
||||
profile = cur.fetchone()
|
||||
return profile['tier'] if profile else 'free'
|
||||
else:
|
||||
# Open new connection if none provided
|
||||
with get_db() as conn:
|
||||
return get_effective_tier(profile_id, conn)
|
||||
|
||||
|
||||
def check_feature_access(profile_id: str, feature_id: str) -> dict:
|
||||
def check_feature_access(profile_id: str, feature_id: str, conn=None) -> dict:
|
||||
"""
|
||||
Check if a profile has access to a feature.
|
||||
|
||||
|
|
@ -165,6 +174,11 @@ def check_feature_access(profile_id: str, feature_id: str) -> dict:
|
|||
2. Tier limit (tier_limits)
|
||||
3. Feature default (features.default_limit)
|
||||
|
||||
Args:
|
||||
profile_id: User profile ID
|
||||
feature_id: Feature ID to check
|
||||
conn: Optional existing DB connection (to avoid pool exhaustion)
|
||||
|
||||
Returns:
|
||||
dict: {
|
||||
'allowed': bool,
|
||||
|
|
@ -174,118 +188,127 @@ def check_feature_access(profile_id: str, feature_id: str) -> dict:
|
|||
'reason': str # 'unlimited', 'within_limit', 'limit_exceeded', 'feature_disabled'
|
||||
}
|
||||
"""
|
||||
with get_db() as conn:
|
||||
cur = get_cursor(conn)
|
||||
# Use existing connection if provided
|
||||
if conn:
|
||||
return _check_impl(profile_id, feature_id, conn)
|
||||
else:
|
||||
with get_db() as conn:
|
||||
return _check_impl(profile_id, feature_id, conn)
|
||||
|
||||
# Get feature info
|
||||
cur.execute("""
|
||||
SELECT limit_type, reset_period, default_limit
|
||||
FROM features
|
||||
WHERE id = %s AND active = true
|
||||
""", (feature_id,))
|
||||
feature = cur.fetchone()
|
||||
|
||||
if not feature:
|
||||
return {
|
||||
'allowed': False,
|
||||
'limit': None,
|
||||
'used': 0,
|
||||
'remaining': None,
|
||||
'reason': 'feature_not_found'
|
||||
}
|
||||
def _check_impl(profile_id: str, feature_id: str, conn) -> dict:
|
||||
"""Internal implementation of check_feature_access."""
|
||||
cur = get_cursor(conn)
|
||||
|
||||
# Priority 1: Check user-specific restriction
|
||||
# Get feature info
|
||||
cur.execute("""
|
||||
SELECT limit_type, reset_period, default_limit
|
||||
FROM features
|
||||
WHERE id = %s AND active = true
|
||||
""", (feature_id,))
|
||||
feature = cur.fetchone()
|
||||
|
||||
if not feature:
|
||||
return {
|
||||
'allowed': False,
|
||||
'limit': None,
|
||||
'used': 0,
|
||||
'remaining': None,
|
||||
'reason': 'feature_not_found'
|
||||
}
|
||||
|
||||
# Priority 1: Check user-specific restriction
|
||||
cur.execute("""
|
||||
SELECT limit_value
|
||||
FROM user_feature_restrictions
|
||||
WHERE profile_id = %s AND feature_id = %s
|
||||
""", (profile_id, feature_id))
|
||||
restriction = cur.fetchone()
|
||||
|
||||
if restriction is not None:
|
||||
limit = restriction['limit_value']
|
||||
else:
|
||||
# Priority 2: Check tier limit
|
||||
tier_id = get_effective_tier(profile_id, conn)
|
||||
cur.execute("""
|
||||
SELECT limit_value
|
||||
FROM user_feature_restrictions
|
||||
WHERE profile_id = %s AND feature_id = %s
|
||||
""", (profile_id, feature_id))
|
||||
restriction = cur.fetchone()
|
||||
FROM tier_limits
|
||||
WHERE tier_id = %s AND feature_id = %s
|
||||
""", (tier_id, feature_id))
|
||||
tier_limit = cur.fetchone()
|
||||
|
||||
if restriction is not None:
|
||||
limit = restriction['limit_value']
|
||||
if tier_limit is not None:
|
||||
limit = tier_limit['limit_value']
|
||||
else:
|
||||
# Priority 2: Check tier limit
|
||||
tier_id = get_effective_tier(profile_id)
|
||||
cur.execute("""
|
||||
SELECT limit_value
|
||||
FROM tier_limits
|
||||
WHERE tier_id = %s AND feature_id = %s
|
||||
""", (tier_id, feature_id))
|
||||
tier_limit = cur.fetchone()
|
||||
|
||||
if tier_limit is not None:
|
||||
limit = tier_limit['limit_value']
|
||||
else:
|
||||
# Priority 3: Feature default
|
||||
limit = feature['default_limit']
|
||||
|
||||
# For boolean features (limit 0 = disabled, 1 = enabled)
|
||||
if feature['limit_type'] == 'boolean':
|
||||
allowed = limit == 1
|
||||
return {
|
||||
'allowed': allowed,
|
||||
'limit': limit,
|
||||
'used': 0,
|
||||
'remaining': None,
|
||||
'reason': 'enabled' if allowed else 'feature_disabled'
|
||||
}
|
||||
|
||||
# For count-based features
|
||||
# Check current usage
|
||||
cur.execute("""
|
||||
SELECT usage_count, reset_at
|
||||
FROM user_feature_usage
|
||||
WHERE profile_id = %s AND feature_id = %s
|
||||
""", (profile_id, feature_id))
|
||||
usage = cur.fetchone()
|
||||
|
||||
used = usage['usage_count'] if usage else 0
|
||||
|
||||
# Check if reset is needed
|
||||
if usage and usage['reset_at'] and datetime.now() > usage['reset_at']:
|
||||
# Reset usage
|
||||
used = 0
|
||||
next_reset = _calculate_next_reset(feature['reset_period'])
|
||||
cur.execute("""
|
||||
UPDATE user_feature_usage
|
||||
SET usage_count = 0, reset_at = %s, updated = CURRENT_TIMESTAMP
|
||||
WHERE profile_id = %s AND feature_id = %s
|
||||
""", (next_reset, profile_id, feature_id))
|
||||
conn.commit()
|
||||
|
||||
# NULL limit = unlimited
|
||||
if limit is None:
|
||||
return {
|
||||
'allowed': True,
|
||||
'limit': None,
|
||||
'used': used,
|
||||
'remaining': None,
|
||||
'reason': 'unlimited'
|
||||
}
|
||||
|
||||
# 0 limit = disabled
|
||||
if limit == 0:
|
||||
return {
|
||||
'allowed': False,
|
||||
'limit': 0,
|
||||
'used': used,
|
||||
'remaining': 0,
|
||||
'reason': 'feature_disabled'
|
||||
}
|
||||
|
||||
# Check if within limit
|
||||
allowed = used < limit
|
||||
remaining = limit - used if limit else None
|
||||
# Priority 3: Feature default
|
||||
limit = feature['default_limit']
|
||||
|
||||
# For boolean features (limit 0 = disabled, 1 = enabled)
|
||||
if feature['limit_type'] == 'boolean':
|
||||
allowed = limit == 1
|
||||
return {
|
||||
'allowed': allowed,
|
||||
'limit': limit,
|
||||
'used': used,
|
||||
'remaining': remaining,
|
||||
'reason': 'within_limit' if allowed else 'limit_exceeded'
|
||||
'used': 0,
|
||||
'remaining': None,
|
||||
'reason': 'enabled' if allowed else 'feature_disabled'
|
||||
}
|
||||
|
||||
# For count-based features
|
||||
# Check current usage
|
||||
cur.execute("""
|
||||
SELECT usage_count, reset_at
|
||||
FROM user_feature_usage
|
||||
WHERE profile_id = %s AND feature_id = %s
|
||||
""", (profile_id, feature_id))
|
||||
usage = cur.fetchone()
|
||||
|
||||
used = usage['usage_count'] if usage else 0
|
||||
|
||||
# Check if reset is needed
|
||||
if usage and usage['reset_at'] and datetime.now() > usage['reset_at']:
|
||||
# Reset usage
|
||||
used = 0
|
||||
next_reset = _calculate_next_reset(feature['reset_period'])
|
||||
cur.execute("""
|
||||
UPDATE user_feature_usage
|
||||
SET usage_count = 0, reset_at = %s, updated = CURRENT_TIMESTAMP
|
||||
WHERE profile_id = %s AND feature_id = %s
|
||||
""", (next_reset, profile_id, feature_id))
|
||||
conn.commit()
|
||||
|
||||
# NULL limit = unlimited
|
||||
if limit is None:
|
||||
return {
|
||||
'allowed': True,
|
||||
'limit': None,
|
||||
'used': used,
|
||||
'remaining': None,
|
||||
'reason': 'unlimited'
|
||||
}
|
||||
|
||||
# 0 limit = disabled
|
||||
if limit == 0:
|
||||
return {
|
||||
'allowed': False,
|
||||
'limit': 0,
|
||||
'used': used,
|
||||
'remaining': 0,
|
||||
'reason': 'feature_disabled'
|
||||
}
|
||||
|
||||
# Check if within limit
|
||||
allowed = used < limit
|
||||
remaining = limit - used if limit else None
|
||||
|
||||
return {
|
||||
'allowed': allowed,
|
||||
'limit': limit,
|
||||
'used': used,
|
||||
'remaining': remaining,
|
||||
'reason': 'within_limit' if allowed else 'limit_exceeded'
|
||||
}
|
||||
|
||||
|
||||
def increment_feature_usage(profile_id: str, feature_id: str) -> None:
|
||||
"""
|
||||
|
|
|
|||
36
backend/check_features.py
Normal file
36
backend/check_features.py
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Quick diagnostic script to check features table."""
|
||||
|
||||
from db import get_db, get_cursor
|
||||
|
||||
with get_db() as conn:
|
||||
cur = get_cursor(conn)
|
||||
|
||||
print("\n=== FEATURES TABLE ===")
|
||||
cur.execute("SELECT id, name, active, limit_type, reset_period FROM features ORDER BY id")
|
||||
features = cur.fetchall()
|
||||
|
||||
if not features:
|
||||
print("❌ NO FEATURES FOUND! Migration failed!")
|
||||
else:
|
||||
for r in features:
|
||||
print(f" {r['id']:30} {r['name']:40} active={r['active']} type={r['limit_type']:8} reset={r['reset_period']}")
|
||||
|
||||
print(f"\nTotal features: {len(features)}")
|
||||
|
||||
print("\n=== USER_FEATURE_USAGE (recent) ===")
|
||||
cur.execute("""
|
||||
SELECT profile_id, feature_id, usage_count, reset_at
|
||||
FROM user_feature_usage
|
||||
ORDER BY updated DESC
|
||||
LIMIT 10
|
||||
""")
|
||||
usages = cur.fetchall()
|
||||
|
||||
if not usages:
|
||||
print(" (no usage records yet)")
|
||||
else:
|
||||
for r in usages:
|
||||
print(f" {r['profile_id'][:8]}... -> {r['feature_id']:30} used={r['usage_count']} reset_at={r['reset_at']}")
|
||||
|
||||
print(f"\nTotal usage records: {len(usages)}")
|
||||
76
backend/feature_logger.py
Normal file
76
backend/feature_logger.py
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
"""
|
||||
Feature Usage Logger for Mitai Jinkendo
|
||||
|
||||
Logs all feature access checks to a separate JSON log file for analysis.
|
||||
Phase 2: Non-blocking monitoring of feature usage.
|
||||
"""
|
||||
import logging
|
||||
import json
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
# ── Setup Feature Usage Logger ───────────────────────────────────────────────
|
||||
feature_usage_logger = logging.getLogger('feature_usage')
|
||||
feature_usage_logger.setLevel(logging.INFO)
|
||||
feature_usage_logger.propagate = False # Don't propagate to root logger
|
||||
|
||||
# Ensure logs directory exists
|
||||
LOG_DIR = Path('/app/logs')
|
||||
LOG_DIR.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# FileHandler for JSON logs
|
||||
log_file = LOG_DIR / 'feature-usage.log'
|
||||
file_handler = logging.FileHandler(log_file)
|
||||
file_handler.setLevel(logging.INFO)
|
||||
file_handler.setFormatter(logging.Formatter('%(message)s')) # JSON only
|
||||
feature_usage_logger.addHandler(file_handler)
|
||||
|
||||
# Also log to console in dev (optional)
|
||||
# console_handler = logging.StreamHandler()
|
||||
# console_handler.setFormatter(logging.Formatter('[FEATURE-USAGE] %(message)s'))
|
||||
# feature_usage_logger.addHandler(console_handler)
|
||||
|
||||
|
||||
# ── Logging Function ──────────────────────────────────────────────────────────
|
||||
def log_feature_usage(user_id: str, feature_id: str, access: dict, action: str):
|
||||
"""
|
||||
Log feature usage in structured JSON format.
|
||||
|
||||
Args:
|
||||
user_id: Profile UUID
|
||||
feature_id: Feature identifier (e.g., 'weight_entries', 'ai_calls')
|
||||
access: Result from check_feature_access() containing:
|
||||
- allowed: bool
|
||||
- limit: int | None
|
||||
- used: int
|
||||
- remaining: int | None
|
||||
- reason: str
|
||||
action: Type of action (e.g., 'create', 'export', 'analyze')
|
||||
|
||||
Example log entry:
|
||||
{
|
||||
"timestamp": "2026-03-20T15:30:45.123456",
|
||||
"user_id": "abc-123",
|
||||
"feature": "weight_entries",
|
||||
"action": "create",
|
||||
"used": 5,
|
||||
"limit": 100,
|
||||
"remaining": 95,
|
||||
"allowed": true,
|
||||
"reason": "within_limit"
|
||||
}
|
||||
"""
|
||||
entry = {
|
||||
"timestamp": datetime.now().isoformat(),
|
||||
"user_id": user_id,
|
||||
"feature": feature_id,
|
||||
"action": action,
|
||||
"used": access.get('used', 0),
|
||||
"limit": access.get('limit'), # None for unlimited
|
||||
"remaining": access.get('remaining'), # None for unlimited
|
||||
"allowed": access.get('allowed', True),
|
||||
"reason": access.get('reason', 'unknown')
|
||||
}
|
||||
|
||||
feature_usage_logger.info(json.dumps(entry))
|
||||
50
backend/migrations/check_features.sql
Normal file
50
backend/migrations/check_features.sql
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
-- ============================================================================
|
||||
-- Feature Check Script - Diagnose vor/nach Migration
|
||||
-- ============================================================================
|
||||
-- Usage: psql -U mitai_dev -d mitai_dev -f check_features.sql
|
||||
-- ============================================================================
|
||||
|
||||
\echo '=== CURRENT FEATURES ==='
|
||||
SELECT id, name, category, limit_type, reset_period, default_limit, active
|
||||
FROM features
|
||||
ORDER BY category, id;
|
||||
|
||||
\echo ''
|
||||
\echo '=== TIER LIMITS MATRIX ==='
|
||||
SELECT
|
||||
f.id as feature,
|
||||
f.category,
|
||||
MAX(CASE WHEN tl.tier_id = 'free' THEN COALESCE(tl.limit_value::text, '∞') END) as free,
|
||||
MAX(CASE WHEN tl.tier_id = 'basic' THEN COALESCE(tl.limit_value::text, '∞') END) as basic,
|
||||
MAX(CASE WHEN tl.tier_id = 'premium' THEN COALESCE(tl.limit_value::text, '∞') END) as premium,
|
||||
MAX(CASE WHEN tl.tier_id = 'selfhosted' THEN COALESCE(tl.limit_value::text, '∞') END) as selfhosted
|
||||
FROM features f
|
||||
LEFT JOIN tier_limits tl ON f.id = tl.feature_id
|
||||
GROUP BY f.id, f.category
|
||||
ORDER BY f.category, f.id;
|
||||
|
||||
\echo ''
|
||||
\echo '=== FEATURE COUNT BY CATEGORY ==='
|
||||
SELECT category, COUNT(*) as count
|
||||
FROM features
|
||||
WHERE active = true
|
||||
GROUP BY category
|
||||
ORDER BY category;
|
||||
|
||||
\echo ''
|
||||
\echo '=== ORPHANED TIER LIMITS (feature not exists) ==='
|
||||
SELECT tl.tier_id, tl.feature_id, tl.limit_value
|
||||
FROM tier_limits tl
|
||||
LEFT JOIN features f ON tl.feature_id = f.id
|
||||
WHERE f.id IS NULL;
|
||||
|
||||
\echo ''
|
||||
\echo '=== USER FEATURE USAGE (current usage tracking) ==='
|
||||
SELECT
|
||||
p.name as user,
|
||||
ufu.feature_id,
|
||||
ufu.usage_count,
|
||||
ufu.reset_at
|
||||
FROM user_feature_usage ufu
|
||||
JOIN profiles p ON ufu.profile_id = p.id
|
||||
ORDER BY p.name, ufu.feature_id;
|
||||
141
backend/migrations/v9c_cleanup_features.sql
Normal file
141
backend/migrations/v9c_cleanup_features.sql
Normal file
|
|
@ -0,0 +1,141 @@
|
|||
-- ============================================================================
|
||||
-- v9c Cleanup: Feature-Konsolidierung
|
||||
-- ============================================================================
|
||||
-- Created: 2026-03-20
|
||||
-- Purpose: Konsolidiere Export-Features (export_csv/json/zip → data_export)
|
||||
-- und Import-Features (csv_import → data_import)
|
||||
--
|
||||
-- Idempotent: Kann mehrfach ausgeführt werden
|
||||
--
|
||||
-- Lessons Learned:
|
||||
-- "Ein Feature für Export, nicht drei (csv/json/zip)"
|
||||
-- ============================================================================
|
||||
|
||||
-- ============================================================================
|
||||
-- 1. Rename csv_import to data_import
|
||||
-- ============================================================================
|
||||
UPDATE features
|
||||
SET
|
||||
id = 'data_import',
|
||||
name = 'Daten importieren',
|
||||
description = 'CSV-Import (FDDB, Apple Health) + ZIP-Backup-Import'
|
||||
WHERE id = 'csv_import';
|
||||
|
||||
-- Update tier_limits references
|
||||
UPDATE tier_limits
|
||||
SET feature_id = 'data_import'
|
||||
WHERE feature_id = 'csv_import';
|
||||
|
||||
-- Update user_feature_restrictions references
|
||||
UPDATE user_feature_restrictions
|
||||
SET feature_id = 'data_import'
|
||||
WHERE feature_id = 'csv_import';
|
||||
|
||||
-- Update user_feature_usage references
|
||||
UPDATE user_feature_usage
|
||||
SET feature_id = 'data_import'
|
||||
WHERE feature_id = 'csv_import';
|
||||
|
||||
-- ============================================================================
|
||||
-- 2. Remove old export_csv/json/zip features
|
||||
-- ============================================================================
|
||||
|
||||
-- Remove tier_limits for old features
|
||||
DELETE FROM tier_limits
|
||||
WHERE feature_id IN ('export_csv', 'export_json', 'export_zip');
|
||||
|
||||
-- Remove user restrictions for old features
|
||||
DELETE FROM user_feature_restrictions
|
||||
WHERE feature_id IN ('export_csv', 'export_json', 'export_zip');
|
||||
|
||||
-- Remove usage tracking for old features
|
||||
DELETE FROM user_feature_usage
|
||||
WHERE feature_id IN ('export_csv', 'export_json', 'export_zip');
|
||||
|
||||
-- Remove old feature definitions
|
||||
DELETE FROM features
|
||||
WHERE id IN ('export_csv', 'export_json', 'export_zip');
|
||||
|
||||
-- ============================================================================
|
||||
-- 3. Ensure data_export exists and is properly configured
|
||||
-- ============================================================================
|
||||
INSERT INTO features (id, name, description, category, limit_type, reset_period, default_limit, active)
|
||||
VALUES ('data_export', 'Daten exportieren', 'CSV/JSON/ZIP Export', 'export', 'count', 'monthly', 0, true)
|
||||
ON CONFLICT (id) DO UPDATE SET
|
||||
name = EXCLUDED.name,
|
||||
description = EXCLUDED.description,
|
||||
category = EXCLUDED.category,
|
||||
limit_type = EXCLUDED.limit_type,
|
||||
reset_period = EXCLUDED.reset_period;
|
||||
|
||||
-- ============================================================================
|
||||
-- 4. Ensure data_import exists and is properly configured
|
||||
-- ============================================================================
|
||||
INSERT INTO features (id, name, description, category, limit_type, reset_period, default_limit, active)
|
||||
VALUES ('data_import', 'Daten importieren', 'CSV-Import (FDDB, Apple Health) + ZIP-Backup-Import', 'import', 'count', 'monthly', 0, true)
|
||||
ON CONFLICT (id) DO UPDATE SET
|
||||
name = EXCLUDED.name,
|
||||
description = EXCLUDED.description,
|
||||
category = EXCLUDED.category,
|
||||
limit_type = EXCLUDED.limit_type,
|
||||
reset_period = EXCLUDED.reset_period;
|
||||
|
||||
-- ============================================================================
|
||||
-- 5. Update tier_limits for data_export (consolidate from old features)
|
||||
-- ============================================================================
|
||||
|
||||
-- FREE tier: no export
|
||||
INSERT INTO tier_limits (tier_id, feature_id, limit_value)
|
||||
VALUES ('free', 'data_export', 0)
|
||||
ON CONFLICT (tier_id, feature_id) DO UPDATE SET limit_value = EXCLUDED.limit_value;
|
||||
|
||||
-- BASIC tier: 5 exports/month
|
||||
INSERT INTO tier_limits (tier_id, feature_id, limit_value)
|
||||
VALUES ('basic', 'data_export', 5)
|
||||
ON CONFLICT (tier_id, feature_id) DO UPDATE SET limit_value = EXCLUDED.limit_value;
|
||||
|
||||
-- PREMIUM tier: unlimited
|
||||
INSERT INTO tier_limits (tier_id, feature_id, limit_value)
|
||||
VALUES ('premium', 'data_export', NULL)
|
||||
ON CONFLICT (tier_id, feature_id) DO UPDATE SET limit_value = EXCLUDED.limit_value;
|
||||
|
||||
-- SELFHOSTED tier: unlimited
|
||||
INSERT INTO tier_limits (tier_id, feature_id, limit_value)
|
||||
VALUES ('selfhosted', 'data_export', NULL)
|
||||
ON CONFLICT (tier_id, feature_id) DO UPDATE SET limit_value = EXCLUDED.limit_value;
|
||||
|
||||
-- ============================================================================
|
||||
-- 6. Update tier_limits for data_import
|
||||
-- ============================================================================
|
||||
|
||||
-- FREE tier: no import
|
||||
INSERT INTO tier_limits (tier_id, feature_id, limit_value)
|
||||
VALUES ('free', 'data_import', 0)
|
||||
ON CONFLICT (tier_id, feature_id) DO UPDATE SET limit_value = EXCLUDED.limit_value;
|
||||
|
||||
-- BASIC tier: 3 imports/month
|
||||
INSERT INTO tier_limits (tier_id, feature_id, limit_value)
|
||||
VALUES ('basic', 'data_import', 3)
|
||||
ON CONFLICT (tier_id, feature_id) DO UPDATE SET limit_value = EXCLUDED.limit_value;
|
||||
|
||||
-- PREMIUM tier: unlimited
|
||||
INSERT INTO tier_limits (tier_id, feature_id, limit_value)
|
||||
VALUES ('premium', 'data_import', NULL)
|
||||
ON CONFLICT (tier_id, feature_id) DO UPDATE SET limit_value = EXCLUDED.limit_value;
|
||||
|
||||
-- SELFHOSTED tier: unlimited
|
||||
INSERT INTO tier_limits (tier_id, feature_id, limit_value)
|
||||
VALUES ('selfhosted', 'data_import', NULL)
|
||||
ON CONFLICT (tier_id, feature_id) DO UPDATE SET limit_value = EXCLUDED.limit_value;
|
||||
|
||||
-- ============================================================================
|
||||
-- Cleanup complete
|
||||
-- ============================================================================
|
||||
-- Final feature list:
|
||||
-- Data: weight_entries, circumference_entries, caliper_entries,
|
||||
-- nutrition_entries, activity_entries, photos
|
||||
-- AI: ai_calls, ai_pipeline
|
||||
-- Export/Import: data_export, data_import
|
||||
--
|
||||
-- Total: 10 features (down from 13)
|
||||
-- ============================================================================
|
||||
33
backend/migrations/v9c_fix_features.sql
Normal file
33
backend/migrations/v9c_fix_features.sql
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
-- Fix missing features for v9c feature enforcement
|
||||
-- 2026-03-20
|
||||
|
||||
-- Add missing features
|
||||
INSERT INTO features (id, name, description, category, limit_type, reset_period, default_limit, active) VALUES
|
||||
('data_export', 'Daten exportieren', 'CSV/JSON/ZIP Export', 'export', 'count', 'monthly', 0, true),
|
||||
('csv_import', 'CSV importieren', 'FDDB/Apple Health CSV Import + ZIP Backup Import', 'import', 'count', 'monthly', 0, true)
|
||||
ON CONFLICT (id) DO NOTHING;
|
||||
|
||||
-- Add tier limits for new features
|
||||
-- FREE tier
|
||||
INSERT INTO tier_limits (tier_id, feature_id, limit_value) VALUES
|
||||
('free', 'data_export', 0), -- Kein Export
|
||||
('free', 'csv_import', 0) -- Kein Import
|
||||
ON CONFLICT (tier_id, feature_id) DO NOTHING;
|
||||
|
||||
-- BASIC tier
|
||||
INSERT INTO tier_limits (tier_id, feature_id, limit_value) VALUES
|
||||
('basic', 'data_export', 5), -- 5 Exporte/Monat
|
||||
('basic', 'csv_import', 3) -- 3 Imports/Monat
|
||||
ON CONFLICT (tier_id, feature_id) DO NOTHING;
|
||||
|
||||
-- PREMIUM tier
|
||||
INSERT INTO tier_limits (tier_id, feature_id, limit_value) VALUES
|
||||
('premium', 'data_export', NULL), -- Unbegrenzt
|
||||
('premium', 'csv_import', NULL) -- Unbegrenzt
|
||||
ON CONFLICT (tier_id, feature_id) DO NOTHING;
|
||||
|
||||
-- SELFHOSTED tier
|
||||
INSERT INTO tier_limits (tier_id, feature_id, limit_value) VALUES
|
||||
('selfhosted', 'data_export', NULL), -- Unbegrenzt
|
||||
('selfhosted', 'csv_import', NULL) -- Unbegrenzt
|
||||
ON CONFLICT (tier_id, feature_id) DO NOTHING;
|
||||
|
|
@ -6,16 +6,19 @@ Handles workout/activity logging, statistics, and Apple Health CSV import.
|
|||
import csv
|
||||
import io
|
||||
import uuid
|
||||
import logging
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, HTTPException, UploadFile, File, Header, Depends
|
||||
|
||||
from db import get_db, get_cursor, r2d
|
||||
from auth import require_auth
|
||||
from auth import require_auth, check_feature_access, increment_feature_usage
|
||||
from models import ActivityEntry
|
||||
from routers.profiles import get_pid
|
||||
from feature_logger import log_feature_usage
|
||||
|
||||
router = APIRouter(prefix="/api/activity", tags=["activity"])
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@router.get("")
|
||||
|
|
@ -33,6 +36,22 @@ def list_activity(limit: int=200, x_profile_id: Optional[str]=Header(default=Non
|
|||
def create_activity(e: ActivityEntry, x_profile_id: Optional[str]=Header(default=None), session: dict=Depends(require_auth)):
|
||||
"""Create new activity entry."""
|
||||
pid = get_pid(x_profile_id)
|
||||
|
||||
# Phase 4: Check feature access and ENFORCE
|
||||
access = check_feature_access(pid, 'activity_entries')
|
||||
log_feature_usage(pid, 'activity_entries', access, 'create')
|
||||
|
||||
if not access['allowed']:
|
||||
logger.warning(
|
||||
f"[FEATURE-LIMIT] User {pid} blocked: "
|
||||
f"activity_entries {access['reason']} (used: {access['used']}, limit: {access['limit']})"
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail=f"Limit erreicht: Du hast das Kontingent für Aktivitätseinträge überschritten ({access['used']}/{access['limit']}). "
|
||||
f"Bitte kontaktiere den Admin oder warte bis zum nächsten Reset."
|
||||
)
|
||||
|
||||
eid = str(uuid.uuid4())
|
||||
d = e.model_dump()
|
||||
with get_db() as conn:
|
||||
|
|
@ -44,6 +63,10 @@ def create_activity(e: ActivityEntry, x_profile_id: Optional[str]=Header(default
|
|||
(eid,pid,d['date'],d['start_time'],d['end_time'],d['activity_type'],d['duration_min'],
|
||||
d['kcal_active'],d['kcal_resting'],d['hr_avg'],d['hr_max'],d['distance_km'],
|
||||
d['rpe'],d['source'],d['notes']))
|
||||
|
||||
# Phase 2: Increment usage counter (always for new entries)
|
||||
increment_feature_usage(pid, 'activity_entries')
|
||||
|
||||
return {"id":eid,"date":e.date}
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -4,16 +4,19 @@ Caliper/Skinfold Tracking Endpoints for Mitai Jinkendo
|
|||
Handles body fat measurements via skinfold caliper (4 methods supported).
|
||||
"""
|
||||
import uuid
|
||||
import logging
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, Header, Depends
|
||||
from fastapi import APIRouter, Header, Depends, HTTPException
|
||||
|
||||
from db import get_db, get_cursor, r2d
|
||||
from auth import require_auth
|
||||
from auth import require_auth, check_feature_access, increment_feature_usage
|
||||
from models import CaliperEntry
|
||||
from routers.profiles import get_pid
|
||||
from feature_logger import log_feature_usage
|
||||
|
||||
router = APIRouter(prefix="/api/caliper", tags=["caliper"])
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@router.get("")
|
||||
|
|
@ -31,17 +34,37 @@ def list_caliper(limit: int=100, x_profile_id: Optional[str]=Header(default=None
|
|||
def upsert_caliper(e: CaliperEntry, x_profile_id: Optional[str]=Header(default=None), session: dict=Depends(require_auth)):
|
||||
"""Create or update caliper entry (upsert by date)."""
|
||||
pid = get_pid(x_profile_id)
|
||||
|
||||
# Phase 4: Check feature access and ENFORCE
|
||||
access = check_feature_access(pid, 'caliper_entries')
|
||||
log_feature_usage(pid, 'caliper_entries', access, 'create')
|
||||
|
||||
if not access['allowed']:
|
||||
logger.warning(
|
||||
f"[FEATURE-LIMIT] User {pid} blocked: "
|
||||
f"caliper_entries {access['reason']} (used: {access['used']}, limit: {access['limit']})"
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail=f"Limit erreicht: Du hast das Kontingent für Caliper-Einträge überschritten ({access['used']}/{access['limit']}). "
|
||||
f"Bitte kontaktiere den Admin oder warte bis zum nächsten Reset."
|
||||
)
|
||||
|
||||
with get_db() as conn:
|
||||
cur = get_cursor(conn)
|
||||
cur.execute("SELECT id FROM caliper_log WHERE profile_id=%s AND date=%s", (pid,e.date))
|
||||
ex = cur.fetchone()
|
||||
d = e.model_dump()
|
||||
is_new_entry = not ex
|
||||
|
||||
if ex:
|
||||
# UPDATE existing entry
|
||||
eid = ex['id']
|
||||
sets = ', '.join(f"{k}=%s" for k in d if k!='date')
|
||||
cur.execute(f"UPDATE caliper_log SET {sets} WHERE id=%s",
|
||||
[v for k,v in d.items() if k!='date']+[eid])
|
||||
else:
|
||||
# INSERT new entry
|
||||
eid = str(uuid.uuid4())
|
||||
cur.execute("""INSERT INTO caliper_log
|
||||
(id,profile_id,date,sf_method,sf_chest,sf_axilla,sf_triceps,sf_subscap,sf_suprailiac,
|
||||
|
|
@ -50,6 +73,10 @@ def upsert_caliper(e: CaliperEntry, x_profile_id: Optional[str]=Header(default=N
|
|||
(eid,pid,d['date'],d['sf_method'],d['sf_chest'],d['sf_axilla'],d['sf_triceps'],
|
||||
d['sf_subscap'],d['sf_suprailiac'],d['sf_abdomen'],d['sf_thigh'],d['sf_calf_med'],
|
||||
d['sf_lowerback'],d['sf_biceps'],d['body_fat_pct'],d['lean_mass'],d['fat_mass'],d['notes']))
|
||||
|
||||
# Phase 2: Increment usage counter (only for new entries)
|
||||
increment_feature_usage(pid, 'caliper_entries')
|
||||
|
||||
return {"id":eid,"date":e.date}
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -4,16 +4,19 @@ Circumference Tracking Endpoints for Mitai Jinkendo
|
|||
Handles body circumference measurements (8 measurement points).
|
||||
"""
|
||||
import uuid
|
||||
import logging
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, Header, Depends
|
||||
from fastapi import APIRouter, Header, Depends, HTTPException
|
||||
|
||||
from db import get_db, get_cursor, r2d
|
||||
from auth import require_auth
|
||||
from auth import require_auth, check_feature_access, increment_feature_usage
|
||||
from models import CircumferenceEntry
|
||||
from routers.profiles import get_pid
|
||||
from feature_logger import log_feature_usage
|
||||
|
||||
router = APIRouter(prefix="/api/circumferences", tags=["circumference"])
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@router.get("")
|
||||
|
|
@ -31,23 +34,47 @@ def list_circs(limit: int=100, x_profile_id: Optional[str]=Header(default=None),
|
|||
def upsert_circ(e: CircumferenceEntry, x_profile_id: Optional[str]=Header(default=None), session: dict=Depends(require_auth)):
|
||||
"""Create or update circumference entry (upsert by date)."""
|
||||
pid = get_pid(x_profile_id)
|
||||
|
||||
# Phase 4: Check feature access and ENFORCE
|
||||
access = check_feature_access(pid, 'circumference_entries')
|
||||
log_feature_usage(pid, 'circumference_entries', access, 'create')
|
||||
|
||||
if not access['allowed']:
|
||||
logger.warning(
|
||||
f"[FEATURE-LIMIT] User {pid} blocked: "
|
||||
f"circumference_entries {access['reason']} (used: {access['used']}, limit: {access['limit']})"
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail=f"Limit erreicht: Du hast das Kontingent für Umfangs-Einträge überschritten ({access['used']}/{access['limit']}). "
|
||||
f"Bitte kontaktiere den Admin oder warte bis zum nächsten Reset."
|
||||
)
|
||||
|
||||
with get_db() as conn:
|
||||
cur = get_cursor(conn)
|
||||
cur.execute("SELECT id FROM circumference_log WHERE profile_id=%s AND date=%s", (pid,e.date))
|
||||
ex = cur.fetchone()
|
||||
d = e.model_dump()
|
||||
is_new_entry = not ex
|
||||
|
||||
if ex:
|
||||
# UPDATE existing entry
|
||||
eid = ex['id']
|
||||
sets = ', '.join(f"{k}=%s" for k in d if k!='date')
|
||||
cur.execute(f"UPDATE circumference_log SET {sets} WHERE id=%s",
|
||||
[v for k,v in d.items() if k!='date']+[eid])
|
||||
else:
|
||||
# INSERT new entry
|
||||
eid = str(uuid.uuid4())
|
||||
cur.execute("""INSERT INTO circumference_log
|
||||
(id,profile_id,date,c_neck,c_chest,c_waist,c_belly,c_hip,c_thigh,c_calf,c_arm,notes,photo_id,created)
|
||||
VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,CURRENT_TIMESTAMP)""",
|
||||
(eid,pid,d['date'],d['c_neck'],d['c_chest'],d['c_waist'],d['c_belly'],
|
||||
d['c_hip'],d['c_thigh'],d['c_calf'],d['c_arm'],d['notes'],d['photo_id']))
|
||||
|
||||
# Phase 2: Increment usage counter (only for new entries)
|
||||
increment_feature_usage(pid, 'circumference_entries')
|
||||
|
||||
return {"id":eid,"date":e.date}
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import os
|
|||
import csv
|
||||
import io
|
||||
import json
|
||||
import logging
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
|
@ -17,10 +18,12 @@ from fastapi import APIRouter, HTTPException, Header, Depends
|
|||
from fastapi.responses import StreamingResponse, Response
|
||||
|
||||
from db import get_db, get_cursor, r2d
|
||||
from auth import require_auth
|
||||
from auth import require_auth, check_feature_access, increment_feature_usage
|
||||
from routers.profiles import get_pid
|
||||
from feature_logger import log_feature_usage
|
||||
|
||||
router = APIRouter(prefix="/api/export", tags=["export"])
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
PHOTOS_DIR = Path(os.getenv("PHOTOS_DIR", "./photos"))
|
||||
|
||||
|
|
@ -30,13 +33,20 @@ def export_csv(x_profile_id: Optional[str]=Header(default=None), session: dict=D
|
|||
"""Export all data as CSV."""
|
||||
pid = get_pid(x_profile_id)
|
||||
|
||||
# Check export permission
|
||||
with get_db() as conn:
|
||||
cur = get_cursor(conn)
|
||||
cur.execute("SELECT export_enabled FROM profiles WHERE id=%s", (pid,))
|
||||
prof = cur.fetchone()
|
||||
if not prof or not prof['export_enabled']:
|
||||
raise HTTPException(403, "Export ist für dieses Profil deaktiviert")
|
||||
# Phase 4: Check feature access and ENFORCE
|
||||
access = check_feature_access(pid, 'data_export')
|
||||
log_feature_usage(pid, 'data_export', access, 'export_csv')
|
||||
|
||||
if not access['allowed']:
|
||||
logger.warning(
|
||||
f"[FEATURE-LIMIT] User {pid} blocked: "
|
||||
f"data_export {access['reason']} (used: {access['used']}, limit: {access['limit']})"
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail=f"Limit erreicht: Du hast das Kontingent für Daten-Exporte überschritten ({access['used']}/{access['limit']}). "
|
||||
f"Bitte kontaktiere den Admin oder warte bis zum nächsten Reset."
|
||||
)
|
||||
|
||||
# Build CSV
|
||||
output = io.StringIO()
|
||||
|
|
@ -74,6 +84,10 @@ def export_csv(x_profile_id: Optional[str]=Header(default=None), session: dict=D
|
|||
writer.writerow(["Training", r['date'], r['activity_type'], f"{float(r['duration_min'])}min {float(r['kcal_active'])}kcal"])
|
||||
|
||||
output.seek(0)
|
||||
|
||||
# Phase 2: Increment usage counter
|
||||
increment_feature_usage(pid, 'data_export')
|
||||
|
||||
return StreamingResponse(
|
||||
iter([output.getvalue()]),
|
||||
media_type="text/csv",
|
||||
|
|
@ -86,13 +100,20 @@ def export_json(x_profile_id: Optional[str]=Header(default=None), session: dict=
|
|||
"""Export all data as JSON."""
|
||||
pid = get_pid(x_profile_id)
|
||||
|
||||
# Check export permission
|
||||
with get_db() as conn:
|
||||
cur = get_cursor(conn)
|
||||
cur.execute("SELECT export_enabled FROM profiles WHERE id=%s", (pid,))
|
||||
prof = cur.fetchone()
|
||||
if not prof or not prof['export_enabled']:
|
||||
raise HTTPException(403, "Export ist für dieses Profil deaktiviert")
|
||||
# Phase 4: Check feature access and ENFORCE
|
||||
access = check_feature_access(pid, 'data_export')
|
||||
log_feature_usage(pid, 'data_export', access, 'export_json')
|
||||
|
||||
if not access['allowed']:
|
||||
logger.warning(
|
||||
f"[FEATURE-LIMIT] User {pid} blocked: "
|
||||
f"data_export {access['reason']} (used: {access['used']}, limit: {access['limit']})"
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail=f"Limit erreicht: Du hast das Kontingent für Daten-Exporte überschritten ({access['used']}/{access['limit']}). "
|
||||
f"Bitte kontaktiere den Admin oder warte bis zum nächsten Reset."
|
||||
)
|
||||
|
||||
# Collect all data
|
||||
data = {}
|
||||
|
|
@ -126,6 +147,10 @@ def export_json(x_profile_id: Optional[str]=Header(default=None), session: dict=
|
|||
return str(obj)
|
||||
|
||||
json_str = json.dumps(data, indent=2, default=decimal_handler)
|
||||
|
||||
# Phase 2: Increment usage counter
|
||||
increment_feature_usage(pid, 'data_export')
|
||||
|
||||
return Response(
|
||||
content=json_str,
|
||||
media_type="application/json",
|
||||
|
|
@ -138,13 +163,26 @@ def export_zip(x_profile_id: Optional[str]=Header(default=None), session: dict=D
|
|||
"""Export all data as ZIP (CSV + JSON + photos) per specification."""
|
||||
pid = get_pid(x_profile_id)
|
||||
|
||||
# Check export permission & get profile
|
||||
# Phase 4: Check feature access and ENFORCE
|
||||
access = check_feature_access(pid, 'data_export')
|
||||
log_feature_usage(pid, 'data_export', access, 'export_zip')
|
||||
|
||||
if not access['allowed']:
|
||||
logger.warning(
|
||||
f"[FEATURE-LIMIT] User {pid} blocked: "
|
||||
f"data_export {access['reason']} (used: {access['used']}, limit: {access['limit']})"
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail=f"Limit erreicht: Du hast das Kontingent für Daten-Exporte überschritten ({access['used']}/{access['limit']}). "
|
||||
f"Bitte kontaktiere den Admin oder warte bis zum nächsten Reset."
|
||||
)
|
||||
|
||||
# Get profile
|
||||
with get_db() as conn:
|
||||
cur = get_cursor(conn)
|
||||
cur.execute("SELECT * FROM profiles WHERE id=%s", (pid,))
|
||||
prof = r2d(cur.fetchone())
|
||||
if not prof or not prof.get('export_enabled'):
|
||||
raise HTTPException(403, "Export ist für dieses Profil deaktiviert")
|
||||
|
||||
# Helper: CSV writer with UTF-8 BOM + semicolon
|
||||
def write_csv(zf, filename, rows, columns):
|
||||
|
|
@ -297,6 +335,10 @@ Datumsformat: YYYY-MM-DD
|
|||
|
||||
zip_buffer.seek(0)
|
||||
filename = f"mitai-export-{profile_name.replace(' ','-')}-{export_date}.zip"
|
||||
|
||||
# Phase 2: Increment usage counter
|
||||
increment_feature_usage(pid, 'data_export')
|
||||
|
||||
return StreamingResponse(
|
||||
iter([zip_buffer.getvalue()]),
|
||||
media_type="application/zip",
|
||||
|
|
|
|||
|
|
@ -2,11 +2,16 @@
|
|||
Feature Management Endpoints for Mitai Jinkendo
|
||||
|
||||
Admin-only CRUD for features registry.
|
||||
User endpoint for feature usage overview (Phase 3).
|
||||
"""
|
||||
from fastapi import APIRouter, HTTPException, Depends
|
||||
from typing import Optional
|
||||
from datetime import datetime
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Header, Depends
|
||||
|
||||
from db import get_db, get_cursor, r2d
|
||||
from auth import require_admin
|
||||
from auth import require_admin, require_auth, check_feature_access
|
||||
from routers.profiles import get_pid
|
||||
|
||||
router = APIRouter(prefix="/api/features", tags=["features"])
|
||||
|
||||
|
|
@ -119,3 +124,100 @@ def delete_feature(feature_id: str, session: dict = Depends(require_admin)):
|
|||
cur.execute("UPDATE features SET active = false WHERE id = %s", (feature_id,))
|
||||
conn.commit()
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@router.get("/{feature_id}/check-access")
|
||||
def check_access(feature_id: str, session: dict = Depends(require_auth)):
|
||||
"""
|
||||
User: Check if current user can access a feature.
|
||||
|
||||
Returns:
|
||||
- allowed: bool - whether user can use the feature
|
||||
- limit: int|null - total limit (null = unlimited)
|
||||
- used: int - current usage
|
||||
- remaining: int|null - remaining uses (null = unlimited)
|
||||
- reason: str - why access is granted/denied
|
||||
"""
|
||||
profile_id = session['profile_id']
|
||||
result = check_feature_access(profile_id, feature_id)
|
||||
return result
|
||||
|
||||
|
||||
@router.get("/usage")
|
||||
def get_feature_usage(x_profile_id: Optional[str]=Header(default=None), session: dict=Depends(require_auth)):
|
||||
"""
|
||||
User: Get usage overview for all active features (Phase 3: Frontend Display).
|
||||
|
||||
Returns list of all features with current usage, limits, and reset info.
|
||||
Automatically includes new features from database - no code changes needed.
|
||||
|
||||
Response:
|
||||
[
|
||||
{
|
||||
"feature_id": "weight_entries",
|
||||
"name": "Gewichtseinträge",
|
||||
"description": "Anzahl der Gewichtseinträge",
|
||||
"category": "data",
|
||||
"limit_type": "count",
|
||||
"reset_period": "never",
|
||||
"used": 5,
|
||||
"limit": 10,
|
||||
"remaining": 5,
|
||||
"allowed": true,
|
||||
"reset_at": null
|
||||
},
|
||||
...
|
||||
]
|
||||
"""
|
||||
pid = get_pid(x_profile_id)
|
||||
|
||||
with get_db() as conn:
|
||||
cur = get_cursor(conn)
|
||||
|
||||
# Get all active features (dynamic - picks up new features automatically)
|
||||
cur.execute("""
|
||||
SELECT id, name, description, category, limit_type, reset_period
|
||||
FROM features
|
||||
WHERE active = true
|
||||
ORDER BY category, name
|
||||
""")
|
||||
features = [r2d(r) for r in cur.fetchall()]
|
||||
|
||||
result = []
|
||||
for feature in features:
|
||||
# Use existing check_feature_access to get usage and limits
|
||||
# This respects user overrides, tier limits, and feature defaults
|
||||
# Pass connection to avoid pool exhaustion
|
||||
access = check_feature_access(pid, feature['id'], conn)
|
||||
|
||||
# Get reset date from user_feature_usage
|
||||
cur.execute("""
|
||||
SELECT reset_at
|
||||
FROM user_feature_usage
|
||||
WHERE profile_id = %s AND feature_id = %s
|
||||
""", (pid, feature['id']))
|
||||
usage_row = cur.fetchone()
|
||||
|
||||
# Format reset_at as ISO string
|
||||
reset_at = None
|
||||
if usage_row and usage_row['reset_at']:
|
||||
if isinstance(usage_row['reset_at'], datetime):
|
||||
reset_at = usage_row['reset_at'].isoformat()
|
||||
else:
|
||||
reset_at = str(usage_row['reset_at'])
|
||||
|
||||
result.append({
|
||||
'feature_id': feature['id'],
|
||||
'name': feature['name'],
|
||||
'description': feature.get('description'),
|
||||
'category': feature.get('category'),
|
||||
'limit_type': feature['limit_type'],
|
||||
'reset_period': feature['reset_period'],
|
||||
'used': access['used'],
|
||||
'limit': access['limit'],
|
||||
'remaining': access['remaining'],
|
||||
'allowed': access['allowed'],
|
||||
'reset_at': reset_at
|
||||
})
|
||||
|
||||
return result
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import csv
|
|||
import io
|
||||
import json
|
||||
import uuid
|
||||
import logging
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
|
@ -16,10 +17,12 @@ from datetime import datetime
|
|||
from fastapi import APIRouter, HTTPException, UploadFile, File, Header, Depends
|
||||
|
||||
from db import get_db, get_cursor
|
||||
from auth import require_auth
|
||||
from auth import require_auth, check_feature_access, increment_feature_usage
|
||||
from routers.profiles import get_pid
|
||||
from feature_logger import log_feature_usage
|
||||
|
||||
router = APIRouter(prefix="/api/import", tags=["import"])
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
PHOTOS_DIR = Path(os.getenv("PHOTOS_DIR", "./photos"))
|
||||
|
||||
|
|
@ -41,6 +44,21 @@ async def import_zip(
|
|||
"""
|
||||
pid = get_pid(x_profile_id)
|
||||
|
||||
# Phase 4: Check feature access and ENFORCE
|
||||
access = check_feature_access(pid, 'data_import')
|
||||
log_feature_usage(pid, 'data_import', access, 'import_zip')
|
||||
|
||||
if not access['allowed']:
|
||||
logger.warning(
|
||||
f"[FEATURE-LIMIT] User {pid} blocked: "
|
||||
f"data_import {access['reason']} (used: {access['used']}, limit: {access['limit']})"
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail=f"Limit erreicht: Du hast das Kontingent für Daten-Importe überschritten ({access['used']}/{access['limit']}). "
|
||||
f"Bitte kontaktiere den Admin oder warte bis zum nächsten Reset."
|
||||
)
|
||||
|
||||
# Read uploaded file
|
||||
content = await file.read()
|
||||
zip_buffer = io.BytesIO(content)
|
||||
|
|
@ -254,6 +272,9 @@ async def import_zip(
|
|||
conn.rollback()
|
||||
raise HTTPException(500, f"Import fehlgeschlagen: {str(e)}")
|
||||
|
||||
# Phase 2: Increment usage counter
|
||||
increment_feature_usage(pid, 'data_import')
|
||||
|
||||
return {
|
||||
"ok": True,
|
||||
"message": "Import erfolgreich",
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ Handles AI analysis execution, prompt management, and usage tracking.
|
|||
import os
|
||||
import json
|
||||
import uuid
|
||||
import logging
|
||||
import httpx
|
||||
from typing import Optional
|
||||
from datetime import datetime
|
||||
|
|
@ -13,10 +14,12 @@ from datetime import datetime
|
|||
from fastapi import APIRouter, HTTPException, Header, Depends
|
||||
|
||||
from db import get_db, get_cursor, r2d
|
||||
from auth import require_auth, require_admin
|
||||
from auth import require_auth, require_admin, check_feature_access, increment_feature_usage
|
||||
from routers.profiles import get_pid
|
||||
from feature_logger import log_feature_usage
|
||||
|
||||
router = APIRouter(prefix="/api", tags=["insights"])
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
OPENROUTER_KEY = os.getenv("OPENROUTER_API_KEY", "")
|
||||
OPENROUTER_MODEL = os.getenv("OPENROUTER_MODEL", "anthropic/claude-sonnet-4")
|
||||
|
|
@ -251,7 +254,21 @@ def delete_ai_insight(scope: str, x_profile_id: Optional[str]=Header(default=Non
|
|||
async def analyze_with_prompt(slug: str, x_profile_id: Optional[str]=Header(default=None), session: dict=Depends(require_auth)):
|
||||
"""Run AI analysis with specified prompt template."""
|
||||
pid = get_pid(x_profile_id)
|
||||
check_ai_limit(pid)
|
||||
|
||||
# Phase 4: Check feature access and ENFORCE
|
||||
access = check_feature_access(pid, 'ai_calls')
|
||||
log_feature_usage(pid, 'ai_calls', access, 'analyze')
|
||||
|
||||
if not access['allowed']:
|
||||
logger.warning(
|
||||
f"[FEATURE-LIMIT] User {pid} blocked: "
|
||||
f"ai_calls {access['reason']} (used: {access['used']}, limit: {access['limit']})"
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail=f"Limit erreicht: Du hast das Kontingent für KI-Analysen überschritten ({access['used']}/{access['limit']}). "
|
||||
f"Bitte kontaktiere den Admin oder warte bis zum nächsten Reset."
|
||||
)
|
||||
|
||||
# Get prompt template
|
||||
with get_db() as conn:
|
||||
|
|
@ -294,14 +311,18 @@ async def analyze_with_prompt(slug: str, x_profile_id: Optional[str]=Header(defa
|
|||
else:
|
||||
raise HTTPException(500, "Keine KI-API konfiguriert")
|
||||
|
||||
# Save insight
|
||||
# Save insight (with history - no DELETE)
|
||||
with get_db() as conn:
|
||||
cur = get_cursor(conn)
|
||||
cur.execute("DELETE FROM ai_insights WHERE profile_id=%s AND scope=%s", (pid, slug))
|
||||
cur.execute("INSERT INTO ai_insights (id, profile_id, scope, content, created) VALUES (%s,%s,%s,%s,CURRENT_TIMESTAMP)",
|
||||
(str(uuid.uuid4()), pid, slug, content))
|
||||
|
||||
# Phase 2: Increment new feature usage counter
|
||||
increment_feature_usage(pid, 'ai_calls')
|
||||
|
||||
# Old usage tracking (keep for now)
|
||||
inc_ai_usage(pid)
|
||||
|
||||
return {"scope": slug, "content": content}
|
||||
|
||||
|
||||
|
|
@ -309,7 +330,35 @@ async def analyze_with_prompt(slug: str, x_profile_id: Optional[str]=Header(defa
|
|||
async def analyze_pipeline(x_profile_id: Optional[str]=Header(default=None), session: dict=Depends(require_auth)):
|
||||
"""Run 3-stage pipeline analysis."""
|
||||
pid = get_pid(x_profile_id)
|
||||
check_ai_limit(pid)
|
||||
|
||||
# Phase 4: Check pipeline feature access (boolean - enabled/disabled)
|
||||
access_pipeline = check_feature_access(pid, 'ai_pipeline')
|
||||
log_feature_usage(pid, 'ai_pipeline', access_pipeline, 'pipeline')
|
||||
|
||||
if not access_pipeline['allowed']:
|
||||
logger.warning(
|
||||
f"[FEATURE-LIMIT] User {pid} blocked: "
|
||||
f"ai_pipeline {access_pipeline['reason']}"
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail=f"Pipeline-Analyse ist nicht verfügbar. Bitte kontaktiere den Admin."
|
||||
)
|
||||
|
||||
# Also check ai_calls (pipeline uses API calls too)
|
||||
access_calls = check_feature_access(pid, 'ai_calls')
|
||||
log_feature_usage(pid, 'ai_calls', access_calls, 'pipeline_calls')
|
||||
|
||||
if not access_calls['allowed']:
|
||||
logger.warning(
|
||||
f"[FEATURE-LIMIT] User {pid} blocked: "
|
||||
f"ai_calls {access_calls['reason']} (used: {access_calls['used']}, limit: {access_calls['limit']})"
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail=f"Limit erreicht: Du hast das Kontingent für KI-Analysen überschritten ({access_calls['used']}/{access_calls['limit']}). "
|
||||
f"Bitte kontaktiere den Admin oder warte bis zum nächsten Reset."
|
||||
)
|
||||
|
||||
data = _get_profile_data(pid)
|
||||
vars = _prepare_template_vars(data)
|
||||
|
|
@ -431,15 +480,20 @@ async def analyze_pipeline(x_profile_id: Optional[str]=Header(default=None), ses
|
|||
if goals_text:
|
||||
final_content += "\n\n" + goals_text
|
||||
|
||||
# Save as 'gesamt' scope
|
||||
# Save as 'pipeline' scope (with history - no DELETE)
|
||||
with get_db() as conn:
|
||||
cur = get_cursor(conn)
|
||||
cur.execute("DELETE FROM ai_insights WHERE profile_id=%s AND scope='gesamt'", (pid,))
|
||||
cur.execute("INSERT INTO ai_insights (id, profile_id, scope, content, created) VALUES (%s,%s,'gesamt',%s,CURRENT_TIMESTAMP)",
|
||||
cur.execute("INSERT INTO ai_insights (id, profile_id, scope, content, created) VALUES (%s,%s,'pipeline',%s,CURRENT_TIMESTAMP)",
|
||||
(str(uuid.uuid4()), pid, final_content))
|
||||
|
||||
# Phase 2: Increment ai_calls usage (pipeline uses multiple API calls)
|
||||
# Note: We increment once per pipeline run, not per individual call
|
||||
increment_feature_usage(pid, 'ai_calls')
|
||||
|
||||
# Old usage tracking (keep for now)
|
||||
inc_ai_usage(pid)
|
||||
return {"scope": "gesamt", "content": final_content, "stage1": stage1_results}
|
||||
|
||||
return {"scope": "pipeline", "content": final_content, "stage1": stage1_results}
|
||||
|
||||
|
||||
@router.get("/ai/usage")
|
||||
|
|
|
|||
|
|
@ -6,16 +6,19 @@ Handles nutrition data, FDDB CSV import, correlations, and weekly aggregates.
|
|||
import csv
|
||||
import io
|
||||
import uuid
|
||||
import logging
|
||||
from typing import Optional
|
||||
from datetime import datetime
|
||||
|
||||
from fastapi import APIRouter, HTTPException, UploadFile, File, Header, Depends
|
||||
|
||||
from db import get_db, get_cursor, r2d
|
||||
from auth import require_auth
|
||||
from auth import require_auth, check_feature_access, increment_feature_usage
|
||||
from routers.profiles import get_pid
|
||||
from feature_logger import log_feature_usage
|
||||
|
||||
router = APIRouter(prefix="/api/nutrition", tags=["nutrition"])
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# ── Helper ────────────────────────────────────────────────────────────────────
|
||||
|
|
@ -30,6 +33,23 @@ def _pf(s):
|
|||
async def import_nutrition_csv(file: UploadFile=File(...), x_profile_id: Optional[str]=Header(default=None), session: dict=Depends(require_auth)):
|
||||
"""Import FDDB nutrition CSV."""
|
||||
pid = get_pid(x_profile_id)
|
||||
|
||||
# Phase 4: Check feature access and ENFORCE
|
||||
# Note: CSV import can create many entries - we check once before import
|
||||
access = check_feature_access(pid, 'nutrition_entries')
|
||||
log_feature_usage(pid, 'nutrition_entries', access, 'import_csv')
|
||||
|
||||
if not access['allowed']:
|
||||
logger.warning(
|
||||
f"[FEATURE-LIMIT] User {pid} blocked: "
|
||||
f"nutrition_entries {access['reason']} (used: {access['used']}, limit: {access['limit']})"
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail=f"Limit erreicht: Du hast das Kontingent für Ernährungseinträge überschritten ({access['used']}/{access['limit']}). "
|
||||
f"Bitte kontaktiere den Admin oder warte bis zum nächsten Reset."
|
||||
)
|
||||
|
||||
raw = await file.read()
|
||||
try: text = raw.decode('utf-8')
|
||||
except: text = raw.decode('latin-1')
|
||||
|
|
@ -52,23 +72,88 @@ async def import_nutrition_csv(file: UploadFile=File(...), x_profile_id: Optiona
|
|||
days[iso]['protein_g'] += _pf(row.get('protein_g',0))
|
||||
count+=1
|
||||
inserted=0
|
||||
new_entries=0
|
||||
with get_db() as conn:
|
||||
cur = get_cursor(conn)
|
||||
for iso,vals in days.items():
|
||||
kcal=round(vals['kcal'],1); fat=round(vals['fat_g'],1)
|
||||
carbs=round(vals['carbs_g'],1); prot=round(vals['protein_g'],1)
|
||||
cur.execute("SELECT id FROM nutrition_log WHERE profile_id=%s AND date=%s",(pid,iso))
|
||||
if cur.fetchone():
|
||||
is_new = not cur.fetchone()
|
||||
if not is_new:
|
||||
# UPDATE existing
|
||||
cur.execute("UPDATE nutrition_log SET kcal=%s,protein_g=%s,fat_g=%s,carbs_g=%s WHERE profile_id=%s AND date=%s",
|
||||
(kcal,prot,fat,carbs,pid,iso))
|
||||
else:
|
||||
# INSERT new
|
||||
cur.execute("INSERT INTO nutrition_log (id,profile_id,date,kcal,protein_g,fat_g,carbs_g,source,created) VALUES (%s,%s,%s,%s,%s,%s,%s,'csv',CURRENT_TIMESTAMP)",
|
||||
(str(uuid.uuid4()),pid,iso,kcal,prot,fat,carbs))
|
||||
new_entries += 1
|
||||
inserted+=1
|
||||
return {"rows_parsed":count,"days_imported":inserted,
|
||||
|
||||
# Phase 2: Increment usage counter for each new entry created
|
||||
for _ in range(new_entries):
|
||||
increment_feature_usage(pid, 'nutrition_entries')
|
||||
|
||||
return {"rows_parsed":count,"days_imported":inserted,"new_entries":new_entries,
|
||||
"date_range":{"from":min(days) if days else None,"to":max(days) if days else None}}
|
||||
|
||||
|
||||
@router.post("")
|
||||
def create_nutrition(date: str, kcal: float, protein_g: float, fat_g: float, carbs_g: float,
|
||||
x_profile_id: Optional[str]=Header(default=None), session: dict=Depends(require_auth)):
|
||||
"""Create or update nutrition entry for a specific date."""
|
||||
pid = get_pid(x_profile_id)
|
||||
|
||||
# Validate date format
|
||||
try:
|
||||
datetime.strptime(date, '%Y-%m-%d')
|
||||
except ValueError:
|
||||
raise HTTPException(400, "Ungültiges Datumsformat. Erwartet: YYYY-MM-DD")
|
||||
|
||||
with get_db() as conn:
|
||||
cur = get_cursor(conn)
|
||||
# Check if entry exists
|
||||
cur.execute("SELECT id FROM nutrition_log WHERE profile_id=%s AND date=%s", (pid, date))
|
||||
existing = cur.fetchone()
|
||||
|
||||
if existing:
|
||||
# UPDATE existing entry
|
||||
cur.execute("""
|
||||
UPDATE nutrition_log
|
||||
SET kcal=%s, protein_g=%s, fat_g=%s, carbs_g=%s, source='manual'
|
||||
WHERE id=%s AND profile_id=%s
|
||||
""", (round(kcal,1), round(protein_g,1), round(fat_g,1), round(carbs_g,1), existing['id'], pid))
|
||||
return {"success": True, "mode": "updated", "id": existing['id']}
|
||||
else:
|
||||
# Phase 4: Check feature access before INSERT
|
||||
access = check_feature_access(pid, 'nutrition_entries')
|
||||
log_feature_usage(pid, 'nutrition_entries', access, 'create')
|
||||
|
||||
if not access['allowed']:
|
||||
logger.warning(
|
||||
f"[FEATURE-LIMIT] User {pid} blocked: "
|
||||
f"nutrition_entries {access['reason']} (used: {access['used']}, limit: {access['limit']})"
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail=f"Limit erreicht: Du hast das Kontingent für Ernährungseinträge überschritten ({access['used']}/{access['limit']}). "
|
||||
f"Bitte kontaktiere den Admin oder warte bis zum nächsten Reset."
|
||||
)
|
||||
|
||||
# INSERT new entry
|
||||
new_id = str(uuid.uuid4())
|
||||
cur.execute("""
|
||||
INSERT INTO nutrition_log (id, profile_id, date, kcal, protein_g, fat_g, carbs_g, source, created)
|
||||
VALUES (%s, %s, %s, %s, %s, %s, %s, 'manual', CURRENT_TIMESTAMP)
|
||||
""", (new_id, pid, date, round(kcal,1), round(protein_g,1), round(fat_g,1), round(carbs_g,1)))
|
||||
|
||||
# Phase 2: Increment usage counter
|
||||
increment_feature_usage(pid, 'nutrition_entries')
|
||||
|
||||
return {"success": True, "mode": "created", "id": new_id}
|
||||
|
||||
|
||||
@router.get("")
|
||||
def list_nutrition(limit: int=365, x_profile_id: Optional[str]=Header(default=None), session: dict=Depends(require_auth)):
|
||||
"""Get nutrition entries for current profile."""
|
||||
|
|
@ -80,6 +165,17 @@ def list_nutrition(limit: int=365, x_profile_id: Optional[str]=Header(default=No
|
|||
return [r2d(r) for r in cur.fetchall()]
|
||||
|
||||
|
||||
@router.get("/by-date/{date}")
|
||||
def get_nutrition_by_date(date: str, x_profile_id: Optional[str]=Header(default=None), session: dict=Depends(require_auth)):
|
||||
"""Get nutrition entry for a specific date."""
|
||||
pid = get_pid(x_profile_id)
|
||||
with get_db() as conn:
|
||||
cur = get_cursor(conn)
|
||||
cur.execute("SELECT * FROM nutrition_log WHERE profile_id=%s AND date=%s", (pid, date))
|
||||
row = cur.fetchone()
|
||||
return r2d(row) if row else None
|
||||
|
||||
|
||||
@router.get("/correlations")
|
||||
def nutrition_correlations(x_profile_id: Optional[str]=Header(default=None), session: dict=Depends(require_auth)):
|
||||
"""Get nutrition data correlated with weight and body fat."""
|
||||
|
|
@ -123,7 +219,9 @@ def nutrition_weekly(weeks: int=16, x_profile_id: Optional[str]=Header(default=N
|
|||
if not rows: return []
|
||||
wm={}
|
||||
for d in rows:
|
||||
wk=datetime.strptime(d['date'],'%Y-%m-%d').strftime('%Y-W%V')
|
||||
# Handle both datetime.date objects (from DB) and strings
|
||||
date_obj = d['date'] if hasattr(d['date'], 'strftime') else datetime.strptime(d['date'],'%Y-%m-%d')
|
||||
wk = date_obj.strftime('%Y-W%V')
|
||||
wm.setdefault(wk,[]).append(d)
|
||||
result=[]
|
||||
for wk in sorted(wm):
|
||||
|
|
@ -131,3 +229,61 @@ def nutrition_weekly(weeks: int=16, x_profile_id: Optional[str]=Header(default=N
|
|||
def avg(k): return round(sum(float(e.get(k) or 0) for e in en)/n,1)
|
||||
result.append({'week':wk,'days':n,'kcal':avg('kcal'),'protein_g':avg('protein_g'),'fat_g':avg('fat_g'),'carbs_g':avg('carbs_g')})
|
||||
return result
|
||||
|
||||
|
||||
@router.get("/import-history")
|
||||
def import_history(x_profile_id: Optional[str]=Header(default=None), session: dict=Depends(require_auth)):
|
||||
"""Get import history by grouping entries by created timestamp."""
|
||||
pid = get_pid(x_profile_id)
|
||||
with get_db() as conn:
|
||||
cur = get_cursor(conn)
|
||||
cur.execute("""
|
||||
SELECT
|
||||
DATE(created) as import_date,
|
||||
COUNT(*) as count,
|
||||
MIN(date) as date_from,
|
||||
MAX(date) as date_to,
|
||||
MAX(created) as last_created
|
||||
FROM nutrition_log
|
||||
WHERE profile_id=%s AND source='csv'
|
||||
GROUP BY DATE(created)
|
||||
ORDER BY DATE(created) DESC
|
||||
""", (pid,))
|
||||
return [r2d(r) for r in cur.fetchall()]
|
||||
|
||||
|
||||
@router.put("/{entry_id}")
|
||||
def update_nutrition(entry_id: str, kcal: float, protein_g: float, fat_g: float, carbs_g: float,
|
||||
x_profile_id: Optional[str]=Header(default=None), session: dict=Depends(require_auth)):
|
||||
"""Update nutrition entry macros."""
|
||||
pid = get_pid(x_profile_id)
|
||||
with get_db() as conn:
|
||||
cur = get_cursor(conn)
|
||||
# Verify ownership
|
||||
cur.execute("SELECT id FROM nutrition_log WHERE id=%s AND profile_id=%s", (entry_id, pid))
|
||||
if not cur.fetchone():
|
||||
raise HTTPException(404, "Eintrag nicht gefunden")
|
||||
|
||||
cur.execute("""
|
||||
UPDATE nutrition_log
|
||||
SET kcal=%s, protein_g=%s, fat_g=%s, carbs_g=%s
|
||||
WHERE id=%s AND profile_id=%s
|
||||
""", (round(kcal,1), round(protein_g,1), round(fat_g,1), round(carbs_g,1), entry_id, pid))
|
||||
|
||||
return {"success": True}
|
||||
|
||||
|
||||
@router.delete("/{entry_id}")
|
||||
def delete_nutrition(entry_id: str, x_profile_id: Optional[str]=Header(default=None), session: dict=Depends(require_auth)):
|
||||
"""Delete nutrition entry."""
|
||||
pid = get_pid(x_profile_id)
|
||||
with get_db() as conn:
|
||||
cur = get_cursor(conn)
|
||||
# Verify ownership
|
||||
cur.execute("SELECT id FROM nutrition_log WHERE id=%s AND profile_id=%s", (entry_id, pid))
|
||||
if not cur.fetchone():
|
||||
raise HTTPException(404, "Eintrag nicht gefunden")
|
||||
|
||||
cur.execute("DELETE FROM nutrition_log WHERE id=%s AND profile_id=%s", (entry_id, pid))
|
||||
|
||||
return {"success": True}
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ Handles progress photo uploads and retrieval.
|
|||
"""
|
||||
import os
|
||||
import uuid
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
|
|
@ -13,10 +14,12 @@ from fastapi.responses import FileResponse
|
|||
import aiofiles
|
||||
|
||||
from db import get_db, get_cursor, r2d
|
||||
from auth import require_auth, require_auth_flexible
|
||||
from auth import require_auth, require_auth_flexible, check_feature_access, increment_feature_usage
|
||||
from routers.profiles import get_pid
|
||||
from feature_logger import log_feature_usage
|
||||
|
||||
router = APIRouter(prefix="/api/photos", tags=["photos"])
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
PHOTOS_DIR = Path(os.getenv("PHOTOS_DIR", "./photos"))
|
||||
PHOTOS_DIR.mkdir(parents=True, exist_ok=True)
|
||||
|
|
@ -27,6 +30,22 @@ async def upload_photo(file: UploadFile=File(...), date: str="",
|
|||
x_profile_id: Optional[str]=Header(default=None), session: dict=Depends(require_auth)):
|
||||
"""Upload progress photo."""
|
||||
pid = get_pid(x_profile_id)
|
||||
|
||||
# Phase 4: Check feature access and ENFORCE
|
||||
access = check_feature_access(pid, 'photos')
|
||||
log_feature_usage(pid, 'photos', access, 'upload')
|
||||
|
||||
if not access['allowed']:
|
||||
logger.warning(
|
||||
f"[FEATURE-LIMIT] User {pid} blocked: "
|
||||
f"photos {access['reason']} (used: {access['used']}, limit: {access['limit']})"
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail=f"Limit erreicht: Du hast das Kontingent für Fotos überschritten ({access['used']}/{access['limit']}). "
|
||||
f"Bitte kontaktiere den Admin oder warte bis zum nächsten Reset."
|
||||
)
|
||||
|
||||
fid = str(uuid.uuid4())
|
||||
ext = Path(file.filename).suffix or '.jpg'
|
||||
path = PHOTOS_DIR / f"{fid}{ext}"
|
||||
|
|
@ -35,6 +54,10 @@ async def upload_photo(file: UploadFile=File(...), date: str="",
|
|||
cur = get_cursor(conn)
|
||||
cur.execute("INSERT INTO photos (id,profile_id,date,path,created) VALUES (%s,%s,%s,%s,CURRENT_TIMESTAMP)",
|
||||
(fid,pid,date,str(path)))
|
||||
|
||||
# Phase 2: Increment usage counter
|
||||
increment_feature_usage(pid, 'photos')
|
||||
|
||||
return {"id":fid,"date":date}
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -29,13 +29,13 @@ def get_tier_limits_matrix(session: dict = Depends(require_admin)):
|
|||
with get_db() as conn:
|
||||
cur = get_cursor(conn)
|
||||
|
||||
# Get all tiers
|
||||
cur.execute("SELECT id, name, sort_order FROM tiers WHERE active = true ORDER BY sort_order")
|
||||
# Get all tiers (including inactive - admin needs to configure all)
|
||||
cur.execute("SELECT id, name, sort_order FROM tiers ORDER BY sort_order")
|
||||
tiers = [r2d(r) for r in cur.fetchall()]
|
||||
|
||||
# Get all features
|
||||
cur.execute("""
|
||||
SELECT id, name, category, limit_type, default_limit
|
||||
SELECT id, name, category, limit_type, default_limit, reset_period
|
||||
FROM features
|
||||
WHERE active = true
|
||||
ORDER BY category, name
|
||||
|
|
|
|||
|
|
@ -4,16 +4,19 @@ Weight Tracking Endpoints for Mitai Jinkendo
|
|||
Handles weight log CRUD operations and statistics.
|
||||
"""
|
||||
import uuid
|
||||
import logging
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, Header, Depends
|
||||
from fastapi import APIRouter, Header, Depends, HTTPException
|
||||
|
||||
from db import get_db, get_cursor, r2d
|
||||
from auth import require_auth
|
||||
from auth import require_auth, check_feature_access, increment_feature_usage
|
||||
from models import WeightEntry
|
||||
from routers.profiles import get_pid
|
||||
from feature_logger import log_feature_usage
|
||||
|
||||
router = APIRouter(prefix="/api/weight", tags=["weight"])
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@router.get("")
|
||||
|
|
@ -31,17 +34,44 @@ def list_weight(limit: int=365, x_profile_id: Optional[str]=Header(default=None)
|
|||
def upsert_weight(e: WeightEntry, x_profile_id: Optional[str]=Header(default=None), session: dict=Depends(require_auth)):
|
||||
"""Create or update weight entry (upsert by date)."""
|
||||
pid = get_pid(x_profile_id)
|
||||
|
||||
# Phase 4: Check feature access and ENFORCE
|
||||
access = check_feature_access(pid, 'weight_entries')
|
||||
|
||||
# Structured logging (always)
|
||||
log_feature_usage(pid, 'weight_entries', access, 'create')
|
||||
|
||||
# BLOCK if limit exceeded
|
||||
if not access['allowed']:
|
||||
logger.warning(
|
||||
f"[FEATURE-LIMIT] User {pid} blocked: "
|
||||
f"weight_entries {access['reason']} (used: {access['used']}, limit: {access['limit']})"
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail=f"Limit erreicht: Du hast das Kontingent für Gewichtseinträge überschritten ({access['used']}/{access['limit']}). "
|
||||
f"Bitte kontaktiere den Admin oder warte bis zum nächsten Reset."
|
||||
)
|
||||
|
||||
with get_db() as conn:
|
||||
cur = get_cursor(conn)
|
||||
cur.execute("SELECT id FROM weight_log WHERE profile_id=%s AND date=%s", (pid,e.date))
|
||||
ex = cur.fetchone()
|
||||
is_new_entry = not ex
|
||||
|
||||
if ex:
|
||||
# UPDATE existing entry
|
||||
cur.execute("UPDATE weight_log SET weight=%s,note=%s WHERE id=%s", (e.weight,e.note,ex['id']))
|
||||
wid = ex['id']
|
||||
else:
|
||||
# INSERT new entry
|
||||
wid = str(uuid.uuid4())
|
||||
cur.execute("INSERT INTO weight_log (id,profile_id,date,weight,note,created) VALUES (%s,%s,%s,%s,%s,CURRENT_TIMESTAMP)",
|
||||
(wid,pid,e.date,e.weight,e.note))
|
||||
|
||||
# Phase 2: Increment usage counter (only for new entries)
|
||||
increment_feature_usage(pid, 'weight_entries')
|
||||
|
||||
return {"id":wid,"date":e.date,"weight":e.weight}
|
||||
|
||||
|
||||
|
|
|
|||
1058
docs/MEMBERSHIP_SYSTEM.md
Normal file
1058
docs/MEMBERSHIP_SYSTEM.md
Normal file
File diff suppressed because it is too large
Load Diff
6766
frontend/package-lock.json
generated
Normal file
6766
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
|
|
@ -20,6 +20,12 @@ 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 AdminFeaturesPage from './pages/AdminFeaturesPage'
|
||||
import AdminTiersPage from './pages/AdminTiersPage'
|
||||
import AdminCouponsPage from './pages/AdminCouponsPage'
|
||||
import AdminUserRestrictionsPage from './pages/AdminUserRestrictionsPage'
|
||||
import SubscriptionPage from './pages/SubscriptionPage'
|
||||
import './app.css'
|
||||
|
||||
function Nav() {
|
||||
|
|
@ -115,6 +121,12 @@ function AppShell() {
|
|||
<Route path="/analysis" element={<Analysis/>}/>
|
||||
<Route path="/settings" element={<SettingsPage/>}/>
|
||||
<Route path="/guide" element={<GuidePage/>}/>
|
||||
<Route path="/admin/tier-limits" element={<AdminTierLimitsPage/>}/>
|
||||
<Route path="/admin/features" element={<AdminFeaturesPage/>}/>
|
||||
<Route path="/admin/tiers" element={<AdminTiersPage/>}/>
|
||||
<Route path="/admin/coupons" element={<AdminCouponsPage/>}/>
|
||||
<Route path="/admin/user-restrictions" element={<AdminUserRestrictionsPage/>}/>
|
||||
<Route path="/subscription" element={<SubscriptionPage/>}/>
|
||||
</Routes>
|
||||
</main>
|
||||
<Nav/>
|
||||
|
|
|
|||
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>
|
||||
)
|
||||
}
|
||||
103
frontend/src/components/UsageBadge.css
Normal file
103
frontend/src/components/UsageBadge.css
Normal file
|
|
@ -0,0 +1,103 @@
|
|||
/**
|
||||
* UsageBadge Styles - Dezente Version
|
||||
*
|
||||
* Sehr kleine, subtile Badge für Usage-Anzeige
|
||||
* Phase 3: Frontend Display
|
||||
*/
|
||||
|
||||
.usage-badge {
|
||||
display: inline-block;
|
||||
font-size: 0.65rem;
|
||||
font-weight: 500;
|
||||
padding: 2px 5px;
|
||||
border-radius: 3px;
|
||||
margin-left: 10px;
|
||||
opacity: 0.6;
|
||||
font-variant-numeric: tabular-nums;
|
||||
white-space: nowrap;
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
|
||||
.usage-badge:hover {
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.usage-badge--ok {
|
||||
color: var(--text3, #888);
|
||||
background: rgba(136, 135, 128, 0.08);
|
||||
}
|
||||
|
||||
.usage-badge--warning {
|
||||
color: #d97706;
|
||||
background: rgba(217, 119, 6, 0.08);
|
||||
}
|
||||
|
||||
.usage-badge--exceeded {
|
||||
color: var(--danger, #D85A30);
|
||||
background: rgba(216, 90, 48, 0.1);
|
||||
font-weight: 600;
|
||||
opacity: 0.75;
|
||||
}
|
||||
|
||||
.usage-badge--exceeded:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* Responsive: Even smaller on mobile */
|
||||
@media (max-width: 640px) {
|
||||
.usage-badge {
|
||||
font-size: 0.6rem;
|
||||
padding: 1px 4px;
|
||||
margin-left: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
/* ============================================================================
|
||||
Badge Container Styles - Positioning
|
||||
============================================================================
|
||||
|
||||
Zentrale Konfiguration für Badge-Platzierung in verschiedenen Kontexten.
|
||||
Alle Anpassungen an Layout/Spacing hier vornehmen!
|
||||
*/
|
||||
|
||||
/* Badge rechts außen (für Headings/Titles) */
|
||||
.badge-container-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
/* Badge rechts im Button (mit Beschreibung darunter) */
|
||||
.badge-button-layout {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.badge-button-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.badge-button-description {
|
||||
font-size: 11px;
|
||||
opacity: 0.8;
|
||||
display: block;
|
||||
margin-top: 2px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
/* Mobile Anpassungen */
|
||||
@media (max-width: 640px) {
|
||||
.badge-container-right {
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.badge-button-description {
|
||||
font-size: 10px;
|
||||
}
|
||||
}
|
||||
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>
|
||||
)
|
||||
}
|
||||
|
|
@ -2,6 +2,7 @@ import { useState, useEffect, useRef } from 'react'
|
|||
import { Upload, Pencil, Trash2, Check, X, CheckCircle } from 'lucide-react'
|
||||
import { BarChart, Bar, XAxis, YAxis, Tooltip, ResponsiveContainer, CartesianGrid } from 'recharts'
|
||||
import { api } from '../utils/api'
|
||||
import UsageBadge from '../components/UsageBadge'
|
||||
import dayjs from 'dayjs'
|
||||
import 'dayjs/locale/de'
|
||||
dayjs.locale('de')
|
||||
|
|
@ -79,7 +80,7 @@ function ImportPanel({ onImported }) {
|
|||
}
|
||||
|
||||
// ── Manual Entry ──────────────────────────────────────────────────────────────
|
||||
function EntryForm({ form, setForm, onSave, onCancel, saveLabel='Speichern' }) {
|
||||
function EntryForm({ form, setForm, onSave, onCancel, saveLabel='Speichern', saving=false, error=null, usage=null }) {
|
||||
const set = (k,v) => setForm(f=>({...f,[k]:v}))
|
||||
return (
|
||||
<div>
|
||||
|
|
@ -130,8 +131,25 @@ function EntryForm({ form, setForm, onSave, onCancel, saveLabel='Speichern' }) {
|
|||
value={form.notes||''} onChange={e=>set('notes',e.target.value)}/>
|
||||
<span className="form-unit"/>
|
||||
</div>
|
||||
{error && (
|
||||
<div style={{padding:'10px',background:'var(--danger-bg)',border:'1px solid var(--danger)',borderRadius:8,fontSize:13,color:'var(--danger)',marginBottom:8}}>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
<div style={{display:'flex',gap:6,marginTop:8}}>
|
||||
<button className="btn btn-primary" style={{flex:1}} onClick={onSave}>{saveLabel}</button>
|
||||
<div
|
||||
title={usage && !usage.allowed ? `Limit erreicht (${usage.used}/${usage.limit}). Kontaktiere den Admin oder warte bis zum nächsten Reset.` : ''}
|
||||
style={{flex:1,display:'inline-block'}}
|
||||
>
|
||||
<button
|
||||
className="btn btn-primary"
|
||||
style={{width:'100%', cursor: (usage && !usage.allowed) ? 'not-allowed' : 'pointer'}}
|
||||
onClick={onSave}
|
||||
disabled={saving || (usage && !usage.allowed)}
|
||||
>
|
||||
{(usage && !usage.allowed) ? '🔒 Limit erreicht' : saveLabel}
|
||||
</button>
|
||||
</div>
|
||||
{onCancel && <button className="btn btn-secondary" style={{flex:1}} onClick={onCancel}><X size={13}/> Abbrechen</button>}
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -145,25 +163,51 @@ export default function ActivityPage() {
|
|||
const [tab, setTab] = useState('list')
|
||||
const [form, setForm] = useState(empty())
|
||||
const [editing, setEditing] = useState(null)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [saved, setSaved] = useState(false)
|
||||
const [error, setError] = useState(null)
|
||||
const [activityUsage, setActivityUsage] = useState(null) // Phase 4: Usage badge
|
||||
|
||||
const load = async () => {
|
||||
const [e, s] = await Promise.all([api.listActivity(), api.activityStats()])
|
||||
setEntries(e); setStats(s)
|
||||
}
|
||||
useEffect(()=>{ load() },[])
|
||||
|
||||
const loadUsage = () => {
|
||||
api.getFeatureUsage().then(features => {
|
||||
const activityFeature = features.find(f => f.feature_id === 'activity_entries')
|
||||
setActivityUsage(activityFeature)
|
||||
}).catch(err => console.error('Failed to load usage:', err))
|
||||
}
|
||||
|
||||
useEffect(()=>{
|
||||
load()
|
||||
loadUsage()
|
||||
},[])
|
||||
|
||||
const handleSave = async () => {
|
||||
const payload = {...form}
|
||||
if(payload.duration_min) payload.duration_min = parseFloat(payload.duration_min)
|
||||
if(payload.kcal_active) payload.kcal_active = parseFloat(payload.kcal_active)
|
||||
if(payload.hr_avg) payload.hr_avg = parseFloat(payload.hr_avg)
|
||||
if(payload.hr_max) payload.hr_max = parseFloat(payload.hr_max)
|
||||
if(payload.rpe) payload.rpe = parseInt(payload.rpe)
|
||||
payload.source = 'manual'
|
||||
await api.createActivity(payload)
|
||||
setSaved(true); await load()
|
||||
setTimeout(()=>{ setSaved(false); setForm(empty()) }, 1500)
|
||||
setSaving(true)
|
||||
setError(null)
|
||||
try {
|
||||
const payload = {...form}
|
||||
if(payload.duration_min) payload.duration_min = parseFloat(payload.duration_min)
|
||||
if(payload.kcal_active) payload.kcal_active = parseFloat(payload.kcal_active)
|
||||
if(payload.hr_avg) payload.hr_avg = parseFloat(payload.hr_avg)
|
||||
if(payload.hr_max) payload.hr_max = parseFloat(payload.hr_max)
|
||||
if(payload.rpe) payload.rpe = parseInt(payload.rpe)
|
||||
payload.source = 'manual'
|
||||
await api.createActivity(payload)
|
||||
setSaved(true)
|
||||
await load()
|
||||
await loadUsage() // Reload usage after save
|
||||
setTimeout(()=>{ setSaved(false); setForm(empty()) }, 1500)
|
||||
} catch (err) {
|
||||
console.error('Save failed:', err)
|
||||
setError(err.message || 'Fehler beim Speichern')
|
||||
setTimeout(()=>setError(null), 5000)
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleUpdate = async () => {
|
||||
|
|
@ -225,9 +269,13 @@ export default function ActivityPage() {
|
|||
|
||||
{tab==='add' && (
|
||||
<div className="card section-gap">
|
||||
<div className="card-title">Training eintragen</div>
|
||||
<div className="card-title badge-container-right">
|
||||
<span>Training eintragen</span>
|
||||
{activityUsage && <UsageBadge {...activityUsage} />}
|
||||
</div>
|
||||
<EntryForm form={form} setForm={setForm}
|
||||
onSave={handleSave} saveLabel={saved?'✓ Gespeichert!':'Speichern'}/>
|
||||
onSave={handleSave} saveLabel={saved?'✓ Gespeichert!':'Speichern'}
|
||||
saving={saving} error={error} usage={activityUsage}/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
|
|
|||
523
frontend/src/pages/AdminCouponsPage.jsx
Normal file
523
frontend/src/pages/AdminCouponsPage.jsx
Normal file
|
|
@ -0,0 +1,523 @@
|
|||
import { useState, useEffect } from 'react'
|
||||
import { Save, Plus, Edit2, Trash2, X, Eye, Gift, Ticket } from 'lucide-react'
|
||||
import { api } from '../utils/api'
|
||||
|
||||
export default function AdminCouponsPage() {
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState('')
|
||||
const [success, setSuccess] = useState('')
|
||||
const [coupons, setCoupons] = useState([])
|
||||
const [editingId, setEditingId] = useState(null)
|
||||
const [showAddForm, setShowAddForm] = useState(false)
|
||||
const [viewingRedemptions, setViewingRedemptions] = useState(null)
|
||||
const [redemptions, setRedemptions] = useState([])
|
||||
const [formData, setFormData] = useState({
|
||||
code: '',
|
||||
type: 'single_use',
|
||||
valid_from: '',
|
||||
valid_until: '',
|
||||
grants_tier: 'premium',
|
||||
duration_days: 30,
|
||||
max_redemptions: 1,
|
||||
notes: '',
|
||||
active: true
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
loadCoupons()
|
||||
}, [])
|
||||
|
||||
async function loadCoupons() {
|
||||
try {
|
||||
setLoading(true)
|
||||
const data = await api.listCoupons()
|
||||
setCoupons(data)
|
||||
setError('')
|
||||
} catch (e) {
|
||||
setError(e.message)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function loadRedemptions(couponId) {
|
||||
try {
|
||||
const data = await api.getCouponRedemptions(couponId)
|
||||
setRedemptions(data)
|
||||
setViewingRedemptions(couponId)
|
||||
} catch (e) {
|
||||
setError(e.message)
|
||||
}
|
||||
}
|
||||
|
||||
function resetForm() {
|
||||
setFormData({
|
||||
code: '',
|
||||
type: 'single_use',
|
||||
valid_from: '',
|
||||
valid_until: '',
|
||||
grants_tier: 'premium',
|
||||
duration_days: 30,
|
||||
max_redemptions: 1,
|
||||
notes: '',
|
||||
active: true
|
||||
})
|
||||
setEditingId(null)
|
||||
setShowAddForm(false)
|
||||
}
|
||||
|
||||
function startEdit(coupon) {
|
||||
setFormData({
|
||||
code: coupon.code,
|
||||
type: coupon.type,
|
||||
valid_from: coupon.valid_from ? coupon.valid_from.split('T')[0] : '',
|
||||
valid_until: coupon.valid_until ? coupon.valid_until.split('T')[0] : '',
|
||||
grants_tier: coupon.grants_tier,
|
||||
duration_days: coupon.duration_days || 30,
|
||||
max_redemptions: coupon.max_redemptions || 1,
|
||||
notes: coupon.notes || '',
|
||||
active: coupon.active
|
||||
})
|
||||
setEditingId(coupon.id)
|
||||
setShowAddForm(false)
|
||||
}
|
||||
|
||||
function startAdd() {
|
||||
// Generate random code
|
||||
const randomCode = `${formData.type === 'gift' ? 'GIFT' : 'PROMO'}-${Math.random().toString(36).substr(2, 8).toUpperCase()}`
|
||||
setFormData({ ...formData, code: randomCode })
|
||||
setShowAddForm(true)
|
||||
setEditingId(null)
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
try {
|
||||
setError('')
|
||||
setSuccess('')
|
||||
|
||||
if (!formData.code.trim()) {
|
||||
setError('Code erforderlich')
|
||||
return
|
||||
}
|
||||
|
||||
const payload = {
|
||||
code: formData.code.trim().toUpperCase(),
|
||||
type: formData.type,
|
||||
valid_from: formData.valid_from || null,
|
||||
valid_until: formData.valid_until || null,
|
||||
grants_tier: formData.grants_tier,
|
||||
duration_days: parseInt(formData.duration_days) || null,
|
||||
max_redemptions: formData.type === 'single_use' ? 1 : (parseInt(formData.max_redemptions) || null),
|
||||
notes: formData.notes.trim(),
|
||||
active: formData.active
|
||||
}
|
||||
|
||||
if (editingId) {
|
||||
await api.updateCoupon(editingId, payload)
|
||||
setSuccess('Coupon aktualisiert')
|
||||
} else {
|
||||
await api.createCoupon(payload)
|
||||
setSuccess('Coupon erstellt')
|
||||
}
|
||||
|
||||
await loadCoupons()
|
||||
resetForm()
|
||||
} catch (e) {
|
||||
setError(e.message)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDelete(couponId) {
|
||||
if (!confirm('Coupon wirklich löschen?')) return
|
||||
try {
|
||||
setError('')
|
||||
await api.deleteCoupon(couponId)
|
||||
setSuccess('Coupon gelöscht')
|
||||
await loadCoupons()
|
||||
} catch (e) {
|
||||
setError(e.message)
|
||||
}
|
||||
}
|
||||
|
||||
function formatDate(dateStr) {
|
||||
if (!dateStr) return '-'
|
||||
return new Date(dateStr).toLocaleDateString('de-DE')
|
||||
}
|
||||
|
||||
if (loading) return (
|
||||
<div style={{ display: 'flex', justifyContent: 'center', padding: 40 }}>
|
||||
<div className="spinner" />
|
||||
</div>
|
||||
)
|
||||
|
||||
const couponTypes = [
|
||||
{ value: 'single_use', label: 'Single-Use (einmalig)', icon: '🎟️' },
|
||||
{ value: 'multi_use_period', label: 'Multi-Use Period (Zeitraum)', icon: '🔄' },
|
||||
{ value: 'gift', label: 'Geschenk-Coupon', icon: '🎁' }
|
||||
]
|
||||
|
||||
const tierOptions = [
|
||||
{ value: 'free', label: 'Free' },
|
||||
{ value: 'basic', label: 'Basic' },
|
||||
{ value: 'premium', label: 'Premium' }
|
||||
]
|
||||
|
||||
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)' }}>
|
||||
Coupon-Verwaltung
|
||||
</div>
|
||||
<div style={{ fontSize: 13, color: 'var(--text3)', marginTop: 4 }}>
|
||||
Trial-Codes, Wellpass-Integration, Geschenk-Coupons
|
||||
</div>
|
||||
</div>
|
||||
{!showAddForm && !editingId && (
|
||||
<button className="btn btn-primary" onClick={startAdd}>
|
||||
<Plus size={16} /> Neuer Coupon
|
||||
</button>
|
||||
)}
|
||||
</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>
|
||||
)}
|
||||
|
||||
{/* Add/Edit Form */}
|
||||
{(showAddForm || editingId) && (
|
||||
<div className="card" style={{ padding: 20, marginBottom: 20 }}>
|
||||
<div style={{
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
|
||||
marginBottom: 16
|
||||
}}>
|
||||
<div style={{ fontSize: 16, fontWeight: 600 }}>
|
||||
{editingId ? 'Coupon bearbeiten' : 'Neuer Coupon'}
|
||||
</div>
|
||||
<button
|
||||
className="btn btn-secondary"
|
||||
onClick={resetForm}
|
||||
style={{ padding: '6px 12px' }}
|
||||
>
|
||||
<X size={16} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'grid', gap: 16 }}>
|
||||
{/* Code */}
|
||||
<div>
|
||||
<label className="form-label" style={{ display: 'block', marginBottom: 6 }}>
|
||||
Coupon-Code *
|
||||
</label>
|
||||
<input
|
||||
className="form-input"
|
||||
style={{ width: '100%', fontFamily: 'monospace', textTransform: 'uppercase' }}
|
||||
value={formData.code}
|
||||
onChange={(e) => setFormData({ ...formData, code: e.target.value })}
|
||||
placeholder="Z.B. PROMO-2026"
|
||||
/>
|
||||
<div style={{ fontSize: 11, color: 'var(--text3)', marginTop: 4 }}>
|
||||
Wird automatisch in Großbuchstaben konvertiert
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Type */}
|
||||
<div>
|
||||
<label className="form-label" style={{ display: 'block', marginBottom: 6 }}>
|
||||
Coupon-Typ
|
||||
</label>
|
||||
<select
|
||||
className="form-input"
|
||||
style={{ width: '100%' }}
|
||||
value={formData.type}
|
||||
onChange={(e) => setFormData({ ...formData, type: e.target.value })}
|
||||
>
|
||||
{couponTypes.map(t => (
|
||||
<option key={t.value} value={t.value}>
|
||||
{t.icon} {t.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<div style={{ fontSize: 11, color: 'var(--text3)', marginTop: 4 }}>
|
||||
{formData.type === 'single_use' && 'Kann nur einmal pro User eingelöst werden'}
|
||||
{formData.type === 'multi_use_period' && 'Unbegrenzte Einlösungen im Gültigkeitszeitraum (z.B. Wellpass)'}
|
||||
{formData.type === 'gift' && 'Geschenk-Coupon für Bonus-System'}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tier */}
|
||||
<div>
|
||||
<label className="form-label" style={{ display: 'block', marginBottom: 6 }}>
|
||||
Gewährt Tier
|
||||
</label>
|
||||
<select
|
||||
className="form-input"
|
||||
style={{ width: '100%' }}
|
||||
value={formData.grants_tier}
|
||||
onChange={(e) => setFormData({ ...formData, grants_tier: e.target.value })}
|
||||
>
|
||||
{tierOptions.map(t => (
|
||||
<option key={t.value} value={t.value}>{t.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Duration (only for single_use and gift) */}
|
||||
{(formData.type === 'single_use' || formData.type === 'gift') && (
|
||||
<div>
|
||||
<label className="form-label" style={{ display: 'block', marginBottom: 6 }}>
|
||||
Gültigkeitsdauer (Tage)
|
||||
</label>
|
||||
<input
|
||||
className="form-input"
|
||||
style={{ width: '100%' }}
|
||||
type="number"
|
||||
value={formData.duration_days}
|
||||
onChange={(e) => setFormData({ ...formData, duration_days: e.target.value })}
|
||||
/>
|
||||
<div style={{ fontSize: 11, color: 'var(--text3)', marginTop: 4 }}>
|
||||
Wie lange ist der gewährte Zugriff gültig?
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Valid From/Until (for multi_use_period) */}
|
||||
{formData.type === 'multi_use_period' && (
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12 }}>
|
||||
<div>
|
||||
<label className="form-label" style={{ display: 'block', marginBottom: 6 }}>
|
||||
Gültig ab
|
||||
</label>
|
||||
<input
|
||||
className="form-input"
|
||||
style={{ width: '100%' }}
|
||||
type="date"
|
||||
value={formData.valid_from}
|
||||
onChange={(e) => setFormData({ ...formData, valid_from: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="form-label" style={{ display: 'block', marginBottom: 6 }}>
|
||||
Gültig bis
|
||||
</label>
|
||||
<input
|
||||
className="form-input"
|
||||
style={{ width: '100%' }}
|
||||
type="date"
|
||||
value={formData.valid_until}
|
||||
onChange={(e) => setFormData({ ...formData, valid_until: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Max Redemptions (not for single_use) */}
|
||||
{formData.type !== 'single_use' && (
|
||||
<div>
|
||||
<label className="form-label" style={{ display: 'block', marginBottom: 6 }}>
|
||||
Maximale Einlösungen (optional)
|
||||
</label>
|
||||
<input
|
||||
className="form-input"
|
||||
style={{ width: '100%' }}
|
||||
type="number"
|
||||
value={formData.max_redemptions}
|
||||
onChange={(e) => setFormData({ ...formData, max_redemptions: e.target.value })}
|
||||
placeholder="Leer = unbegrenzt"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Notes */}
|
||||
<div>
|
||||
<label className="form-label" style={{ display: 'block', marginBottom: 6 }}>
|
||||
Notizen (optional)
|
||||
</label>
|
||||
<input
|
||||
className="form-input"
|
||||
style={{ width: '100%' }}
|
||||
value={formData.notes}
|
||||
onChange={(e) => setFormData({ ...formData, notes: e.target.value })}
|
||||
placeholder="Z.B. 'Für Marketing-Kampagne März 2026'"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Active */}
|
||||
<div>
|
||||
<label style={{ display: 'flex', alignItems: 'center', gap: 6, fontSize: 13 }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={formData.active}
|
||||
onChange={(e) => setFormData({ ...formData, active: e.target.checked })}
|
||||
/>
|
||||
Coupon aktiviert (kann eingelöst werden)
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div style={{ display: 'flex', gap: 8, marginTop: 8 }}>
|
||||
<button className="btn btn-primary" onClick={handleSave}>
|
||||
<Save size={14} /> {editingId ? 'Aktualisieren' : 'Erstellen'}
|
||||
</button>
|
||||
<button className="btn btn-secondary" onClick={resetForm}>
|
||||
Abbrechen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Redemptions Modal */}
|
||||
{viewingRedemptions && (
|
||||
<div style={{
|
||||
position: 'fixed', top: 0, left: 0, right: 0, bottom: 0,
|
||||
background: 'rgba(0,0,0,0.5)', zIndex: 1000,
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
padding: 20
|
||||
}} onClick={() => setViewingRedemptions(null)}>
|
||||
<div className="card" style={{ padding: 20, maxWidth: 600, width: '100%', maxHeight: '80vh', overflow: 'auto' }}
|
||||
onClick={(e) => e.stopPropagation()}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 16 }}>
|
||||
<div style={{ fontSize: 16, fontWeight: 600 }}>Einlösungen</div>
|
||||
<button className="btn btn-secondary" onClick={() => setViewingRedemptions(null)} style={{ padding: '6px 12px' }}>
|
||||
<X size={16} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{redemptions.length === 0 ? (
|
||||
<div style={{ textAlign: 'center', padding: 40, color: 'var(--text3)' }}>
|
||||
Noch keine Einlösungen
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ display: 'grid', gap: 8 }}>
|
||||
{redemptions.map(r => (
|
||||
<div key={r.id} className="card" style={{ padding: 12, background: 'var(--surface)' }}>
|
||||
<div style={{ fontWeight: 500 }}>{r.profile_name || `User #${r.profile_id}`}</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--text3)', marginTop: 2 }}>
|
||||
Eingelöst am {formatDate(r.redeemed_at)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Coupons List */}
|
||||
<div style={{ display: 'grid', gap: 12 }}>
|
||||
{coupons.length === 0 && (
|
||||
<div className="card" style={{ padding: 40, textAlign: 'center', color: 'var(--text3)' }}>
|
||||
Keine Coupons vorhanden
|
||||
</div>
|
||||
)}
|
||||
{coupons.map(coupon => (
|
||||
<div
|
||||
key={coupon.id}
|
||||
className="card"
|
||||
style={{
|
||||
padding: 16,
|
||||
opacity: coupon.active ? 1 : 0.6,
|
||||
border: coupon.active ? '1px solid var(--border)' : '1px dashed var(--border)'
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', gap: 12 }}>
|
||||
<div style={{ flex: 1 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 4 }}>
|
||||
<div style={{
|
||||
fontSize: 16, fontWeight: 700, color: 'var(--text1)',
|
||||
fontFamily: 'monospace', background: 'var(--surface2)',
|
||||
padding: '4px 8px', borderRadius: 4
|
||||
}}>
|
||||
{coupon.code}
|
||||
</div>
|
||||
<span style={{
|
||||
padding: '2px 8px', borderRadius: 4, fontSize: 11,
|
||||
background: 'var(--accent-light)', color: 'var(--accent-dark)', fontWeight: 600
|
||||
}}>
|
||||
{couponTypes.find(t => t.value === coupon.type)?.icon} {couponTypes.find(t => t.value === coupon.type)?.label}
|
||||
</span>
|
||||
{!coupon.active && (
|
||||
<span style={{
|
||||
padding: '2px 8px', borderRadius: 4, fontSize: 10,
|
||||
background: 'var(--danger)', color: 'white', fontWeight: 600
|
||||
}}>
|
||||
INAKTIV
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div style={{ fontSize: 13, color: 'var(--text2)', marginBottom: 8 }}>
|
||||
Gewährt: <strong>{coupon.grants_tier}</strong>
|
||||
{coupon.duration_days && ` für ${coupon.duration_days} Tage`}
|
||||
</div>
|
||||
|
||||
{coupon.notes && (
|
||||
<div style={{ fontSize: 12, color: 'var(--text3)', marginBottom: 8 }}>
|
||||
📝 {coupon.notes}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={{ display: 'flex', gap: 16, fontSize: 12, color: 'var(--text3)' }}>
|
||||
{coupon.valid_from && (
|
||||
<div><strong>Gültig ab:</strong> {formatDate(coupon.valid_from)}</div>
|
||||
)}
|
||||
{coupon.valid_until && (
|
||||
<div><strong>Gültig bis:</strong> {formatDate(coupon.valid_until)}</div>
|
||||
)}
|
||||
<div>
|
||||
<strong>Einlösungen:</strong> {coupon.current_redemptions || 0}
|
||||
{coupon.max_redemptions ? ` / ${coupon.max_redemptions}` : ' / ∞'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', gap: 4, flexShrink: 0 }}>
|
||||
<button
|
||||
className="btn btn-secondary"
|
||||
onClick={() => loadRedemptions(coupon.id)}
|
||||
style={{ padding: '6px 12px', fontSize: 12 }}
|
||||
title="Einlösungen anzeigen"
|
||||
>
|
||||
<Eye size={14} />
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-secondary"
|
||||
onClick={() => startEdit(coupon)}
|
||||
style={{ padding: '6px 12px', fontSize: 12 }}
|
||||
>
|
||||
<Edit2 size={14} />
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-secondary"
|
||||
onClick={() => handleDelete(coupon.id)}
|
||||
style={{ padding: '6px 12px', fontSize: 12, color: 'var(--danger)' }}
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
480
frontend/src/pages/AdminFeaturesPage.jsx
Normal file
480
frontend/src/pages/AdminFeaturesPage.jsx
Normal file
|
|
@ -0,0 +1,480 @@
|
|||
import { useState, useEffect } from 'react'
|
||||
import { Save, Edit2, X, Info } from 'lucide-react'
|
||||
import { api } from '../utils/api'
|
||||
|
||||
export default function AdminFeaturesPage() {
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState('')
|
||||
const [success, setSuccess] = useState('')
|
||||
const [features, setFeatures] = useState([])
|
||||
const [editingId, setEditingId] = useState(null)
|
||||
const [formData, setFormData] = useState({
|
||||
name: '',
|
||||
category: 'data',
|
||||
description: '',
|
||||
limit_type: 'count',
|
||||
default_limit: '',
|
||||
reset_period: 'never',
|
||||
visible_in_admin: true,
|
||||
sort_order: 50,
|
||||
active: true
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
loadFeatures()
|
||||
}, [])
|
||||
|
||||
async function loadFeatures() {
|
||||
try {
|
||||
setLoading(true)
|
||||
const data = await api.listFeatures()
|
||||
setFeatures(data)
|
||||
setError('')
|
||||
} catch (e) {
|
||||
setError(e.message)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
function resetForm() {
|
||||
setFormData({
|
||||
name: '',
|
||||
category: 'data',
|
||||
description: '',
|
||||
limit_type: 'count',
|
||||
default_limit: '',
|
||||
reset_period: 'never',
|
||||
visible_in_admin: true,
|
||||
sort_order: 50,
|
||||
active: true
|
||||
})
|
||||
setEditingId(null)
|
||||
}
|
||||
|
||||
function startEdit(feature) {
|
||||
setFormData({
|
||||
name: feature.name,
|
||||
category: feature.category,
|
||||
description: feature.description || '',
|
||||
limit_type: feature.limit_type,
|
||||
default_limit: feature.default_limit === null ? '' : feature.default_limit,
|
||||
reset_period: feature.reset_period,
|
||||
visible_in_admin: feature.visible_in_admin,
|
||||
sort_order: feature.sort_order || 50,
|
||||
active: feature.active
|
||||
})
|
||||
setEditingId(feature.id)
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
try {
|
||||
setError('')
|
||||
setSuccess('')
|
||||
|
||||
if (!formData.name.trim()) {
|
||||
setError('Name erforderlich')
|
||||
return
|
||||
}
|
||||
|
||||
const payload = {
|
||||
name: formData.name.trim(),
|
||||
category: formData.category,
|
||||
description: formData.description.trim(),
|
||||
limit_type: formData.limit_type,
|
||||
default_limit: formData.default_limit === '' ? null : parseInt(formData.default_limit),
|
||||
reset_period: formData.reset_period,
|
||||
visible_in_admin: formData.visible_in_admin,
|
||||
sort_order: formData.sort_order,
|
||||
active: formData.active
|
||||
}
|
||||
|
||||
await api.updateFeature(editingId, payload)
|
||||
setSuccess('Feature aktualisiert')
|
||||
await loadFeatures()
|
||||
resetForm()
|
||||
} catch (e) {
|
||||
setError(e.message)
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) return (
|
||||
<div style={{ display: 'flex', justifyContent: 'center', padding: 40 }}>
|
||||
<div className="spinner" />
|
||||
</div>
|
||||
)
|
||||
|
||||
const categoryOptions = [
|
||||
{ value: 'data', label: 'Daten' },
|
||||
{ value: 'ai', label: 'KI' },
|
||||
{ value: 'export', label: 'Export' },
|
||||
{ value: 'integration', label: 'Integrationen' }
|
||||
]
|
||||
|
||||
const resetPeriodOptions = [
|
||||
{ value: 'never', label: 'Nie (akkumuliert)' },
|
||||
{ value: 'daily', label: 'Täglich' },
|
||||
{ value: 'monthly', label: 'Monatlich' }
|
||||
]
|
||||
|
||||
const limitTypeOptions = [
|
||||
{ value: 'count', label: 'Anzahl (Count)' },
|
||||
{ value: 'boolean', label: 'Ja/Nein (Boolean)' }
|
||||
]
|
||||
|
||||
return (
|
||||
<div style={{ paddingBottom: 80 }}>
|
||||
{/* Header */}
|
||||
<div style={{ marginBottom: 20 }}>
|
||||
<div style={{ fontSize: 20, fontWeight: 700, color: 'var(--text1)' }}>
|
||||
Feature-Konfiguration
|
||||
</div>
|
||||
<div style={{ fontSize: 13, color: 'var(--text3)', marginTop: 4 }}>
|
||||
Limitierungs-Einstellungen für registrierte Features
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Info Box */}
|
||||
<div style={{
|
||||
padding: 12, background: 'var(--accent-light)', borderRadius: 8,
|
||||
marginBottom: 16, fontSize: 12, color: 'var(--accent-dark)',
|
||||
display: 'flex', gap: 8, alignItems: 'flex-start'
|
||||
}}>
|
||||
<Info size={16} style={{ marginTop: 2, flexShrink: 0 }} />
|
||||
<div>
|
||||
<strong>Hinweis:</strong> Features werden automatisch via Code registriert.
|
||||
Hier können nur Basis-Einstellungen (Limit-Typ, Reset-Periode, Standards) angepasst werden.
|
||||
Neue Features hinzuzufügen erfordert Code-Änderungen im Backend.
|
||||
</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>
|
||||
)}
|
||||
|
||||
{/* Edit Form */}
|
||||
{editingId && (
|
||||
<div className="card" style={{ padding: 20, marginBottom: 20 }}>
|
||||
<div style={{
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
|
||||
marginBottom: 16
|
||||
}}>
|
||||
<div style={{ fontSize: 16, fontWeight: 600 }}>
|
||||
Feature konfigurieren
|
||||
</div>
|
||||
<button
|
||||
className="btn btn-secondary"
|
||||
onClick={resetForm}
|
||||
style={{ padding: '6px 12px' }}
|
||||
>
|
||||
<X size={16} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'grid', gap: 16 }}>
|
||||
{/* Feature ID (read-only) */}
|
||||
<div>
|
||||
<label className="form-label" style={{ display: 'block', marginBottom: 6 }}>
|
||||
Feature ID
|
||||
</label>
|
||||
<input
|
||||
className="form-input"
|
||||
value={editingId}
|
||||
disabled
|
||||
style={{
|
||||
width: '100%',
|
||||
background: 'var(--surface2)',
|
||||
color: 'var(--text3)',
|
||||
cursor: 'not-allowed',
|
||||
fontFamily: 'monospace'
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Name */}
|
||||
<div>
|
||||
<label className="form-label" style={{ display: 'block', marginBottom: 6 }}>
|
||||
Name *
|
||||
</label>
|
||||
<input
|
||||
className="form-input"
|
||||
style={{ width: '100%' }}
|
||||
value={formData.name}
|
||||
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
||||
placeholder="z.B. Gewichtseinträge"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
<div>
|
||||
<label className="form-label" style={{ display: 'block', marginBottom: 6 }}>
|
||||
Beschreibung (optional)
|
||||
</label>
|
||||
<input
|
||||
className="form-input"
|
||||
style={{ width: '100%' }}
|
||||
value={formData.description}
|
||||
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
|
||||
placeholder="Kurze Erklärung was dieses Feature limitiert"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Category + Limit Type */}
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12 }}>
|
||||
<div>
|
||||
<label className="form-label" style={{ display: 'block', marginBottom: 6 }}>
|
||||
Kategorie
|
||||
</label>
|
||||
<select
|
||||
className="form-input"
|
||||
style={{ width: '100%' }}
|
||||
value={formData.category}
|
||||
onChange={(e) => setFormData({ ...formData, category: e.target.value })}
|
||||
>
|
||||
{categoryOptions.map(o => (
|
||||
<option key={o.value} value={o.value}>{o.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="form-label" style={{ display: 'block', marginBottom: 6 }}>
|
||||
Limit-Typ
|
||||
</label>
|
||||
<select
|
||||
className="form-input"
|
||||
style={{ width: '100%' }}
|
||||
value={formData.limit_type}
|
||||
onChange={(e) => setFormData({ ...formData, limit_type: e.target.value })}
|
||||
>
|
||||
{limitTypeOptions.map(o => (
|
||||
<option key={o.value} value={o.value}>{o.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Count-specific fields (only for limit_type='count') */}
|
||||
{formData.limit_type === 'count' && (
|
||||
<>
|
||||
{/* Reset Period */}
|
||||
<div>
|
||||
<label className="form-label" style={{ display: 'block', marginBottom: 6 }}>
|
||||
Reset-Periode
|
||||
</label>
|
||||
<select
|
||||
className="form-input"
|
||||
style={{ width: '100%' }}
|
||||
value={formData.reset_period}
|
||||
onChange={(e) => setFormData({ ...formData, reset_period: e.target.value })}
|
||||
>
|
||||
{resetPeriodOptions.map(o => (
|
||||
<option key={o.value} value={o.value}>{o.label}</option>
|
||||
))}
|
||||
</select>
|
||||
<div style={{ fontSize: 11, color: 'var(--text3)', marginTop: 4 }}>
|
||||
Wann wird der Nutzungszähler zurückgesetzt?
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Default Limit */}
|
||||
<div>
|
||||
<label className="form-label" style={{ display: 'block', marginBottom: 6 }}>
|
||||
Standard-Limit
|
||||
</label>
|
||||
<input
|
||||
className="form-input"
|
||||
style={{ width: '100%' }}
|
||||
type="number"
|
||||
value={formData.default_limit}
|
||||
onChange={(e) => setFormData({ ...formData, default_limit: e.target.value })}
|
||||
placeholder="Leer = unbegrenzt"
|
||||
/>
|
||||
<div style={{ fontSize: 11, color: 'var(--text3)', marginTop: 4 }}>
|
||||
Fallback-Wert wenn kein Tier-spezifisches Limit gesetzt ist
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Boolean info */}
|
||||
{formData.limit_type === 'boolean' && (
|
||||
<div style={{
|
||||
padding: 12, background: 'var(--accent-light)', borderRadius: 8,
|
||||
fontSize: 12, color: 'var(--accent-dark)'
|
||||
}}>
|
||||
<strong>Boolean-Feature:</strong> Ist entweder verfügbar (AN) oder nicht verfügbar (AUS).
|
||||
Keine Zähler oder Reset-Perioden notwendig.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Sort Order */}
|
||||
<div>
|
||||
<label className="form-label" style={{ display: 'block', marginBottom: 6 }}>
|
||||
Anzeigereihenfolge
|
||||
</label>
|
||||
<input
|
||||
className="form-input"
|
||||
style={{ width: '100%' }}
|
||||
type="number"
|
||||
value={formData.sort_order}
|
||||
onChange={(e) => setFormData({ ...formData, sort_order: parseInt(e.target.value) || 50 })}
|
||||
/>
|
||||
<div style={{ fontSize: 11, color: 'var(--text3)', marginTop: 4 }}>
|
||||
Niedrigere Werte erscheinen weiter oben in Listen (Standard: 50)
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Checkboxes */}
|
||||
<div style={{ display: 'flex', gap: 16, paddingTop: 8 }}>
|
||||
<label style={{ display: 'flex', alignItems: 'center', gap: 6, fontSize: 13 }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={formData.visible_in_admin}
|
||||
onChange={(e) => setFormData({ ...formData, visible_in_admin: e.target.checked })}
|
||||
/>
|
||||
Im Admin sichtbar
|
||||
</label>
|
||||
<label style={{ display: 'flex', alignItems: 'center', gap: 6, fontSize: 13 }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={formData.active}
|
||||
onChange={(e) => setFormData({ ...formData, active: e.target.checked })}
|
||||
/>
|
||||
Feature aktiviert
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div style={{ display: 'flex', gap: 8, marginTop: 8 }}>
|
||||
<button className="btn btn-primary" onClick={handleSave}>
|
||||
<Save size={14} /> Speichern
|
||||
</button>
|
||||
<button className="btn btn-secondary" onClick={resetForm}>
|
||||
Abbrechen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Features List */}
|
||||
<div className="card" style={{ padding: 0, overflow: 'auto' }}>
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 13 }}>
|
||||
<thead>
|
||||
<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 }}>Kategorie</th>
|
||||
<th style={{ textAlign: 'center', padding: '12px 16px', fontWeight: 600 }}>Limit-Typ</th>
|
||||
<th style={{ textAlign: 'center', padding: '12px 16px', fontWeight: 600 }}>Reset</th>
|
||||
<th style={{ textAlign: 'center', padding: '12px 16px', fontWeight: 600 }}>Standard</th>
|
||||
<th style={{ textAlign: 'center', padding: '12px 16px', fontWeight: 600 }}>Status</th>
|
||||
<th style={{ textAlign: 'right', padding: '12px 16px', fontWeight: 600 }}>Aktion</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{features.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={7} style={{ textAlign: 'center', padding: 40, color: 'var(--text3)' }}>
|
||||
Keine Features registriert
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
{features.map((feature, idx) => (
|
||||
<tr
|
||||
key={feature.id}
|
||||
style={{
|
||||
borderBottom: idx === features.length - 1 ? 'none' : '1px solid var(--border)',
|
||||
background: feature.active ? 'transparent' : 'var(--surface)',
|
||||
opacity: feature.active ? 1 : 0.6
|
||||
}}
|
||||
>
|
||||
<td style={{ padding: '12px 16px' }}>
|
||||
<div style={{ fontWeight: 500 }}>{feature.name}</div>
|
||||
<div style={{ fontSize: 11, color: 'var(--text3)', marginTop: 2, fontFamily: 'monospace' }}>
|
||||
{feature.id}
|
||||
</div>
|
||||
{feature.description && (
|
||||
<div style={{ fontSize: 11, color: 'var(--text3)', marginTop: 2 }}>
|
||||
{feature.description}
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
<td style={{ padding: '12px 16px' }}>
|
||||
<span style={{
|
||||
padding: '2px 8px', borderRadius: 4, fontSize: 11,
|
||||
background: 'var(--accent-light)', color: 'var(--accent-dark)', fontWeight: 600
|
||||
}}>
|
||||
{feature.category}
|
||||
</span>
|
||||
</td>
|
||||
<td style={{ padding: '12px 16px', textAlign: 'center' }}>
|
||||
<span style={{
|
||||
padding: '2px 8px', borderRadius: 4, fontSize: 11,
|
||||
background: feature.limit_type === 'boolean' ? 'var(--surface2)' : 'var(--surface2)',
|
||||
fontWeight: 500
|
||||
}}>
|
||||
{feature.limit_type === 'boolean' ? '✓/✗' : '123'}
|
||||
</span>
|
||||
</td>
|
||||
<td style={{ padding: '12px 16px', textAlign: 'center', fontSize: 11, color: 'var(--text3)' }}>
|
||||
{feature.reset_period === 'never' ? '∞' : feature.reset_period === 'daily' ? '1d' : '1m'}
|
||||
</td>
|
||||
<td style={{ padding: '12px 16px', textAlign: 'center', fontWeight: 500 }}>
|
||||
{feature.default_limit === null ? '∞' : feature.default_limit}
|
||||
</td>
|
||||
<td style={{ padding: '12px 16px', textAlign: 'center' }}>
|
||||
{feature.active ? (
|
||||
<span style={{ color: 'var(--accent)', fontWeight: 600 }}>✓ Aktiv</span>
|
||||
) : (
|
||||
<span style={{ color: 'var(--text3)' }}>✗ Inaktiv</span>
|
||||
)}
|
||||
</td>
|
||||
<td style={{ padding: '12px 16px', textAlign: 'right' }}>
|
||||
<button
|
||||
className="btn btn-secondary"
|
||||
onClick={() => startEdit(feature)}
|
||||
style={{ padding: '6px 12px', fontSize: 12 }}
|
||||
>
|
||||
<Edit2 size={14} /> Konfigurieren
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* Legend */}
|
||||
<div style={{
|
||||
marginTop: 16, padding: 12, background: 'var(--surface2)',
|
||||
borderRadius: 8, fontSize: 12, color: 'var(--text3)'
|
||||
}}>
|
||||
<strong>Limit-Typ:</strong>
|
||||
<div style={{ marginTop: 4 }}>
|
||||
<strong>Boolean (✓/✗):</strong> Feature ist entweder verfügbar oder nicht (z.B. "KI aktiviert")
|
||||
</div>
|
||||
<div style={{ marginTop: 2 }}>
|
||||
<strong>Count (123):</strong> Feature hat ein Nutzungs-Limit (z.B. "max. 50 Einträge")
|
||||
</div>
|
||||
<div style={{ marginTop: 8 }}>
|
||||
<strong>Reset-Periode:</strong> ∞ = nie, 1d = täglich, 1m = monatlich
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -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'
|
||||
|
||||
|
|
@ -142,10 +143,7 @@ function EmailEditor({ profileId, currentEmail, onSaved }) {
|
|||
function ProfileCard({ profile, currentId, onRefresh }) {
|
||||
const [expanded, setExpanded] = useState(false)
|
||||
const [perms, setPerms] = useState({
|
||||
ai_enabled: profile.ai_enabled ?? 1,
|
||||
ai_limit_day: profile.ai_limit_day || '',
|
||||
export_enabled: profile.export_enabled ?? 1,
|
||||
role: profile.role || 'user',
|
||||
role: profile.role || 'user',
|
||||
})
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [newPin, setNewPin] = useState('')
|
||||
|
|
@ -156,10 +154,7 @@ function ProfileCard({ profile, currentId, onRefresh }) {
|
|||
setSaving(true)
|
||||
try {
|
||||
await api.adminSetPermissions(profile.id, {
|
||||
ai_enabled: perms.ai_enabled,
|
||||
ai_limit_day: perms.ai_limit_day ? parseInt(perms.ai_limit_day) : null,
|
||||
export_enabled: perms.export_enabled,
|
||||
role: perms.role,
|
||||
role: perms.role,
|
||||
})
|
||||
await onRefresh()
|
||||
} finally { setSaving(false) }
|
||||
|
|
@ -195,9 +190,8 @@ function ProfileCard({ profile, currentId, onRefresh }) {
|
|||
{isSelf && <span style={{fontSize:10,color:'var(--text3)'}}>Du</span>}
|
||||
</div>
|
||||
<div style={{fontSize:11,color:'var(--text3)'}}>
|
||||
KI: {profile.ai_enabled?`✓${profile.ai_limit_day?` (max ${profile.ai_limit_day}/Tag)`:''}` : '✗'} ·
|
||||
Export: {profile.export_enabled?'✓':'✗'} ·
|
||||
Calls heute: {profile.ai_calls_today||0}
|
||||
Tier: {profile.tier || 'free'} ·
|
||||
Email: {profile.email || 'nicht gesetzt'}
|
||||
</div>
|
||||
</div>
|
||||
<div style={{display:'flex',gap:6}}>
|
||||
|
|
@ -232,23 +226,19 @@ function ProfileCard({ profile, currentId, onRefresh }) {
|
|||
</button>
|
||||
))}
|
||||
</div>
|
||||
<button className="btn btn-primary btn-full" style={{marginTop:8}} onClick={savePerms} disabled={saving}>
|
||||
{saving?'Speichern…':'Rolle speichern'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<Toggle value={!!perms.ai_enabled} onChange={v=>setPerms(p=>({...p,ai_enabled:v?1:0}))} label="KI-Analysen erlaubt"/>
|
||||
{!!perms.ai_enabled && (
|
||||
<div className="form-row" style={{paddingTop:6}}>
|
||||
<label className="form-label" style={{fontSize:12}}>Max. KI-Calls/Tag</label>
|
||||
<input type="number" className="form-input" style={{width:70}} min={1} max={100}
|
||||
placeholder="∞" value={perms.ai_limit_day}
|
||||
onChange={e=>setPerms(p=>({...p,ai_limit_day:e.target.value}))}/>
|
||||
<span className="form-unit" style={{fontSize:11}}>/Tag</span>
|
||||
</div>
|
||||
)}
|
||||
<Toggle value={!!perms.export_enabled} onChange={v=>setPerms(p=>({...p,export_enabled:v?1:0}))} label="Daten-Export erlaubt"/>
|
||||
|
||||
<button className="btn btn-primary btn-full" style={{marginTop:10}} onClick={savePerms} disabled={saving}>
|
||||
{saving?'Speichern…':'Berechtigungen speichern'}
|
||||
</button>
|
||||
{/* Feature-Overrides */}
|
||||
<div style={{marginBottom:12,padding:10,background:'var(--accent-light)',borderRadius:6,fontSize:12}}>
|
||||
<strong>Feature-Limits:</strong> Nutze die neue{' '}
|
||||
<Link to="/admin/user-restrictions" style={{color:'var(--accent-dark)',fontWeight:600}}>
|
||||
User Feature-Overrides
|
||||
</Link>{' '}
|
||||
Seite um individuelle Limits zu setzen.
|
||||
</div>
|
||||
|
||||
{/* Email */}
|
||||
<div style={{marginTop:12,paddingTop:12,borderTop:'1px solid var(--border)'}}>
|
||||
|
|
@ -397,6 +387,43 @@ 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>
|
||||
<div style={{display:'grid',gap:8}}>
|
||||
<Link to="/admin/tiers">
|
||||
<button className="btn btn-secondary btn-full">
|
||||
🎯 Tiers verwalten
|
||||
</button>
|
||||
</Link>
|
||||
<Link to="/admin/features">
|
||||
<button className="btn btn-secondary btn-full">
|
||||
🔧 Feature-Registry verwalten
|
||||
</button>
|
||||
</Link>
|
||||
<Link to="/admin/tier-limits">
|
||||
<button className="btn btn-secondary btn-full">
|
||||
📊 Tier Limits Matrix bearbeiten
|
||||
</button>
|
||||
</Link>
|
||||
<Link to="/admin/coupons">
|
||||
<button className="btn btn-secondary btn-full">
|
||||
🎟️ Coupons verwalten
|
||||
</button>
|
||||
</Link>
|
||||
<Link to="/admin/user-restrictions">
|
||||
<button className="btn btn-secondary btn-full">
|
||||
👤 User Feature-Overrides
|
||||
</button>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
499
frontend/src/pages/AdminTierLimitsPage.jsx
Normal file
499
frontend/src/pages/AdminTierLimitsPage.jsx
Normal file
|
|
@ -0,0 +1,499 @@
|
|||
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>
|
||||
)
|
||||
}
|
||||
392
frontend/src/pages/AdminTiersPage.jsx
Normal file
392
frontend/src/pages/AdminTiersPage.jsx
Normal file
|
|
@ -0,0 +1,392 @@
|
|||
import { useState, useEffect } from 'react'
|
||||
import { Save, Plus, Edit2, Trash2, X } from 'lucide-react'
|
||||
import { api } from '../utils/api'
|
||||
|
||||
export default function AdminTiersPage() {
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState('')
|
||||
const [success, setSuccess] = useState('')
|
||||
const [tiers, setTiers] = useState([])
|
||||
const [editingId, setEditingId] = useState(null)
|
||||
const [showAddForm, setShowAddForm] = useState(false)
|
||||
const [formData, setFormData] = useState({
|
||||
id: '',
|
||||
name: '',
|
||||
description: '',
|
||||
price_monthly_cents: '',
|
||||
price_yearly_cents: '',
|
||||
sort_order: 50,
|
||||
active: true
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
loadTiers()
|
||||
}, [])
|
||||
|
||||
async function loadTiers() {
|
||||
try {
|
||||
setLoading(true)
|
||||
const data = await api.listTiers()
|
||||
setTiers(data)
|
||||
setError('')
|
||||
} catch (e) {
|
||||
setError(e.message)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
function resetForm() {
|
||||
setFormData({
|
||||
id: '',
|
||||
name: '',
|
||||
description: '',
|
||||
price_monthly_cents: '',
|
||||
price_yearly_cents: '',
|
||||
sort_order: 50,
|
||||
active: true
|
||||
})
|
||||
setEditingId(null)
|
||||
setShowAddForm(false)
|
||||
}
|
||||
|
||||
function startEdit(tier) {
|
||||
setFormData({
|
||||
id: tier.id,
|
||||
name: tier.name,
|
||||
description: tier.description || '',
|
||||
price_monthly_cents: tier.price_monthly_cents === null ? '' : tier.price_monthly_cents,
|
||||
price_yearly_cents: tier.price_yearly_cents === null ? '' : tier.price_yearly_cents,
|
||||
sort_order: tier.sort_order || 50,
|
||||
active: tier.active
|
||||
})
|
||||
setEditingId(tier.id)
|
||||
setShowAddForm(false)
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
try {
|
||||
setError('')
|
||||
setSuccess('')
|
||||
|
||||
// Validation
|
||||
if (!formData.name.trim()) {
|
||||
setError('Name erforderlich')
|
||||
return
|
||||
}
|
||||
|
||||
const payload = {
|
||||
name: formData.name.trim(),
|
||||
description: formData.description.trim(),
|
||||
price_monthly_cents: formData.price_monthly_cents === '' ? null : parseInt(formData.price_monthly_cents),
|
||||
price_yearly_cents: formData.price_yearly_cents === '' ? null : parseInt(formData.price_yearly_cents),
|
||||
sort_order: formData.sort_order,
|
||||
active: formData.active
|
||||
}
|
||||
|
||||
if (editingId) {
|
||||
// Update existing
|
||||
await api.updateTier(editingId, payload)
|
||||
setSuccess('Tier aktualisiert')
|
||||
} else {
|
||||
// Create new
|
||||
if (!formData.id.trim()) {
|
||||
setError('ID erforderlich')
|
||||
return
|
||||
}
|
||||
payload.id = formData.id.trim()
|
||||
await api.createTier(payload)
|
||||
setSuccess('Tier erstellt')
|
||||
}
|
||||
|
||||
await loadTiers()
|
||||
resetForm()
|
||||
} catch (e) {
|
||||
setError(e.message)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDelete(tierId) {
|
||||
if (!confirm('Tier wirklich deaktivieren?')) return
|
||||
try {
|
||||
setError('')
|
||||
await api.deleteTier(tierId)
|
||||
setSuccess('Tier deaktiviert')
|
||||
await loadTiers()
|
||||
} catch (e) {
|
||||
setError(e.message)
|
||||
}
|
||||
}
|
||||
|
||||
function formatPrice(cents) {
|
||||
if (cents === null || cents === undefined) return 'Kostenlos'
|
||||
return `${(cents / 100).toFixed(2)} €`
|
||||
}
|
||||
|
||||
if (loading) return (
|
||||
<div style={{ display: 'flex', justifyContent: 'center', padding: 40 }}>
|
||||
<div className="spinner" />
|
||||
</div>
|
||||
)
|
||||
|
||||
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-Verwaltung
|
||||
</div>
|
||||
<div style={{ fontSize: 13, color: 'var(--text3)', marginTop: 4 }}>
|
||||
Subscription-Tiers konfigurieren
|
||||
</div>
|
||||
</div>
|
||||
{!showAddForm && !editingId && (
|
||||
<button
|
||||
className="btn btn-primary"
|
||||
onClick={() => setShowAddForm(true)}
|
||||
>
|
||||
<Plus size={16} /> Neuer Tier
|
||||
</button>
|
||||
)}
|
||||
</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>
|
||||
)}
|
||||
|
||||
{/* Add/Edit Form */}
|
||||
{(showAddForm || editingId) && (
|
||||
<div className="card" style={{ padding: 20, marginBottom: 20 }}>
|
||||
<div style={{
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
|
||||
marginBottom: 16
|
||||
}}>
|
||||
<div style={{ fontSize: 16, fontWeight: 600 }}>
|
||||
{editingId ? 'Tier bearbeiten' : 'Neuen Tier erstellen'}
|
||||
</div>
|
||||
<button
|
||||
className="btn btn-secondary"
|
||||
onClick={resetForm}
|
||||
style={{ padding: '6px 12px' }}
|
||||
>
|
||||
<X size={16} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'grid', gap: 12 }}>
|
||||
{/* ID (nur bei Neuanlage) */}
|
||||
{!editingId && (
|
||||
<div className="form-row">
|
||||
<label className="form-label">ID (Slug) *</label>
|
||||
<input
|
||||
className="form-input"
|
||||
value={formData.id}
|
||||
onChange={(e) => setFormData({ ...formData, id: e.target.value })}
|
||||
placeholder="z.B. enterprise"
|
||||
/>
|
||||
<span className="form-unit" style={{ fontSize: 11, color: 'var(--text3)' }}>
|
||||
Kleinbuchstaben, keine Leerzeichen
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Name */}
|
||||
<div className="form-row">
|
||||
<label className="form-label">Name *</label>
|
||||
<input
|
||||
className="form-input"
|
||||
value={formData.name}
|
||||
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
||||
placeholder="z.B. Enterprise"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
<div className="form-row">
|
||||
<label className="form-label">Beschreibung</label>
|
||||
<input
|
||||
className="form-input"
|
||||
value={formData.description}
|
||||
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
|
||||
placeholder="z.B. Für Teams und Unternehmen"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Pricing */}
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12 }}>
|
||||
<div className="form-row">
|
||||
<label className="form-label">Monatspreis (Cent)</label>
|
||||
<input
|
||||
className="form-input"
|
||||
type="number"
|
||||
value={formData.price_monthly_cents}
|
||||
onChange={(e) => setFormData({ ...formData, price_monthly_cents: e.target.value })}
|
||||
placeholder="Leer = kostenlos"
|
||||
/>
|
||||
<span className="form-unit" style={{ fontSize: 11 }}>
|
||||
{formData.price_monthly_cents ? formatPrice(parseInt(formData.price_monthly_cents)) : '-'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="form-row">
|
||||
<label className="form-label">Jahrespreis (Cent)</label>
|
||||
<input
|
||||
className="form-input"
|
||||
type="number"
|
||||
value={formData.price_yearly_cents}
|
||||
onChange={(e) => setFormData({ ...formData, price_yearly_cents: e.target.value })}
|
||||
placeholder="Leer = kostenlos"
|
||||
/>
|
||||
<span className="form-unit" style={{ fontSize: 11 }}>
|
||||
{formData.price_yearly_cents ? formatPrice(parseInt(formData.price_yearly_cents)) : '-'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Sort Order + Active */}
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr auto', gap: 12, alignItems: 'end' }}>
|
||||
<div className="form-row">
|
||||
<label className="form-label">Sortierung</label>
|
||||
<input
|
||||
className="form-input"
|
||||
type="number"
|
||||
value={formData.sort_order}
|
||||
onChange={(e) => setFormData({ ...formData, sort_order: parseInt(e.target.value) || 50 })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<label style={{ display: 'flex', alignItems: 'center', gap: 6, fontSize: 13, paddingBottom: 8 }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={formData.active}
|
||||
onChange={(e) => setFormData({ ...formData, active: e.target.checked })}
|
||||
/>
|
||||
Aktiv
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div style={{ display: 'flex', gap: 8, marginTop: 8 }}>
|
||||
<button className="btn btn-primary" onClick={handleSave}>
|
||||
<Save size={14} /> {editingId ? 'Aktualisieren' : 'Erstellen'}
|
||||
</button>
|
||||
<button className="btn btn-secondary" onClick={resetForm}>
|
||||
Abbrechen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Tiers List */}
|
||||
<div style={{ display: 'grid', gap: 12 }}>
|
||||
{tiers.length === 0 && (
|
||||
<div className="card" style={{ padding: 40, textAlign: 'center', color: 'var(--text3)' }}>
|
||||
Keine Tiers vorhanden
|
||||
</div>
|
||||
)}
|
||||
{tiers.map(tier => (
|
||||
<div
|
||||
key={tier.id}
|
||||
className="card"
|
||||
style={{
|
||||
padding: 16,
|
||||
opacity: tier.active ? 1 : 0.6,
|
||||
border: tier.active ? '1px solid var(--border)' : '1px dashed var(--border)'
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between' }}>
|
||||
<div style={{ flex: 1 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 4 }}>
|
||||
<div style={{ fontSize: 16, fontWeight: 700, color: 'var(--text1)' }}>
|
||||
{tier.name}
|
||||
</div>
|
||||
<span style={{
|
||||
padding: '2px 8px', borderRadius: 4, fontSize: 10,
|
||||
background: 'var(--surface2)', color: 'var(--text3)', fontFamily: 'monospace'
|
||||
}}>
|
||||
{tier.id}
|
||||
</span>
|
||||
{!tier.active && (
|
||||
<span style={{
|
||||
padding: '2px 8px', borderRadius: 4, fontSize: 10,
|
||||
background: 'var(--danger)', color: 'white', fontWeight: 600
|
||||
}}>
|
||||
INAKTIV
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{tier.description && (
|
||||
<div style={{ fontSize: 13, color: 'var(--text2)', marginBottom: 8 }}>
|
||||
{tier.description}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={{ display: 'flex', gap: 16, fontSize: 12, color: 'var(--text3)' }}>
|
||||
<div>
|
||||
<strong>Monatlich:</strong> {formatPrice(tier.price_monthly_cents)}
|
||||
</div>
|
||||
<div>
|
||||
<strong>Jährlich:</strong> {formatPrice(tier.price_yearly_cents)}
|
||||
</div>
|
||||
<div>
|
||||
<strong>Sortierung:</strong> {tier.sort_order}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', gap: 4 }}>
|
||||
<button
|
||||
className="btn btn-secondary"
|
||||
onClick={() => startEdit(tier)}
|
||||
style={{ padding: '6px 12px', fontSize: 12 }}
|
||||
>
|
||||
<Edit2 size={14} />
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-secondary"
|
||||
onClick={() => handleDelete(tier.id)}
|
||||
style={{ padding: '6px 12px', fontSize: 12, color: 'var(--danger)' }}
|
||||
disabled={!tier.active}
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Info */}
|
||||
<div style={{
|
||||
marginTop: 16, padding: 12, background: 'var(--surface2)',
|
||||
borderRadius: 8, fontSize: 12, color: 'var(--text3)'
|
||||
}}>
|
||||
<strong>Hinweis:</strong> Limits für jeden Tier können in der{' '}
|
||||
<a href="/admin/tier-limits" style={{ color: 'var(--accent)', textDecoration: 'none' }}>
|
||||
Tier Limits Matrix
|
||||
</a>{' '}
|
||||
konfiguriert werden.
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
538
frontend/src/pages/AdminUserRestrictionsPage.jsx
Normal file
538
frontend/src/pages/AdminUserRestrictionsPage.jsx
Normal file
|
|
@ -0,0 +1,538 @@
|
|||
import { useState, useEffect } from 'react'
|
||||
import { Save, AlertCircle, X, RotateCcw } from 'lucide-react'
|
||||
import { api } from '../utils/api'
|
||||
|
||||
export default function AdminUserRestrictionsPage() {
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState('')
|
||||
const [success, setSuccess] = useState('')
|
||||
const [users, setUsers] = useState([])
|
||||
const [features, setFeatures] = useState([])
|
||||
const [selectedUserId, setSelectedUserId] = useState('')
|
||||
const [selectedUser, setSelectedUser] = useState(null)
|
||||
const [restrictions, setRestrictions] = useState([])
|
||||
const [tierLimits, setTierLimits] = useState({})
|
||||
const [changes, setChanges] = useState({})
|
||||
const [saving, setSaving] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
loadInitialData()
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedUserId) {
|
||||
loadUserData(selectedUserId)
|
||||
} else {
|
||||
setSelectedUser(null)
|
||||
setRestrictions([])
|
||||
setChanges({})
|
||||
}
|
||||
}, [selectedUserId])
|
||||
|
||||
async function loadInitialData() {
|
||||
try {
|
||||
setLoading(true)
|
||||
const [usersData, featuresData] = await Promise.all([
|
||||
api.adminListProfiles(),
|
||||
api.listFeatures()
|
||||
])
|
||||
setUsers(usersData)
|
||||
setFeatures(featuresData.filter(f => f.active))
|
||||
setError('')
|
||||
} catch (e) {
|
||||
setError(e.message)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function loadUserData(userId) {
|
||||
try {
|
||||
const [user, restrictionsData, limitsMatrix] = await Promise.all([
|
||||
api.adminListProfiles().then(users => users.find(u => u.id === userId)),
|
||||
api.listUserRestrictions(userId),
|
||||
api.getTierLimitsMatrix()
|
||||
])
|
||||
|
||||
setSelectedUser(user)
|
||||
setRestrictions(restrictionsData)
|
||||
|
||||
// Build tier limits lookup for this user's tier
|
||||
const userTier = user.tier || 'free'
|
||||
const limits = {}
|
||||
features.forEach(feature => {
|
||||
const key = `${userTier}:${feature.id}`
|
||||
// Use same fallback logic as TierLimitsPage: undefined → null (unlimited)
|
||||
limits[feature.id] = limitsMatrix.limits[key] ?? null
|
||||
})
|
||||
setTierLimits(limits)
|
||||
|
||||
setChanges({})
|
||||
setError('')
|
||||
setSuccess('')
|
||||
} catch (e) {
|
||||
setError(e.message)
|
||||
}
|
||||
}
|
||||
|
||||
function handleChange(featureId, value) {
|
||||
const newChanges = { ...changes }
|
||||
const tierLimit = tierLimits[featureId]
|
||||
|
||||
// Parse value (EXACTLY like TierLimitsPage)
|
||||
let parsedValue = null
|
||||
if (value === 'unlimited' || value === '∞') {
|
||||
parsedValue = null // unlimited
|
||||
} else if (value === '0' || value === 'disabled') {
|
||||
parsedValue = 0 // disabled
|
||||
} else if (value === '') {
|
||||
parsedValue = null // empty → unlimited
|
||||
} else {
|
||||
const num = parseInt(value)
|
||||
if (!isNaN(num) && num >= 0) {
|
||||
parsedValue = num
|
||||
} else {
|
||||
return // invalid input, ignore
|
||||
}
|
||||
}
|
||||
|
||||
// Check if value equals tier limit → remove override
|
||||
if (parsedValue === tierLimit) {
|
||||
newChanges[featureId] = { action: 'remove', tempValue: value }
|
||||
} else {
|
||||
// Different from tier default → set override
|
||||
newChanges[featureId] = { action: 'set', value: parsedValue, tempValue: value }
|
||||
}
|
||||
|
||||
setChanges(newChanges)
|
||||
}
|
||||
|
||||
function handleToggle(featureId) {
|
||||
// Get current state
|
||||
const restriction = restrictions.find(r => r.feature_id === featureId)
|
||||
let currentValue = restriction?.limit_value ?? null
|
||||
|
||||
// Check if there's a pending change
|
||||
if (featureId in changes && changes[featureId].action === 'set') {
|
||||
currentValue = changes[featureId].value
|
||||
}
|
||||
|
||||
// Toggle between 1 (enabled) and 0 (disabled)
|
||||
const isCurrentlyEnabled = currentValue !== 0 && currentValue !== '0'
|
||||
const newValue = isCurrentlyEnabled ? 0 : 1
|
||||
|
||||
const newChanges = { ...changes }
|
||||
newChanges[featureId] = { action: 'set', value: newValue, tempValue: newValue.toString() }
|
||||
setChanges(newChanges)
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
if (!selectedUserId) return
|
||||
|
||||
try {
|
||||
setSaving(true)
|
||||
setError('')
|
||||
setSuccess('')
|
||||
|
||||
let changeCount = 0
|
||||
|
||||
for (const [featureId, change] of Object.entries(changes)) {
|
||||
const existingRestriction = restrictions.find(r => r.feature_id === featureId)
|
||||
|
||||
if (change.action === 'remove') {
|
||||
// Remove restriction if exists
|
||||
if (existingRestriction) {
|
||||
await api.deleteUserRestriction(existingRestriction.id)
|
||||
changeCount++
|
||||
}
|
||||
} else if (change.action === 'set') {
|
||||
// Create or update
|
||||
if (existingRestriction) {
|
||||
await api.updateUserRestriction(existingRestriction.id, {
|
||||
limit_value: change.value,
|
||||
enabled: true
|
||||
})
|
||||
} else {
|
||||
await api.createUserRestriction({
|
||||
profile_id: selectedUserId,
|
||||
feature_id: featureId,
|
||||
limit_value: change.value,
|
||||
enabled: true,
|
||||
reason: 'Admin override'
|
||||
})
|
||||
}
|
||||
changeCount++
|
||||
}
|
||||
}
|
||||
|
||||
setSuccess(`${changeCount} Änderung(en) gespeichert`)
|
||||
await loadUserData(selectedUserId)
|
||||
} catch (e) {
|
||||
setError(e.message)
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
function getDisplayValue(featureId) {
|
||||
// Check pending changes first
|
||||
if (featureId in changes) {
|
||||
const change = changes[featureId]
|
||||
if (change.action === 'remove') {
|
||||
// Returning to tier default
|
||||
return formatValue(tierLimits[featureId])
|
||||
}
|
||||
if (change.action === 'set') {
|
||||
// Use tempValue for display if available, otherwise format the value
|
||||
return change.tempValue !== undefined ? change.tempValue : formatValue(change.value)
|
||||
}
|
||||
}
|
||||
|
||||
// Show override if exists, otherwise tier limit (= effective value)
|
||||
const restriction = restrictions.find(r => r.feature_id === featureId)
|
||||
if (restriction) {
|
||||
return formatValue(restriction.limit_value)
|
||||
}
|
||||
|
||||
// No override: show tier limit as default
|
||||
return formatValue(tierLimits[featureId])
|
||||
}
|
||||
|
||||
function formatValue(val) {
|
||||
if (val === null || val === undefined) return 'unlimited'
|
||||
if (val === '' ) return ''
|
||||
if (val === '∞' || val === 'unlimited') return 'unlimited'
|
||||
if (val === 0 || val === '0') return '0'
|
||||
return val.toString()
|
||||
}
|
||||
|
||||
function getToggleState(featureId) {
|
||||
// Check pending changes first
|
||||
if (featureId in changes && changes[featureId].action === 'set') {
|
||||
const val = changes[featureId].value
|
||||
return val !== 0 && val !== '0'
|
||||
}
|
||||
|
||||
// Check existing restriction
|
||||
const restriction = restrictions.find(r => r.feature_id === featureId)
|
||||
if (!restriction) {
|
||||
// No override: use tier default
|
||||
const tierLimit = tierLimits[featureId]
|
||||
return tierLimit !== 0 && tierLimit !== '0'
|
||||
}
|
||||
|
||||
// For boolean features: limit_value determines state
|
||||
return restriction.limit_value !== 0 && restriction.limit_value !== '0'
|
||||
}
|
||||
|
||||
function hasOverride(featureId) {
|
||||
return restrictions.some(r => r.feature_id === featureId)
|
||||
}
|
||||
|
||||
function isChanged(featureId) {
|
||||
return featureId in changes
|
||||
}
|
||||
|
||||
if (loading) return (
|
||||
<div style={{ display: 'flex', justifyContent: 'center', padding: 40 }}>
|
||||
<div className="spinner" />
|
||||
</div>
|
||||
)
|
||||
|
||||
const hasChanges = Object.keys(changes).length > 0
|
||||
const categoryGroups = {}
|
||||
features.forEach(f => {
|
||||
if (!categoryGroups[f.category]) categoryGroups[f.category] = []
|
||||
categoryGroups[f.category].push(f)
|
||||
})
|
||||
|
||||
const categoryIcons = { data: '📊', ai: '🤖', export: '📤', integration: '🔗' }
|
||||
const categoryNames = { data: 'DATEN', ai: 'KI', export: 'EXPORT', integration: 'INTEGRATIONEN' }
|
||||
|
||||
return (
|
||||
<div style={{ paddingBottom: 80 }}>
|
||||
{/* Header */}
|
||||
<div style={{ marginBottom: 20 }}>
|
||||
<div style={{ fontSize: 20, fontWeight: 700, color: 'var(--text1)' }}>
|
||||
User Feature-Overrides
|
||||
</div>
|
||||
<div style={{ fontSize: 13, color: 'var(--text3)', marginTop: 4 }}>
|
||||
Individuelle Feature-Limits für einzelne User setzen
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Info Box */}
|
||||
<div style={{
|
||||
padding: 12, background: 'var(--accent-light)', borderRadius: 8,
|
||||
marginBottom: 16, fontSize: 12, color: 'var(--accent-dark)',
|
||||
display: 'flex', gap: 8, alignItems: 'flex-start'
|
||||
}}>
|
||||
<AlertCircle size={16} style={{ marginTop: 2, flexShrink: 0 }} />
|
||||
<div>
|
||||
<strong>Hinweis:</strong> Felder zeigen effektive Werte (Override falls gesetzt, sonst Tier-Standard).
|
||||
Wert ändern → Override wird gesetzt. Wert = Tier-Standard → Override wird entfernt.
|
||||
</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>
|
||||
)}
|
||||
|
||||
{/* User Selection */}
|
||||
<div className="card" style={{ padding: 16, marginBottom: 16 }}>
|
||||
<label className="form-label" style={{ display: 'block', marginBottom: 8 }}>
|
||||
User auswählen
|
||||
</label>
|
||||
<select
|
||||
className="form-input"
|
||||
style={{ width: '100%' }}
|
||||
value={selectedUserId}
|
||||
onChange={(e) => setSelectedUserId(e.target.value)}
|
||||
>
|
||||
<option value="">-- User auswählen --</option>
|
||||
{users.map(u => (
|
||||
<option key={u.id} value={u.id}>
|
||||
{u.name} ({u.email || u.id}) - Tier: {u.tier || 'free'}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* User Info + Features */}
|
||||
{selectedUser && (
|
||||
<>
|
||||
{/* Action Buttons */}
|
||||
<div style={{
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
|
||||
marginBottom: 16
|
||||
}}>
|
||||
<div style={{ fontSize: 16, fontWeight: 600 }}>
|
||||
Feature-Overrides für {selectedUser.name}
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
{hasChanges && (
|
||||
<button
|
||||
className="btn btn-secondary"
|
||||
onClick={() => setChanges({})}
|
||||
disabled={saving}
|
||||
>
|
||||
<X size={14} /> Abbrechen
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
className="btn btn-primary"
|
||||
onClick={handleSave}
|
||||
disabled={!hasChanges || saving}
|
||||
>
|
||||
{saving ? 'Speichern...' : hasChanges ? `${Object.keys(changes).length} Änderung(en) speichern` : 'Keine Änderungen'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* User Info Card */}
|
||||
<div className="card" style={{ padding: 16, marginBottom: 16 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
|
||||
<div style={{
|
||||
width: 40, height: 40, borderRadius: '50%',
|
||||
background: selectedUser.avatar_color || 'var(--accent)',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
color: 'white', fontWeight: 700, fontSize: 18
|
||||
}}>
|
||||
{selectedUser.name?.charAt(0).toUpperCase()}
|
||||
</div>
|
||||
<div style={{ flex: 1 }}>
|
||||
<div style={{ fontWeight: 600, fontSize: 14 }}>{selectedUser.name}</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--text3)' }}>
|
||||
{selectedUser.email || `ID: ${selectedUser.id}`}
|
||||
</div>
|
||||
</div>
|
||||
<div style={{
|
||||
padding: '4px 12px', borderRadius: 6,
|
||||
background: 'var(--accent-light)', color: 'var(--accent-dark)',
|
||||
fontSize: 12, fontWeight: 600
|
||||
}}>
|
||||
Tier: {selectedUser.tier || 'free'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Features Table */}
|
||||
<div className="card" style={{ padding: 0, overflow: 'auto', marginBottom: 16 }}>
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 13 }}>
|
||||
<thead>
|
||||
<tr style={{ background: 'var(--surface2)' }}>
|
||||
<th style={{ textAlign: 'left', padding: '12px 16px', fontWeight: 600 }}>
|
||||
Feature
|
||||
</th>
|
||||
<th style={{ textAlign: 'center', padding: '12px 16px', fontWeight: 600 }}>
|
||||
Tier-Limit
|
||||
</th>
|
||||
<th style={{ textAlign: 'center', padding: '12px 16px', fontWeight: 600 }}>
|
||||
Override-Wert
|
||||
</th>
|
||||
<th style={{ textAlign: 'right', padding: '12px 16px', fontWeight: 600 }}>
|
||||
Aktion
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{Object.entries(categoryGroups).map(([category, categoryFeatures]) => (
|
||||
<>
|
||||
{/* Category Header */}
|
||||
<tr key={`cat-${category}`} style={{ background: 'var(--accent-light)' }}>
|
||||
<td colSpan={4} 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 */}
|
||||
{categoryFeatures.map(feature => {
|
||||
const displayValue = getDisplayValue(feature.id)
|
||||
const toggleState = getToggleState(feature.id)
|
||||
const override = hasOverride(feature.id)
|
||||
const changed = isChanged(feature.id)
|
||||
|
||||
return (
|
||||
<tr key={feature.id} style={{
|
||||
borderBottom: '1px solid var(--border)',
|
||||
background: changed ? 'var(--accent-light)' : 'transparent'
|
||||
}}>
|
||||
{/* Feature Name */}
|
||||
<td style={{ padding: '12px 16px' }}>
|
||||
<div style={{ fontWeight: 500 }}>{feature.name}</div>
|
||||
<div style={{ fontSize: 11, color: 'var(--text3)', marginTop: 2 }}>
|
||||
{feature.limit_type === 'boolean' ? '(ja/nein)' : `(${feature.reset_period})`}
|
||||
</div>
|
||||
</td>
|
||||
|
||||
{/* Tier-Limit */}
|
||||
<td style={{ padding: '12px 16px', textAlign: 'center' }}>
|
||||
{feature.limit_type === 'boolean' ? (
|
||||
<span style={{
|
||||
padding: '6px 12px', borderRadius: 20,
|
||||
background: tierLimits[feature.id] !== 0 ? 'var(--accent-light)' : 'var(--surface2)',
|
||||
color: tierLimits[feature.id] !== 0 ? 'var(--accent-dark)' : 'var(--text3)',
|
||||
fontSize: 12, fontWeight: 600
|
||||
}}>
|
||||
{tierLimits[feature.id] !== 0 ? '✓ AN' : '✗ AUS'}
|
||||
</span>
|
||||
) : (
|
||||
<span style={{ fontWeight: 500, color: 'var(--text2)' }}>
|
||||
{tierLimits[feature.id] === null ? '∞' : tierLimits[feature.id]}
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
|
||||
{/* Override Input */}
|
||||
<td style={{ padding: '12px 16px', textAlign: 'center' }}>
|
||||
{feature.limit_type === 'boolean' ? (
|
||||
<button
|
||||
onClick={() => handleToggle(feature.id)}
|
||||
style={{
|
||||
padding: '6px 16px',
|
||||
border: `2px solid ${toggleState ? 'var(--accent)' : 'var(--border)'}`,
|
||||
borderRadius: 20,
|
||||
background: toggleState ? 'var(--accent)' : 'var(--surface)',
|
||||
color: toggleState ? 'white' : 'var(--text3)',
|
||||
fontSize: 12,
|
||||
fontWeight: 600,
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.2s',
|
||||
minWidth: 80
|
||||
}}
|
||||
>
|
||||
{toggleState ? '✓ AN' : '✗ AUS'}
|
||||
</button>
|
||||
) : (
|
||||
<input
|
||||
type="text"
|
||||
value={displayValue}
|
||||
onChange={(e) => handleChange(feature.id, e.target.value)}
|
||||
placeholder=""
|
||||
style={{
|
||||
width: '120px',
|
||||
padding: '6px 8px',
|
||||
border: `1.5px solid ${changed ? 'var(--accent)' : override ? 'var(--accent)' : 'var(--border)'}`,
|
||||
borderRadius: 6,
|
||||
textAlign: 'center',
|
||||
fontSize: 13,
|
||||
fontWeight: override || changed ? 600 : 400,
|
||||
background: override || changed ? 'var(--accent-light)' : 'var(--bg)',
|
||||
color: displayValue === '0' ? 'var(--danger)' :
|
||||
displayValue === 'unlimited' ? 'var(--accent)' : 'var(--text1)'
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</td>
|
||||
|
||||
{/* Action */}
|
||||
<td style={{ padding: '12px 16px', textAlign: 'right' }}>
|
||||
<button
|
||||
className="btn btn-secondary"
|
||||
onClick={() => {
|
||||
// Reset to tier default
|
||||
const tierValue = tierLimits[feature.id]
|
||||
handleChange(feature.id, formatValue(tierValue))
|
||||
}}
|
||||
disabled={!override}
|
||||
style={{
|
||||
padding: '4px 8px',
|
||||
fontSize: 11,
|
||||
opacity: override ? 1 : 0.4,
|
||||
cursor: override ? 'pointer' : 'not-allowed'
|
||||
}}
|
||||
>
|
||||
↺ Zurück
|
||||
</button>
|
||||
</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: 16, flexWrap: 'wrap' }}>
|
||||
<span><strong>unlimited</strong> = Unbegrenzt</span>
|
||||
<span><strong style={{ color: 'var(--danger)' }}>0</strong> = Feature deaktiviert</span>
|
||||
<span><strong>1+</strong> = Limit-Wert</span>
|
||||
</div>
|
||||
<div style={{ marginTop: 8, fontSize: 11, opacity: 0.8 }}>
|
||||
• Feld zeigt effektiven Wert (Override falls gesetzt, sonst Tier-Standard)<br />
|
||||
• Wert ändern → Override wird gesetzt<br />
|
||||
• Wert = Tier-Standard → Override wird entfernt
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -3,6 +3,7 @@ import { Brain, Pencil, Trash2, ChevronDown, ChevronUp, Check, X } from 'lucide-
|
|||
import { api } from '../utils/api'
|
||||
import { useAuth } from '../context/AuthContext'
|
||||
import Markdown from '../utils/Markdown'
|
||||
import UsageBadge from '../components/UsageBadge'
|
||||
import dayjs from 'dayjs'
|
||||
import 'dayjs/locale/de'
|
||||
dayjs.locale('de')
|
||||
|
|
@ -114,6 +115,7 @@ export default function Analysis() {
|
|||
const [tab, setTab] = useState('run')
|
||||
const [newResult, setNewResult] = useState(null)
|
||||
const [pipelineLoading, setPipelineLoading] = useState(false)
|
||||
const [aiUsage, setAiUsage] = useState(null) // Phase 3: Usage badge
|
||||
|
||||
const loadAll = async () => {
|
||||
const [p, i] = await Promise.all([
|
||||
|
|
@ -123,7 +125,15 @@ export default function Analysis() {
|
|||
setPrompts(Array.isArray(p)?p:[])
|
||||
setAllInsights(Array.isArray(i)?i:[])
|
||||
}
|
||||
useEffect(()=>{ loadAll() },[])
|
||||
|
||||
useEffect(()=>{
|
||||
loadAll()
|
||||
// Load feature usage for badges
|
||||
api.getFeatureUsage().then(features => {
|
||||
const aiFeature = features.find(f => f.feature_id === 'ai_calls')
|
||||
setAiUsage(aiFeature)
|
||||
}).catch(err => console.error('Failed to load usage:', err))
|
||||
},[])
|
||||
|
||||
const runPipeline = async () => {
|
||||
setPipelineLoading(true); setError(null); setNewResult(null)
|
||||
|
|
@ -177,7 +187,7 @@ export default function Analysis() {
|
|||
grouped[key].push(ins)
|
||||
})
|
||||
|
||||
const activePrompts = prompts.filter(p=>p.active && !p.slug.startsWith('pipeline_'))
|
||||
const activePrompts = prompts.filter(p=>p.active && !p.slug.startsWith('pipeline_') && p.slug !== 'pipeline')
|
||||
|
||||
// Pipeline is available if the "pipeline" prompt is active
|
||||
const pipelinePrompt = prompts.find(p=>p.slug==='pipeline')
|
||||
|
|
@ -230,7 +240,10 @@ export default function Analysis() {
|
|||
<div className="card" style={{marginBottom:16,borderColor:'var(--accent)',borderWidth:2}}>
|
||||
<div style={{display:'flex',alignItems:'flex-start',gap:12}}>
|
||||
<div style={{flex:1}}>
|
||||
<div style={{fontWeight:700,fontSize:15,color:'var(--accent)'}}>🔬 Mehrstufige Gesamtanalyse</div>
|
||||
<div className="badge-container-right" style={{fontWeight:700,fontSize:15,color:'var(--accent)'}}>
|
||||
<span>🔬 Mehrstufige Gesamtanalyse</span>
|
||||
{aiUsage && <UsageBadge {...aiUsage} />}
|
||||
</div>
|
||||
<div style={{fontSize:12,color:'var(--text2)',marginTop:3,lineHeight:1.5}}>
|
||||
3 spezialisierte KI-Calls parallel (Körper + Ernährung + Aktivität),
|
||||
dann Synthese + Zielabgleich. Detaillierteste Auswertung.
|
||||
|
|
@ -241,12 +254,22 @@ export default function Analysis() {
|
|||
</div>
|
||||
)}
|
||||
</div>
|
||||
<button className="btn btn-primary" style={{flexShrink:0,minWidth:100}}
|
||||
onClick={runPipeline} disabled={!!loading||pipelineLoading}>
|
||||
{pipelineLoading
|
||||
? <><div className="spinner" style={{width:13,height:13}}/> Läuft…</>
|
||||
: <><Brain size={13}/> Starten</>}
|
||||
</button>
|
||||
<div
|
||||
title={aiUsage && !aiUsage.allowed ? `Limit erreicht (${aiUsage.used}/${aiUsage.limit}). Kontaktiere den Admin oder warte bis zum nächsten Reset.` : ''}
|
||||
style={{display:'inline-block'}}
|
||||
>
|
||||
<button
|
||||
className="btn btn-primary"
|
||||
style={{flexShrink:0,minWidth:100, cursor: (aiUsage && !aiUsage.allowed) ? 'not-allowed' : 'pointer'}}
|
||||
onClick={runPipeline}
|
||||
disabled={!!loading||pipelineLoading||(aiUsage && !aiUsage.allowed)}
|
||||
>
|
||||
{pipelineLoading
|
||||
? <><div className="spinner" style={{width:13,height:13}}/> Läuft…</>
|
||||
: (aiUsage && !aiUsage.allowed) ? '🔒 Limit'
|
||||
: <><Brain size={13}/> Starten</>}
|
||||
</button>
|
||||
</div>
|
||||
{!canUseAI && <div style={{fontSize:11,color:'#D85A30',marginTop:4}}>🔒 KI nicht freigeschaltet</div>}
|
||||
</div>
|
||||
{pipelineLoading && (
|
||||
|
|
@ -282,7 +305,10 @@ export default function Analysis() {
|
|||
<div key={p.id} className="card section-gap">
|
||||
<div style={{display:'flex',alignItems:'flex-start',gap:12}}>
|
||||
<div style={{flex:1}}>
|
||||
<div style={{fontWeight:600,fontSize:15}}>{SLUG_LABELS[p.slug]||p.name}</div>
|
||||
<div className="badge-container-right" style={{fontWeight:600,fontSize:15}}>
|
||||
<span>{SLUG_LABELS[p.slug]||p.name}</span>
|
||||
{aiUsage && <UsageBadge {...aiUsage} />}
|
||||
</div>
|
||||
{p.description && <div style={{fontSize:12,color:'var(--text3)',marginTop:2}}>{p.description}</div>}
|
||||
{existing && (
|
||||
<div style={{fontSize:11,color:'var(--text3)',marginTop:3}}>
|
||||
|
|
@ -290,12 +316,22 @@ export default function Analysis() {
|
|||
</div>
|
||||
)}
|
||||
</div>
|
||||
<button className="btn btn-primary" style={{flexShrink:0,minWidth:90}}
|
||||
onClick={()=>runPrompt(p.slug)} disabled={!!loading||!canUseAI}>
|
||||
{loading===p.slug
|
||||
? <><div className="spinner" style={{width:13,height:13}}/> Läuft…</>
|
||||
: <><Brain size={13}/> {existing?'Neu erstellen':'Starten'}</>}
|
||||
</button>
|
||||
<div
|
||||
title={aiUsage && !aiUsage.allowed ? `Limit erreicht (${aiUsage.used}/${aiUsage.limit}). Kontaktiere den Admin oder warte bis zum nächsten Reset.` : ''}
|
||||
style={{display:'inline-block'}}
|
||||
>
|
||||
<button
|
||||
className="btn btn-primary"
|
||||
style={{flexShrink:0,minWidth:90, cursor: (aiUsage && !aiUsage.allowed) ? 'not-allowed' : 'pointer'}}
|
||||
onClick={()=>runPrompt(p.slug)}
|
||||
disabled={!!loading||!canUseAI||(aiUsage && !aiUsage.allowed)}
|
||||
>
|
||||
{loading===p.slug
|
||||
? <><div className="spinner" style={{width:13,height:13}}/> Läuft…</>
|
||||
: (aiUsage && !aiUsage.allowed) ? '🔒 Limit'
|
||||
: <><Brain size={13}/> {existing?'Neu erstellen':'Starten'}</>}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/* Show existing result collapsed */}
|
||||
{existing && newResult?.id !== existing.id && (
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import { useNavigate } from 'react-router-dom'
|
|||
import { api } from '../utils/api'
|
||||
import { calcBodyFat, getBfCategory, METHOD_POINTS } from '../utils/calc'
|
||||
import { CALIPER_POINTS, CALIPER_METHODS } from '../utils/guideData'
|
||||
import UsageBadge from '../components/UsageBadge'
|
||||
import dayjs from 'dayjs'
|
||||
|
||||
function emptyForm() {
|
||||
|
|
@ -15,7 +16,7 @@ function emptyForm() {
|
|||
}
|
||||
}
|
||||
|
||||
function CaliperForm({ form, setForm, profile, onSave, onCancel, saveLabel='Speichern' }) {
|
||||
function CaliperForm({ form, setForm, profile, onSave, onCancel, saveLabel='Speichern', saving=false, error=null, usage=null }) {
|
||||
const sex = profile?.sex||'m'
|
||||
const age = profile?.dob ? Math.floor((Date.now()-new Date(profile.dob))/(365.25*24*3600*1000)) : 30
|
||||
const weight = form.weight || 80
|
||||
|
|
@ -65,8 +66,25 @@ function CaliperForm({ form, setForm, profile, onSave, onCancel, saveLabel='Spei
|
|||
<input type="text" className="form-input" placeholder="optional" value={form.notes||''} onChange={e=>set('notes',e.target.value)}/>
|
||||
<span className="form-unit"/>
|
||||
</div>
|
||||
{error && (
|
||||
<div style={{padding:'10px',background:'var(--danger-bg)',border:'1px solid var(--danger)',borderRadius:8,fontSize:13,color:'var(--danger)',marginBottom:8}}>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
<div style={{display:'flex',gap:6,marginTop:8}}>
|
||||
<button className="btn btn-primary" style={{flex:1}} onClick={()=>onSave(bfPct, sex)}>{saveLabel}</button>
|
||||
<div
|
||||
title={usage && !usage.allowed ? `Limit erreicht (${usage.used}/${usage.limit}). Kontaktiere den Admin oder warte bis zum nächsten Reset.` : ''}
|
||||
style={{flex:1,display:'inline-block'}}
|
||||
>
|
||||
<button
|
||||
className="btn btn-primary"
|
||||
style={{width:'100%', cursor: (usage && !usage.allowed) ? 'not-allowed' : 'pointer'}}
|
||||
onClick={()=>onSave(bfPct, sex)}
|
||||
disabled={saving || (usage && !usage.allowed)}
|
||||
>
|
||||
{(usage && !usage.allowed) ? '🔒 Limit erreicht' : saveLabel}
|
||||
</button>
|
||||
</div>
|
||||
{onCancel && <button className="btn btn-secondary" style={{flex:1}} onClick={onCancel}><X size={13}/> Abbrechen</button>}
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -78,12 +96,26 @@ export default function CaliperScreen() {
|
|||
const [profile, setProfile] = useState(null)
|
||||
const [form, setForm] = useState(emptyForm())
|
||||
const [editing, setEditing] = useState(null)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [saved, setSaved] = useState(false)
|
||||
const [error, setError] = useState(null)
|
||||
const [caliperUsage, setCaliperUsage] = useState(null) // Phase 4: Usage badge
|
||||
const nav = useNavigate()
|
||||
|
||||
const load = () => Promise.all([api.listCaliper(), api.getProfile()])
|
||||
.then(([e,p])=>{ setEntries(e); setProfile(p) })
|
||||
useEffect(()=>{ load() },[])
|
||||
|
||||
const loadUsage = () => {
|
||||
api.getFeatureUsage().then(features => {
|
||||
const caliperFeature = features.find(f => f.feature_id === 'caliper_entries')
|
||||
setCaliperUsage(caliperFeature)
|
||||
}).catch(err => console.error('Failed to load usage:', err))
|
||||
}
|
||||
|
||||
useEffect(()=>{
|
||||
load()
|
||||
loadUsage()
|
||||
},[])
|
||||
|
||||
const buildPayload = (f, bfPct, sex) => {
|
||||
const weight = profile?.weight || null
|
||||
|
|
@ -97,11 +129,23 @@ export default function CaliperScreen() {
|
|||
}
|
||||
|
||||
const handleSave = async (bfPct, sex) => {
|
||||
const payload = buildPayload(form, bfPct, sex)
|
||||
await api.upsertCaliper(payload)
|
||||
setSaved(true); await load()
|
||||
setTimeout(()=>setSaved(false),2000)
|
||||
setForm(emptyForm())
|
||||
setSaving(true)
|
||||
setError(null)
|
||||
try {
|
||||
const payload = buildPayload(form, bfPct, sex)
|
||||
await api.upsertCaliper(payload)
|
||||
setSaved(true)
|
||||
await load()
|
||||
await loadUsage() // Reload usage after save
|
||||
setTimeout(()=>setSaved(false),2000)
|
||||
setForm(emptyForm())
|
||||
} catch (err) {
|
||||
console.error('Save failed:', err)
|
||||
setError(err.message || 'Fehler beim Speichern')
|
||||
setTimeout(()=>setError(null), 5000)
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleUpdate = async (bfPct, sex) => {
|
||||
|
|
@ -125,9 +169,13 @@ export default function CaliperScreen() {
|
|||
</div>
|
||||
|
||||
<div className="card section-gap">
|
||||
<div className="card-title">Neue Messung</div>
|
||||
<div className="card-title badge-container-right">
|
||||
<span>Neue Messung</span>
|
||||
{caliperUsage && <UsageBadge {...caliperUsage} />}
|
||||
</div>
|
||||
<CaliperForm form={form} setForm={setForm} profile={profile}
|
||||
onSave={handleSave} saveLabel={saved?'✓ Gespeichert!':'Speichern'}/>
|
||||
onSave={handleSave} saveLabel={saved?'✓ Gespeichert!':'Speichern'}
|
||||
saving={saving} error={error} usage={caliperUsage}/>
|
||||
</div>
|
||||
|
||||
<div className="section-gap">
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import { Pencil, Trash2, Check, X, Camera, BookOpen } from 'lucide-react'
|
|||
import { useNavigate } from 'react-router-dom'
|
||||
import { api } from '../utils/api'
|
||||
import { CIRCUMFERENCE_POINTS } from '../utils/guideData'
|
||||
import UsageBadge from '../components/UsageBadge'
|
||||
import dayjs from 'dayjs'
|
||||
|
||||
const FIELDS = ['c_neck','c_chest','c_waist','c_belly','c_hip','c_thigh','c_calf','c_arm']
|
||||
|
|
@ -16,18 +17,32 @@ export default function CircumScreen() {
|
|||
const [editing, setEditing] = useState(null)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [saved, setSaved] = useState(false)
|
||||
const [error, setError] = useState(null)
|
||||
const [photoFile, setPhotoFile] = useState(null)
|
||||
const [photoPreview, setPhotoPreview] = useState(null)
|
||||
const [circumUsage, setCircumUsage] = useState(null) // Phase 4: Usage badge
|
||||
const fileRef = useRef()
|
||||
const nav = useNavigate()
|
||||
|
||||
const load = () => api.listCirc().then(setEntries)
|
||||
useEffect(()=>{ load() },[])
|
||||
|
||||
const loadUsage = () => {
|
||||
api.getFeatureUsage().then(features => {
|
||||
const circumFeature = features.find(f => f.feature_id === 'circumference_entries')
|
||||
setCircumUsage(circumFeature)
|
||||
}).catch(err => console.error('Failed to load usage:', err))
|
||||
}
|
||||
|
||||
useEffect(()=>{
|
||||
load()
|
||||
loadUsage()
|
||||
},[])
|
||||
|
||||
const set = (k,v) => setForm(f=>({...f,[k]:v}))
|
||||
|
||||
const handleSave = async () => {
|
||||
setSaving(true)
|
||||
setError(null)
|
||||
try {
|
||||
const payload = {}
|
||||
payload.date = form.date
|
||||
|
|
@ -38,10 +53,18 @@ export default function CircumScreen() {
|
|||
payload.photo_id = pr.id
|
||||
}
|
||||
await api.upsertCirc(payload)
|
||||
setSaved(true); await load()
|
||||
setSaved(true)
|
||||
await load()
|
||||
await loadUsage() // Reload usage after save
|
||||
setTimeout(()=>setSaved(false),2000)
|
||||
setForm(empty()); setPhotoFile(null); setPhotoPreview(null)
|
||||
} finally { setSaving(false) }
|
||||
} catch (err) {
|
||||
console.error('Save failed:', err)
|
||||
setError(err.message || 'Fehler beim Speichern')
|
||||
setTimeout(()=>setError(null), 5000)
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const startEdit = (e) => setEditing({...e})
|
||||
|
|
@ -72,7 +95,10 @@ export default function CircumScreen() {
|
|||
|
||||
{/* Eingabe */}
|
||||
<div className="card section-gap">
|
||||
<div className="card-title">Neue Messung</div>
|
||||
<div className="card-title badge-container-right">
|
||||
<span>Neue Messung</span>
|
||||
{circumUsage && <UsageBadge {...circumUsage} />}
|
||||
</div>
|
||||
<div className="form-row">
|
||||
<label className="form-label">Datum</label>
|
||||
<input type="date" className="form-input" style={{width:140}} value={form.date} onChange={e=>set('date',e.target.value)}/>
|
||||
|
|
@ -99,9 +125,27 @@ export default function CircumScreen() {
|
|||
<button className="btn btn-secondary btn-full" onClick={()=>fileRef.current.click()}>
|
||||
<Camera size={14}/> {photoPreview?'Foto ändern':'Foto hinzufügen'}
|
||||
</button>
|
||||
<button className="btn btn-primary btn-full" style={{marginTop:8}} onClick={handleSave} disabled={saving}>
|
||||
{saved ? <><Check size={14}/> Gespeichert!</> : saving ? '…' : 'Speichern'}
|
||||
</button>
|
||||
{error && (
|
||||
<div style={{padding:'10px',background:'var(--danger-bg)',border:'1px solid var(--danger)',borderRadius:8,fontSize:13,color:'var(--danger)',marginTop:8}}>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
title={circumUsage && !circumUsage.allowed ? `Limit erreicht (${circumUsage.used}/${circumUsage.limit}). Kontaktiere den Admin oder warte bis zum nächsten Reset.` : ''}
|
||||
style={{display:'inline-block',width:'100%',marginTop:8}}
|
||||
>
|
||||
<button
|
||||
className="btn btn-primary btn-full"
|
||||
style={{cursor: (circumUsage && !circumUsage.allowed) ? 'not-allowed' : 'pointer'}}
|
||||
onClick={handleSave}
|
||||
disabled={saving || (circumUsage && !circumUsage.allowed)}
|
||||
>
|
||||
{saved ? <><Check size={14}/> Gespeichert!</>
|
||||
: saving ? '…'
|
||||
: (circumUsage && !circumUsage.allowed) ? '🔒 Limit erreicht'
|
||||
: 'Speichern'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Liste */}
|
||||
|
|
|
|||
|
|
@ -27,32 +27,75 @@ function QuickWeight({ onSaved }) {
|
|||
const [input, setInput] = useState('')
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [saved, setSaved] = useState(false)
|
||||
const [error, setError] = useState(null)
|
||||
const [weightUsage, setWeightUsage] = useState(null)
|
||||
const today = dayjs().format('YYYY-MM-DD')
|
||||
|
||||
const loadUsage = () => {
|
||||
api.getFeatureUsage().then(features => {
|
||||
const weightFeature = features.find(f => f.feature_id === 'weight_entries')
|
||||
setWeightUsage(weightFeature)
|
||||
}).catch(err => console.error('Failed to load usage:', err))
|
||||
}
|
||||
|
||||
useEffect(()=>{
|
||||
api.weightStats().then(s=>{
|
||||
if(s?.latest?.date===today) setInput(String(s.latest.weight))
|
||||
})
|
||||
loadUsage()
|
||||
},[])
|
||||
|
||||
const handleSave = async () => {
|
||||
const w=parseFloat(input); if(!w||w<20||w>300) return
|
||||
setSaving(true)
|
||||
try{ await api.upsertWeight(today,w); setSaved(true); onSaved?.(); setTimeout(()=>setSaved(false),2000) }
|
||||
finally{ setSaving(false) }
|
||||
setError(null)
|
||||
try{
|
||||
await api.upsertWeight(today,w)
|
||||
setSaved(true)
|
||||
await loadUsage() // Reload usage after save
|
||||
onSaved?.()
|
||||
setTimeout(()=>setSaved(false),2000)
|
||||
} catch(err) {
|
||||
console.error('Save failed:', err)
|
||||
setError(err.message || 'Fehler beim Speichern')
|
||||
setTimeout(()=>setError(null), 5000)
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const isDisabled = saving || !input || (weightUsage && !weightUsage.allowed)
|
||||
const tooltipText = weightUsage && !weightUsage.allowed
|
||||
? `Limit erreicht (${weightUsage.used}/${weightUsage.limit}). Kontaktiere den Admin oder warte bis zum nächsten Reset.`
|
||||
: ''
|
||||
|
||||
return (
|
||||
<div style={{display:'flex',gap:8,alignItems:'center'}}>
|
||||
<input type="number" min={20} max={300} step={0.1} className="form-input"
|
||||
style={{flex:1,fontSize:17,fontWeight:600,textAlign:'center'}}
|
||||
placeholder="kg eingeben" value={input} onChange={e=>setInput(e.target.value)}
|
||||
onKeyDown={e=>e.key==='Enter'&&handleSave()}/>
|
||||
<span style={{fontSize:13,color:'var(--text3)'}}>kg</span>
|
||||
<button className="btn btn-primary" style={{padding:'8px 14px'}}
|
||||
onClick={handleSave} disabled={saving||!input}>
|
||||
{saved?<Check size={15}/>:saving?<div className="spinner" style={{width:14,height:14}}/>:'Speichern'}
|
||||
</button>
|
||||
<div>
|
||||
{error && (
|
||||
<div style={{padding:'8px 10px',background:'var(--danger-bg)',border:'1px solid var(--danger)',borderRadius:8,fontSize:12,color:'var(--danger)',marginBottom:8}}>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
<div style={{display:'flex',gap:8,alignItems:'center'}}>
|
||||
<input type="number" min={20} max={300} step={0.1} className="form-input"
|
||||
style={{flex:1,fontSize:17,fontWeight:600,textAlign:'center'}}
|
||||
placeholder="kg eingeben" value={input} onChange={e=>setInput(e.target.value)}
|
||||
onKeyDown={e=>e.key==='Enter'&&!isDisabled&&handleSave()}/>
|
||||
<span style={{fontSize:13,color:'var(--text3)'}}>kg</span>
|
||||
<div title={tooltipText} style={{display:'inline-block'}}>
|
||||
<button
|
||||
className="btn btn-primary"
|
||||
style={{padding:'8px 14px', cursor: isDisabled ? 'not-allowed' : 'pointer'}}
|
||||
onClick={handleSave}
|
||||
disabled={isDisabled}
|
||||
>
|
||||
{saved ? <Check size={15}/>
|
||||
: saving ? <div className="spinner" style={{width:14,height:14}}/>
|
||||
: (weightUsage && !weightUsage.allowed) ? '🔒 Limit'
|
||||
: 'Speichern'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -18,6 +18,419 @@ function rollingAvg(arr, key, window=7) {
|
|||
})
|
||||
}
|
||||
|
||||
// ── Entry Form (Create/Update) ───────────────────────────────────────────────
|
||||
function EntryForm({ onSaved }) {
|
||||
const [date, setDate] = useState(dayjs().format('YYYY-MM-DD'))
|
||||
const [values, setValues] = useState({ kcal: '', protein_g: '', fat_g: '', carbs_g: '' })
|
||||
const [existingId, setExistingId] = useState(null)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [error, setError] = useState(null)
|
||||
const [success, setSuccess] = useState(null)
|
||||
|
||||
// Load data for selected date
|
||||
useEffect(() => {
|
||||
const load = async () => {
|
||||
if (!date) return
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
const data = await nutritionApi.getNutritionByDate(date)
|
||||
if (data) {
|
||||
setValues({
|
||||
kcal: data.kcal || '',
|
||||
protein_g: data.protein_g || '',
|
||||
fat_g: data.fat_g || '',
|
||||
carbs_g: data.carbs_g || ''
|
||||
})
|
||||
setExistingId(data.id)
|
||||
} else {
|
||||
setValues({ kcal: '', protein_g: '', fat_g: '', carbs_g: '' })
|
||||
setExistingId(null)
|
||||
}
|
||||
} catch(e) {
|
||||
console.error('Failed to load entry:', e)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
load()
|
||||
}, [date])
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!date || !values.kcal) {
|
||||
setError('Datum und Kalorien sind Pflichtfelder')
|
||||
return
|
||||
}
|
||||
|
||||
setSaving(true)
|
||||
setError(null)
|
||||
setSuccess(null)
|
||||
try {
|
||||
const result = await nutritionApi.createNutrition(
|
||||
date,
|
||||
parseFloat(values.kcal) || 0,
|
||||
parseFloat(values.protein_g) || 0,
|
||||
parseFloat(values.fat_g) || 0,
|
||||
parseFloat(values.carbs_g) || 0
|
||||
)
|
||||
setSuccess(result.mode === 'created' ? 'Eintrag hinzugefügt' : 'Eintrag aktualisiert')
|
||||
setTimeout(() => setSuccess(null), 3000)
|
||||
onSaved()
|
||||
} catch(e) {
|
||||
if (e.message.includes('Limit erreicht')) {
|
||||
setError(e.message)
|
||||
} else {
|
||||
setError('Speichern fehlgeschlagen: ' + e.message)
|
||||
}
|
||||
setTimeout(() => setError(null), 5000)
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="card section-gap">
|
||||
<div className="card-title">Eintrag hinzufügen / bearbeiten</div>
|
||||
|
||||
{error && (
|
||||
<div style={{padding:'8px 12px',background:'#FCEBEB',borderRadius:8,fontSize:13,color:'#D85A30',marginBottom:12}}>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
{success && (
|
||||
<div style={{padding:'8px 12px',background:'var(--accent-light)',borderRadius:8,fontSize:13,color:'var(--accent-dark)',marginBottom:12}}>
|
||||
✓ {success}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={{display:'grid',gridTemplateColumns:'1fr 1fr',gap:12,marginBottom:12}}>
|
||||
<div style={{gridColumn:'1 / -1'}}>
|
||||
<label className="form-label">Datum</label>
|
||||
<input
|
||||
type="date"
|
||||
className="form-input"
|
||||
value={date}
|
||||
onChange={e => setDate(e.target.value)}
|
||||
max={dayjs().format('YYYY-MM-DD')}
|
||||
style={{width:'100%'}}
|
||||
/>
|
||||
{existingId && !loading && (
|
||||
<div style={{fontSize:11,color:'var(--accent)',marginTop:4}}>
|
||||
ℹ️ Eintrag existiert bereits – wird beim Speichern aktualisiert
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="form-label">Kalorien *</label>
|
||||
<input
|
||||
type="number"
|
||||
className="form-input"
|
||||
value={values.kcal}
|
||||
onChange={e => setValues({...values, kcal: e.target.value})}
|
||||
placeholder="z.B. 2000"
|
||||
disabled={loading}
|
||||
style={{width:'100%'}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="form-label">Protein (g)</label>
|
||||
<input
|
||||
type="number"
|
||||
className="form-input"
|
||||
value={values.protein_g}
|
||||
onChange={e => setValues({...values, protein_g: e.target.value})}
|
||||
placeholder="z.B. 150"
|
||||
disabled={loading}
|
||||
style={{width:'100%'}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="form-label">Fett (g)</label>
|
||||
<input
|
||||
type="number"
|
||||
className="form-input"
|
||||
value={values.fat_g}
|
||||
onChange={e => setValues({...values, fat_g: e.target.value})}
|
||||
placeholder="z.B. 80"
|
||||
disabled={loading}
|
||||
style={{width:'100%'}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="form-label">Kohlenhydrate (g)</label>
|
||||
<input
|
||||
type="number"
|
||||
className="form-input"
|
||||
value={values.carbs_g}
|
||||
onChange={e => setValues({...values, carbs_g: e.target.value})}
|
||||
placeholder="z.B. 200"
|
||||
disabled={loading}
|
||||
style={{width:'100%'}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
className="btn btn-primary btn-full"
|
||||
onClick={handleSave}
|
||||
disabled={saving || loading || !date || !values.kcal}>
|
||||
{saving ? (
|
||||
<><div className="spinner" style={{width:14,height:14}}/> Speichere…</>
|
||||
) : existingId ? (
|
||||
'📝 Eintrag aktualisieren'
|
||||
) : (
|
||||
'➕ Eintrag hinzufügen'
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Data Tab (Editable Entry List) ───────────────────────────────────────────
|
||||
function DataTab({ entries, onUpdate }) {
|
||||
const [editId, setEditId] = useState(null)
|
||||
const [editValues, setEditValues] = useState({})
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [error, setError] = useState(null)
|
||||
const [filter, setFilter] = useState('30') // days to show (7, 30, 90, 'all')
|
||||
|
||||
const startEdit = (e) => {
|
||||
setEditId(e.id)
|
||||
setEditValues({
|
||||
kcal: e.kcal || 0,
|
||||
protein_g: e.protein_g || 0,
|
||||
fat_g: e.fat_g || 0,
|
||||
carbs_g: e.carbs_g || 0
|
||||
})
|
||||
}
|
||||
|
||||
const cancelEdit = () => {
|
||||
setEditId(null)
|
||||
setEditValues({})
|
||||
setError(null)
|
||||
}
|
||||
|
||||
const saveEdit = async (id) => {
|
||||
setSaving(true)
|
||||
setError(null)
|
||||
try {
|
||||
await nutritionApi.updateNutrition(
|
||||
id,
|
||||
editValues.kcal,
|
||||
editValues.protein_g,
|
||||
editValues.fat_g,
|
||||
editValues.carbs_g
|
||||
)
|
||||
setEditId(null)
|
||||
setEditValues({})
|
||||
onUpdate()
|
||||
} catch(e) {
|
||||
setError('Speichern fehlgeschlagen: ' + e.message)
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const deleteEntry = async (id, date) => {
|
||||
if (!confirm(`Eintrag vom ${dayjs(date).format('DD.MM.YYYY')} wirklich löschen?`)) return
|
||||
try {
|
||||
await nutritionApi.deleteNutrition(id)
|
||||
onUpdate()
|
||||
} catch(e) {
|
||||
setError('Löschen fehlgeschlagen: ' + e.message)
|
||||
}
|
||||
}
|
||||
|
||||
// Filter entries by date range
|
||||
const filteredEntries = filter === 'all'
|
||||
? entries
|
||||
: entries.filter(e => {
|
||||
const daysDiff = dayjs().diff(dayjs(e.date), 'day')
|
||||
return daysDiff <= parseInt(filter)
|
||||
})
|
||||
|
||||
if (entries.length === 0) {
|
||||
return (
|
||||
<div className="card section-gap">
|
||||
<div className="card-title">Alle Einträge (0)</div>
|
||||
<p className="muted">Noch keine Ernährungsdaten. Importiere FDDB CSV oben.</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="card section-gap">
|
||||
<div style={{display:'flex',justifyContent:'space-between',alignItems:'center',marginBottom:16}}>
|
||||
<div className="card-title" style={{margin:0}}>
|
||||
Alle Einträge ({filteredEntries.length}{filteredEntries.length !== entries.length ? ` von ${entries.length}` : ''})
|
||||
</div>
|
||||
<select
|
||||
value={filter}
|
||||
onChange={e => setFilter(e.target.value)}
|
||||
style={{
|
||||
padding:'6px 10px',fontSize:12,borderRadius:8,border:'1.5px solid var(--border2)',
|
||||
background:'var(--surface)',color:'var(--text2)',cursor:'pointer',fontFamily:'var(--font)'
|
||||
}}>
|
||||
<option value="7">Letzte 7 Tage</option>
|
||||
<option value="30">Letzte 30 Tage</option>
|
||||
<option value="90">Letzte 90 Tage</option>
|
||||
<option value="365">Letztes Jahr</option>
|
||||
<option value="all">Alle anzeigen</option>
|
||||
</select>
|
||||
</div>
|
||||
{error && (
|
||||
<div style={{padding:'8px 12px',background:'#FCEBEB',borderRadius:8,fontSize:13,color:'#D85A30',marginBottom:12}}>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
{filteredEntries.map((e, i) => {
|
||||
const isEditing = editId === e.id
|
||||
return (
|
||||
<div key={e.id || i} style={{
|
||||
borderBottom: i < filteredEntries.length - 1 ? '1px solid var(--border)' : 'none',
|
||||
padding: '12px 0'
|
||||
}}>
|
||||
{!isEditing ? (
|
||||
<>
|
||||
<div style={{display:'flex', justifyContent:'space-between', alignItems:'center', marginBottom:6}}>
|
||||
<strong style={{fontSize:14}}>{dayjs(e.date).format('dd, DD. MMMM YYYY')}</strong>
|
||||
<div style={{display:'flex',gap:6}}>
|
||||
<button onClick={() => startEdit(e)}
|
||||
style={{padding:'4px 10px',fontSize:11,borderRadius:6,border:'1px solid var(--border2)',
|
||||
background:'var(--surface)',color:'var(--text2)',cursor:'pointer'}}>
|
||||
✏️ Bearbeiten
|
||||
</button>
|
||||
<button onClick={() => deleteEntry(e.id, e.date)}
|
||||
style={{padding:'4px 10px',fontSize:11,borderRadius:6,border:'1px solid #D85A30',
|
||||
background:'#FCEBEB',color:'#D85A30',cursor:'pointer'}}>
|
||||
🗑️
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{fontSize:13, fontWeight:600, color:'#EF9F27',marginBottom:6}}>
|
||||
{Math.round(e.kcal || 0)} kcal
|
||||
</div>
|
||||
<div style={{display:'flex', gap:12, fontSize:12, color:'var(--text2)'}}>
|
||||
<span>🥩 Protein: <strong>{Math.round(e.protein_g || 0)}g</strong></span>
|
||||
<span>🫙 Fett: <strong>{Math.round(e.fat_g || 0)}g</strong></span>
|
||||
<span>🍞 Kohlenhydrate: <strong>{Math.round(e.carbs_g || 0)}g</strong></span>
|
||||
</div>
|
||||
{e.source && (
|
||||
<div style={{fontSize:10, color:'var(--text3)', marginTop:4}}>
|
||||
Quelle: {e.source}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div style={{marginBottom:8}}>
|
||||
<strong style={{fontSize:14}}>{dayjs(e.date).format('dd, DD. MMMM YYYY')}</strong>
|
||||
</div>
|
||||
<div style={{display:'grid',gridTemplateColumns:'1fr 1fr',gap:8,marginBottom:10}}>
|
||||
<div>
|
||||
<label style={{fontSize:11,color:'var(--text3)',display:'block',marginBottom:4}}>Kalorien</label>
|
||||
<input type="number" className="form-input" value={editValues.kcal}
|
||||
onChange={e => setEditValues({...editValues, kcal: parseFloat(e.target.value)||0})}
|
||||
style={{width:'100%'}}/>
|
||||
</div>
|
||||
<div>
|
||||
<label style={{fontSize:11,color:'var(--text3)',display:'block',marginBottom:4}}>Protein (g)</label>
|
||||
<input type="number" className="form-input" value={editValues.protein_g}
|
||||
onChange={e => setEditValues({...editValues, protein_g: parseFloat(e.target.value)||0})}
|
||||
style={{width:'100%'}}/>
|
||||
</div>
|
||||
<div>
|
||||
<label style={{fontSize:11,color:'var(--text3)',display:'block',marginBottom:4}}>Fett (g)</label>
|
||||
<input type="number" className="form-input" value={editValues.fat_g}
|
||||
onChange={e => setEditValues({...editValues, fat_g: parseFloat(e.target.value)||0})}
|
||||
style={{width:'100%'}}/>
|
||||
</div>
|
||||
<div>
|
||||
<label style={{fontSize:11,color:'var(--text3)',display:'block',marginBottom:4}}>Kohlenhydrate (g)</label>
|
||||
<input type="number" className="form-input" value={editValues.carbs_g}
|
||||
onChange={e => setEditValues({...editValues, carbs_g: parseFloat(e.target.value)||0})}
|
||||
style={{width:'100%'}}/>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{display:'flex',gap:8}}>
|
||||
<button onClick={() => saveEdit(e.id)} disabled={saving}
|
||||
className="btn btn-primary" style={{flex:1}}>
|
||||
{saving ? 'Speichere…' : '✓ Speichern'}
|
||||
</button>
|
||||
<button onClick={cancelEdit} disabled={saving}
|
||||
className="btn btn-secondary" style={{flex:1}}>
|
||||
Abbrechen
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Import History ────────────────────────────────────────────────────────────
|
||||
function ImportHistory() {
|
||||
const [history, setHistory] = useState([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
const load = async () => {
|
||||
try {
|
||||
const data = await nutritionApi.nutritionImportHistory()
|
||||
setHistory(Array.isArray(data) ? data : [])
|
||||
} catch(e) {
|
||||
console.error('Failed to load import history:', e)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
load()
|
||||
}, [])
|
||||
|
||||
if (loading) return null
|
||||
if (!history.length) return null
|
||||
|
||||
return (
|
||||
<div className="card section-gap">
|
||||
<div className="card-title">Import-Historie</div>
|
||||
<div style={{display:'flex',flexDirection:'column',gap:8}}>
|
||||
{history.map((h, i) => (
|
||||
<div key={i} style={{
|
||||
padding: '10px 12px',
|
||||
background: 'var(--surface2)',
|
||||
borderRadius: 8,
|
||||
borderLeft: '3px solid var(--accent)',
|
||||
fontSize: 13
|
||||
}}>
|
||||
<div style={{display:'flex',justifyContent:'space-between',marginBottom:4}}>
|
||||
<strong>{dayjs(h.import_date).format('DD.MM.YYYY')}</strong>
|
||||
<span style={{color:'var(--text3)',fontSize:11}}>
|
||||
{dayjs(h.last_created).format('HH:mm')} Uhr
|
||||
</span>
|
||||
</div>
|
||||
<div style={{color:'var(--text2)',fontSize:12}}>
|
||||
<span>{h.count} {h.count === 1 ? 'Eintrag' : 'Einträge'}</span>
|
||||
{h.date_from && h.date_to && (
|
||||
<span style={{marginLeft:8,color:'var(--text3)'}}>
|
||||
({dayjs(h.date_from).format('DD.MM.YY')} – {dayjs(h.date_to).format('DD.MM.YY')})
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Import Panel ──────────────────────────────────────────────────────────────
|
||||
function ImportPanel({ onImported }) {
|
||||
const fileRef = useRef()
|
||||
|
|
@ -322,9 +735,11 @@ function CalorieBalance({ data, profile }) {
|
|||
|
||||
// ── Main Page ─────────────────────────────────────────────────────────────────
|
||||
export default function NutritionPage() {
|
||||
const [tab, setTab] = useState('overview')
|
||||
const [inputTab, setInputTab] = useState('entry') // 'entry' or 'import'
|
||||
const [analysisTab,setAnalysisTab] = useState('data')
|
||||
const [corrData, setCorr] = useState([])
|
||||
const [weekly, setWeekly] = useState([])
|
||||
const [entries, setEntries]= useState([])
|
||||
const [profile, setProf] = useState(null)
|
||||
const [loading, setLoad] = useState(true)
|
||||
const [hasData, setHasData]= useState(false)
|
||||
|
|
@ -332,13 +747,15 @@ export default function NutritionPage() {
|
|||
const load = async () => {
|
||||
setLoad(true)
|
||||
try {
|
||||
const [corr, wkly, prof] = await Promise.all([
|
||||
const [corr, wkly, ent, prof] = await Promise.all([
|
||||
nutritionApi.nutritionCorrelations(),
|
||||
nutritionApi.nutritionWeekly(16),
|
||||
api.getActiveProfile(),
|
||||
nutritionApi.listNutrition(365), // BUG-002 fix: load raw entries
|
||||
nutritionApi.getActiveProfile(),
|
||||
])
|
||||
setCorr(Array.isArray(corr)?corr:[])
|
||||
setWeekly(Array.isArray(wkly)?wkly:[])
|
||||
setEntries(Array.isArray(ent)?ent:[]) // BUG-002 fix
|
||||
setProf(prof)
|
||||
setHasData(Array.isArray(corr) && corr.some(d=>d.kcal))
|
||||
} catch(e) { console.error('load error:', e) }
|
||||
|
|
@ -351,29 +768,52 @@ export default function NutritionPage() {
|
|||
<div>
|
||||
<h1 className="page-title">Ernährung</h1>
|
||||
|
||||
<ImportPanel onImported={load}/>
|
||||
{/* Input Method Tabs */}
|
||||
<div className="tabs section-gap" style={{marginBottom:0}}>
|
||||
<button className={'tab'+(inputTab==='entry'?' active':'')} onClick={()=>setInputTab('entry')}>
|
||||
✏️ Einzelerfassung
|
||||
</button>
|
||||
<button className={'tab'+(inputTab==='import'?' active':'')} onClick={()=>setInputTab('import')}>
|
||||
📥 Import
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Entry Form */}
|
||||
{inputTab==='entry' && <EntryForm onSaved={load}/>}
|
||||
|
||||
{/* Import Panel + History */}
|
||||
{inputTab==='import' && (
|
||||
<>
|
||||
<ImportPanel onImported={load}/>
|
||||
<ImportHistory/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{loading && <div className="empty-state"><div className="spinner"/></div>}
|
||||
|
||||
{!loading && !hasData && (
|
||||
<div className="empty-state">
|
||||
<h3>Noch keine Ernährungsdaten</h3>
|
||||
<p>Importiere deinen FDDB-Export oben um Auswertungen zu sehen.</p>
|
||||
<p>Erfasse Daten über Einzelerfassung oder importiere deinen FDDB-Export.</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Analysis Section */}
|
||||
{!loading && hasData && (
|
||||
<>
|
||||
<OverviewCards data={corrData}/>
|
||||
|
||||
<div className="tabs section-gap" style={{overflowX:'auto',flexWrap:'nowrap'}}>
|
||||
<button className={'tab'+(tab==='overview'?' active':'')} onClick={()=>setTab('overview')}>Übersicht</button>
|
||||
<button className={'tab'+(tab==='weight'?' active':'')} onClick={()=>setTab('weight')}>Kcal vs. Gewicht</button>
|
||||
<button className={'tab'+(tab==='protein'?' active':'')} onClick={()=>setTab('protein')}>Protein vs. Mager</button>
|
||||
<button className={'tab'+(tab==='balance'?' active':'')} onClick={()=>setTab('balance')}>Bilanz</button>
|
||||
<button className={'tab'+(analysisTab==='data'?' active':'')} onClick={()=>setAnalysisTab('data')}>Daten</button>
|
||||
<button className={'tab'+(analysisTab==='overview'?' active':'')} onClick={()=>setAnalysisTab('overview')}>Übersicht</button>
|
||||
<button className={'tab'+(analysisTab==='weight'?' active':'')} onClick={()=>setAnalysisTab('weight')}>Kcal vs. Gewicht</button>
|
||||
<button className={'tab'+(analysisTab==='protein'?' active':'')} onClick={()=>setAnalysisTab('protein')}>Protein vs. Mager</button>
|
||||
<button className={'tab'+(analysisTab==='balance'?' active':'')} onClick={()=>setAnalysisTab('balance')}>Bilanz</button>
|
||||
</div>
|
||||
|
||||
{tab==='overview' && (
|
||||
{analysisTab==='data' && <DataTab entries={entries} onUpdate={load}/>}
|
||||
|
||||
{analysisTab==='overview' && (
|
||||
<div className="card section-gap">
|
||||
<div className="card-title">Makro-Verteilung pro Woche (Ø g/Tag)</div>
|
||||
<WeeklyMacros weekly={weekly}/>
|
||||
|
|
@ -385,7 +825,7 @@ export default function NutritionPage() {
|
|||
</div>
|
||||
)}
|
||||
|
||||
{tab==='weight' && (
|
||||
{analysisTab==='weight' && (
|
||||
<div className="card section-gap">
|
||||
<div className="card-title">Kalorien vs. Gewichtsverlauf</div>
|
||||
<div style={{fontSize:11,color:'var(--text3)',marginBottom:8}}>
|
||||
|
|
@ -401,7 +841,7 @@ export default function NutritionPage() {
|
|||
</div>
|
||||
)}
|
||||
|
||||
{tab==='protein' && (
|
||||
{analysisTab==='protein' && (
|
||||
<div className="card section-gap">
|
||||
<div className="card-title">Protein vs. Magermasse</div>
|
||||
<div style={{fontSize:11,color:'var(--text3)',marginBottom:8}}>
|
||||
|
|
@ -417,7 +857,7 @@ export default function NutritionPage() {
|
|||
</div>
|
||||
)}
|
||||
|
||||
{tab==='balance' && (
|
||||
{analysisTab==='balance' && (
|
||||
<div className="card section-gap">
|
||||
<div className="card-title">Kaloriendefizit / -überschuss</div>
|
||||
<CalorieBalance data={corrData} profile={profile}/>
|
||||
|
|
|
|||
|
|
@ -1,10 +1,12 @@
|
|||
import { useState } from 'react'
|
||||
import { Save, Download, Upload, Trash2, Plus, Check, Pencil, X, LogOut, Shield, Key } from 'lucide-react'
|
||||
import { useState, useEffect } from 'react'
|
||||
import { Save, Download, Upload, Trash2, Plus, Check, Pencil, X, LogOut, Shield, Key, BarChart3 } from 'lucide-react'
|
||||
import { useProfile } from '../context/ProfileContext'
|
||||
import { useAuth } from '../context/AuthContext'
|
||||
import { Avatar } from './ProfileSelect'
|
||||
import { api } from '../utils/api'
|
||||
import AdminPanel from './AdminPanel'
|
||||
import FeatureUsageOverview from '../components/FeatureUsageOverview'
|
||||
import UsageBadge from '../components/UsageBadge'
|
||||
|
||||
const COLORS = ['#1D9E75','#378ADD','#D85A30','#EF9F27','#7F77DD','#D4537E','#639922','#888780']
|
||||
|
||||
|
|
@ -99,6 +101,15 @@ export default function SettingsPage() {
|
|||
const [pinOpen, setPinOpen] = useState(false)
|
||||
const [newPin, setNewPin] = useState('')
|
||||
const [pinMsg, setPinMsg] = useState(null)
|
||||
const [exportUsage, setExportUsage] = useState(null) // Phase 3: Usage badge
|
||||
|
||||
// Load feature usage for export badges
|
||||
useEffect(() => {
|
||||
api.getFeatureUsage().then(features => {
|
||||
const exportFeature = features.find(f => f.feature_id === 'data_export')
|
||||
setExportUsage(exportFeature)
|
||||
}).catch(err => console.error('Failed to load usage:', err))
|
||||
}, [])
|
||||
|
||||
const handleLogout = async () => {
|
||||
if (!confirm('Ausloggen?')) return
|
||||
|
|
@ -326,6 +337,17 @@ export default function SettingsPage() {
|
|||
</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 */}
|
||||
{isAdmin && (
|
||||
<div className="card section-gap">
|
||||
|
|
@ -359,13 +381,23 @@ export default function SettingsPage() {
|
|||
{canExport && <>
|
||||
<button className="btn btn-primary btn-full"
|
||||
onClick={()=>api.exportZip()}>
|
||||
<Download size={14}/> ZIP exportieren
|
||||
<span style={{fontSize:11,opacity:0.8,marginLeft:6}}>— je eine CSV pro Kategorie</span>
|
||||
<div className="badge-button-layout">
|
||||
<div className="badge-button-header">
|
||||
<span><Download size={14}/> ZIP exportieren</span>
|
||||
{exportUsage && <UsageBadge {...exportUsage} />}
|
||||
</div>
|
||||
<span className="badge-button-description">je eine CSV pro Kategorie</span>
|
||||
</div>
|
||||
</button>
|
||||
<button className="btn btn-secondary btn-full"
|
||||
onClick={()=>api.exportJson()}>
|
||||
<Download size={14}/> JSON exportieren
|
||||
<span style={{fontSize:11,opacity:0.8,marginLeft:6}}>— maschinenlesbar, alles in einer Datei</span>
|
||||
<div className="badge-button-layout">
|
||||
<div className="badge-button-header">
|
||||
<span><Download size={14}/> JSON exportieren</span>
|
||||
{exportUsage && <UsageBadge {...exportUsage} />}
|
||||
</div>
|
||||
<span className="badge-button-description">maschinenlesbar, alles in einer Datei</span>
|
||||
</div>
|
||||
</button>
|
||||
</>}
|
||||
</div>
|
||||
|
|
|
|||
241
frontend/src/pages/SubscriptionPage.jsx
Normal file
241
frontend/src/pages/SubscriptionPage.jsx
Normal file
|
|
@ -0,0 +1,241 @@
|
|||
import { useState, useEffect } from 'react'
|
||||
import { Gift, AlertCircle, TrendingUp, Award } from 'lucide-react'
|
||||
import { api } from '../utils/api'
|
||||
|
||||
export default function SubscriptionPage() {
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState('')
|
||||
const [subscription, setSubscription] = useState(null)
|
||||
const [usage, setUsage] = useState([])
|
||||
const [limits, setLimits] = useState([])
|
||||
const [couponCode, setCouponCode] = useState('')
|
||||
const [redeeming, setRedeeming] = useState(false)
|
||||
const [couponSuccess, setCouponSuccess] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
loadData()
|
||||
}, [])
|
||||
|
||||
async function loadData() {
|
||||
try {
|
||||
setLoading(true)
|
||||
const [subData, usageData, limitsData] = await Promise.all([
|
||||
api.getMySubscription(),
|
||||
api.getMyUsage(),
|
||||
api.getMyLimits()
|
||||
])
|
||||
setSubscription(subData)
|
||||
setUsage(usageData)
|
||||
setLimits(limitsData)
|
||||
setError('')
|
||||
} catch (e) {
|
||||
setError(e.message)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleRedeemCoupon() {
|
||||
if (!couponCode.trim()) return
|
||||
|
||||
try {
|
||||
setRedeeming(true)
|
||||
setError('')
|
||||
setCouponSuccess('')
|
||||
await api.redeemCoupon(couponCode.trim().toUpperCase())
|
||||
setCouponSuccess('Coupon erfolgreich eingelöst!')
|
||||
setCouponCode('')
|
||||
await loadData()
|
||||
} catch (e) {
|
||||
setError(e.message)
|
||||
} finally {
|
||||
setRedeeming(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) return (
|
||||
<div style={{ display: 'flex', justifyContent: 'center', padding: 40 }}>
|
||||
<div className="spinner" />
|
||||
</div>
|
||||
)
|
||||
|
||||
const tierColors = {
|
||||
free: { bg: 'var(--surface2)', color: 'var(--text2)', icon: '🆓' },
|
||||
basic: { bg: '#E3F2FD', color: '#1565C0', icon: '⭐' },
|
||||
premium: { bg: '#F3E5F5', color: '#6A1B9A', icon: '👑' },
|
||||
selfhosted: { bg: 'var(--accent-light)', color: 'var(--accent-dark)', icon: '🏠' }
|
||||
}
|
||||
|
||||
const currentTier = subscription?.current_tier || 'free'
|
||||
const tierStyle = tierColors[currentTier] || tierColors.free
|
||||
|
||||
return (
|
||||
<div style={{ paddingBottom: 40 }}>
|
||||
{/* Header */}
|
||||
<div style={{ marginBottom: 20 }}>
|
||||
<div style={{ fontSize: 20, fontWeight: 700, color: 'var(--text1)' }}>
|
||||
Mein Abo
|
||||
</div>
|
||||
<div style={{ fontSize: 13, color: 'var(--text3)', marginTop: 4 }}>
|
||||
Tier, Limits und Nutzung
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Messages */}
|
||||
{error && (
|
||||
<div style={{
|
||||
padding: 12, background: 'var(--danger)', color: 'white',
|
||||
borderRadius: 8, marginBottom: 16, fontSize: 14
|
||||
}}>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
{couponSuccess && (
|
||||
<div style={{
|
||||
padding: 12, background: 'var(--accent)', color: 'white',
|
||||
borderRadius: 8, marginBottom: 16, fontSize: 14
|
||||
}}>
|
||||
{couponSuccess}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Current Tier Card */}
|
||||
<div className="card" style={{ padding: 20, marginBottom: 16 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: 16 }}>
|
||||
<div style={{
|
||||
width: 48, height: 48, borderRadius: 12,
|
||||
background: tierStyle.bg,
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
fontSize: 24
|
||||
}}>
|
||||
{tierStyle.icon}
|
||||
</div>
|
||||
<div style={{ flex: 1 }}>
|
||||
<div style={{ fontSize: 12, color: 'var(--text3)', textTransform: 'uppercase', fontWeight: 600 }}>
|
||||
Aktueller Tier
|
||||
</div>
|
||||
<div style={{ fontSize: 20, fontWeight: 700, color: tierStyle.color }}>
|
||||
{currentTier.charAt(0).toUpperCase() + currentTier.slice(1)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{subscription?.trial_ends_at && new Date(subscription.trial_ends_at) > new Date() && (
|
||||
<div style={{
|
||||
padding: 10, background: '#FFF3CD', borderRadius: 8,
|
||||
fontSize: 13, color: '#856404', display: 'flex', gap: 8, alignItems: 'center'
|
||||
}}>
|
||||
<AlertCircle size={16} />
|
||||
<div>
|
||||
<strong>Trial aktiv:</strong> Endet am {new Date(subscription.trial_ends_at).toLocaleDateString('de-DE')}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{subscription?.access_until && (
|
||||
<div style={{
|
||||
padding: 10, background: 'var(--accent-light)', borderRadius: 8,
|
||||
fontSize: 13, color: 'var(--accent-dark)', display: 'flex', gap: 8, alignItems: 'center',
|
||||
marginTop: 12
|
||||
}}>
|
||||
<Award size={16} />
|
||||
<div>
|
||||
<strong>Zugriff bis:</strong> {new Date(subscription.access_until).toLocaleDateString('de-DE')}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Feature Limits */}
|
||||
<div className="card" style={{ padding: 20, marginBottom: 16 }}>
|
||||
<div style={{
|
||||
fontSize: 14, fontWeight: 600, marginBottom: 12,
|
||||
display: 'flex', alignItems: 'center', gap: 6
|
||||
}}>
|
||||
<TrendingUp size={16} color="var(--accent)" />
|
||||
Feature-Limits & Nutzung
|
||||
</div>
|
||||
|
||||
{limits.length === 0 ? (
|
||||
<div style={{ textAlign: 'center', padding: 20, color: 'var(--text3)', fontSize: 13 }}>
|
||||
Keine Limits konfiguriert
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ display: 'grid', gap: 12 }}>
|
||||
{limits.map(limit => {
|
||||
const usageEntry = usage.find(u => u.feature_id === limit.feature_id)
|
||||
const used = usageEntry?.usage_count || 0
|
||||
const limitValue = limit.limit_value
|
||||
const percentage = limitValue ? Math.min((used / limitValue) * 100, 100) : 0
|
||||
|
||||
return (
|
||||
<div key={limit.feature_id} style={{
|
||||
padding: 12, background: 'var(--surface)', borderRadius: 8
|
||||
}}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 8 }}>
|
||||
<div style={{ fontWeight: 500, fontSize: 13 }}>{limit.feature_name}</div>
|
||||
<div style={{ fontSize: 13, color: 'var(--text3)' }}>
|
||||
{used} / {limitValue === null ? '∞' : limitValue}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{limitValue !== null && (
|
||||
<div style={{
|
||||
height: 6, background: 'var(--border)', borderRadius: 3, overflow: 'hidden'
|
||||
}}>
|
||||
<div style={{
|
||||
width: `${percentage}%`,
|
||||
height: '100%',
|
||||
background: percentage > 90 ? 'var(--danger)' : percentage > 70 ? '#FFA726' : 'var(--accent)',
|
||||
transition: 'width 0.3s'
|
||||
}} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{limit.reset_period !== 'never' && (
|
||||
<div style={{ fontSize: 11, color: 'var(--text3)', marginTop: 4 }}>
|
||||
Reset: {limit.reset_period === 'daily' ? 'Täglich' : 'Monatlich'}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Coupon Redemption */}
|
||||
<div className="card" style={{ padding: 20 }}>
|
||||
<div style={{
|
||||
fontSize: 14, fontWeight: 600, marginBottom: 12,
|
||||
display: 'flex', alignItems: 'center', gap: 6
|
||||
}}>
|
||||
<Gift size={16} color="var(--accent)" />
|
||||
Coupon einlösen
|
||||
</div>
|
||||
|
||||
<div style={{ fontSize: 12, color: 'var(--text3)', marginBottom: 12 }}>
|
||||
Hast du einen Coupon-Code? Löse ihn hier ein um Zugriff auf Premium-Features zu erhalten.
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<input
|
||||
className="form-input"
|
||||
style={{ flex: 1, textTransform: 'uppercase', fontFamily: 'monospace' }}
|
||||
value={couponCode}
|
||||
onChange={(e) => setCouponCode(e.target.value)}
|
||||
placeholder="Z.B. PROMO-2026"
|
||||
onKeyPress={(e) => e.key === 'Enter' && handleRedeemCoupon()}
|
||||
/>
|
||||
<button
|
||||
className="btn btn-primary"
|
||||
onClick={handleRedeemCoupon}
|
||||
disabled={!couponCode.trim() || redeeming}
|
||||
>
|
||||
{redeeming ? 'Prüfen...' : 'Einlösen'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -2,6 +2,7 @@ import { useState, useEffect, useRef } from 'react'
|
|||
import { Pencil, Trash2, Check, X } from 'lucide-react'
|
||||
import { api } from '../utils/api'
|
||||
import { LineChart, Line, XAxis, YAxis, Tooltip, ResponsiveContainer, CartesianGrid, ReferenceLine } from 'recharts'
|
||||
import UsageBadge from '../components/UsageBadge'
|
||||
import dayjs from 'dayjs'
|
||||
import 'dayjs/locale/de'
|
||||
dayjs.locale('de')
|
||||
|
|
@ -21,19 +22,42 @@ export default function WeightScreen() {
|
|||
const [newNote, setNewNote] = useState('')
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [saved, setSaved] = useState(false)
|
||||
const [error, setError] = useState(null)
|
||||
const [weightUsage, setWeightUsage] = useState(null) // Phase 3: Usage badge
|
||||
|
||||
const load = () => api.listWeight(365).then(data => setEntries(data))
|
||||
useEffect(()=>{ load() },[])
|
||||
|
||||
const loadUsage = () => {
|
||||
// Load feature usage for badge
|
||||
api.getFeatureUsage().then(features => {
|
||||
const weightFeature = features.find(f => f.feature_id === 'weight_entries')
|
||||
setWeightUsage(weightFeature)
|
||||
}).catch(err => console.error('Failed to load usage:', err))
|
||||
}
|
||||
|
||||
useEffect(()=>{
|
||||
load()
|
||||
loadUsage()
|
||||
},[])
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!newWeight) return
|
||||
setSaving(true)
|
||||
setError(null)
|
||||
try {
|
||||
await api.upsertWeight(newDate, parseFloat(newWeight), newNote)
|
||||
setSaved(true); await load()
|
||||
setSaved(true)
|
||||
await load()
|
||||
await loadUsage() // Reload usage after save
|
||||
setTimeout(()=>setSaved(false), 2000)
|
||||
setNewWeight(''); setNewNote('')
|
||||
} finally { setSaving(false) }
|
||||
} catch (err) {
|
||||
console.error('Save failed:', err)
|
||||
setError(err.message || 'Fehler beim Speichern')
|
||||
setTimeout(()=>setError(null), 5000)
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleUpdate = async () => {
|
||||
|
|
@ -59,7 +83,10 @@ export default function WeightScreen() {
|
|||
|
||||
{/* Eingabe */}
|
||||
<div className="card section-gap">
|
||||
<div className="card-title">Eintrag hinzufügen</div>
|
||||
<div className="card-title badge-container-right">
|
||||
<span>Eintrag hinzufügen</span>
|
||||
{weightUsage && <UsageBadge {...weightUsage} />}
|
||||
</div>
|
||||
<div className="form-row">
|
||||
<label className="form-label">Datum</label>
|
||||
<input type="date" className="form-input" style={{width:140}}
|
||||
|
|
@ -79,11 +106,27 @@ export default function WeightScreen() {
|
|||
value={newNote} onChange={e=>setNewNote(e.target.value)}/>
|
||||
<span className="form-unit"/>
|
||||
</div>
|
||||
<button className="btn btn-primary btn-full" onClick={handleSave} disabled={saving||!newWeight}>
|
||||
{saved ? <><Check size={15}/> Gespeichert!</>
|
||||
: saving ? <><div className="spinner" style={{width:14,height:14}}/> …</>
|
||||
: 'Speichern'}
|
||||
</button>
|
||||
{error && (
|
||||
<div style={{padding:'10px',background:'var(--danger-bg)',border:'1px solid var(--danger)',borderRadius:8,fontSize:13,color:'var(--danger)',marginBottom:12}}>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
title={weightUsage && !weightUsage.allowed ? `Limit erreicht (${weightUsage.used}/${weightUsage.limit}). Kontaktiere den Admin oder warte bis zum nächsten Reset.` : ''}
|
||||
style={{display:'inline-block',width:'100%'}}
|
||||
>
|
||||
<button
|
||||
className="btn btn-primary btn-full"
|
||||
onClick={handleSave}
|
||||
disabled={saving || !newWeight || (weightUsage && !weightUsage.allowed)}
|
||||
style={{cursor: (weightUsage && !weightUsage.allowed) ? 'not-allowed' : 'pointer'}}
|
||||
>
|
||||
{saved ? <><Check size={15}/> Gespeichert!</>
|
||||
: saving ? <><div className="spinner" style={{width:14,height:14}}/> …</>
|
||||
: (weightUsage && !weightUsage.allowed) ? '🔒 Limit erreicht'
|
||||
: 'Speichern'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Chart */}
|
||||
|
|
|
|||
|
|
@ -82,6 +82,11 @@ export const api = {
|
|||
listNutrition: (l=365) => req(`/nutrition?limit=${l}`),
|
||||
nutritionCorrelations: () => req('/nutrition/correlations'),
|
||||
nutritionWeekly: (w=16) => req(`/nutrition/weekly?weeks=${w}`),
|
||||
nutritionImportHistory: () => req('/nutrition/import-history'),
|
||||
getNutritionByDate: (date) => req(`/nutrition/by-date/${date}`),
|
||||
createNutrition: (date,kcal,protein,fat,carbs) => req(`/nutrition?date=${date}&kcal=${kcal}&protein_g=${protein}&fat_g=${fat}&carbs_g=${carbs}`,{method:'POST'}),
|
||||
updateNutrition: (id,kcal,protein,fat,carbs) => req(`/nutrition/${id}?kcal=${kcal}&protein_g=${protein}&fat_g=${fat}&carbs_g=${carbs}`,{method:'PUT'}),
|
||||
deleteNutrition: (id) => req(`/nutrition/${id}`,{method:'DELETE'}),
|
||||
|
||||
// Stats & AI
|
||||
getStats: () => req('/stats'),
|
||||
|
|
@ -137,4 +142,48 @@ 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})),
|
||||
getFeatureUsage: () => req('/features/usage'), // Phase 3: Usage overview
|
||||
|
||||
// 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