Membership-System und Bug Fixing (inkl. Nutrition) #8
2
.gitignore
vendored
2
.gitignore
vendored
|
|
@ -61,4 +61,4 @@ tmp/
|
||||||
|
|
||||||
#.claude Konfiguration
|
#.claude Konfiguration
|
||||||
.claude/
|
.claude/
|
||||||
.claude/settings.local.json
|
.claude/settings.local.jsonfrontend/package-lock.json
|
||||||
|
|
|
||||||
|
|
@ -60,6 +60,9 @@ def apply_migration():
|
||||||
if not migration_needed(conn):
|
if not migration_needed(conn):
|
||||||
print("[v9c Migration] Already applied, skipping.")
|
print("[v9c Migration] Already applied, skipping.")
|
||||||
conn.close()
|
conn.close()
|
||||||
|
|
||||||
|
# Even if main migration is done, check cleanup
|
||||||
|
apply_cleanup_migration()
|
||||||
return
|
return
|
||||||
|
|
||||||
print("[v9c Migration] Applying subscription system migration...")
|
print("[v9c Migration] Applying subscription system migration...")
|
||||||
|
|
@ -83,6 +86,26 @@ def apply_migration():
|
||||||
|
|
||||||
print("[v9c Migration] ✅ Migration completed successfully!")
|
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
|
# Verify tables created
|
||||||
conn = get_db_connection()
|
conn = get_db_connection()
|
||||||
cur = conn.cursor()
|
cur = conn.cursor()
|
||||||
|
|
@ -108,10 +131,123 @@ def apply_migration():
|
||||||
cur.close()
|
cur.close()
|
||||||
conn.close()
|
conn.close()
|
||||||
|
|
||||||
|
# After successful migration, apply cleanup
|
||||||
|
apply_cleanup_migration()
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"[v9c Migration] ❌ Error: {e}")
|
print(f"[v9c Migration] ❌ Error: {e}")
|
||||||
raise
|
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__":
|
if __name__ == "__main__":
|
||||||
apply_migration()
|
apply_migration()
|
||||||
|
|
|
||||||
|
|
@ -121,17 +121,22 @@ def require_admin(x_auth_token: Optional[str] = Header(default=None)):
|
||||||
# Feature Access Control (v9c)
|
# 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.
|
Get the effective tier for a profile.
|
||||||
|
|
||||||
Checks for active access_grants first (from coupons, trials, etc.),
|
Checks for active access_grants first (from coupons, trials, etc.),
|
||||||
then falls back to profile.tier.
|
then falls back to profile.tier.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
profile_id: User profile ID
|
||||||
|
conn: Optional existing DB connection (to avoid pool exhaustion)
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
tier_id (str): 'free', 'basic', 'premium', or 'selfhosted'
|
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)
|
cur = get_cursor(conn)
|
||||||
|
|
||||||
# Check for active access grants (highest priority)
|
# 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,))
|
cur.execute("SELECT tier FROM profiles WHERE id = %s", (profile_id,))
|
||||||
profile = cur.fetchone()
|
profile = cur.fetchone()
|
||||||
return profile['tier'] if profile else 'free'
|
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.
|
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)
|
2. Tier limit (tier_limits)
|
||||||
3. Feature default (features.default_limit)
|
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:
|
Returns:
|
||||||
dict: {
|
dict: {
|
||||||
'allowed': bool,
|
'allowed': bool,
|
||||||
|
|
@ -174,7 +188,16 @@ def check_feature_access(profile_id: str, feature_id: str) -> dict:
|
||||||
'reason': str # 'unlimited', 'within_limit', 'limit_exceeded', 'feature_disabled'
|
'reason': str # 'unlimited', 'within_limit', 'limit_exceeded', 'feature_disabled'
|
||||||
}
|
}
|
||||||
"""
|
"""
|
||||||
|
# Use existing connection if provided
|
||||||
|
if conn:
|
||||||
|
return _check_impl(profile_id, feature_id, conn)
|
||||||
|
else:
|
||||||
with get_db() as conn:
|
with get_db() as conn:
|
||||||
|
return _check_impl(profile_id, feature_id, conn)
|
||||||
|
|
||||||
|
|
||||||
|
def _check_impl(profile_id: str, feature_id: str, conn) -> dict:
|
||||||
|
"""Internal implementation of check_feature_access."""
|
||||||
cur = get_cursor(conn)
|
cur = get_cursor(conn)
|
||||||
|
|
||||||
# Get feature info
|
# Get feature info
|
||||||
|
|
@ -206,7 +229,7 @@ def check_feature_access(profile_id: str, feature_id: str) -> dict:
|
||||||
limit = restriction['limit_value']
|
limit = restriction['limit_value']
|
||||||
else:
|
else:
|
||||||
# Priority 2: Check tier limit
|
# Priority 2: Check tier limit
|
||||||
tier_id = get_effective_tier(profile_id)
|
tier_id = get_effective_tier(profile_id, conn)
|
||||||
cur.execute("""
|
cur.execute("""
|
||||||
SELECT limit_value
|
SELECT limit_value
|
||||||
FROM tier_limits
|
FROM tier_limits
|
||||||
|
|
|
||||||
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 csv
|
||||||
import io
|
import io
|
||||||
import uuid
|
import uuid
|
||||||
|
import logging
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
from fastapi import APIRouter, HTTPException, UploadFile, File, Header, Depends
|
from fastapi import APIRouter, HTTPException, UploadFile, File, Header, Depends
|
||||||
|
|
||||||
from db import get_db, get_cursor, r2d
|
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 models import ActivityEntry
|
||||||
from routers.profiles import get_pid
|
from routers.profiles import get_pid
|
||||||
|
from feature_logger import log_feature_usage
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/activity", tags=["activity"])
|
router = APIRouter(prefix="/api/activity", tags=["activity"])
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
@router.get("")
|
@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)):
|
def create_activity(e: ActivityEntry, x_profile_id: Optional[str]=Header(default=None), session: dict=Depends(require_auth)):
|
||||||
"""Create new activity entry."""
|
"""Create new activity entry."""
|
||||||
pid = get_pid(x_profile_id)
|
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())
|
eid = str(uuid.uuid4())
|
||||||
d = e.model_dump()
|
d = e.model_dump()
|
||||||
with get_db() as conn:
|
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'],
|
(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['kcal_active'],d['kcal_resting'],d['hr_avg'],d['hr_max'],d['distance_km'],
|
||||||
d['rpe'],d['source'],d['notes']))
|
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}
|
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).
|
Handles body fat measurements via skinfold caliper (4 methods supported).
|
||||||
"""
|
"""
|
||||||
import uuid
|
import uuid
|
||||||
|
import logging
|
||||||
from typing import Optional
|
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 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 models import CaliperEntry
|
||||||
from routers.profiles import get_pid
|
from routers.profiles import get_pid
|
||||||
|
from feature_logger import log_feature_usage
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/caliper", tags=["caliper"])
|
router = APIRouter(prefix="/api/caliper", tags=["caliper"])
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
@router.get("")
|
@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)):
|
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)."""
|
"""Create or update caliper entry (upsert by date)."""
|
||||||
pid = get_pid(x_profile_id)
|
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:
|
with get_db() as conn:
|
||||||
cur = get_cursor(conn)
|
cur = get_cursor(conn)
|
||||||
cur.execute("SELECT id FROM caliper_log WHERE profile_id=%s AND date=%s", (pid,e.date))
|
cur.execute("SELECT id FROM caliper_log WHERE profile_id=%s AND date=%s", (pid,e.date))
|
||||||
ex = cur.fetchone()
|
ex = cur.fetchone()
|
||||||
d = e.model_dump()
|
d = e.model_dump()
|
||||||
|
is_new_entry = not ex
|
||||||
|
|
||||||
if ex:
|
if ex:
|
||||||
|
# UPDATE existing entry
|
||||||
eid = ex['id']
|
eid = ex['id']
|
||||||
sets = ', '.join(f"{k}=%s" for k in d if k!='date')
|
sets = ', '.join(f"{k}=%s" for k in d if k!='date')
|
||||||
cur.execute(f"UPDATE caliper_log SET {sets} WHERE id=%s",
|
cur.execute(f"UPDATE caliper_log SET {sets} WHERE id=%s",
|
||||||
[v for k,v in d.items() if k!='date']+[eid])
|
[v for k,v in d.items() if k!='date']+[eid])
|
||||||
else:
|
else:
|
||||||
|
# INSERT new entry
|
||||||
eid = str(uuid.uuid4())
|
eid = str(uuid.uuid4())
|
||||||
cur.execute("""INSERT INTO caliper_log
|
cur.execute("""INSERT INTO caliper_log
|
||||||
(id,profile_id,date,sf_method,sf_chest,sf_axilla,sf_triceps,sf_subscap,sf_suprailiac,
|
(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'],
|
(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_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']))
|
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}
|
return {"id":eid,"date":e.date}
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -4,16 +4,19 @@ Circumference Tracking Endpoints for Mitai Jinkendo
|
||||||
Handles body circumference measurements (8 measurement points).
|
Handles body circumference measurements (8 measurement points).
|
||||||
"""
|
"""
|
||||||
import uuid
|
import uuid
|
||||||
|
import logging
|
||||||
from typing import Optional
|
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 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 models import CircumferenceEntry
|
||||||
from routers.profiles import get_pid
|
from routers.profiles import get_pid
|
||||||
|
from feature_logger import log_feature_usage
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/circumferences", tags=["circumference"])
|
router = APIRouter(prefix="/api/circumferences", tags=["circumference"])
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
@router.get("")
|
@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)):
|
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)."""
|
"""Create or update circumference entry (upsert by date)."""
|
||||||
pid = get_pid(x_profile_id)
|
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:
|
with get_db() as conn:
|
||||||
cur = get_cursor(conn)
|
cur = get_cursor(conn)
|
||||||
cur.execute("SELECT id FROM circumference_log WHERE profile_id=%s AND date=%s", (pid,e.date))
|
cur.execute("SELECT id FROM circumference_log WHERE profile_id=%s AND date=%s", (pid,e.date))
|
||||||
ex = cur.fetchone()
|
ex = cur.fetchone()
|
||||||
d = e.model_dump()
|
d = e.model_dump()
|
||||||
|
is_new_entry = not ex
|
||||||
|
|
||||||
if ex:
|
if ex:
|
||||||
|
# UPDATE existing entry
|
||||||
eid = ex['id']
|
eid = ex['id']
|
||||||
sets = ', '.join(f"{k}=%s" for k in d if k!='date')
|
sets = ', '.join(f"{k}=%s" for k in d if k!='date')
|
||||||
cur.execute(f"UPDATE circumference_log SET {sets} WHERE id=%s",
|
cur.execute(f"UPDATE circumference_log SET {sets} WHERE id=%s",
|
||||||
[v for k,v in d.items() if k!='date']+[eid])
|
[v for k,v in d.items() if k!='date']+[eid])
|
||||||
else:
|
else:
|
||||||
|
# INSERT new entry
|
||||||
eid = str(uuid.uuid4())
|
eid = str(uuid.uuid4())
|
||||||
cur.execute("""INSERT INTO circumference_log
|
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)
|
(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)""",
|
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'],
|
(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']))
|
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}
|
return {"id":eid,"date":e.date}
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ import os
|
||||||
import csv
|
import csv
|
||||||
import io
|
import io
|
||||||
import json
|
import json
|
||||||
|
import logging
|
||||||
import zipfile
|
import zipfile
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
@ -17,10 +18,12 @@ from fastapi import APIRouter, HTTPException, Header, Depends
|
||||||
from fastapi.responses import StreamingResponse, Response
|
from fastapi.responses import StreamingResponse, Response
|
||||||
|
|
||||||
from db import get_db, get_cursor, r2d
|
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 routers.profiles import get_pid
|
||||||
|
from feature_logger import log_feature_usage
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/export", tags=["export"])
|
router = APIRouter(prefix="/api/export", tags=["export"])
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
PHOTOS_DIR = Path(os.getenv("PHOTOS_DIR", "./photos"))
|
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."""
|
"""Export all data as CSV."""
|
||||||
pid = get_pid(x_profile_id)
|
pid = get_pid(x_profile_id)
|
||||||
|
|
||||||
# Check export permission
|
# Phase 4: Check feature access and ENFORCE
|
||||||
with get_db() as conn:
|
access = check_feature_access(pid, 'data_export')
|
||||||
cur = get_cursor(conn)
|
log_feature_usage(pid, 'data_export', access, 'export_csv')
|
||||||
cur.execute("SELECT export_enabled FROM profiles WHERE id=%s", (pid,))
|
|
||||||
prof = cur.fetchone()
|
if not access['allowed']:
|
||||||
if not prof or not prof['export_enabled']:
|
logger.warning(
|
||||||
raise HTTPException(403, "Export ist für dieses Profil deaktiviert")
|
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
|
# Build CSV
|
||||||
output = io.StringIO()
|
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"])
|
writer.writerow(["Training", r['date'], r['activity_type'], f"{float(r['duration_min'])}min {float(r['kcal_active'])}kcal"])
|
||||||
|
|
||||||
output.seek(0)
|
output.seek(0)
|
||||||
|
|
||||||
|
# Phase 2: Increment usage counter
|
||||||
|
increment_feature_usage(pid, 'data_export')
|
||||||
|
|
||||||
return StreamingResponse(
|
return StreamingResponse(
|
||||||
iter([output.getvalue()]),
|
iter([output.getvalue()]),
|
||||||
media_type="text/csv",
|
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."""
|
"""Export all data as JSON."""
|
||||||
pid = get_pid(x_profile_id)
|
pid = get_pid(x_profile_id)
|
||||||
|
|
||||||
# Check export permission
|
# Phase 4: Check feature access and ENFORCE
|
||||||
with get_db() as conn:
|
access = check_feature_access(pid, 'data_export')
|
||||||
cur = get_cursor(conn)
|
log_feature_usage(pid, 'data_export', access, 'export_json')
|
||||||
cur.execute("SELECT export_enabled FROM profiles WHERE id=%s", (pid,))
|
|
||||||
prof = cur.fetchone()
|
if not access['allowed']:
|
||||||
if not prof or not prof['export_enabled']:
|
logger.warning(
|
||||||
raise HTTPException(403, "Export ist für dieses Profil deaktiviert")
|
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
|
# Collect all data
|
||||||
data = {}
|
data = {}
|
||||||
|
|
@ -126,6 +147,10 @@ def export_json(x_profile_id: Optional[str]=Header(default=None), session: dict=
|
||||||
return str(obj)
|
return str(obj)
|
||||||
|
|
||||||
json_str = json.dumps(data, indent=2, default=decimal_handler)
|
json_str = json.dumps(data, indent=2, default=decimal_handler)
|
||||||
|
|
||||||
|
# Phase 2: Increment usage counter
|
||||||
|
increment_feature_usage(pid, 'data_export')
|
||||||
|
|
||||||
return Response(
|
return Response(
|
||||||
content=json_str,
|
content=json_str,
|
||||||
media_type="application/json",
|
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."""
|
"""Export all data as ZIP (CSV + JSON + photos) per specification."""
|
||||||
pid = get_pid(x_profile_id)
|
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:
|
with get_db() as conn:
|
||||||
cur = get_cursor(conn)
|
cur = get_cursor(conn)
|
||||||
cur.execute("SELECT * FROM profiles WHERE id=%s", (pid,))
|
cur.execute("SELECT * FROM profiles WHERE id=%s", (pid,))
|
||||||
prof = r2d(cur.fetchone())
|
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
|
# Helper: CSV writer with UTF-8 BOM + semicolon
|
||||||
def write_csv(zf, filename, rows, columns):
|
def write_csv(zf, filename, rows, columns):
|
||||||
|
|
@ -297,6 +335,10 @@ Datumsformat: YYYY-MM-DD
|
||||||
|
|
||||||
zip_buffer.seek(0)
|
zip_buffer.seek(0)
|
||||||
filename = f"mitai-export-{profile_name.replace(' ','-')}-{export_date}.zip"
|
filename = f"mitai-export-{profile_name.replace(' ','-')}-{export_date}.zip"
|
||||||
|
|
||||||
|
# Phase 2: Increment usage counter
|
||||||
|
increment_feature_usage(pid, 'data_export')
|
||||||
|
|
||||||
return StreamingResponse(
|
return StreamingResponse(
|
||||||
iter([zip_buffer.getvalue()]),
|
iter([zip_buffer.getvalue()]),
|
||||||
media_type="application/zip",
|
media_type="application/zip",
|
||||||
|
|
|
||||||
|
|
@ -2,11 +2,16 @@
|
||||||
Feature Management Endpoints for Mitai Jinkendo
|
Feature Management Endpoints for Mitai Jinkendo
|
||||||
|
|
||||||
Admin-only CRUD for features registry.
|
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 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"])
|
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,))
|
cur.execute("UPDATE features SET active = false WHERE id = %s", (feature_id,))
|
||||||
conn.commit()
|
conn.commit()
|
||||||
return {"ok": True}
|
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 io
|
||||||
import json
|
import json
|
||||||
import uuid
|
import uuid
|
||||||
|
import logging
|
||||||
import zipfile
|
import zipfile
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
@ -16,10 +17,12 @@ from datetime import datetime
|
||||||
from fastapi import APIRouter, HTTPException, UploadFile, File, Header, Depends
|
from fastapi import APIRouter, HTTPException, UploadFile, File, Header, Depends
|
||||||
|
|
||||||
from db import get_db, get_cursor
|
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 routers.profiles import get_pid
|
||||||
|
from feature_logger import log_feature_usage
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/import", tags=["import"])
|
router = APIRouter(prefix="/api/import", tags=["import"])
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
PHOTOS_DIR = Path(os.getenv("PHOTOS_DIR", "./photos"))
|
PHOTOS_DIR = Path(os.getenv("PHOTOS_DIR", "./photos"))
|
||||||
|
|
||||||
|
|
@ -41,6 +44,21 @@ async def import_zip(
|
||||||
"""
|
"""
|
||||||
pid = get_pid(x_profile_id)
|
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
|
# Read uploaded file
|
||||||
content = await file.read()
|
content = await file.read()
|
||||||
zip_buffer = io.BytesIO(content)
|
zip_buffer = io.BytesIO(content)
|
||||||
|
|
@ -254,6 +272,9 @@ async def import_zip(
|
||||||
conn.rollback()
|
conn.rollback()
|
||||||
raise HTTPException(500, f"Import fehlgeschlagen: {str(e)}")
|
raise HTTPException(500, f"Import fehlgeschlagen: {str(e)}")
|
||||||
|
|
||||||
|
# Phase 2: Increment usage counter
|
||||||
|
increment_feature_usage(pid, 'data_import')
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"ok": True,
|
"ok": True,
|
||||||
"message": "Import erfolgreich",
|
"message": "Import erfolgreich",
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ Handles AI analysis execution, prompt management, and usage tracking.
|
||||||
import os
|
import os
|
||||||
import json
|
import json
|
||||||
import uuid
|
import uuid
|
||||||
|
import logging
|
||||||
import httpx
|
import httpx
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
|
@ -13,10 +14,12 @@ from datetime import datetime
|
||||||
from fastapi import APIRouter, HTTPException, Header, Depends
|
from fastapi import APIRouter, HTTPException, Header, Depends
|
||||||
|
|
||||||
from db import get_db, get_cursor, r2d
|
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 routers.profiles import get_pid
|
||||||
|
from feature_logger import log_feature_usage
|
||||||
|
|
||||||
router = APIRouter(prefix="/api", tags=["insights"])
|
router = APIRouter(prefix="/api", tags=["insights"])
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
OPENROUTER_KEY = os.getenv("OPENROUTER_API_KEY", "")
|
OPENROUTER_KEY = os.getenv("OPENROUTER_API_KEY", "")
|
||||||
OPENROUTER_MODEL = os.getenv("OPENROUTER_MODEL", "anthropic/claude-sonnet-4")
|
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)):
|
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."""
|
"""Run AI analysis with specified prompt template."""
|
||||||
pid = get_pid(x_profile_id)
|
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
|
# Get prompt template
|
||||||
with get_db() as conn:
|
with get_db() as conn:
|
||||||
|
|
@ -294,14 +311,18 @@ async def analyze_with_prompt(slug: str, x_profile_id: Optional[str]=Header(defa
|
||||||
else:
|
else:
|
||||||
raise HTTPException(500, "Keine KI-API konfiguriert")
|
raise HTTPException(500, "Keine KI-API konfiguriert")
|
||||||
|
|
||||||
# Save insight
|
# Save insight (with history - no DELETE)
|
||||||
with get_db() as conn:
|
with get_db() as conn:
|
||||||
cur = get_cursor(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)",
|
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))
|
(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)
|
inc_ai_usage(pid)
|
||||||
|
|
||||||
return {"scope": slug, "content": content}
|
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)):
|
async def analyze_pipeline(x_profile_id: Optional[str]=Header(default=None), session: dict=Depends(require_auth)):
|
||||||
"""Run 3-stage pipeline analysis."""
|
"""Run 3-stage pipeline analysis."""
|
||||||
pid = get_pid(x_profile_id)
|
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)
|
data = _get_profile_data(pid)
|
||||||
vars = _prepare_template_vars(data)
|
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:
|
if goals_text:
|
||||||
final_content += "\n\n" + 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:
|
with get_db() as conn:
|
||||||
cur = get_cursor(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,'pipeline',%s,CURRENT_TIMESTAMP)",
|
||||||
cur.execute("INSERT INTO ai_insights (id, profile_id, scope, content, created) VALUES (%s,%s,'gesamt',%s,CURRENT_TIMESTAMP)",
|
|
||||||
(str(uuid.uuid4()), pid, final_content))
|
(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)
|
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")
|
@router.get("/ai/usage")
|
||||||
|
|
|
||||||
|
|
@ -6,16 +6,19 @@ Handles nutrition data, FDDB CSV import, correlations, and weekly aggregates.
|
||||||
import csv
|
import csv
|
||||||
import io
|
import io
|
||||||
import uuid
|
import uuid
|
||||||
|
import logging
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
|
||||||
from fastapi import APIRouter, HTTPException, UploadFile, File, Header, Depends
|
from fastapi import APIRouter, HTTPException, UploadFile, File, Header, Depends
|
||||||
|
|
||||||
from db import get_db, get_cursor, r2d
|
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 routers.profiles import get_pid
|
||||||
|
from feature_logger import log_feature_usage
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/nutrition", tags=["nutrition"])
|
router = APIRouter(prefix="/api/nutrition", tags=["nutrition"])
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
# ── Helper ────────────────────────────────────────────────────────────────────
|
# ── 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)):
|
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."""
|
"""Import FDDB nutrition CSV."""
|
||||||
pid = get_pid(x_profile_id)
|
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()
|
raw = await file.read()
|
||||||
try: text = raw.decode('utf-8')
|
try: text = raw.decode('utf-8')
|
||||||
except: text = raw.decode('latin-1')
|
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))
|
days[iso]['protein_g'] += _pf(row.get('protein_g',0))
|
||||||
count+=1
|
count+=1
|
||||||
inserted=0
|
inserted=0
|
||||||
|
new_entries=0
|
||||||
with get_db() as conn:
|
with get_db() as conn:
|
||||||
cur = get_cursor(conn)
|
cur = get_cursor(conn)
|
||||||
for iso,vals in days.items():
|
for iso,vals in days.items():
|
||||||
kcal=round(vals['kcal'],1); fat=round(vals['fat_g'],1)
|
kcal=round(vals['kcal'],1); fat=round(vals['fat_g'],1)
|
||||||
carbs=round(vals['carbs_g'],1); prot=round(vals['protein_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))
|
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",
|
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))
|
(kcal,prot,fat,carbs,pid,iso))
|
||||||
else:
|
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)",
|
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))
|
(str(uuid.uuid4()),pid,iso,kcal,prot,fat,carbs))
|
||||||
|
new_entries += 1
|
||||||
inserted+=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}}
|
"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("")
|
@router.get("")
|
||||||
def list_nutrition(limit: int=365, x_profile_id: Optional[str]=Header(default=None), session: dict=Depends(require_auth)):
|
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."""
|
"""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()]
|
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")
|
@router.get("/correlations")
|
||||||
def nutrition_correlations(x_profile_id: Optional[str]=Header(default=None), session: dict=Depends(require_auth)):
|
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."""
|
"""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 []
|
if not rows: return []
|
||||||
wm={}
|
wm={}
|
||||||
for d in rows:
|
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)
|
wm.setdefault(wk,[]).append(d)
|
||||||
result=[]
|
result=[]
|
||||||
for wk in sorted(wm):
|
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)
|
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')})
|
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
|
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 os
|
||||||
import uuid
|
import uuid
|
||||||
|
import logging
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
|
|
@ -13,10 +14,12 @@ from fastapi.responses import FileResponse
|
||||||
import aiofiles
|
import aiofiles
|
||||||
|
|
||||||
from db import get_db, get_cursor, r2d
|
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 routers.profiles import get_pid
|
||||||
|
from feature_logger import log_feature_usage
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/photos", tags=["photos"])
|
router = APIRouter(prefix="/api/photos", tags=["photos"])
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
PHOTOS_DIR = Path(os.getenv("PHOTOS_DIR", "./photos"))
|
PHOTOS_DIR = Path(os.getenv("PHOTOS_DIR", "./photos"))
|
||||||
PHOTOS_DIR.mkdir(parents=True, exist_ok=True)
|
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)):
|
x_profile_id: Optional[str]=Header(default=None), session: dict=Depends(require_auth)):
|
||||||
"""Upload progress photo."""
|
"""Upload progress photo."""
|
||||||
pid = get_pid(x_profile_id)
|
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())
|
fid = str(uuid.uuid4())
|
||||||
ext = Path(file.filename).suffix or '.jpg'
|
ext = Path(file.filename).suffix or '.jpg'
|
||||||
path = PHOTOS_DIR / f"{fid}{ext}"
|
path = PHOTOS_DIR / f"{fid}{ext}"
|
||||||
|
|
@ -35,6 +54,10 @@ async def upload_photo(file: UploadFile=File(...), date: str="",
|
||||||
cur = get_cursor(conn)
|
cur = get_cursor(conn)
|
||||||
cur.execute("INSERT INTO photos (id,profile_id,date,path,created) VALUES (%s,%s,%s,%s,CURRENT_TIMESTAMP)",
|
cur.execute("INSERT INTO photos (id,profile_id,date,path,created) VALUES (%s,%s,%s,%s,CURRENT_TIMESTAMP)",
|
||||||
(fid,pid,date,str(path)))
|
(fid,pid,date,str(path)))
|
||||||
|
|
||||||
|
# Phase 2: Increment usage counter
|
||||||
|
increment_feature_usage(pid, 'photos')
|
||||||
|
|
||||||
return {"id":fid,"date":date}
|
return {"id":fid,"date":date}
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -29,13 +29,13 @@ def get_tier_limits_matrix(session: dict = Depends(require_admin)):
|
||||||
with get_db() as conn:
|
with get_db() as conn:
|
||||||
cur = get_cursor(conn)
|
cur = get_cursor(conn)
|
||||||
|
|
||||||
# Get all tiers
|
# Get all tiers (including inactive - admin needs to configure all)
|
||||||
cur.execute("SELECT id, name, sort_order FROM tiers WHERE active = true ORDER BY sort_order")
|
cur.execute("SELECT id, name, sort_order FROM tiers ORDER BY sort_order")
|
||||||
tiers = [r2d(r) for r in cur.fetchall()]
|
tiers = [r2d(r) for r in cur.fetchall()]
|
||||||
|
|
||||||
# Get all features
|
# Get all features
|
||||||
cur.execute("""
|
cur.execute("""
|
||||||
SELECT id, name, category, limit_type, default_limit
|
SELECT id, name, category, limit_type, default_limit, reset_period
|
||||||
FROM features
|
FROM features
|
||||||
WHERE active = true
|
WHERE active = true
|
||||||
ORDER BY category, name
|
ORDER BY category, name
|
||||||
|
|
|
||||||
|
|
@ -4,16 +4,19 @@ Weight Tracking Endpoints for Mitai Jinkendo
|
||||||
Handles weight log CRUD operations and statistics.
|
Handles weight log CRUD operations and statistics.
|
||||||
"""
|
"""
|
||||||
import uuid
|
import uuid
|
||||||
|
import logging
|
||||||
from typing import Optional
|
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 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 models import WeightEntry
|
||||||
from routers.profiles import get_pid
|
from routers.profiles import get_pid
|
||||||
|
from feature_logger import log_feature_usage
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/weight", tags=["weight"])
|
router = APIRouter(prefix="/api/weight", tags=["weight"])
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
@router.get("")
|
@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)):
|
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)."""
|
"""Create or update weight entry (upsert by date)."""
|
||||||
pid = get_pid(x_profile_id)
|
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:
|
with get_db() as conn:
|
||||||
cur = get_cursor(conn)
|
cur = get_cursor(conn)
|
||||||
cur.execute("SELECT id FROM weight_log WHERE profile_id=%s AND date=%s", (pid,e.date))
|
cur.execute("SELECT id FROM weight_log WHERE profile_id=%s AND date=%s", (pid,e.date))
|
||||||
ex = cur.fetchone()
|
ex = cur.fetchone()
|
||||||
|
is_new_entry = not ex
|
||||||
|
|
||||||
if 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']))
|
cur.execute("UPDATE weight_log SET weight=%s,note=%s WHERE id=%s", (e.weight,e.note,ex['id']))
|
||||||
wid = ex['id']
|
wid = ex['id']
|
||||||
else:
|
else:
|
||||||
|
# INSERT new entry
|
||||||
wid = str(uuid.uuid4())
|
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)",
|
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))
|
(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}
|
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 Analysis from './pages/Analysis'
|
||||||
import SettingsPage from './pages/SettingsPage'
|
import SettingsPage from './pages/SettingsPage'
|
||||||
import GuidePage from './pages/GuidePage'
|
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'
|
import './app.css'
|
||||||
|
|
||||||
function Nav() {
|
function Nav() {
|
||||||
|
|
@ -115,6 +121,12 @@ function AppShell() {
|
||||||
<Route path="/analysis" element={<Analysis/>}/>
|
<Route path="/analysis" element={<Analysis/>}/>
|
||||||
<Route path="/settings" element={<SettingsPage/>}/>
|
<Route path="/settings" element={<SettingsPage/>}/>
|
||||||
<Route path="/guide" element={<GuidePage/>}/>
|
<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>
|
</Routes>
|
||||||
</main>
|
</main>
|
||||||
<Nav/>
|
<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 { Upload, Pencil, Trash2, Check, X, CheckCircle } from 'lucide-react'
|
||||||
import { BarChart, Bar, XAxis, YAxis, Tooltip, ResponsiveContainer, CartesianGrid } from 'recharts'
|
import { BarChart, Bar, XAxis, YAxis, Tooltip, ResponsiveContainer, CartesianGrid } from 'recharts'
|
||||||
import { api } from '../utils/api'
|
import { api } from '../utils/api'
|
||||||
|
import UsageBadge from '../components/UsageBadge'
|
||||||
import dayjs from 'dayjs'
|
import dayjs from 'dayjs'
|
||||||
import 'dayjs/locale/de'
|
import 'dayjs/locale/de'
|
||||||
dayjs.locale('de')
|
dayjs.locale('de')
|
||||||
|
|
@ -79,7 +80,7 @@ function ImportPanel({ onImported }) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Manual Entry ──────────────────────────────────────────────────────────────
|
// ── 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}))
|
const set = (k,v) => setForm(f=>({...f,[k]:v}))
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
|
|
@ -130,8 +131,25 @@ function EntryForm({ form, setForm, onSave, onCancel, saveLabel='Speichern' }) {
|
||||||
value={form.notes||''} onChange={e=>set('notes',e.target.value)}/>
|
value={form.notes||''} onChange={e=>set('notes',e.target.value)}/>
|
||||||
<span className="form-unit"/>
|
<span className="form-unit"/>
|
||||||
</div>
|
</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}}>
|
<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>}
|
{onCancel && <button className="btn btn-secondary" style={{flex:1}} onClick={onCancel}><X size={13}/> Abbrechen</button>}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -145,15 +163,32 @@ export default function ActivityPage() {
|
||||||
const [tab, setTab] = useState('list')
|
const [tab, setTab] = useState('list')
|
||||||
const [form, setForm] = useState(empty())
|
const [form, setForm] = useState(empty())
|
||||||
const [editing, setEditing] = useState(null)
|
const [editing, setEditing] = useState(null)
|
||||||
|
const [saving, setSaving] = useState(false)
|
||||||
const [saved, setSaved] = 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 load = async () => {
|
||||||
const [e, s] = await Promise.all([api.listActivity(), api.activityStats()])
|
const [e, s] = await Promise.all([api.listActivity(), api.activityStats()])
|
||||||
setEntries(e); setStats(s)
|
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 handleSave = async () => {
|
||||||
|
setSaving(true)
|
||||||
|
setError(null)
|
||||||
|
try {
|
||||||
const payload = {...form}
|
const payload = {...form}
|
||||||
if(payload.duration_min) payload.duration_min = parseFloat(payload.duration_min)
|
if(payload.duration_min) payload.duration_min = parseFloat(payload.duration_min)
|
||||||
if(payload.kcal_active) payload.kcal_active = parseFloat(payload.kcal_active)
|
if(payload.kcal_active) payload.kcal_active = parseFloat(payload.kcal_active)
|
||||||
|
|
@ -162,8 +197,17 @@ export default function ActivityPage() {
|
||||||
if(payload.rpe) payload.rpe = parseInt(payload.rpe)
|
if(payload.rpe) payload.rpe = parseInt(payload.rpe)
|
||||||
payload.source = 'manual'
|
payload.source = 'manual'
|
||||||
await api.createActivity(payload)
|
await api.createActivity(payload)
|
||||||
setSaved(true); await load()
|
setSaved(true)
|
||||||
|
await load()
|
||||||
|
await loadUsage() // Reload usage after save
|
||||||
setTimeout(()=>{ setSaved(false); setForm(empty()) }, 1500)
|
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 () => {
|
const handleUpdate = async () => {
|
||||||
|
|
@ -225,9 +269,13 @@ export default function ActivityPage() {
|
||||||
|
|
||||||
{tab==='add' && (
|
{tab==='add' && (
|
||||||
<div className="card section-gap">
|
<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}
|
<EntryForm form={form} setForm={setForm}
|
||||||
onSave={handleSave} saveLabel={saved?'✓ Gespeichert!':'Speichern'}/>
|
onSave={handleSave} saveLabel={saved?'✓ Gespeichert!':'Speichern'}
|
||||||
|
saving={saving} error={error} usage={activityUsage}/>
|
||||||
</div>
|
</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 { 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 { useAuth } from '../context/AuthContext'
|
||||||
import { api } from '../utils/api'
|
import { api } from '../utils/api'
|
||||||
|
|
||||||
|
|
@ -142,9 +143,6 @@ function EmailEditor({ profileId, currentEmail, onSaved }) {
|
||||||
function ProfileCard({ profile, currentId, onRefresh }) {
|
function ProfileCard({ profile, currentId, onRefresh }) {
|
||||||
const [expanded, setExpanded] = useState(false)
|
const [expanded, setExpanded] = useState(false)
|
||||||
const [perms, setPerms] = useState({
|
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 [saving, setSaving] = useState(false)
|
||||||
|
|
@ -156,9 +154,6 @@ function ProfileCard({ profile, currentId, onRefresh }) {
|
||||||
setSaving(true)
|
setSaving(true)
|
||||||
try {
|
try {
|
||||||
await api.adminSetPermissions(profile.id, {
|
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()
|
await onRefresh()
|
||||||
|
|
@ -195,9 +190,8 @@ function ProfileCard({ profile, currentId, onRefresh }) {
|
||||||
{isSelf && <span style={{fontSize:10,color:'var(--text3)'}}>Du</span>}
|
{isSelf && <span style={{fontSize:10,color:'var(--text3)'}}>Du</span>}
|
||||||
</div>
|
</div>
|
||||||
<div style={{fontSize:11,color:'var(--text3)'}}>
|
<div style={{fontSize:11,color:'var(--text3)'}}>
|
||||||
KI: {profile.ai_enabled?`✓${profile.ai_limit_day?` (max ${profile.ai_limit_day}/Tag)`:''}` : '✗'} ·
|
Tier: {profile.tier || 'free'} ·
|
||||||
Export: {profile.export_enabled?'✓':'✗'} ·
|
Email: {profile.email || 'nicht gesetzt'}
|
||||||
Calls heute: {profile.ai_calls_today||0}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div style={{display:'flex',gap:6}}>
|
<div style={{display:'flex',gap:6}}>
|
||||||
|
|
@ -232,23 +226,19 @@ function ProfileCard({ profile, currentId, onRefresh }) {
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<button className="btn btn-primary btn-full" style={{marginTop:8}} onClick={savePerms} disabled={saving}>
|
||||||
|
{saving?'Speichern…':'Rolle speichern'}
|
||||||
<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>
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 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 */}
|
{/* Email */}
|
||||||
<div style={{marginTop:12,paddingTop:12,borderTop:'1px solid var(--border)'}}>
|
<div style={{marginTop:12,paddingTop:12,borderTop:'1px solid var(--border)'}}>
|
||||||
|
|
@ -397,6 +387,43 @@ export default function AdminPanel() {
|
||||||
|
|
||||||
{/* Email Settings */}
|
{/* Email Settings */}
|
||||||
<EmailSettings/>
|
<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>
|
</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 { api } from '../utils/api'
|
||||||
import { useAuth } from '../context/AuthContext'
|
import { useAuth } from '../context/AuthContext'
|
||||||
import Markdown from '../utils/Markdown'
|
import Markdown from '../utils/Markdown'
|
||||||
|
import UsageBadge from '../components/UsageBadge'
|
||||||
import dayjs from 'dayjs'
|
import dayjs from 'dayjs'
|
||||||
import 'dayjs/locale/de'
|
import 'dayjs/locale/de'
|
||||||
dayjs.locale('de')
|
dayjs.locale('de')
|
||||||
|
|
@ -114,6 +115,7 @@ export default function Analysis() {
|
||||||
const [tab, setTab] = useState('run')
|
const [tab, setTab] = useState('run')
|
||||||
const [newResult, setNewResult] = useState(null)
|
const [newResult, setNewResult] = useState(null)
|
||||||
const [pipelineLoading, setPipelineLoading] = useState(false)
|
const [pipelineLoading, setPipelineLoading] = useState(false)
|
||||||
|
const [aiUsage, setAiUsage] = useState(null) // Phase 3: Usage badge
|
||||||
|
|
||||||
const loadAll = async () => {
|
const loadAll = async () => {
|
||||||
const [p, i] = await Promise.all([
|
const [p, i] = await Promise.all([
|
||||||
|
|
@ -123,7 +125,15 @@ export default function Analysis() {
|
||||||
setPrompts(Array.isArray(p)?p:[])
|
setPrompts(Array.isArray(p)?p:[])
|
||||||
setAllInsights(Array.isArray(i)?i:[])
|
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 () => {
|
const runPipeline = async () => {
|
||||||
setPipelineLoading(true); setError(null); setNewResult(null)
|
setPipelineLoading(true); setError(null); setNewResult(null)
|
||||||
|
|
@ -177,7 +187,7 @@ export default function Analysis() {
|
||||||
grouped[key].push(ins)
|
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
|
// Pipeline is available if the "pipeline" prompt is active
|
||||||
const pipelinePrompt = prompts.find(p=>p.slug==='pipeline')
|
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 className="card" style={{marginBottom:16,borderColor:'var(--accent)',borderWidth:2}}>
|
||||||
<div style={{display:'flex',alignItems:'flex-start',gap:12}}>
|
<div style={{display:'flex',alignItems:'flex-start',gap:12}}>
|
||||||
<div style={{flex:1}}>
|
<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}}>
|
<div style={{fontSize:12,color:'var(--text2)',marginTop:3,lineHeight:1.5}}>
|
||||||
3 spezialisierte KI-Calls parallel (Körper + Ernährung + Aktivität),
|
3 spezialisierte KI-Calls parallel (Körper + Ernährung + Aktivität),
|
||||||
dann Synthese + Zielabgleich. Detaillierteste Auswertung.
|
dann Synthese + Zielabgleich. Detaillierteste Auswertung.
|
||||||
|
|
@ -241,12 +254,22 @@ export default function Analysis() {
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<button className="btn btn-primary" style={{flexShrink:0,minWidth:100}}
|
<div
|
||||||
onClick={runPipeline} disabled={!!loading||pipelineLoading}>
|
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
|
{pipelineLoading
|
||||||
? <><div className="spinner" style={{width:13,height:13}}/> Läuft…</>
|
? <><div className="spinner" style={{width:13,height:13}}/> Läuft…</>
|
||||||
|
: (aiUsage && !aiUsage.allowed) ? '🔒 Limit'
|
||||||
: <><Brain size={13}/> Starten</>}
|
: <><Brain size={13}/> Starten</>}
|
||||||
</button>
|
</button>
|
||||||
|
</div>
|
||||||
{!canUseAI && <div style={{fontSize:11,color:'#D85A30',marginTop:4}}>🔒 KI nicht freigeschaltet</div>}
|
{!canUseAI && <div style={{fontSize:11,color:'#D85A30',marginTop:4}}>🔒 KI nicht freigeschaltet</div>}
|
||||||
</div>
|
</div>
|
||||||
{pipelineLoading && (
|
{pipelineLoading && (
|
||||||
|
|
@ -282,7 +305,10 @@ export default function Analysis() {
|
||||||
<div key={p.id} className="card section-gap">
|
<div key={p.id} className="card section-gap">
|
||||||
<div style={{display:'flex',alignItems:'flex-start',gap:12}}>
|
<div style={{display:'flex',alignItems:'flex-start',gap:12}}>
|
||||||
<div style={{flex:1}}>
|
<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>}
|
{p.description && <div style={{fontSize:12,color:'var(--text3)',marginTop:2}}>{p.description}</div>}
|
||||||
{existing && (
|
{existing && (
|
||||||
<div style={{fontSize:11,color:'var(--text3)',marginTop:3}}>
|
<div style={{fontSize:11,color:'var(--text3)',marginTop:3}}>
|
||||||
|
|
@ -290,13 +316,23 @@ export default function Analysis() {
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<button className="btn btn-primary" style={{flexShrink:0,minWidth:90}}
|
<div
|
||||||
onClick={()=>runPrompt(p.slug)} disabled={!!loading||!canUseAI}>
|
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
|
{loading===p.slug
|
||||||
? <><div className="spinner" style={{width:13,height:13}}/> Läuft…</>
|
? <><div className="spinner" style={{width:13,height:13}}/> Läuft…</>
|
||||||
|
: (aiUsage && !aiUsage.allowed) ? '🔒 Limit'
|
||||||
: <><Brain size={13}/> {existing?'Neu erstellen':'Starten'}</>}
|
: <><Brain size={13}/> {existing?'Neu erstellen':'Starten'}</>}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
{/* Show existing result collapsed */}
|
{/* Show existing result collapsed */}
|
||||||
{existing && newResult?.id !== existing.id && (
|
{existing && newResult?.id !== existing.id && (
|
||||||
<div style={{marginTop:8,borderTop:'1px solid var(--border)',paddingTop:8}}>
|
<div style={{marginTop:8,borderTop:'1px solid var(--border)',paddingTop:8}}>
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ import { useNavigate } from 'react-router-dom'
|
||||||
import { api } from '../utils/api'
|
import { api } from '../utils/api'
|
||||||
import { calcBodyFat, getBfCategory, METHOD_POINTS } from '../utils/calc'
|
import { calcBodyFat, getBfCategory, METHOD_POINTS } from '../utils/calc'
|
||||||
import { CALIPER_POINTS, CALIPER_METHODS } from '../utils/guideData'
|
import { CALIPER_POINTS, CALIPER_METHODS } from '../utils/guideData'
|
||||||
|
import UsageBadge from '../components/UsageBadge'
|
||||||
import dayjs from 'dayjs'
|
import dayjs from 'dayjs'
|
||||||
|
|
||||||
function emptyForm() {
|
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 sex = profile?.sex||'m'
|
||||||
const age = profile?.dob ? Math.floor((Date.now()-new Date(profile.dob))/(365.25*24*3600*1000)) : 30
|
const age = profile?.dob ? Math.floor((Date.now()-new Date(profile.dob))/(365.25*24*3600*1000)) : 30
|
||||||
const weight = form.weight || 80
|
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)}/>
|
<input type="text" className="form-input" placeholder="optional" value={form.notes||''} onChange={e=>set('notes',e.target.value)}/>
|
||||||
<span className="form-unit"/>
|
<span className="form-unit"/>
|
||||||
</div>
|
</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}}>
|
<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>}
|
{onCancel && <button className="btn btn-secondary" style={{flex:1}} onClick={onCancel}><X size={13}/> Abbrechen</button>}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -78,12 +96,26 @@ export default function CaliperScreen() {
|
||||||
const [profile, setProfile] = useState(null)
|
const [profile, setProfile] = useState(null)
|
||||||
const [form, setForm] = useState(emptyForm())
|
const [form, setForm] = useState(emptyForm())
|
||||||
const [editing, setEditing] = useState(null)
|
const [editing, setEditing] = useState(null)
|
||||||
|
const [saving, setSaving] = useState(false)
|
||||||
const [saved, setSaved] = 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 nav = useNavigate()
|
||||||
|
|
||||||
const load = () => Promise.all([api.listCaliper(), api.getProfile()])
|
const load = () => Promise.all([api.listCaliper(), api.getProfile()])
|
||||||
.then(([e,p])=>{ setEntries(e); setProfile(p) })
|
.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 buildPayload = (f, bfPct, sex) => {
|
||||||
const weight = profile?.weight || null
|
const weight = profile?.weight || null
|
||||||
|
|
@ -97,11 +129,23 @@ export default function CaliperScreen() {
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleSave = async (bfPct, sex) => {
|
const handleSave = async (bfPct, sex) => {
|
||||||
|
setSaving(true)
|
||||||
|
setError(null)
|
||||||
|
try {
|
||||||
const payload = buildPayload(form, bfPct, sex)
|
const payload = buildPayload(form, bfPct, sex)
|
||||||
await api.upsertCaliper(payload)
|
await api.upsertCaliper(payload)
|
||||||
setSaved(true); await load()
|
setSaved(true)
|
||||||
|
await load()
|
||||||
|
await loadUsage() // Reload usage after save
|
||||||
setTimeout(()=>setSaved(false),2000)
|
setTimeout(()=>setSaved(false),2000)
|
||||||
setForm(emptyForm())
|
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) => {
|
const handleUpdate = async (bfPct, sex) => {
|
||||||
|
|
@ -125,9 +169,13 @@ export default function CaliperScreen() {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="card section-gap">
|
<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}
|
<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>
|
||||||
|
|
||||||
<div className="section-gap">
|
<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 { useNavigate } from 'react-router-dom'
|
||||||
import { api } from '../utils/api'
|
import { api } from '../utils/api'
|
||||||
import { CIRCUMFERENCE_POINTS } from '../utils/guideData'
|
import { CIRCUMFERENCE_POINTS } from '../utils/guideData'
|
||||||
|
import UsageBadge from '../components/UsageBadge'
|
||||||
import dayjs from 'dayjs'
|
import dayjs from 'dayjs'
|
||||||
|
|
||||||
const FIELDS = ['c_neck','c_chest','c_waist','c_belly','c_hip','c_thigh','c_calf','c_arm']
|
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 [editing, setEditing] = useState(null)
|
||||||
const [saving, setSaving] = useState(false)
|
const [saving, setSaving] = useState(false)
|
||||||
const [saved, setSaved] = useState(false)
|
const [saved, setSaved] = useState(false)
|
||||||
|
const [error, setError] = useState(null)
|
||||||
const [photoFile, setPhotoFile] = useState(null)
|
const [photoFile, setPhotoFile] = useState(null)
|
||||||
const [photoPreview, setPhotoPreview] = useState(null)
|
const [photoPreview, setPhotoPreview] = useState(null)
|
||||||
|
const [circumUsage, setCircumUsage] = useState(null) // Phase 4: Usage badge
|
||||||
const fileRef = useRef()
|
const fileRef = useRef()
|
||||||
const nav = useNavigate()
|
const nav = useNavigate()
|
||||||
|
|
||||||
const load = () => api.listCirc().then(setEntries)
|
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 set = (k,v) => setForm(f=>({...f,[k]:v}))
|
||||||
|
|
||||||
const handleSave = async () => {
|
const handleSave = async () => {
|
||||||
setSaving(true)
|
setSaving(true)
|
||||||
|
setError(null)
|
||||||
try {
|
try {
|
||||||
const payload = {}
|
const payload = {}
|
||||||
payload.date = form.date
|
payload.date = form.date
|
||||||
|
|
@ -38,10 +53,18 @@ export default function CircumScreen() {
|
||||||
payload.photo_id = pr.id
|
payload.photo_id = pr.id
|
||||||
}
|
}
|
||||||
await api.upsertCirc(payload)
|
await api.upsertCirc(payload)
|
||||||
setSaved(true); await load()
|
setSaved(true)
|
||||||
|
await load()
|
||||||
|
await loadUsage() // Reload usage after save
|
||||||
setTimeout(()=>setSaved(false),2000)
|
setTimeout(()=>setSaved(false),2000)
|
||||||
setForm(empty()); setPhotoFile(null); setPhotoPreview(null)
|
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})
|
const startEdit = (e) => setEditing({...e})
|
||||||
|
|
@ -72,7 +95,10 @@ export default function CircumScreen() {
|
||||||
|
|
||||||
{/* Eingabe */}
|
{/* Eingabe */}
|
||||||
<div className="card section-gap">
|
<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">
|
<div className="form-row">
|
||||||
<label className="form-label">Datum</label>
|
<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)}/>
|
<input type="date" className="form-input" style={{width:140}} value={form.date} onChange={e=>set('date',e.target.value)}/>
|
||||||
|
|
@ -99,10 +125,28 @@ export default function CircumScreen() {
|
||||||
<button className="btn btn-secondary btn-full" onClick={()=>fileRef.current.click()}>
|
<button className="btn btn-secondary btn-full" onClick={()=>fileRef.current.click()}>
|
||||||
<Camera size={14}/> {photoPreview?'Foto ändern':'Foto hinzufügen'}
|
<Camera size={14}/> {photoPreview?'Foto ändern':'Foto hinzufügen'}
|
||||||
</button>
|
</button>
|
||||||
<button className="btn btn-primary btn-full" style={{marginTop:8}} onClick={handleSave} disabled={saving}>
|
{error && (
|
||||||
{saved ? <><Check size={14}/> Gespeichert!</> : saving ? '…' : 'Speichern'}
|
<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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Liste */}
|
{/* Liste */}
|
||||||
<div className="section-gap">
|
<div className="section-gap">
|
||||||
|
|
|
||||||
|
|
@ -27,33 +27,76 @@ function QuickWeight({ onSaved }) {
|
||||||
const [input, setInput] = useState('')
|
const [input, setInput] = useState('')
|
||||||
const [saving, setSaving] = useState(false)
|
const [saving, setSaving] = useState(false)
|
||||||
const [saved, setSaved] = 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 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(()=>{
|
useEffect(()=>{
|
||||||
api.weightStats().then(s=>{
|
api.weightStats().then(s=>{
|
||||||
if(s?.latest?.date===today) setInput(String(s.latest.weight))
|
if(s?.latest?.date===today) setInput(String(s.latest.weight))
|
||||||
})
|
})
|
||||||
|
loadUsage()
|
||||||
},[])
|
},[])
|
||||||
|
|
||||||
const handleSave = async () => {
|
const handleSave = async () => {
|
||||||
const w=parseFloat(input); if(!w||w<20||w>300) return
|
const w=parseFloat(input); if(!w||w<20||w>300) return
|
||||||
setSaving(true)
|
setSaving(true)
|
||||||
try{ await api.upsertWeight(today,w); setSaved(true); onSaved?.(); setTimeout(()=>setSaved(false),2000) }
|
setError(null)
|
||||||
finally{ setSaving(false) }
|
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 (
|
return (
|
||||||
|
<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'}}>
|
<div style={{display:'flex',gap:8,alignItems:'center'}}>
|
||||||
<input type="number" min={20} max={300} step={0.1} className="form-input"
|
<input type="number" min={20} max={300} step={0.1} className="form-input"
|
||||||
style={{flex:1,fontSize:17,fontWeight:600,textAlign:'center'}}
|
style={{flex:1,fontSize:17,fontWeight:600,textAlign:'center'}}
|
||||||
placeholder="kg eingeben" value={input} onChange={e=>setInput(e.target.value)}
|
placeholder="kg eingeben" value={input} onChange={e=>setInput(e.target.value)}
|
||||||
onKeyDown={e=>e.key==='Enter'&&handleSave()}/>
|
onKeyDown={e=>e.key==='Enter'&&!isDisabled&&handleSave()}/>
|
||||||
<span style={{fontSize:13,color:'var(--text3)'}}>kg</span>
|
<span style={{fontSize:13,color:'var(--text3)'}}>kg</span>
|
||||||
<button className="btn btn-primary" style={{padding:'8px 14px'}}
|
<div title={tooltipText} style={{display:'inline-block'}}>
|
||||||
onClick={handleSave} disabled={saving||!input}>
|
<button
|
||||||
{saved?<Check size={15}/>:saving?<div className="spinner" style={{width:14,height:14}}/>:'Speichern'}
|
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>
|
</button>
|
||||||
</div>
|
</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 ──────────────────────────────────────────────────────────────
|
// ── Import Panel ──────────────────────────────────────────────────────────────
|
||||||
function ImportPanel({ onImported }) {
|
function ImportPanel({ onImported }) {
|
||||||
const fileRef = useRef()
|
const fileRef = useRef()
|
||||||
|
|
@ -322,9 +735,11 @@ function CalorieBalance({ data, profile }) {
|
||||||
|
|
||||||
// ── Main Page ─────────────────────────────────────────────────────────────────
|
// ── Main Page ─────────────────────────────────────────────────────────────────
|
||||||
export default function NutritionPage() {
|
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 [corrData, setCorr] = useState([])
|
||||||
const [weekly, setWeekly] = useState([])
|
const [weekly, setWeekly] = useState([])
|
||||||
|
const [entries, setEntries]= useState([])
|
||||||
const [profile, setProf] = useState(null)
|
const [profile, setProf] = useState(null)
|
||||||
const [loading, setLoad] = useState(true)
|
const [loading, setLoad] = useState(true)
|
||||||
const [hasData, setHasData]= useState(false)
|
const [hasData, setHasData]= useState(false)
|
||||||
|
|
@ -332,13 +747,15 @@ export default function NutritionPage() {
|
||||||
const load = async () => {
|
const load = async () => {
|
||||||
setLoad(true)
|
setLoad(true)
|
||||||
try {
|
try {
|
||||||
const [corr, wkly, prof] = await Promise.all([
|
const [corr, wkly, ent, prof] = await Promise.all([
|
||||||
nutritionApi.nutritionCorrelations(),
|
nutritionApi.nutritionCorrelations(),
|
||||||
nutritionApi.nutritionWeekly(16),
|
nutritionApi.nutritionWeekly(16),
|
||||||
api.getActiveProfile(),
|
nutritionApi.listNutrition(365), // BUG-002 fix: load raw entries
|
||||||
|
nutritionApi.getActiveProfile(),
|
||||||
])
|
])
|
||||||
setCorr(Array.isArray(corr)?corr:[])
|
setCorr(Array.isArray(corr)?corr:[])
|
||||||
setWeekly(Array.isArray(wkly)?wkly:[])
|
setWeekly(Array.isArray(wkly)?wkly:[])
|
||||||
|
setEntries(Array.isArray(ent)?ent:[]) // BUG-002 fix
|
||||||
setProf(prof)
|
setProf(prof)
|
||||||
setHasData(Array.isArray(corr) && corr.some(d=>d.kcal))
|
setHasData(Array.isArray(corr) && corr.some(d=>d.kcal))
|
||||||
} catch(e) { console.error('load error:', e) }
|
} catch(e) { console.error('load error:', e) }
|
||||||
|
|
@ -351,29 +768,52 @@ export default function NutritionPage() {
|
||||||
<div>
|
<div>
|
||||||
<h1 className="page-title">Ernährung</h1>
|
<h1 className="page-title">Ernährung</h1>
|
||||||
|
|
||||||
|
{/* 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}/>
|
<ImportPanel onImported={load}/>
|
||||||
|
<ImportHistory/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
{loading && <div className="empty-state"><div className="spinner"/></div>}
|
{loading && <div className="empty-state"><div className="spinner"/></div>}
|
||||||
|
|
||||||
{!loading && !hasData && (
|
{!loading && !hasData && (
|
||||||
<div className="empty-state">
|
<div className="empty-state">
|
||||||
<h3>Noch keine Ernährungsdaten</h3>
|
<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>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Analysis Section */}
|
||||||
{!loading && hasData && (
|
{!loading && hasData && (
|
||||||
<>
|
<>
|
||||||
<OverviewCards data={corrData}/>
|
<OverviewCards data={corrData}/>
|
||||||
|
|
||||||
<div className="tabs section-gap" style={{overflowX:'auto',flexWrap:'nowrap'}}>
|
<div className="tabs section-gap" style={{overflowX:'auto',flexWrap:'nowrap'}}>
|
||||||
<button className={'tab'+(tab==='overview'?' active':'')} onClick={()=>setTab('overview')}>Übersicht</button>
|
<button className={'tab'+(analysisTab==='data'?' active':'')} onClick={()=>setAnalysisTab('data')}>Daten</button>
|
||||||
<button className={'tab'+(tab==='weight'?' active':'')} onClick={()=>setTab('weight')}>Kcal vs. Gewicht</button>
|
<button className={'tab'+(analysisTab==='overview'?' active':'')} onClick={()=>setAnalysisTab('overview')}>Übersicht</button>
|
||||||
<button className={'tab'+(tab==='protein'?' active':'')} onClick={()=>setTab('protein')}>Protein vs. Mager</button>
|
<button className={'tab'+(analysisTab==='weight'?' active':'')} onClick={()=>setAnalysisTab('weight')}>Kcal vs. Gewicht</button>
|
||||||
<button className={'tab'+(tab==='balance'?' active':'')} onClick={()=>setTab('balance')}>Bilanz</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>
|
</div>
|
||||||
|
|
||||||
{tab==='overview' && (
|
{analysisTab==='data' && <DataTab entries={entries} onUpdate={load}/>}
|
||||||
|
|
||||||
|
{analysisTab==='overview' && (
|
||||||
<div className="card section-gap">
|
<div className="card section-gap">
|
||||||
<div className="card-title">Makro-Verteilung pro Woche (Ø g/Tag)</div>
|
<div className="card-title">Makro-Verteilung pro Woche (Ø g/Tag)</div>
|
||||||
<WeeklyMacros weekly={weekly}/>
|
<WeeklyMacros weekly={weekly}/>
|
||||||
|
|
@ -385,7 +825,7 @@ export default function NutritionPage() {
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{tab==='weight' && (
|
{analysisTab==='weight' && (
|
||||||
<div className="card section-gap">
|
<div className="card section-gap">
|
||||||
<div className="card-title">Kalorien vs. Gewichtsverlauf</div>
|
<div className="card-title">Kalorien vs. Gewichtsverlauf</div>
|
||||||
<div style={{fontSize:11,color:'var(--text3)',marginBottom:8}}>
|
<div style={{fontSize:11,color:'var(--text3)',marginBottom:8}}>
|
||||||
|
|
@ -401,7 +841,7 @@ export default function NutritionPage() {
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{tab==='protein' && (
|
{analysisTab==='protein' && (
|
||||||
<div className="card section-gap">
|
<div className="card section-gap">
|
||||||
<div className="card-title">Protein vs. Magermasse</div>
|
<div className="card-title">Protein vs. Magermasse</div>
|
||||||
<div style={{fontSize:11,color:'var(--text3)',marginBottom:8}}>
|
<div style={{fontSize:11,color:'var(--text3)',marginBottom:8}}>
|
||||||
|
|
@ -417,7 +857,7 @@ export default function NutritionPage() {
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{tab==='balance' && (
|
{analysisTab==='balance' && (
|
||||||
<div className="card section-gap">
|
<div className="card section-gap">
|
||||||
<div className="card-title">Kaloriendefizit / -überschuss</div>
|
<div className="card-title">Kaloriendefizit / -überschuss</div>
|
||||||
<CalorieBalance data={corrData} profile={profile}/>
|
<CalorieBalance data={corrData} profile={profile}/>
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,12 @@
|
||||||
import { useState } from 'react'
|
import { useState, useEffect } from 'react'
|
||||||
import { Save, Download, Upload, Trash2, Plus, Check, Pencil, X, LogOut, Shield, Key } from 'lucide-react'
|
import { Save, Download, Upload, Trash2, Plus, Check, Pencil, X, LogOut, Shield, Key, BarChart3 } from 'lucide-react'
|
||||||
import { useProfile } from '../context/ProfileContext'
|
import { useProfile } from '../context/ProfileContext'
|
||||||
import { useAuth } from '../context/AuthContext'
|
import { useAuth } from '../context/AuthContext'
|
||||||
import { Avatar } from './ProfileSelect'
|
import { Avatar } from './ProfileSelect'
|
||||||
import { api } from '../utils/api'
|
import { api } from '../utils/api'
|
||||||
import AdminPanel from './AdminPanel'
|
import AdminPanel from './AdminPanel'
|
||||||
|
import FeatureUsageOverview from '../components/FeatureUsageOverview'
|
||||||
|
import UsageBadge from '../components/UsageBadge'
|
||||||
|
|
||||||
const COLORS = ['#1D9E75','#378ADD','#D85A30','#EF9F27','#7F77DD','#D4537E','#639922','#888780']
|
const COLORS = ['#1D9E75','#378ADD','#D85A30','#EF9F27','#7F77DD','#D4537E','#639922','#888780']
|
||||||
|
|
||||||
|
|
@ -99,6 +101,15 @@ export default function SettingsPage() {
|
||||||
const [pinOpen, setPinOpen] = useState(false)
|
const [pinOpen, setPinOpen] = useState(false)
|
||||||
const [newPin, setNewPin] = useState('')
|
const [newPin, setNewPin] = useState('')
|
||||||
const [pinMsg, setPinMsg] = useState(null)
|
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 () => {
|
const handleLogout = async () => {
|
||||||
if (!confirm('Ausloggen?')) return
|
if (!confirm('Ausloggen?')) return
|
||||||
|
|
@ -326,6 +337,17 @@ export default function SettingsPage() {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Feature Usage Overview (Phase 3) */}
|
||||||
|
<div className="card section-gap">
|
||||||
|
<div className="card-title" style={{display:'flex',alignItems:'center',gap:6}}>
|
||||||
|
<BarChart3 size={15} color="var(--accent)"/> Kontingente
|
||||||
|
</div>
|
||||||
|
<p style={{fontSize:13,color:'var(--text2)',marginBottom:12,lineHeight:1.6}}>
|
||||||
|
Übersicht über deine Feature-Nutzung und verfügbare Kontingente.
|
||||||
|
</p>
|
||||||
|
<FeatureUsageOverview />
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Admin Panel */}
|
{/* Admin Panel */}
|
||||||
{isAdmin && (
|
{isAdmin && (
|
||||||
<div className="card section-gap">
|
<div className="card section-gap">
|
||||||
|
|
@ -359,13 +381,23 @@ export default function SettingsPage() {
|
||||||
{canExport && <>
|
{canExport && <>
|
||||||
<button className="btn btn-primary btn-full"
|
<button className="btn btn-primary btn-full"
|
||||||
onClick={()=>api.exportZip()}>
|
onClick={()=>api.exportZip()}>
|
||||||
<Download size={14}/> ZIP exportieren
|
<div className="badge-button-layout">
|
||||||
<span style={{fontSize:11,opacity:0.8,marginLeft:6}}>— je eine CSV pro Kategorie</span>
|
<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>
|
||||||
<button className="btn btn-secondary btn-full"
|
<button className="btn btn-secondary btn-full"
|
||||||
onClick={()=>api.exportJson()}>
|
onClick={()=>api.exportJson()}>
|
||||||
<Download size={14}/> JSON exportieren
|
<div className="badge-button-layout">
|
||||||
<span style={{fontSize:11,opacity:0.8,marginLeft:6}}>— maschinenlesbar, alles in einer Datei</span>
|
<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>
|
</button>
|
||||||
</>}
|
</>}
|
||||||
</div>
|
</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 { Pencil, Trash2, Check, X } from 'lucide-react'
|
||||||
import { api } from '../utils/api'
|
import { api } from '../utils/api'
|
||||||
import { LineChart, Line, XAxis, YAxis, Tooltip, ResponsiveContainer, CartesianGrid, ReferenceLine } from 'recharts'
|
import { LineChart, Line, XAxis, YAxis, Tooltip, ResponsiveContainer, CartesianGrid, ReferenceLine } from 'recharts'
|
||||||
|
import UsageBadge from '../components/UsageBadge'
|
||||||
import dayjs from 'dayjs'
|
import dayjs from 'dayjs'
|
||||||
import 'dayjs/locale/de'
|
import 'dayjs/locale/de'
|
||||||
dayjs.locale('de')
|
dayjs.locale('de')
|
||||||
|
|
@ -21,19 +22,42 @@ export default function WeightScreen() {
|
||||||
const [newNote, setNewNote] = useState('')
|
const [newNote, setNewNote] = useState('')
|
||||||
const [saving, setSaving] = useState(false)
|
const [saving, setSaving] = useState(false)
|
||||||
const [saved, setSaved] = 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))
|
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 () => {
|
const handleSave = async () => {
|
||||||
if (!newWeight) return
|
if (!newWeight) return
|
||||||
setSaving(true)
|
setSaving(true)
|
||||||
|
setError(null)
|
||||||
try {
|
try {
|
||||||
await api.upsertWeight(newDate, parseFloat(newWeight), newNote)
|
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)
|
setTimeout(()=>setSaved(false), 2000)
|
||||||
setNewWeight(''); setNewNote('')
|
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 () => {
|
const handleUpdate = async () => {
|
||||||
|
|
@ -59,7 +83,10 @@ export default function WeightScreen() {
|
||||||
|
|
||||||
{/* Eingabe */}
|
{/* Eingabe */}
|
||||||
<div className="card section-gap">
|
<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">
|
<div className="form-row">
|
||||||
<label className="form-label">Datum</label>
|
<label className="form-label">Datum</label>
|
||||||
<input type="date" className="form-input" style={{width:140}}
|
<input type="date" className="form-input" style={{width:140}}
|
||||||
|
|
@ -79,12 +106,28 @@ export default function WeightScreen() {
|
||||||
value={newNote} onChange={e=>setNewNote(e.target.value)}/>
|
value={newNote} onChange={e=>setNewNote(e.target.value)}/>
|
||||||
<span className="form-unit"/>
|
<span className="form-unit"/>
|
||||||
</div>
|
</div>
|
||||||
<button className="btn btn-primary btn-full" onClick={handleSave} disabled={saving||!newWeight}>
|
{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!</>
|
{saved ? <><Check size={15}/> Gespeichert!</>
|
||||||
: saving ? <><div className="spinner" style={{width:14,height:14}}/> …</>
|
: saving ? <><div className="spinner" style={{width:14,height:14}}/> …</>
|
||||||
|
: (weightUsage && !weightUsage.allowed) ? '🔒 Limit erreicht'
|
||||||
: 'Speichern'}
|
: 'Speichern'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Chart */}
|
{/* Chart */}
|
||||||
{chartData.length >= 2 && (
|
{chartData.length >= 2 && (
|
||||||
|
|
|
||||||
|
|
@ -82,6 +82,11 @@ export const api = {
|
||||||
listNutrition: (l=365) => req(`/nutrition?limit=${l}`),
|
listNutrition: (l=365) => req(`/nutrition?limit=${l}`),
|
||||||
nutritionCorrelations: () => req('/nutrition/correlations'),
|
nutritionCorrelations: () => req('/nutrition/correlations'),
|
||||||
nutritionWeekly: (w=16) => req(`/nutrition/weekly?weeks=${w}`),
|
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
|
// Stats & AI
|
||||||
getStats: () => req('/stats'),
|
getStats: () => req('/stats'),
|
||||||
|
|
@ -137,4 +142,48 @@ export const api = {
|
||||||
adminDeleteProfile: (id) => req(`/admin/profiles/${id}`,{method:'DELETE'}),
|
adminDeleteProfile: (id) => req(`/admin/profiles/${id}`,{method:'DELETE'}),
|
||||||
adminSetPermissions: (id,d) => req(`/admin/profiles/${id}/permissions`,jput(d)),
|
adminSetPermissions: (id,d) => req(`/admin/profiles/${id}/permissions`,jput(d)),
|
||||||
changePin: (pin) => req('/auth/pin',json({pin})),
|
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