Compare commits
No commits in common. "b551365fb57a77f08736461fff6093892f8b6959" and "272c12395296b63710e8e23904e7ce4b6d9abc46" have entirely different histories.
b551365fb5
...
272c123952
2
.gitignore
vendored
2
.gitignore
vendored
|
|
@ -61,4 +61,4 @@ tmp/
|
||||||
|
|
||||||
#.claude Konfiguration
|
#.claude Konfiguration
|
||||||
.claude/
|
.claude/
|
||||||
.claude/settings.local.jsonfrontend/package-lock.json
|
.claude/settings.local.json
|
||||||
|
|
@ -60,9 +60,6 @@ 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...")
|
||||||
|
|
@ -86,26 +83,6 @@ 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()
|
||||||
|
|
@ -131,123 +108,10 @@ 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()
|
||||||
|
|
|
||||||
225
backend/auth.py
225
backend/auth.py
|
|
@ -121,22 +121,17 @@ 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, conn=None) -> str:
|
def get_effective_tier(profile_id: str) -> 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'
|
||||||
"""
|
"""
|
||||||
# Use existing connection if provided, otherwise open new one
|
with get_db() as conn:
|
||||||
if conn:
|
|
||||||
cur = get_cursor(conn)
|
cur = get_cursor(conn)
|
||||||
|
|
||||||
# Check for active access grants (highest priority)
|
# Check for active access grants (highest priority)
|
||||||
|
|
@ -159,13 +154,9 @@ def get_effective_tier(profile_id: str, conn=None) -> 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, conn=None) -> dict:
|
def check_feature_access(profile_id: str, feature_id: str) -> dict:
|
||||||
"""
|
"""
|
||||||
Check if a profile has access to a feature.
|
Check if a profile has access to a feature.
|
||||||
|
|
||||||
|
|
@ -174,11 +165,6 @@ def check_feature_access(profile_id: str, feature_id: str, conn=None) -> 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,
|
||||||
|
|
@ -188,127 +174,118 @@ def check_feature_access(profile_id: str, feature_id: str, conn=None) -> dict:
|
||||||
'reason': str # 'unlimited', 'within_limit', 'limit_exceeded', 'feature_disabled'
|
'reason': str # 'unlimited', 'within_limit', 'limit_exceeded', 'feature_disabled'
|
||||||
}
|
}
|
||||||
"""
|
"""
|
||||||
# Use existing connection if provided
|
with get_db() as conn:
|
||||||
if conn:
|
cur = get_cursor(conn)
|
||||||
return _check_impl(profile_id, feature_id, conn)
|
|
||||||
else:
|
|
||||||
with get_db() as conn:
|
|
||||||
return _check_impl(profile_id, feature_id, conn)
|
|
||||||
|
|
||||||
|
# Get feature info
|
||||||
|
cur.execute("""
|
||||||
|
SELECT limit_type, reset_period, default_limit
|
||||||
|
FROM features
|
||||||
|
WHERE id = %s AND active = true
|
||||||
|
""", (feature_id,))
|
||||||
|
feature = cur.fetchone()
|
||||||
|
|
||||||
def _check_impl(profile_id: str, feature_id: str, conn) -> dict:
|
if not feature:
|
||||||
"""Internal implementation of check_feature_access."""
|
return {
|
||||||
cur = get_cursor(conn)
|
'allowed': False,
|
||||||
|
'limit': None,
|
||||||
|
'used': 0,
|
||||||
|
'remaining': None,
|
||||||
|
'reason': 'feature_not_found'
|
||||||
|
}
|
||||||
|
|
||||||
# Get feature info
|
# Priority 1: Check user-specific restriction
|
||||||
cur.execute("""
|
|
||||||
SELECT limit_type, reset_period, default_limit
|
|
||||||
FROM features
|
|
||||||
WHERE id = %s AND active = true
|
|
||||||
""", (feature_id,))
|
|
||||||
feature = cur.fetchone()
|
|
||||||
|
|
||||||
if not feature:
|
|
||||||
return {
|
|
||||||
'allowed': False,
|
|
||||||
'limit': None,
|
|
||||||
'used': 0,
|
|
||||||
'remaining': None,
|
|
||||||
'reason': 'feature_not_found'
|
|
||||||
}
|
|
||||||
|
|
||||||
# Priority 1: Check user-specific restriction
|
|
||||||
cur.execute("""
|
|
||||||
SELECT limit_value
|
|
||||||
FROM user_feature_restrictions
|
|
||||||
WHERE profile_id = %s AND feature_id = %s
|
|
||||||
""", (profile_id, feature_id))
|
|
||||||
restriction = cur.fetchone()
|
|
||||||
|
|
||||||
if restriction is not None:
|
|
||||||
limit = restriction['limit_value']
|
|
||||||
else:
|
|
||||||
# Priority 2: Check tier limit
|
|
||||||
tier_id = get_effective_tier(profile_id, conn)
|
|
||||||
cur.execute("""
|
cur.execute("""
|
||||||
SELECT limit_value
|
SELECT limit_value
|
||||||
FROM tier_limits
|
FROM user_feature_restrictions
|
||||||
WHERE tier_id = %s AND feature_id = %s
|
WHERE profile_id = %s AND feature_id = %s
|
||||||
""", (tier_id, feature_id))
|
""", (profile_id, feature_id))
|
||||||
tier_limit = cur.fetchone()
|
restriction = cur.fetchone()
|
||||||
|
|
||||||
if tier_limit is not None:
|
if restriction is not None:
|
||||||
limit = tier_limit['limit_value']
|
limit = restriction['limit_value']
|
||||||
else:
|
else:
|
||||||
# Priority 3: Feature default
|
# Priority 2: Check tier limit
|
||||||
limit = feature['default_limit']
|
tier_id = get_effective_tier(profile_id)
|
||||||
|
cur.execute("""
|
||||||
|
SELECT limit_value
|
||||||
|
FROM tier_limits
|
||||||
|
WHERE tier_id = %s AND feature_id = %s
|
||||||
|
""", (tier_id, feature_id))
|
||||||
|
tier_limit = cur.fetchone()
|
||||||
|
|
||||||
|
if tier_limit is not None:
|
||||||
|
limit = tier_limit['limit_value']
|
||||||
|
else:
|
||||||
|
# Priority 3: Feature default
|
||||||
|
limit = feature['default_limit']
|
||||||
|
|
||||||
|
# For boolean features (limit 0 = disabled, 1 = enabled)
|
||||||
|
if feature['limit_type'] == 'boolean':
|
||||||
|
allowed = limit == 1
|
||||||
|
return {
|
||||||
|
'allowed': allowed,
|
||||||
|
'limit': limit,
|
||||||
|
'used': 0,
|
||||||
|
'remaining': None,
|
||||||
|
'reason': 'enabled' if allowed else 'feature_disabled'
|
||||||
|
}
|
||||||
|
|
||||||
|
# For count-based features
|
||||||
|
# Check current usage
|
||||||
|
cur.execute("""
|
||||||
|
SELECT usage_count, reset_at
|
||||||
|
FROM user_feature_usage
|
||||||
|
WHERE profile_id = %s AND feature_id = %s
|
||||||
|
""", (profile_id, feature_id))
|
||||||
|
usage = cur.fetchone()
|
||||||
|
|
||||||
|
used = usage['usage_count'] if usage else 0
|
||||||
|
|
||||||
|
# Check if reset is needed
|
||||||
|
if usage and usage['reset_at'] and datetime.now() > usage['reset_at']:
|
||||||
|
# Reset usage
|
||||||
|
used = 0
|
||||||
|
next_reset = _calculate_next_reset(feature['reset_period'])
|
||||||
|
cur.execute("""
|
||||||
|
UPDATE user_feature_usage
|
||||||
|
SET usage_count = 0, reset_at = %s, updated = CURRENT_TIMESTAMP
|
||||||
|
WHERE profile_id = %s AND feature_id = %s
|
||||||
|
""", (next_reset, profile_id, feature_id))
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
# NULL limit = unlimited
|
||||||
|
if limit is None:
|
||||||
|
return {
|
||||||
|
'allowed': True,
|
||||||
|
'limit': None,
|
||||||
|
'used': used,
|
||||||
|
'remaining': None,
|
||||||
|
'reason': 'unlimited'
|
||||||
|
}
|
||||||
|
|
||||||
|
# 0 limit = disabled
|
||||||
|
if limit == 0:
|
||||||
|
return {
|
||||||
|
'allowed': False,
|
||||||
|
'limit': 0,
|
||||||
|
'used': used,
|
||||||
|
'remaining': 0,
|
||||||
|
'reason': 'feature_disabled'
|
||||||
|
}
|
||||||
|
|
||||||
|
# Check if within limit
|
||||||
|
allowed = used < limit
|
||||||
|
remaining = limit - used if limit else None
|
||||||
|
|
||||||
# For boolean features (limit 0 = disabled, 1 = enabled)
|
|
||||||
if feature['limit_type'] == 'boolean':
|
|
||||||
allowed = limit == 1
|
|
||||||
return {
|
return {
|
||||||
'allowed': allowed,
|
'allowed': allowed,
|
||||||
'limit': limit,
|
'limit': limit,
|
||||||
'used': 0,
|
|
||||||
'remaining': None,
|
|
||||||
'reason': 'enabled' if allowed else 'feature_disabled'
|
|
||||||
}
|
|
||||||
|
|
||||||
# For count-based features
|
|
||||||
# Check current usage
|
|
||||||
cur.execute("""
|
|
||||||
SELECT usage_count, reset_at
|
|
||||||
FROM user_feature_usage
|
|
||||||
WHERE profile_id = %s AND feature_id = %s
|
|
||||||
""", (profile_id, feature_id))
|
|
||||||
usage = cur.fetchone()
|
|
||||||
|
|
||||||
used = usage['usage_count'] if usage else 0
|
|
||||||
|
|
||||||
# Check if reset is needed
|
|
||||||
if usage and usage['reset_at'] and datetime.now() > usage['reset_at']:
|
|
||||||
# Reset usage
|
|
||||||
used = 0
|
|
||||||
next_reset = _calculate_next_reset(feature['reset_period'])
|
|
||||||
cur.execute("""
|
|
||||||
UPDATE user_feature_usage
|
|
||||||
SET usage_count = 0, reset_at = %s, updated = CURRENT_TIMESTAMP
|
|
||||||
WHERE profile_id = %s AND feature_id = %s
|
|
||||||
""", (next_reset, profile_id, feature_id))
|
|
||||||
conn.commit()
|
|
||||||
|
|
||||||
# NULL limit = unlimited
|
|
||||||
if limit is None:
|
|
||||||
return {
|
|
||||||
'allowed': True,
|
|
||||||
'limit': None,
|
|
||||||
'used': used,
|
'used': used,
|
||||||
'remaining': None,
|
'remaining': remaining,
|
||||||
'reason': 'unlimited'
|
'reason': 'within_limit' if allowed else 'limit_exceeded'
|
||||||
}
|
}
|
||||||
|
|
||||||
# 0 limit = disabled
|
|
||||||
if limit == 0:
|
|
||||||
return {
|
|
||||||
'allowed': False,
|
|
||||||
'limit': 0,
|
|
||||||
'used': used,
|
|
||||||
'remaining': 0,
|
|
||||||
'reason': 'feature_disabled'
|
|
||||||
}
|
|
||||||
|
|
||||||
# Check if within limit
|
|
||||||
allowed = used < limit
|
|
||||||
remaining = limit - used if limit else None
|
|
||||||
|
|
||||||
return {
|
|
||||||
'allowed': allowed,
|
|
||||||
'limit': limit,
|
|
||||||
'used': used,
|
|
||||||
'remaining': remaining,
|
|
||||||
'reason': 'within_limit' if allowed else 'limit_exceeded'
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def increment_feature_usage(profile_id: str, feature_id: str) -> None:
|
def increment_feature_usage(profile_id: str, feature_id: str) -> None:
|
||||||
"""
|
"""
|
||||||
|
|
|
||||||
|
|
@ -1,36 +0,0 @@
|
||||||
#!/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)}")
|
|
||||||
|
|
@ -1,76 +0,0 @@
|
||||||
"""
|
|
||||||
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))
|
|
||||||
|
|
@ -1,50 +0,0 @@
|
||||||
-- ============================================================================
|
|
||||||
-- 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;
|
|
||||||
|
|
@ -1,141 +0,0 @@
|
||||||
-- ============================================================================
|
|
||||||
-- 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)
|
|
||||||
-- ============================================================================
|
|
||||||
|
|
@ -1,33 +0,0 @@
|
||||||
-- 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,19 +6,16 @@ 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, check_feature_access, increment_feature_usage
|
from auth import require_auth
|
||||||
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("")
|
||||||
|
|
@ -36,22 +33,6 @@ 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:
|
||||||
|
|
@ -63,10 +44,6 @@ 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,19 +4,16 @@ 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, HTTPException
|
from fastapi import APIRouter, Header, Depends
|
||||||
|
|
||||||
from db import get_db, get_cursor, r2d
|
from db import get_db, get_cursor, r2d
|
||||||
from auth import require_auth, check_feature_access, increment_feature_usage
|
from auth import require_auth
|
||||||
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("")
|
||||||
|
|
@ -34,37 +31,17 @@ 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,
|
||||||
|
|
@ -73,10 +50,6 @@ 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,19 +4,16 @@ 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, HTTPException
|
from fastapi import APIRouter, Header, Depends
|
||||||
|
|
||||||
from db import get_db, get_cursor, r2d
|
from db import get_db, get_cursor, r2d
|
||||||
from auth import require_auth, check_feature_access, increment_feature_usage
|
from auth import require_auth
|
||||||
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("")
|
||||||
|
|
@ -34,47 +31,23 @@ 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,7 +7,6 @@ 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
|
||||||
|
|
@ -18,12 +17,10 @@ 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, check_feature_access, increment_feature_usage
|
from auth import require_auth
|
||||||
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"))
|
||||||
|
|
||||||
|
|
@ -33,20 +30,13 @@ 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)
|
||||||
|
|
||||||
# Phase 4: Check feature access and ENFORCE
|
# Check export permission
|
||||||
access = check_feature_access(pid, 'data_export')
|
with get_db() as conn:
|
||||||
log_feature_usage(pid, 'data_export', access, 'export_csv')
|
cur = get_cursor(conn)
|
||||||
|
cur.execute("SELECT export_enabled FROM profiles WHERE id=%s", (pid,))
|
||||||
if not access['allowed']:
|
prof = cur.fetchone()
|
||||||
logger.warning(
|
if not prof or not prof['export_enabled']:
|
||||||
f"[FEATURE-LIMIT] User {pid} blocked: "
|
raise HTTPException(403, "Export ist für dieses Profil deaktiviert")
|
||||||
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()
|
||||||
|
|
@ -84,10 +74,6 @@ 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",
|
||||||
|
|
@ -100,20 +86,13 @@ 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)
|
||||||
|
|
||||||
# Phase 4: Check feature access and ENFORCE
|
# Check export permission
|
||||||
access = check_feature_access(pid, 'data_export')
|
with get_db() as conn:
|
||||||
log_feature_usage(pid, 'data_export', access, 'export_json')
|
cur = get_cursor(conn)
|
||||||
|
cur.execute("SELECT export_enabled FROM profiles WHERE id=%s", (pid,))
|
||||||
if not access['allowed']:
|
prof = cur.fetchone()
|
||||||
logger.warning(
|
if not prof or not prof['export_enabled']:
|
||||||
f"[FEATURE-LIMIT] User {pid} blocked: "
|
raise HTTPException(403, "Export ist für dieses Profil deaktiviert")
|
||||||
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 = {}
|
||||||
|
|
@ -147,10 +126,6 @@ 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",
|
||||||
|
|
@ -163,26 +138,13 @@ 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)
|
||||||
|
|
||||||
# Phase 4: Check feature access and ENFORCE
|
# Check export permission & get profile
|
||||||
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):
|
||||||
|
|
@ -335,10 +297,6 @@ 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,16 +2,11 @@
|
||||||
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 typing import Optional
|
from fastapi import APIRouter, HTTPException, Depends
|
||||||
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, require_auth, check_feature_access
|
from auth import require_admin
|
||||||
from routers.profiles import get_pid
|
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/features", tags=["features"])
|
router = APIRouter(prefix="/api/features", tags=["features"])
|
||||||
|
|
||||||
|
|
@ -124,100 +119,3 @@ 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,7 +8,6 @@ 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
|
||||||
|
|
@ -17,12 +16,10 @@ 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, check_feature_access, increment_feature_usage
|
from auth import require_auth
|
||||||
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"))
|
||||||
|
|
||||||
|
|
@ -44,21 +41,6 @@ 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)
|
||||||
|
|
@ -272,9 +254,6 @@ 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,7 +6,6 @@ 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
|
||||||
|
|
@ -14,12 +13,10 @@ 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, check_feature_access, increment_feature_usage
|
from auth import require_auth, require_admin
|
||||||
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")
|
||||||
|
|
@ -254,21 +251,7 @@ 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:
|
||||||
|
|
@ -311,18 +294,14 @@ 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 (with history - no DELETE)
|
# Save insight
|
||||||
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}
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -330,35 +309,7 @@ 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)
|
||||||
|
|
@ -480,20 +431,15 @@ 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 'pipeline' scope (with history - no DELETE)
|
# Save as 'gesamt' scope
|
||||||
with get_db() as conn:
|
with get_db() as conn:
|
||||||
cur = get_cursor(conn)
|
cur = get_cursor(conn)
|
||||||
cur.execute("INSERT INTO ai_insights (id, profile_id, scope, content, created) VALUES (%s,%s,'pipeline',%s,CURRENT_TIMESTAMP)",
|
cur.execute("DELETE FROM ai_insights WHERE profile_id=%s AND scope='gesamt'", (pid,))
|
||||||
|
cur.execute("INSERT INTO ai_insights (id, profile_id, scope, content, created) VALUES (%s,%s,'gesamt',%s,CURRENT_TIMESTAMP)",
|
||||||
(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,19 +6,16 @@ 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, check_feature_access, increment_feature_usage
|
from auth import require_auth
|
||||||
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 ────────────────────────────────────────────────────────────────────
|
||||||
|
|
@ -33,23 +30,6 @@ 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')
|
||||||
|
|
@ -72,88 +52,23 @@ 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))
|
||||||
is_new = not cur.fetchone()
|
if 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."""
|
||||||
|
|
@ -165,17 +80,6 @@ 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."""
|
||||||
|
|
@ -219,9 +123,7 @@ 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:
|
||||||
# Handle both datetime.date objects (from DB) and strings
|
wk=datetime.strptime(d['date'],'%Y-%m-%d').strftime('%Y-W%V')
|
||||||
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):
|
||||||
|
|
@ -229,61 +131,3 @@ 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,7 +5,6 @@ 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
|
||||||
|
|
||||||
|
|
@ -14,12 +13,10 @@ 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, check_feature_access, increment_feature_usage
|
from auth import require_auth, require_auth_flexible
|
||||||
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)
|
||||||
|
|
@ -30,22 +27,6 @@ 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}"
|
||||||
|
|
@ -54,10 +35,6 @@ 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 (including inactive - admin needs to configure all)
|
# Get all tiers
|
||||||
cur.execute("SELECT id, name, sort_order FROM tiers ORDER BY sort_order")
|
cur.execute("SELECT id, name, sort_order FROM tiers WHERE active = true 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, reset_period
|
SELECT id, name, category, limit_type, default_limit
|
||||||
FROM features
|
FROM features
|
||||||
WHERE active = true
|
WHERE active = true
|
||||||
ORDER BY category, name
|
ORDER BY category, name
|
||||||
|
|
|
||||||
|
|
@ -4,19 +4,16 @@ 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, HTTPException
|
from fastapi import APIRouter, Header, Depends
|
||||||
|
|
||||||
from db import get_db, get_cursor, r2d
|
from db import get_db, get_cursor, r2d
|
||||||
from auth import require_auth, check_feature_access, increment_feature_usage
|
from auth import require_auth
|
||||||
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("")
|
||||||
|
|
@ -34,44 +31,17 @@ 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}
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load Diff
6766
frontend/package-lock.json
generated
6766
frontend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
|
|
@ -20,12 +20,6 @@ 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() {
|
||||||
|
|
@ -121,12 +115,6 @@ 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/>
|
||||||
|
|
|
||||||
|
|
@ -1,163 +0,0 @@
|
||||||
/**
|
|
||||||
* 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%;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,142 +0,0 @@
|
||||||
/**
|
|
||||||
* 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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
@ -1,103 +0,0 @@
|
||||||
/**
|
|
||||||
* 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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,31 +0,0 @@
|
||||||
/**
|
|
||||||
* 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,7 +2,6 @@ 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')
|
||||||
|
|
@ -80,7 +79,7 @@ function ImportPanel({ onImported }) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Manual Entry ──────────────────────────────────────────────────────────────
|
// ── Manual Entry ──────────────────────────────────────────────────────────────
|
||||||
function EntryForm({ form, setForm, onSave, onCancel, saveLabel='Speichern', saving=false, error=null, usage=null }) {
|
function EntryForm({ form, setForm, onSave, onCancel, saveLabel='Speichern' }) {
|
||||||
const set = (k,v) => setForm(f=>({...f,[k]:v}))
|
const set = (k,v) => setForm(f=>({...f,[k]:v}))
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
|
|
@ -131,25 +130,8 @@ function EntryForm({ form, setForm, onSave, onCancel, saveLabel='Speichern', sav
|
||||||
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}}>
|
||||||
<div
|
<button className="btn btn-primary" style={{flex:1}} onClick={onSave}>{saveLabel}</button>
|
||||||
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>
|
||||||
|
|
@ -163,51 +145,25 @@ 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)
|
const payload = {...form}
|
||||||
setError(null)
|
if(payload.duration_min) payload.duration_min = parseFloat(payload.duration_min)
|
||||||
try {
|
if(payload.kcal_active) payload.kcal_active = parseFloat(payload.kcal_active)
|
||||||
const payload = {...form}
|
if(payload.hr_avg) payload.hr_avg = parseFloat(payload.hr_avg)
|
||||||
if(payload.duration_min) payload.duration_min = parseFloat(payload.duration_min)
|
if(payload.hr_max) payload.hr_max = parseFloat(payload.hr_max)
|
||||||
if(payload.kcal_active) payload.kcal_active = parseFloat(payload.kcal_active)
|
if(payload.rpe) payload.rpe = parseInt(payload.rpe)
|
||||||
if(payload.hr_avg) payload.hr_avg = parseFloat(payload.hr_avg)
|
payload.source = 'manual'
|
||||||
if(payload.hr_max) payload.hr_max = parseFloat(payload.hr_max)
|
await api.createActivity(payload)
|
||||||
if(payload.rpe) payload.rpe = parseInt(payload.rpe)
|
setSaved(true); await load()
|
||||||
payload.source = 'manual'
|
setTimeout(()=>{ setSaved(false); setForm(empty()) }, 1500)
|
||||||
await api.createActivity(payload)
|
|
||||||
setSaved(true)
|
|
||||||
await load()
|
|
||||||
await loadUsage() // Reload usage after save
|
|
||||||
setTimeout(()=>{ setSaved(false); setForm(empty()) }, 1500)
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Save failed:', err)
|
|
||||||
setError(err.message || 'Fehler beim Speichern')
|
|
||||||
setTimeout(()=>setError(null), 5000)
|
|
||||||
} finally {
|
|
||||||
setSaving(false)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleUpdate = async () => {
|
const handleUpdate = async () => {
|
||||||
|
|
@ -269,13 +225,9 @@ export default function ActivityPage() {
|
||||||
|
|
||||||
{tab==='add' && (
|
{tab==='add' && (
|
||||||
<div className="card section-gap">
|
<div className="card section-gap">
|
||||||
<div className="card-title badge-container-right">
|
<div className="card-title">Training eintragen</div>
|
||||||
<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>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,523 +0,0 @@
|
||||||
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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
@ -1,480 +0,0 @@
|
||||||
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,6 +1,5 @@
|
||||||
import { useState, useEffect } from 'react'
|
import { useState, useEffect } from 'react'
|
||||||
import { Plus, Trash2, Pencil, Check, X, Shield, Key, Settings } from 'lucide-react'
|
import { Plus, Trash2, Pencil, Check, X, Shield, Key } 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'
|
||||||
|
|
||||||
|
|
@ -143,7 +142,10 @@ 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({
|
||||||
role: profile.role || 'user',
|
ai_enabled: profile.ai_enabled ?? 1,
|
||||||
|
ai_limit_day: profile.ai_limit_day || '',
|
||||||
|
export_enabled: profile.export_enabled ?? 1,
|
||||||
|
role: profile.role || 'user',
|
||||||
})
|
})
|
||||||
const [saving, setSaving] = useState(false)
|
const [saving, setSaving] = useState(false)
|
||||||
const [newPin, setNewPin] = useState('')
|
const [newPin, setNewPin] = useState('')
|
||||||
|
|
@ -154,7 +156,10 @@ function ProfileCard({ profile, currentId, onRefresh }) {
|
||||||
setSaving(true)
|
setSaving(true)
|
||||||
try {
|
try {
|
||||||
await api.adminSetPermissions(profile.id, {
|
await api.adminSetPermissions(profile.id, {
|
||||||
role: perms.role,
|
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,
|
||||||
})
|
})
|
||||||
await onRefresh()
|
await onRefresh()
|
||||||
} finally { setSaving(false) }
|
} finally { setSaving(false) }
|
||||||
|
|
@ -190,8 +195,9 @@ 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)'}}>
|
||||||
Tier: {profile.tier || 'free'} ·
|
KI: {profile.ai_enabled?`✓${profile.ai_limit_day?` (max ${profile.ai_limit_day}/Tag)`:''}` : '✗'} ·
|
||||||
Email: {profile.email || 'nicht gesetzt'}
|
Export: {profile.export_enabled?'✓':'✗'} ·
|
||||||
|
Calls heute: {profile.ai_calls_today||0}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div style={{display:'flex',gap:6}}>
|
<div style={{display:'flex',gap:6}}>
|
||||||
|
|
@ -226,19 +232,23 @@ function ProfileCard({ profile, currentId, onRefresh }) {
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<button className="btn btn-primary btn-full" style={{marginTop:8}} onClick={savePerms} disabled={saving}>
|
|
||||||
{saving?'Speichern…':'Rolle speichern'}
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Feature-Overrides */}
|
<Toggle value={!!perms.ai_enabled} onChange={v=>setPerms(p=>({...p,ai_enabled:v?1:0}))} label="KI-Analysen erlaubt"/>
|
||||||
<div style={{marginBottom:12,padding:10,background:'var(--accent-light)',borderRadius:6,fontSize:12}}>
|
{!!perms.ai_enabled && (
|
||||||
<strong>Feature-Limits:</strong> Nutze die neue{' '}
|
<div className="form-row" style={{paddingTop:6}}>
|
||||||
<Link to="/admin/user-restrictions" style={{color:'var(--accent-dark)',fontWeight:600}}>
|
<label className="form-label" style={{fontSize:12}}>Max. KI-Calls/Tag</label>
|
||||||
User Feature-Overrides
|
<input type="number" className="form-input" style={{width:70}} min={1} max={100}
|
||||||
</Link>{' '}
|
placeholder="∞" value={perms.ai_limit_day}
|
||||||
Seite um individuelle Limits zu setzen.
|
onChange={e=>setPerms(p=>({...p,ai_limit_day:e.target.value}))}/>
|
||||||
</div>
|
<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>
|
||||||
|
|
||||||
{/* Email */}
|
{/* Email */}
|
||||||
<div style={{marginTop:12,paddingTop:12,borderTop:'1px solid var(--border)'}}>
|
<div style={{marginTop:12,paddingTop:12,borderTop:'1px solid var(--border)'}}>
|
||||||
|
|
@ -387,43 +397,6 @@ 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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,499 +0,0 @@
|
||||||
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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
@ -1,392 +0,0 @@
|
||||||
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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
@ -1,538 +0,0 @@
|
||||||
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,7 +3,6 @@ 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')
|
||||||
|
|
@ -115,7 +114,6 @@ 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([
|
||||||
|
|
@ -125,15 +123,7 @@ 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)
|
||||||
|
|
@ -187,7 +177,7 @@ export default function Analysis() {
|
||||||
grouped[key].push(ins)
|
grouped[key].push(ins)
|
||||||
})
|
})
|
||||||
|
|
||||||
const activePrompts = prompts.filter(p=>p.active && !p.slug.startsWith('pipeline_') && p.slug !== 'pipeline')
|
const activePrompts = prompts.filter(p=>p.active && !p.slug.startsWith('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')
|
||||||
|
|
@ -240,10 +230,7 @@ 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 className="badge-container-right" style={{fontWeight:700,fontSize:15,color:'var(--accent)'}}>
|
<div style={{fontWeight:700,fontSize:15,color:'var(--accent)'}}>🔬 Mehrstufige Gesamtanalyse</div>
|
||||||
<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.
|
||||||
|
|
@ -254,22 +241,12 @@ export default function Analysis() {
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div
|
<button className="btn btn-primary" style={{flexShrink:0,minWidth:100}}
|
||||||
title={aiUsage && !aiUsage.allowed ? `Limit erreicht (${aiUsage.used}/${aiUsage.limit}). Kontaktiere den Admin oder warte bis zum nächsten Reset.` : ''}
|
onClick={runPipeline} disabled={!!loading||pipelineLoading}>
|
||||||
style={{display:'inline-block'}}
|
{pipelineLoading
|
||||||
>
|
? <><div className="spinner" style={{width:13,height:13}}/> Läuft…</>
|
||||||
<button
|
: <><Brain size={13}/> Starten</>}
|
||||||
className="btn btn-primary"
|
</button>
|
||||||
style={{flexShrink:0,minWidth:100, cursor: (aiUsage && !aiUsage.allowed) ? 'not-allowed' : 'pointer'}}
|
|
||||||
onClick={runPipeline}
|
|
||||||
disabled={!!loading||pipelineLoading||(aiUsage && !aiUsage.allowed)}
|
|
||||||
>
|
|
||||||
{pipelineLoading
|
|
||||||
? <><div className="spinner" style={{width:13,height:13}}/> Läuft…</>
|
|
||||||
: (aiUsage && !aiUsage.allowed) ? '🔒 Limit'
|
|
||||||
: <><Brain size={13}/> Starten</>}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
{!canUseAI && <div style={{fontSize:11,color:'#D85A30',marginTop:4}}>🔒 KI nicht freigeschaltet</div>}
|
{!canUseAI && <div style={{fontSize:11,color:'#D85A30',marginTop:4}}>🔒 KI nicht freigeschaltet</div>}
|
||||||
</div>
|
</div>
|
||||||
{pipelineLoading && (
|
{pipelineLoading && (
|
||||||
|
|
@ -305,10 +282,7 @@ 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 className="badge-container-right" style={{fontWeight:600,fontSize:15}}>
|
<div style={{fontWeight:600,fontSize:15}}>{SLUG_LABELS[p.slug]||p.name}</div>
|
||||||
<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}}>
|
||||||
|
|
@ -316,22 +290,12 @@ export default function Analysis() {
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div
|
<button className="btn btn-primary" style={{flexShrink:0,minWidth:90}}
|
||||||
title={aiUsage && !aiUsage.allowed ? `Limit erreicht (${aiUsage.used}/${aiUsage.limit}). Kontaktiere den Admin oder warte bis zum nächsten Reset.` : ''}
|
onClick={()=>runPrompt(p.slug)} disabled={!!loading||!canUseAI}>
|
||||||
style={{display:'inline-block'}}
|
{loading===p.slug
|
||||||
>
|
? <><div className="spinner" style={{width:13,height:13}}/> Läuft…</>
|
||||||
<button
|
: <><Brain size={13}/> {existing?'Neu erstellen':'Starten'}</>}
|
||||||
className="btn btn-primary"
|
</button>
|
||||||
style={{flexShrink:0,minWidth:90, cursor: (aiUsage && !aiUsage.allowed) ? 'not-allowed' : 'pointer'}}
|
|
||||||
onClick={()=>runPrompt(p.slug)}
|
|
||||||
disabled={!!loading||!canUseAI||(aiUsage && !aiUsage.allowed)}
|
|
||||||
>
|
|
||||||
{loading===p.slug
|
|
||||||
? <><div className="spinner" style={{width:13,height:13}}/> Läuft…</>
|
|
||||||
: (aiUsage && !aiUsage.allowed) ? '🔒 Limit'
|
|
||||||
: <><Brain size={13}/> {existing?'Neu erstellen':'Starten'}</>}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
{/* Show existing result collapsed */}
|
{/* Show existing result collapsed */}
|
||||||
{existing && newResult?.id !== existing.id && (
|
{existing && newResult?.id !== existing.id && (
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,6 @@ 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() {
|
||||||
|
|
@ -16,7 +15,7 @@ function emptyForm() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function CaliperForm({ form, setForm, profile, onSave, onCancel, saveLabel='Speichern', saving=false, error=null, usage=null }) {
|
function CaliperForm({ form, setForm, profile, onSave, onCancel, saveLabel='Speichern' }) {
|
||||||
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
|
||||||
|
|
@ -66,25 +65,8 @@ 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}}>
|
||||||
<div
|
<button className="btn btn-primary" style={{flex:1}} onClick={()=>onSave(bfPct, sex)}>{saveLabel}</button>
|
||||||
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>
|
||||||
|
|
@ -96,26 +78,12 @@ 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
|
||||||
|
|
@ -129,23 +97,11 @@ export default function CaliperScreen() {
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleSave = async (bfPct, sex) => {
|
const handleSave = async (bfPct, sex) => {
|
||||||
setSaving(true)
|
const payload = buildPayload(form, bfPct, sex)
|
||||||
setError(null)
|
await api.upsertCaliper(payload)
|
||||||
try {
|
setSaved(true); await load()
|
||||||
const payload = buildPayload(form, bfPct, sex)
|
setTimeout(()=>setSaved(false),2000)
|
||||||
await api.upsertCaliper(payload)
|
setForm(emptyForm())
|
||||||
setSaved(true)
|
|
||||||
await load()
|
|
||||||
await loadUsage() // Reload usage after save
|
|
||||||
setTimeout(()=>setSaved(false),2000)
|
|
||||||
setForm(emptyForm())
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Save failed:', err)
|
|
||||||
setError(err.message || 'Fehler beim Speichern')
|
|
||||||
setTimeout(()=>setError(null), 5000)
|
|
||||||
} finally {
|
|
||||||
setSaving(false)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleUpdate = async (bfPct, sex) => {
|
const handleUpdate = async (bfPct, sex) => {
|
||||||
|
|
@ -169,13 +125,9 @@ export default function CaliperScreen() {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="card section-gap">
|
<div className="card section-gap">
|
||||||
<div className="card-title badge-container-right">
|
<div className="card-title">Neue Messung</div>
|
||||||
<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,7 +3,6 @@ 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']
|
||||||
|
|
@ -17,32 +16,18 @@ 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
|
||||||
|
|
@ -53,18 +38,10 @@ export default function CircumScreen() {
|
||||||
payload.photo_id = pr.id
|
payload.photo_id = pr.id
|
||||||
}
|
}
|
||||||
await api.upsertCirc(payload)
|
await api.upsertCirc(payload)
|
||||||
setSaved(true)
|
setSaved(true); await load()
|
||||||
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)
|
||||||
} catch (err) {
|
} finally { setSaving(false) }
|
||||||
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})
|
||||||
|
|
@ -95,10 +72,7 @@ export default function CircumScreen() {
|
||||||
|
|
||||||
{/* Eingabe */}
|
{/* Eingabe */}
|
||||||
<div className="card section-gap">
|
<div className="card section-gap">
|
||||||
<div className="card-title badge-container-right">
|
<div className="card-title">Neue Messung</div>
|
||||||
<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)}/>
|
||||||
|
|
@ -125,27 +99,9 @@ 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>
|
||||||
{error && (
|
<button className="btn btn-primary btn-full" style={{marginTop:8}} onClick={handleSave} disabled={saving}>
|
||||||
<div style={{padding:'10px',background:'var(--danger-bg)',border:'1px solid var(--danger)',borderRadius:8,fontSize:13,color:'var(--danger)',marginTop:8}}>
|
{saved ? <><Check size={14}/> Gespeichert!</> : saving ? '…' : 'Speichern'}
|
||||||
{error}
|
</button>
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div
|
|
||||||
title={circumUsage && !circumUsage.allowed ? `Limit erreicht (${circumUsage.used}/${circumUsage.limit}). Kontaktiere den Admin oder warte bis zum nächsten Reset.` : ''}
|
|
||||||
style={{display:'inline-block',width:'100%',marginTop:8}}
|
|
||||||
>
|
|
||||||
<button
|
|
||||||
className="btn btn-primary btn-full"
|
|
||||||
style={{cursor: (circumUsage && !circumUsage.allowed) ? 'not-allowed' : 'pointer'}}
|
|
||||||
onClick={handleSave}
|
|
||||||
disabled={saving || (circumUsage && !circumUsage.allowed)}
|
|
||||||
>
|
|
||||||
{saved ? <><Check size={14}/> Gespeichert!</>
|
|
||||||
: saving ? '…'
|
|
||||||
: (circumUsage && !circumUsage.allowed) ? '🔒 Limit erreicht'
|
|
||||||
: 'Speichern'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Liste */}
|
{/* Liste */}
|
||||||
|
|
|
||||||
|
|
@ -27,75 +27,32 @@ 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)
|
||||||
setError(null)
|
try{ await api.upsertWeight(today,w); setSaved(true); onSaved?.(); setTimeout(()=>setSaved(false),2000) }
|
||||||
try{
|
finally{ setSaving(false) }
|
||||||
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>
|
<div style={{display:'flex',gap:8,alignItems:'center'}}>
|
||||||
{error && (
|
<input type="number" min={20} max={300} step={0.1} className="form-input"
|
||||||
<div style={{padding:'8px 10px',background:'var(--danger-bg)',border:'1px solid var(--danger)',borderRadius:8,fontSize:12,color:'var(--danger)',marginBottom:8}}>
|
style={{flex:1,fontSize:17,fontWeight:600,textAlign:'center'}}
|
||||||
{error}
|
placeholder="kg eingeben" value={input} onChange={e=>setInput(e.target.value)}
|
||||||
</div>
|
onKeyDown={e=>e.key==='Enter'&&handleSave()}/>
|
||||||
)}
|
<span style={{fontSize:13,color:'var(--text3)'}}>kg</span>
|
||||||
<div style={{display:'flex',gap:8,alignItems:'center'}}>
|
<button className="btn btn-primary" style={{padding:'8px 14px'}}
|
||||||
<input type="number" min={20} max={300} step={0.1} className="form-input"
|
onClick={handleSave} disabled={saving||!input}>
|
||||||
style={{flex:1,fontSize:17,fontWeight:600,textAlign:'center'}}
|
{saved?<Check size={15}/>:saving?<div className="spinner" style={{width:14,height:14}}/>:'Speichern'}
|
||||||
placeholder="kg eingeben" value={input} onChange={e=>setInput(e.target.value)}
|
</button>
|
||||||
onKeyDown={e=>e.key==='Enter'&&!isDisabled&&handleSave()}/>
|
|
||||||
<span style={{fontSize:13,color:'var(--text3)'}}>kg</span>
|
|
||||||
<div title={tooltipText} style={{display:'inline-block'}}>
|
|
||||||
<button
|
|
||||||
className="btn btn-primary"
|
|
||||||
style={{padding:'8px 14px', cursor: isDisabled ? 'not-allowed' : 'pointer'}}
|
|
||||||
onClick={handleSave}
|
|
||||||
disabled={isDisabled}
|
|
||||||
>
|
|
||||||
{saved ? <Check size={15}/>
|
|
||||||
: saving ? <div className="spinner" style={{width:14,height:14}}/>
|
|
||||||
: (weightUsage && !weightUsage.allowed) ? '🔒 Limit'
|
|
||||||
: 'Speichern'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -18,419 +18,6 @@ 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()
|
||||||
|
|
@ -735,11 +322,9 @@ function CalorieBalance({ data, profile }) {
|
||||||
|
|
||||||
// ── Main Page ─────────────────────────────────────────────────────────────────
|
// ── Main Page ─────────────────────────────────────────────────────────────────
|
||||||
export default function NutritionPage() {
|
export default function NutritionPage() {
|
||||||
const [inputTab, setInputTab] = useState('entry') // 'entry' or 'import'
|
const [tab, setTab] = useState('overview')
|
||||||
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)
|
||||||
|
|
@ -747,15 +332,13 @@ export default function NutritionPage() {
|
||||||
const load = async () => {
|
const load = async () => {
|
||||||
setLoad(true)
|
setLoad(true)
|
||||||
try {
|
try {
|
||||||
const [corr, wkly, ent, prof] = await Promise.all([
|
const [corr, wkly, prof] = await Promise.all([
|
||||||
nutritionApi.nutritionCorrelations(),
|
nutritionApi.nutritionCorrelations(),
|
||||||
nutritionApi.nutritionWeekly(16),
|
nutritionApi.nutritionWeekly(16),
|
||||||
nutritionApi.listNutrition(365), // BUG-002 fix: load raw entries
|
api.getActiveProfile(),
|
||||||
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) }
|
||||||
|
|
@ -768,52 +351,29 @@ export default function NutritionPage() {
|
||||||
<div>
|
<div>
|
||||||
<h1 className="page-title">Ernährung</h1>
|
<h1 className="page-title">Ernährung</h1>
|
||||||
|
|
||||||
{/* Input Method Tabs */}
|
<ImportPanel onImported={load}/>
|
||||||
<div className="tabs section-gap" style={{marginBottom:0}}>
|
|
||||||
<button className={'tab'+(inputTab==='entry'?' active':'')} onClick={()=>setInputTab('entry')}>
|
|
||||||
✏️ Einzelerfassung
|
|
||||||
</button>
|
|
||||||
<button className={'tab'+(inputTab==='import'?' active':'')} onClick={()=>setInputTab('import')}>
|
|
||||||
📥 Import
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Entry Form */}
|
|
||||||
{inputTab==='entry' && <EntryForm onSaved={load}/>}
|
|
||||||
|
|
||||||
{/* Import Panel + History */}
|
|
||||||
{inputTab==='import' && (
|
|
||||||
<>
|
|
||||||
<ImportPanel onImported={load}/>
|
|
||||||
<ImportHistory/>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{loading && <div className="empty-state"><div className="spinner"/></div>}
|
{loading && <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>Erfasse Daten über Einzelerfassung oder importiere deinen FDDB-Export.</p>
|
<p>Importiere deinen FDDB-Export oben um Auswertungen zu sehen.</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'+(analysisTab==='data'?' active':'')} onClick={()=>setAnalysisTab('data')}>Daten</button>
|
<button className={'tab'+(tab==='overview'?' active':'')} onClick={()=>setTab('overview')}>Übersicht</button>
|
||||||
<button className={'tab'+(analysisTab==='overview'?' active':'')} onClick={()=>setAnalysisTab('overview')}>Übersicht</button>
|
<button className={'tab'+(tab==='weight'?' active':'')} onClick={()=>setTab('weight')}>Kcal vs. Gewicht</button>
|
||||||
<button className={'tab'+(analysisTab==='weight'?' active':'')} onClick={()=>setAnalysisTab('weight')}>Kcal vs. Gewicht</button>
|
<button className={'tab'+(tab==='protein'?' active':'')} onClick={()=>setTab('protein')}>Protein vs. Mager</button>
|
||||||
<button className={'tab'+(analysisTab==='protein'?' active':'')} onClick={()=>setAnalysisTab('protein')}>Protein vs. Mager</button>
|
<button className={'tab'+(tab==='balance'?' active':'')} onClick={()=>setTab('balance')}>Bilanz</button>
|
||||||
<button className={'tab'+(analysisTab==='balance'?' active':'')} onClick={()=>setAnalysisTab('balance')}>Bilanz</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{analysisTab==='data' && <DataTab entries={entries} onUpdate={load}/>}
|
{tab==='overview' && (
|
||||||
|
|
||||||
{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}/>
|
||||||
|
|
@ -825,7 +385,7 @@ export default function NutritionPage() {
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{analysisTab==='weight' && (
|
{tab==='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}}>
|
||||||
|
|
@ -841,7 +401,7 @@ export default function NutritionPage() {
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{analysisTab==='protein' && (
|
{tab==='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}}>
|
||||||
|
|
@ -857,7 +417,7 @@ export default function NutritionPage() {
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{analysisTab==='balance' && (
|
{tab==='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,12 +1,10 @@
|
||||||
import { useState, useEffect } from 'react'
|
import { useState } from 'react'
|
||||||
import { Save, Download, Upload, Trash2, Plus, Check, Pencil, X, LogOut, Shield, Key, BarChart3 } from 'lucide-react'
|
import { Save, Download, Upload, Trash2, Plus, Check, Pencil, X, LogOut, Shield, Key } 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']
|
||||||
|
|
||||||
|
|
@ -101,15 +99,6 @@ 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
|
||||||
|
|
@ -337,17 +326,6 @@ 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">
|
||||||
|
|
@ -381,23 +359,13 @@ 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()}>
|
||||||
<div className="badge-button-layout">
|
<Download size={14}/> ZIP exportieren
|
||||||
<div className="badge-button-header">
|
<span style={{fontSize:11,opacity:0.8,marginLeft:6}}>— je eine CSV pro Kategorie</span>
|
||||||
<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()}>
|
||||||
<div className="badge-button-layout">
|
<Download size={14}/> JSON exportieren
|
||||||
<div className="badge-button-header">
|
<span style={{fontSize:11,opacity:0.8,marginLeft:6}}>— maschinenlesbar, alles in einer Datei</span>
|
||||||
<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>
|
||||||
|
|
|
||||||
|
|
@ -1,241 +0,0 @@
|
||||||
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,7 +2,6 @@ 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')
|
||||||
|
|
@ -22,42 +21,19 @@ 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)
|
setSaved(true); await load()
|
||||||
await load()
|
|
||||||
await loadUsage() // Reload usage after save
|
|
||||||
setTimeout(()=>setSaved(false), 2000)
|
setTimeout(()=>setSaved(false), 2000)
|
||||||
setNewWeight(''); setNewNote('')
|
setNewWeight(''); setNewNote('')
|
||||||
} catch (err) {
|
} finally { setSaving(false) }
|
||||||
console.error('Save failed:', err)
|
|
||||||
setError(err.message || 'Fehler beim Speichern')
|
|
||||||
setTimeout(()=>setError(null), 5000)
|
|
||||||
} finally {
|
|
||||||
setSaving(false)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleUpdate = async () => {
|
const handleUpdate = async () => {
|
||||||
|
|
@ -83,10 +59,7 @@ export default function WeightScreen() {
|
||||||
|
|
||||||
{/* Eingabe */}
|
{/* Eingabe */}
|
||||||
<div className="card section-gap">
|
<div className="card section-gap">
|
||||||
<div className="card-title badge-container-right">
|
<div className="card-title">Eintrag hinzufügen</div>
|
||||||
<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}}
|
||||||
|
|
@ -106,27 +79,11 @@ 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>
|
||||||
{error && (
|
<button className="btn btn-primary btn-full" onClick={handleSave} disabled={saving||!newWeight}>
|
||||||
<div style={{padding:'10px',background:'var(--danger-bg)',border:'1px solid var(--danger)',borderRadius:8,fontSize:13,color:'var(--danger)',marginBottom:12}}>
|
{saved ? <><Check size={15}/> Gespeichert!</>
|
||||||
{error}
|
: saving ? <><div className="spinner" style={{width:14,height:14}}/> …</>
|
||||||
</div>
|
: 'Speichern'}
|
||||||
)}
|
</button>
|
||||||
<div
|
|
||||||
title={weightUsage && !weightUsage.allowed ? `Limit erreicht (${weightUsage.used}/${weightUsage.limit}). Kontaktiere den Admin oder warte bis zum nächsten Reset.` : ''}
|
|
||||||
style={{display:'inline-block',width:'100%'}}
|
|
||||||
>
|
|
||||||
<button
|
|
||||||
className="btn btn-primary btn-full"
|
|
||||||
onClick={handleSave}
|
|
||||||
disabled={saving || !newWeight || (weightUsage && !weightUsage.allowed)}
|
|
||||||
style={{cursor: (weightUsage && !weightUsage.allowed) ? 'not-allowed' : 'pointer'}}
|
|
||||||
>
|
|
||||||
{saved ? <><Check size={15}/> Gespeichert!</>
|
|
||||||
: saving ? <><div className="spinner" style={{width:14,height:14}}/> …</>
|
|
||||||
: (weightUsage && !weightUsage.allowed) ? '🔒 Limit erreicht'
|
|
||||||
: 'Speichern'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Chart */}
|
{/* Chart */}
|
||||||
|
|
|
||||||
|
|
@ -82,11 +82,6 @@ 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'),
|
||||||
|
|
@ -142,48 +137,4 @@ 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