Membership-System und Bug Fixing (inkl. Nutrition) #8

Merged
Lars merged 56 commits from develop into main 2026-03-21 08:48:57 +01:00
43 changed files with 13219 additions and 1347 deletions

2
.gitignore vendored
View File

@ -61,4 +61,4 @@ tmp/
#.claude Konfiguration
.claude/
.claude/settings.local.json
.claude/settings.local.jsonfrontend/package-lock.json

1282
CLAUDE.md

File diff suppressed because it is too large Load Diff

View File

@ -60,6 +60,9 @@ def apply_migration():
if not migration_needed(conn):
print("[v9c Migration] Already applied, skipping.")
conn.close()
# Even if main migration is done, check cleanup
apply_cleanup_migration()
return
print("[v9c Migration] Applying subscription system migration...")
@ -83,6 +86,26 @@ def apply_migration():
print("[v9c Migration] ✅ Migration completed successfully!")
# Apply fix migration if exists
fix_migration_path = os.path.join(
os.path.dirname(__file__),
"migrations",
"v9c_fix_features.sql"
)
if os.path.exists(fix_migration_path):
print("[v9c Migration] Applying feature fixes...")
with open(fix_migration_path, 'r', encoding='utf-8') as f:
fix_sql = f.read()
conn = get_db_connection()
cur = conn.cursor()
cur.execute(fix_sql)
conn.commit()
cur.close()
conn.close()
print("[v9c Migration] ✅ Feature fixes applied!")
# Verify tables created
conn = get_db_connection()
cur = conn.cursor()
@ -108,10 +131,123 @@ def apply_migration():
cur.close()
conn.close()
# After successful migration, apply cleanup
apply_cleanup_migration()
except Exception as e:
print(f"[v9c Migration] ❌ Error: {e}")
raise
def cleanup_features_needed(conn):
"""Check if feature cleanup migration is needed."""
cur = conn.cursor()
# Check if old export features still exist
cur.execute("""
SELECT COUNT(*) as count FROM features
WHERE id IN ('export_csv', 'export_json', 'export_zip')
""")
old_exports = cur.fetchone()['count']
# Check if csv_import needs to be renamed
cur.execute("""
SELECT COUNT(*) as count FROM features
WHERE id = 'csv_import'
""")
old_import = cur.fetchone()['count']
cur.close()
# Cleanup needed if old features exist
return old_exports > 0 or old_import > 0
def apply_cleanup_migration():
"""Apply v9c feature cleanup migration."""
print("[v9c Cleanup] Checking if cleanup migration is needed...")
try:
conn = get_db_connection()
if not cleanup_features_needed(conn):
print("[v9c Cleanup] Already applied, skipping.")
conn.close()
return
print("[v9c Cleanup] Applying feature consolidation...")
# Show BEFORE state
cur = conn.cursor()
cur.execute("SELECT id, name FROM features ORDER BY category, id")
features_before = [f"{r['id']} ({r['name']})" for r in cur.fetchall()]
print(f"[v9c Cleanup] Features BEFORE: {len(features_before)} features")
for f in features_before:
print(f" - {f}")
cur.close()
# Read cleanup migration SQL
cleanup_path = os.path.join(
os.path.dirname(__file__),
"migrations",
"v9c_cleanup_features.sql"
)
if not os.path.exists(cleanup_path):
print(f"[v9c Cleanup] ⚠️ Cleanup migration file not found: {cleanup_path}")
conn.close()
return
with open(cleanup_path, 'r', encoding='utf-8') as f:
cleanup_sql = f.read()
# Execute cleanup migration
cur = conn.cursor()
cur.execute(cleanup_sql)
conn.commit()
cur.close()
# Show AFTER state
cur = conn.cursor()
cur.execute("SELECT id, name, category FROM features ORDER BY category, id")
features_after = cur.fetchall()
print(f"[v9c Cleanup] Features AFTER: {len(features_after)} features")
# Group by category
categories = {}
for f in features_after:
cat = f['category'] or 'other'
if cat not in categories:
categories[cat] = []
categories[cat].append(f"{f['id']} ({f['name']})")
for cat, feats in sorted(categories.items()):
print(f" {cat.upper()}:")
for f in feats:
print(f" - {f}")
# Verify tier_limits updated
cur.execute("""
SELECT tier_id, feature_id, limit_value
FROM tier_limits
WHERE feature_id IN ('data_export', 'data_import')
ORDER BY tier_id, feature_id
""")
limits = cur.fetchall()
print(f"[v9c Cleanup] Tier limits for data_export/data_import:")
for lim in limits:
limit_str = 'unlimited' if lim['limit_value'] is None else lim['limit_value']
print(f" {lim['tier_id']}.{lim['feature_id']} = {limit_str}")
cur.close()
conn.close()
print("[v9c Cleanup] ✅ Feature cleanup completed successfully!")
except Exception as e:
print(f"[v9c Cleanup] ❌ Error: {e}")
raise
if __name__ == "__main__":
apply_migration()

View File

@ -121,17 +121,22 @@ def require_admin(x_auth_token: Optional[str] = Header(default=None)):
# Feature Access Control (v9c)
# ============================================================================
def get_effective_tier(profile_id: str) -> str:
def get_effective_tier(profile_id: str, conn=None) -> str:
"""
Get the effective tier for a profile.
Checks for active access_grants first (from coupons, trials, etc.),
then falls back to profile.tier.
Args:
profile_id: User profile ID
conn: Optional existing DB connection (to avoid pool exhaustion)
Returns:
tier_id (str): 'free', 'basic', 'premium', or 'selfhosted'
"""
with get_db() as conn:
# Use existing connection if provided, otherwise open new one
if conn:
cur = get_cursor(conn)
# Check for active access grants (highest priority)
@ -154,9 +159,13 @@ def get_effective_tier(profile_id: str) -> str:
cur.execute("SELECT tier FROM profiles WHERE id = %s", (profile_id,))
profile = cur.fetchone()
return profile['tier'] if profile else 'free'
else:
# Open new connection if none provided
with get_db() as conn:
return get_effective_tier(profile_id, conn)
def check_feature_access(profile_id: str, feature_id: str) -> dict:
def check_feature_access(profile_id: str, feature_id: str, conn=None) -> dict:
"""
Check if a profile has access to a feature.
@ -165,6 +174,11 @@ def check_feature_access(profile_id: str, feature_id: str) -> dict:
2. Tier limit (tier_limits)
3. Feature default (features.default_limit)
Args:
profile_id: User profile ID
feature_id: Feature ID to check
conn: Optional existing DB connection (to avoid pool exhaustion)
Returns:
dict: {
'allowed': bool,
@ -174,118 +188,127 @@ def check_feature_access(profile_id: str, feature_id: str) -> dict:
'reason': str # 'unlimited', 'within_limit', 'limit_exceeded', 'feature_disabled'
}
"""
with get_db() as conn:
cur = get_cursor(conn)
# Use existing connection if provided
if conn:
return _check_impl(profile_id, feature_id, conn)
else:
with get_db() as conn:
return _check_impl(profile_id, feature_id, conn)
# Get feature info
cur.execute("""
SELECT limit_type, reset_period, default_limit
FROM features
WHERE id = %s AND active = true
""", (feature_id,))
feature = cur.fetchone()
if not feature:
return {
'allowed': False,
'limit': None,
'used': 0,
'remaining': None,
'reason': 'feature_not_found'
}
def _check_impl(profile_id: str, feature_id: str, conn) -> dict:
"""Internal implementation of check_feature_access."""
cur = get_cursor(conn)
# Priority 1: Check user-specific restriction
# Get feature info
cur.execute("""
SELECT limit_type, reset_period, default_limit
FROM features
WHERE id = %s AND active = true
""", (feature_id,))
feature = cur.fetchone()
if not feature:
return {
'allowed': False,
'limit': None,
'used': 0,
'remaining': None,
'reason': 'feature_not_found'
}
# Priority 1: Check user-specific restriction
cur.execute("""
SELECT limit_value
FROM user_feature_restrictions
WHERE profile_id = %s AND feature_id = %s
""", (profile_id, feature_id))
restriction = cur.fetchone()
if restriction is not None:
limit = restriction['limit_value']
else:
# Priority 2: Check tier limit
tier_id = get_effective_tier(profile_id, conn)
cur.execute("""
SELECT limit_value
FROM user_feature_restrictions
WHERE profile_id = %s AND feature_id = %s
""", (profile_id, feature_id))
restriction = cur.fetchone()
FROM tier_limits
WHERE tier_id = %s AND feature_id = %s
""", (tier_id, feature_id))
tier_limit = cur.fetchone()
if restriction is not None:
limit = restriction['limit_value']
if tier_limit is not None:
limit = tier_limit['limit_value']
else:
# Priority 2: Check tier limit
tier_id = get_effective_tier(profile_id)
cur.execute("""
SELECT limit_value
FROM tier_limits
WHERE tier_id = %s AND feature_id = %s
""", (tier_id, feature_id))
tier_limit = cur.fetchone()
if tier_limit is not None:
limit = tier_limit['limit_value']
else:
# Priority 3: Feature default
limit = feature['default_limit']
# For boolean features (limit 0 = disabled, 1 = enabled)
if feature['limit_type'] == 'boolean':
allowed = limit == 1
return {
'allowed': allowed,
'limit': limit,
'used': 0,
'remaining': None,
'reason': 'enabled' if allowed else 'feature_disabled'
}
# For count-based features
# Check current usage
cur.execute("""
SELECT usage_count, reset_at
FROM user_feature_usage
WHERE profile_id = %s AND feature_id = %s
""", (profile_id, feature_id))
usage = cur.fetchone()
used = usage['usage_count'] if usage else 0
# Check if reset is needed
if usage and usage['reset_at'] and datetime.now() > usage['reset_at']:
# Reset usage
used = 0
next_reset = _calculate_next_reset(feature['reset_period'])
cur.execute("""
UPDATE user_feature_usage
SET usage_count = 0, reset_at = %s, updated = CURRENT_TIMESTAMP
WHERE profile_id = %s AND feature_id = %s
""", (next_reset, profile_id, feature_id))
conn.commit()
# NULL limit = unlimited
if limit is None:
return {
'allowed': True,
'limit': None,
'used': used,
'remaining': None,
'reason': 'unlimited'
}
# 0 limit = disabled
if limit == 0:
return {
'allowed': False,
'limit': 0,
'used': used,
'remaining': 0,
'reason': 'feature_disabled'
}
# Check if within limit
allowed = used < limit
remaining = limit - used if limit else None
# Priority 3: Feature default
limit = feature['default_limit']
# For boolean features (limit 0 = disabled, 1 = enabled)
if feature['limit_type'] == 'boolean':
allowed = limit == 1
return {
'allowed': allowed,
'limit': limit,
'used': used,
'remaining': remaining,
'reason': 'within_limit' if allowed else 'limit_exceeded'
'used': 0,
'remaining': None,
'reason': 'enabled' if allowed else 'feature_disabled'
}
# For count-based features
# Check current usage
cur.execute("""
SELECT usage_count, reset_at
FROM user_feature_usage
WHERE profile_id = %s AND feature_id = %s
""", (profile_id, feature_id))
usage = cur.fetchone()
used = usage['usage_count'] if usage else 0
# Check if reset is needed
if usage and usage['reset_at'] and datetime.now() > usage['reset_at']:
# Reset usage
used = 0
next_reset = _calculate_next_reset(feature['reset_period'])
cur.execute("""
UPDATE user_feature_usage
SET usage_count = 0, reset_at = %s, updated = CURRENT_TIMESTAMP
WHERE profile_id = %s AND feature_id = %s
""", (next_reset, profile_id, feature_id))
conn.commit()
# NULL limit = unlimited
if limit is None:
return {
'allowed': True,
'limit': None,
'used': used,
'remaining': None,
'reason': 'unlimited'
}
# 0 limit = disabled
if limit == 0:
return {
'allowed': False,
'limit': 0,
'used': used,
'remaining': 0,
'reason': 'feature_disabled'
}
# Check if within limit
allowed = used < limit
remaining = limit - used if limit else None
return {
'allowed': allowed,
'limit': limit,
'used': used,
'remaining': remaining,
'reason': 'within_limit' if allowed else 'limit_exceeded'
}
def increment_feature_usage(profile_id: str, feature_id: str) -> None:
"""

36
backend/check_features.py Normal file
View File

@ -0,0 +1,36 @@
#!/usr/bin/env python3
"""Quick diagnostic script to check features table."""
from db import get_db, get_cursor
with get_db() as conn:
cur = get_cursor(conn)
print("\n=== FEATURES TABLE ===")
cur.execute("SELECT id, name, active, limit_type, reset_period FROM features ORDER BY id")
features = cur.fetchall()
if not features:
print("❌ NO FEATURES FOUND! Migration failed!")
else:
for r in features:
print(f" {r['id']:30} {r['name']:40} active={r['active']} type={r['limit_type']:8} reset={r['reset_period']}")
print(f"\nTotal features: {len(features)}")
print("\n=== USER_FEATURE_USAGE (recent) ===")
cur.execute("""
SELECT profile_id, feature_id, usage_count, reset_at
FROM user_feature_usage
ORDER BY updated DESC
LIMIT 10
""")
usages = cur.fetchall()
if not usages:
print(" (no usage records yet)")
else:
for r in usages:
print(f" {r['profile_id'][:8]}... -> {r['feature_id']:30} used={r['usage_count']} reset_at={r['reset_at']}")
print(f"\nTotal usage records: {len(usages)}")

76
backend/feature_logger.py Normal file
View File

@ -0,0 +1,76 @@
"""
Feature Usage Logger for Mitai Jinkendo
Logs all feature access checks to a separate JSON log file for analysis.
Phase 2: Non-blocking monitoring of feature usage.
"""
import logging
import json
from datetime import datetime
from pathlib import Path
# ── Setup Feature Usage Logger ───────────────────────────────────────────────
feature_usage_logger = logging.getLogger('feature_usage')
feature_usage_logger.setLevel(logging.INFO)
feature_usage_logger.propagate = False # Don't propagate to root logger
# Ensure logs directory exists
LOG_DIR = Path('/app/logs')
LOG_DIR.mkdir(parents=True, exist_ok=True)
# FileHandler for JSON logs
log_file = LOG_DIR / 'feature-usage.log'
file_handler = logging.FileHandler(log_file)
file_handler.setLevel(logging.INFO)
file_handler.setFormatter(logging.Formatter('%(message)s')) # JSON only
feature_usage_logger.addHandler(file_handler)
# Also log to console in dev (optional)
# console_handler = logging.StreamHandler()
# console_handler.setFormatter(logging.Formatter('[FEATURE-USAGE] %(message)s'))
# feature_usage_logger.addHandler(console_handler)
# ── Logging Function ──────────────────────────────────────────────────────────
def log_feature_usage(user_id: str, feature_id: str, access: dict, action: str):
"""
Log feature usage in structured JSON format.
Args:
user_id: Profile UUID
feature_id: Feature identifier (e.g., 'weight_entries', 'ai_calls')
access: Result from check_feature_access() containing:
- allowed: bool
- limit: int | None
- used: int
- remaining: int | None
- reason: str
action: Type of action (e.g., 'create', 'export', 'analyze')
Example log entry:
{
"timestamp": "2026-03-20T15:30:45.123456",
"user_id": "abc-123",
"feature": "weight_entries",
"action": "create",
"used": 5,
"limit": 100,
"remaining": 95,
"allowed": true,
"reason": "within_limit"
}
"""
entry = {
"timestamp": datetime.now().isoformat(),
"user_id": user_id,
"feature": feature_id,
"action": action,
"used": access.get('used', 0),
"limit": access.get('limit'), # None for unlimited
"remaining": access.get('remaining'), # None for unlimited
"allowed": access.get('allowed', True),
"reason": access.get('reason', 'unknown')
}
feature_usage_logger.info(json.dumps(entry))

View File

@ -0,0 +1,50 @@
-- ============================================================================
-- Feature Check Script - Diagnose vor/nach Migration
-- ============================================================================
-- Usage: psql -U mitai_dev -d mitai_dev -f check_features.sql
-- ============================================================================
\echo '=== CURRENT FEATURES ==='
SELECT id, name, category, limit_type, reset_period, default_limit, active
FROM features
ORDER BY category, id;
\echo ''
\echo '=== TIER LIMITS MATRIX ==='
SELECT
f.id as feature,
f.category,
MAX(CASE WHEN tl.tier_id = 'free' THEN COALESCE(tl.limit_value::text, '') END) as free,
MAX(CASE WHEN tl.tier_id = 'basic' THEN COALESCE(tl.limit_value::text, '') END) as basic,
MAX(CASE WHEN tl.tier_id = 'premium' THEN COALESCE(tl.limit_value::text, '') END) as premium,
MAX(CASE WHEN tl.tier_id = 'selfhosted' THEN COALESCE(tl.limit_value::text, '') END) as selfhosted
FROM features f
LEFT JOIN tier_limits tl ON f.id = tl.feature_id
GROUP BY f.id, f.category
ORDER BY f.category, f.id;
\echo ''
\echo '=== FEATURE COUNT BY CATEGORY ==='
SELECT category, COUNT(*) as count
FROM features
WHERE active = true
GROUP BY category
ORDER BY category;
\echo ''
\echo '=== ORPHANED TIER LIMITS (feature not exists) ==='
SELECT tl.tier_id, tl.feature_id, tl.limit_value
FROM tier_limits tl
LEFT JOIN features f ON tl.feature_id = f.id
WHERE f.id IS NULL;
\echo ''
\echo '=== USER FEATURE USAGE (current usage tracking) ==='
SELECT
p.name as user,
ufu.feature_id,
ufu.usage_count,
ufu.reset_at
FROM user_feature_usage ufu
JOIN profiles p ON ufu.profile_id = p.id
ORDER BY p.name, ufu.feature_id;

View File

@ -0,0 +1,141 @@
-- ============================================================================
-- v9c Cleanup: Feature-Konsolidierung
-- ============================================================================
-- Created: 2026-03-20
-- Purpose: Konsolidiere Export-Features (export_csv/json/zip → data_export)
-- und Import-Features (csv_import → data_import)
--
-- Idempotent: Kann mehrfach ausgeführt werden
--
-- Lessons Learned:
-- "Ein Feature für Export, nicht drei (csv/json/zip)"
-- ============================================================================
-- ============================================================================
-- 1. Rename csv_import to data_import
-- ============================================================================
UPDATE features
SET
id = 'data_import',
name = 'Daten importieren',
description = 'CSV-Import (FDDB, Apple Health) + ZIP-Backup-Import'
WHERE id = 'csv_import';
-- Update tier_limits references
UPDATE tier_limits
SET feature_id = 'data_import'
WHERE feature_id = 'csv_import';
-- Update user_feature_restrictions references
UPDATE user_feature_restrictions
SET feature_id = 'data_import'
WHERE feature_id = 'csv_import';
-- Update user_feature_usage references
UPDATE user_feature_usage
SET feature_id = 'data_import'
WHERE feature_id = 'csv_import';
-- ============================================================================
-- 2. Remove old export_csv/json/zip features
-- ============================================================================
-- Remove tier_limits for old features
DELETE FROM tier_limits
WHERE feature_id IN ('export_csv', 'export_json', 'export_zip');
-- Remove user restrictions for old features
DELETE FROM user_feature_restrictions
WHERE feature_id IN ('export_csv', 'export_json', 'export_zip');
-- Remove usage tracking for old features
DELETE FROM user_feature_usage
WHERE feature_id IN ('export_csv', 'export_json', 'export_zip');
-- Remove old feature definitions
DELETE FROM features
WHERE id IN ('export_csv', 'export_json', 'export_zip');
-- ============================================================================
-- 3. Ensure data_export exists and is properly configured
-- ============================================================================
INSERT INTO features (id, name, description, category, limit_type, reset_period, default_limit, active)
VALUES ('data_export', 'Daten exportieren', 'CSV/JSON/ZIP Export', 'export', 'count', 'monthly', 0, true)
ON CONFLICT (id) DO UPDATE SET
name = EXCLUDED.name,
description = EXCLUDED.description,
category = EXCLUDED.category,
limit_type = EXCLUDED.limit_type,
reset_period = EXCLUDED.reset_period;
-- ============================================================================
-- 4. Ensure data_import exists and is properly configured
-- ============================================================================
INSERT INTO features (id, name, description, category, limit_type, reset_period, default_limit, active)
VALUES ('data_import', 'Daten importieren', 'CSV-Import (FDDB, Apple Health) + ZIP-Backup-Import', 'import', 'count', 'monthly', 0, true)
ON CONFLICT (id) DO UPDATE SET
name = EXCLUDED.name,
description = EXCLUDED.description,
category = EXCLUDED.category,
limit_type = EXCLUDED.limit_type,
reset_period = EXCLUDED.reset_period;
-- ============================================================================
-- 5. Update tier_limits for data_export (consolidate from old features)
-- ============================================================================
-- FREE tier: no export
INSERT INTO tier_limits (tier_id, feature_id, limit_value)
VALUES ('free', 'data_export', 0)
ON CONFLICT (tier_id, feature_id) DO UPDATE SET limit_value = EXCLUDED.limit_value;
-- BASIC tier: 5 exports/month
INSERT INTO tier_limits (tier_id, feature_id, limit_value)
VALUES ('basic', 'data_export', 5)
ON CONFLICT (tier_id, feature_id) DO UPDATE SET limit_value = EXCLUDED.limit_value;
-- PREMIUM tier: unlimited
INSERT INTO tier_limits (tier_id, feature_id, limit_value)
VALUES ('premium', 'data_export', NULL)
ON CONFLICT (tier_id, feature_id) DO UPDATE SET limit_value = EXCLUDED.limit_value;
-- SELFHOSTED tier: unlimited
INSERT INTO tier_limits (tier_id, feature_id, limit_value)
VALUES ('selfhosted', 'data_export', NULL)
ON CONFLICT (tier_id, feature_id) DO UPDATE SET limit_value = EXCLUDED.limit_value;
-- ============================================================================
-- 6. Update tier_limits for data_import
-- ============================================================================
-- FREE tier: no import
INSERT INTO tier_limits (tier_id, feature_id, limit_value)
VALUES ('free', 'data_import', 0)
ON CONFLICT (tier_id, feature_id) DO UPDATE SET limit_value = EXCLUDED.limit_value;
-- BASIC tier: 3 imports/month
INSERT INTO tier_limits (tier_id, feature_id, limit_value)
VALUES ('basic', 'data_import', 3)
ON CONFLICT (tier_id, feature_id) DO UPDATE SET limit_value = EXCLUDED.limit_value;
-- PREMIUM tier: unlimited
INSERT INTO tier_limits (tier_id, feature_id, limit_value)
VALUES ('premium', 'data_import', NULL)
ON CONFLICT (tier_id, feature_id) DO UPDATE SET limit_value = EXCLUDED.limit_value;
-- SELFHOSTED tier: unlimited
INSERT INTO tier_limits (tier_id, feature_id, limit_value)
VALUES ('selfhosted', 'data_import', NULL)
ON CONFLICT (tier_id, feature_id) DO UPDATE SET limit_value = EXCLUDED.limit_value;
-- ============================================================================
-- Cleanup complete
-- ============================================================================
-- Final feature list:
-- Data: weight_entries, circumference_entries, caliper_entries,
-- nutrition_entries, activity_entries, photos
-- AI: ai_calls, ai_pipeline
-- Export/Import: data_export, data_import
--
-- Total: 10 features (down from 13)
-- ============================================================================

View File

@ -0,0 +1,33 @@
-- Fix missing features for v9c feature enforcement
-- 2026-03-20
-- Add missing features
INSERT INTO features (id, name, description, category, limit_type, reset_period, default_limit, active) VALUES
('data_export', 'Daten exportieren', 'CSV/JSON/ZIP Export', 'export', 'count', 'monthly', 0, true),
('csv_import', 'CSV importieren', 'FDDB/Apple Health CSV Import + ZIP Backup Import', 'import', 'count', 'monthly', 0, true)
ON CONFLICT (id) DO NOTHING;
-- Add tier limits for new features
-- FREE tier
INSERT INTO tier_limits (tier_id, feature_id, limit_value) VALUES
('free', 'data_export', 0), -- Kein Export
('free', 'csv_import', 0) -- Kein Import
ON CONFLICT (tier_id, feature_id) DO NOTHING;
-- BASIC tier
INSERT INTO tier_limits (tier_id, feature_id, limit_value) VALUES
('basic', 'data_export', 5), -- 5 Exporte/Monat
('basic', 'csv_import', 3) -- 3 Imports/Monat
ON CONFLICT (tier_id, feature_id) DO NOTHING;
-- PREMIUM tier
INSERT INTO tier_limits (tier_id, feature_id, limit_value) VALUES
('premium', 'data_export', NULL), -- Unbegrenzt
('premium', 'csv_import', NULL) -- Unbegrenzt
ON CONFLICT (tier_id, feature_id) DO NOTHING;
-- SELFHOSTED tier
INSERT INTO tier_limits (tier_id, feature_id, limit_value) VALUES
('selfhosted', 'data_export', NULL), -- Unbegrenzt
('selfhosted', 'csv_import', NULL) -- Unbegrenzt
ON CONFLICT (tier_id, feature_id) DO NOTHING;

View File

@ -6,16 +6,19 @@ Handles workout/activity logging, statistics, and Apple Health CSV import.
import csv
import io
import uuid
import logging
from typing import Optional
from fastapi import APIRouter, HTTPException, UploadFile, File, Header, Depends
from db import get_db, get_cursor, r2d
from auth import require_auth
from auth import require_auth, check_feature_access, increment_feature_usage
from models import ActivityEntry
from routers.profiles import get_pid
from feature_logger import log_feature_usage
router = APIRouter(prefix="/api/activity", tags=["activity"])
logger = logging.getLogger(__name__)
@router.get("")
@ -33,6 +36,22 @@ def list_activity(limit: int=200, x_profile_id: Optional[str]=Header(default=Non
def create_activity(e: ActivityEntry, x_profile_id: Optional[str]=Header(default=None), session: dict=Depends(require_auth)):
"""Create new activity entry."""
pid = get_pid(x_profile_id)
# Phase 4: Check feature access and ENFORCE
access = check_feature_access(pid, 'activity_entries')
log_feature_usage(pid, 'activity_entries', access, 'create')
if not access['allowed']:
logger.warning(
f"[FEATURE-LIMIT] User {pid} blocked: "
f"activity_entries {access['reason']} (used: {access['used']}, limit: {access['limit']})"
)
raise HTTPException(
status_code=403,
detail=f"Limit erreicht: Du hast das Kontingent für Aktivitätseinträge überschritten ({access['used']}/{access['limit']}). "
f"Bitte kontaktiere den Admin oder warte bis zum nächsten Reset."
)
eid = str(uuid.uuid4())
d = e.model_dump()
with get_db() as conn:
@ -44,6 +63,10 @@ def create_activity(e: ActivityEntry, x_profile_id: Optional[str]=Header(default
(eid,pid,d['date'],d['start_time'],d['end_time'],d['activity_type'],d['duration_min'],
d['kcal_active'],d['kcal_resting'],d['hr_avg'],d['hr_max'],d['distance_km'],
d['rpe'],d['source'],d['notes']))
# Phase 2: Increment usage counter (always for new entries)
increment_feature_usage(pid, 'activity_entries')
return {"id":eid,"date":e.date}

View File

@ -4,16 +4,19 @@ Caliper/Skinfold Tracking Endpoints for Mitai Jinkendo
Handles body fat measurements via skinfold caliper (4 methods supported).
"""
import uuid
import logging
from typing import Optional
from fastapi import APIRouter, Header, Depends
from fastapi import APIRouter, Header, Depends, HTTPException
from db import get_db, get_cursor, r2d
from auth import require_auth
from auth import require_auth, check_feature_access, increment_feature_usage
from models import CaliperEntry
from routers.profiles import get_pid
from feature_logger import log_feature_usage
router = APIRouter(prefix="/api/caliper", tags=["caliper"])
logger = logging.getLogger(__name__)
@router.get("")
@ -31,17 +34,37 @@ def list_caliper(limit: int=100, x_profile_id: Optional[str]=Header(default=None
def upsert_caliper(e: CaliperEntry, x_profile_id: Optional[str]=Header(default=None), session: dict=Depends(require_auth)):
"""Create or update caliper entry (upsert by date)."""
pid = get_pid(x_profile_id)
# Phase 4: Check feature access and ENFORCE
access = check_feature_access(pid, 'caliper_entries')
log_feature_usage(pid, 'caliper_entries', access, 'create')
if not access['allowed']:
logger.warning(
f"[FEATURE-LIMIT] User {pid} blocked: "
f"caliper_entries {access['reason']} (used: {access['used']}, limit: {access['limit']})"
)
raise HTTPException(
status_code=403,
detail=f"Limit erreicht: Du hast das Kontingent für Caliper-Einträge überschritten ({access['used']}/{access['limit']}). "
f"Bitte kontaktiere den Admin oder warte bis zum nächsten Reset."
)
with get_db() as conn:
cur = get_cursor(conn)
cur.execute("SELECT id FROM caliper_log WHERE profile_id=%s AND date=%s", (pid,e.date))
ex = cur.fetchone()
d = e.model_dump()
is_new_entry = not ex
if ex:
# UPDATE existing entry
eid = ex['id']
sets = ', '.join(f"{k}=%s" for k in d if k!='date')
cur.execute(f"UPDATE caliper_log SET {sets} WHERE id=%s",
[v for k,v in d.items() if k!='date']+[eid])
else:
# INSERT new entry
eid = str(uuid.uuid4())
cur.execute("""INSERT INTO caliper_log
(id,profile_id,date,sf_method,sf_chest,sf_axilla,sf_triceps,sf_subscap,sf_suprailiac,
@ -50,6 +73,10 @@ def upsert_caliper(e: CaliperEntry, x_profile_id: Optional[str]=Header(default=N
(eid,pid,d['date'],d['sf_method'],d['sf_chest'],d['sf_axilla'],d['sf_triceps'],
d['sf_subscap'],d['sf_suprailiac'],d['sf_abdomen'],d['sf_thigh'],d['sf_calf_med'],
d['sf_lowerback'],d['sf_biceps'],d['body_fat_pct'],d['lean_mass'],d['fat_mass'],d['notes']))
# Phase 2: Increment usage counter (only for new entries)
increment_feature_usage(pid, 'caliper_entries')
return {"id":eid,"date":e.date}

View File

@ -4,16 +4,19 @@ Circumference Tracking Endpoints for Mitai Jinkendo
Handles body circumference measurements (8 measurement points).
"""
import uuid
import logging
from typing import Optional
from fastapi import APIRouter, Header, Depends
from fastapi import APIRouter, Header, Depends, HTTPException
from db import get_db, get_cursor, r2d
from auth import require_auth
from auth import require_auth, check_feature_access, increment_feature_usage
from models import CircumferenceEntry
from routers.profiles import get_pid
from feature_logger import log_feature_usage
router = APIRouter(prefix="/api/circumferences", tags=["circumference"])
logger = logging.getLogger(__name__)
@router.get("")
@ -31,23 +34,47 @@ def list_circs(limit: int=100, x_profile_id: Optional[str]=Header(default=None),
def upsert_circ(e: CircumferenceEntry, x_profile_id: Optional[str]=Header(default=None), session: dict=Depends(require_auth)):
"""Create or update circumference entry (upsert by date)."""
pid = get_pid(x_profile_id)
# Phase 4: Check feature access and ENFORCE
access = check_feature_access(pid, 'circumference_entries')
log_feature_usage(pid, 'circumference_entries', access, 'create')
if not access['allowed']:
logger.warning(
f"[FEATURE-LIMIT] User {pid} blocked: "
f"circumference_entries {access['reason']} (used: {access['used']}, limit: {access['limit']})"
)
raise HTTPException(
status_code=403,
detail=f"Limit erreicht: Du hast das Kontingent für Umfangs-Einträge überschritten ({access['used']}/{access['limit']}). "
f"Bitte kontaktiere den Admin oder warte bis zum nächsten Reset."
)
with get_db() as conn:
cur = get_cursor(conn)
cur.execute("SELECT id FROM circumference_log WHERE profile_id=%s AND date=%s", (pid,e.date))
ex = cur.fetchone()
d = e.model_dump()
is_new_entry = not ex
if ex:
# UPDATE existing entry
eid = ex['id']
sets = ', '.join(f"{k}=%s" for k in d if k!='date')
cur.execute(f"UPDATE circumference_log SET {sets} WHERE id=%s",
[v for k,v in d.items() if k!='date']+[eid])
else:
# INSERT new entry
eid = str(uuid.uuid4())
cur.execute("""INSERT INTO circumference_log
(id,profile_id,date,c_neck,c_chest,c_waist,c_belly,c_hip,c_thigh,c_calf,c_arm,notes,photo_id,created)
VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,CURRENT_TIMESTAMP)""",
(eid,pid,d['date'],d['c_neck'],d['c_chest'],d['c_waist'],d['c_belly'],
d['c_hip'],d['c_thigh'],d['c_calf'],d['c_arm'],d['notes'],d['photo_id']))
# Phase 2: Increment usage counter (only for new entries)
increment_feature_usage(pid, 'circumference_entries')
return {"id":eid,"date":e.date}

View File

@ -7,6 +7,7 @@ import os
import csv
import io
import json
import logging
import zipfile
from pathlib import Path
from typing import Optional
@ -17,10 +18,12 @@ from fastapi import APIRouter, HTTPException, Header, Depends
from fastapi.responses import StreamingResponse, Response
from db import get_db, get_cursor, r2d
from auth import require_auth
from auth import require_auth, check_feature_access, increment_feature_usage
from routers.profiles import get_pid
from feature_logger import log_feature_usage
router = APIRouter(prefix="/api/export", tags=["export"])
logger = logging.getLogger(__name__)
PHOTOS_DIR = Path(os.getenv("PHOTOS_DIR", "./photos"))
@ -30,13 +33,20 @@ def export_csv(x_profile_id: Optional[str]=Header(default=None), session: dict=D
"""Export all data as CSV."""
pid = get_pid(x_profile_id)
# Check export permission
with get_db() as conn:
cur = get_cursor(conn)
cur.execute("SELECT export_enabled FROM profiles WHERE id=%s", (pid,))
prof = cur.fetchone()
if not prof or not prof['export_enabled']:
raise HTTPException(403, "Export ist für dieses Profil deaktiviert")
# Phase 4: Check feature access and ENFORCE
access = check_feature_access(pid, 'data_export')
log_feature_usage(pid, 'data_export', access, 'export_csv')
if not access['allowed']:
logger.warning(
f"[FEATURE-LIMIT] User {pid} blocked: "
f"data_export {access['reason']} (used: {access['used']}, limit: {access['limit']})"
)
raise HTTPException(
status_code=403,
detail=f"Limit erreicht: Du hast das Kontingent für Daten-Exporte überschritten ({access['used']}/{access['limit']}). "
f"Bitte kontaktiere den Admin oder warte bis zum nächsten Reset."
)
# Build CSV
output = io.StringIO()
@ -74,6 +84,10 @@ def export_csv(x_profile_id: Optional[str]=Header(default=None), session: dict=D
writer.writerow(["Training", r['date'], r['activity_type'], f"{float(r['duration_min'])}min {float(r['kcal_active'])}kcal"])
output.seek(0)
# Phase 2: Increment usage counter
increment_feature_usage(pid, 'data_export')
return StreamingResponse(
iter([output.getvalue()]),
media_type="text/csv",
@ -86,13 +100,20 @@ def export_json(x_profile_id: Optional[str]=Header(default=None), session: dict=
"""Export all data as JSON."""
pid = get_pid(x_profile_id)
# Check export permission
with get_db() as conn:
cur = get_cursor(conn)
cur.execute("SELECT export_enabled FROM profiles WHERE id=%s", (pid,))
prof = cur.fetchone()
if not prof or not prof['export_enabled']:
raise HTTPException(403, "Export ist für dieses Profil deaktiviert")
# Phase 4: Check feature access and ENFORCE
access = check_feature_access(pid, 'data_export')
log_feature_usage(pid, 'data_export', access, 'export_json')
if not access['allowed']:
logger.warning(
f"[FEATURE-LIMIT] User {pid} blocked: "
f"data_export {access['reason']} (used: {access['used']}, limit: {access['limit']})"
)
raise HTTPException(
status_code=403,
detail=f"Limit erreicht: Du hast das Kontingent für Daten-Exporte überschritten ({access['used']}/{access['limit']}). "
f"Bitte kontaktiere den Admin oder warte bis zum nächsten Reset."
)
# Collect all data
data = {}
@ -126,6 +147,10 @@ def export_json(x_profile_id: Optional[str]=Header(default=None), session: dict=
return str(obj)
json_str = json.dumps(data, indent=2, default=decimal_handler)
# Phase 2: Increment usage counter
increment_feature_usage(pid, 'data_export')
return Response(
content=json_str,
media_type="application/json",
@ -138,13 +163,26 @@ def export_zip(x_profile_id: Optional[str]=Header(default=None), session: dict=D
"""Export all data as ZIP (CSV + JSON + photos) per specification."""
pid = get_pid(x_profile_id)
# Check export permission & get profile
# Phase 4: Check feature access and ENFORCE
access = check_feature_access(pid, 'data_export')
log_feature_usage(pid, 'data_export', access, 'export_zip')
if not access['allowed']:
logger.warning(
f"[FEATURE-LIMIT] User {pid} blocked: "
f"data_export {access['reason']} (used: {access['used']}, limit: {access['limit']})"
)
raise HTTPException(
status_code=403,
detail=f"Limit erreicht: Du hast das Kontingent für Daten-Exporte überschritten ({access['used']}/{access['limit']}). "
f"Bitte kontaktiere den Admin oder warte bis zum nächsten Reset."
)
# Get profile
with get_db() as conn:
cur = get_cursor(conn)
cur.execute("SELECT * FROM profiles WHERE id=%s", (pid,))
prof = r2d(cur.fetchone())
if not prof or not prof.get('export_enabled'):
raise HTTPException(403, "Export ist für dieses Profil deaktiviert")
# Helper: CSV writer with UTF-8 BOM + semicolon
def write_csv(zf, filename, rows, columns):
@ -297,6 +335,10 @@ Datumsformat: YYYY-MM-DD
zip_buffer.seek(0)
filename = f"mitai-export-{profile_name.replace(' ','-')}-{export_date}.zip"
# Phase 2: Increment usage counter
increment_feature_usage(pid, 'data_export')
return StreamingResponse(
iter([zip_buffer.getvalue()]),
media_type="application/zip",

View File

@ -2,11 +2,16 @@
Feature Management Endpoints for Mitai Jinkendo
Admin-only CRUD for features registry.
User endpoint for feature usage overview (Phase 3).
"""
from fastapi import APIRouter, HTTPException, Depends
from typing import Optional
from datetime import datetime
from fastapi import APIRouter, HTTPException, Header, Depends
from db import get_db, get_cursor, r2d
from auth import require_admin
from auth import require_admin, require_auth, check_feature_access
from routers.profiles import get_pid
router = APIRouter(prefix="/api/features", tags=["features"])
@ -119,3 +124,100 @@ def delete_feature(feature_id: str, session: dict = Depends(require_admin)):
cur.execute("UPDATE features SET active = false WHERE id = %s", (feature_id,))
conn.commit()
return {"ok": True}
@router.get("/{feature_id}/check-access")
def check_access(feature_id: str, session: dict = Depends(require_auth)):
"""
User: Check if current user can access a feature.
Returns:
- allowed: bool - whether user can use the feature
- limit: int|null - total limit (null = unlimited)
- used: int - current usage
- remaining: int|null - remaining uses (null = unlimited)
- reason: str - why access is granted/denied
"""
profile_id = session['profile_id']
result = check_feature_access(profile_id, feature_id)
return result
@router.get("/usage")
def get_feature_usage(x_profile_id: Optional[str]=Header(default=None), session: dict=Depends(require_auth)):
"""
User: Get usage overview for all active features (Phase 3: Frontend Display).
Returns list of all features with current usage, limits, and reset info.
Automatically includes new features from database - no code changes needed.
Response:
[
{
"feature_id": "weight_entries",
"name": "Gewichtseinträge",
"description": "Anzahl der Gewichtseinträge",
"category": "data",
"limit_type": "count",
"reset_period": "never",
"used": 5,
"limit": 10,
"remaining": 5,
"allowed": true,
"reset_at": null
},
...
]
"""
pid = get_pid(x_profile_id)
with get_db() as conn:
cur = get_cursor(conn)
# Get all active features (dynamic - picks up new features automatically)
cur.execute("""
SELECT id, name, description, category, limit_type, reset_period
FROM features
WHERE active = true
ORDER BY category, name
""")
features = [r2d(r) for r in cur.fetchall()]
result = []
for feature in features:
# Use existing check_feature_access to get usage and limits
# This respects user overrides, tier limits, and feature defaults
# Pass connection to avoid pool exhaustion
access = check_feature_access(pid, feature['id'], conn)
# Get reset date from user_feature_usage
cur.execute("""
SELECT reset_at
FROM user_feature_usage
WHERE profile_id = %s AND feature_id = %s
""", (pid, feature['id']))
usage_row = cur.fetchone()
# Format reset_at as ISO string
reset_at = None
if usage_row and usage_row['reset_at']:
if isinstance(usage_row['reset_at'], datetime):
reset_at = usage_row['reset_at'].isoformat()
else:
reset_at = str(usage_row['reset_at'])
result.append({
'feature_id': feature['id'],
'name': feature['name'],
'description': feature.get('description'),
'category': feature.get('category'),
'limit_type': feature['limit_type'],
'reset_period': feature['reset_period'],
'used': access['used'],
'limit': access['limit'],
'remaining': access['remaining'],
'allowed': access['allowed'],
'reset_at': reset_at
})
return result

View File

@ -8,6 +8,7 @@ import csv
import io
import json
import uuid
import logging
import zipfile
from pathlib import Path
from typing import Optional
@ -16,10 +17,12 @@ from datetime import datetime
from fastapi import APIRouter, HTTPException, UploadFile, File, Header, Depends
from db import get_db, get_cursor
from auth import require_auth
from auth import require_auth, check_feature_access, increment_feature_usage
from routers.profiles import get_pid
from feature_logger import log_feature_usage
router = APIRouter(prefix="/api/import", tags=["import"])
logger = logging.getLogger(__name__)
PHOTOS_DIR = Path(os.getenv("PHOTOS_DIR", "./photos"))
@ -41,6 +44,21 @@ async def import_zip(
"""
pid = get_pid(x_profile_id)
# Phase 4: Check feature access and ENFORCE
access = check_feature_access(pid, 'data_import')
log_feature_usage(pid, 'data_import', access, 'import_zip')
if not access['allowed']:
logger.warning(
f"[FEATURE-LIMIT] User {pid} blocked: "
f"data_import {access['reason']} (used: {access['used']}, limit: {access['limit']})"
)
raise HTTPException(
status_code=403,
detail=f"Limit erreicht: Du hast das Kontingent für Daten-Importe überschritten ({access['used']}/{access['limit']}). "
f"Bitte kontaktiere den Admin oder warte bis zum nächsten Reset."
)
# Read uploaded file
content = await file.read()
zip_buffer = io.BytesIO(content)
@ -254,6 +272,9 @@ async def import_zip(
conn.rollback()
raise HTTPException(500, f"Import fehlgeschlagen: {str(e)}")
# Phase 2: Increment usage counter
increment_feature_usage(pid, 'data_import')
return {
"ok": True,
"message": "Import erfolgreich",

View File

@ -6,6 +6,7 @@ Handles AI analysis execution, prompt management, and usage tracking.
import os
import json
import uuid
import logging
import httpx
from typing import Optional
from datetime import datetime
@ -13,10 +14,12 @@ from datetime import datetime
from fastapi import APIRouter, HTTPException, Header, Depends
from db import get_db, get_cursor, r2d
from auth import require_auth, require_admin
from auth import require_auth, require_admin, check_feature_access, increment_feature_usage
from routers.profiles import get_pid
from feature_logger import log_feature_usage
router = APIRouter(prefix="/api", tags=["insights"])
logger = logging.getLogger(__name__)
OPENROUTER_KEY = os.getenv("OPENROUTER_API_KEY", "")
OPENROUTER_MODEL = os.getenv("OPENROUTER_MODEL", "anthropic/claude-sonnet-4")
@ -251,7 +254,21 @@ def delete_ai_insight(scope: str, x_profile_id: Optional[str]=Header(default=Non
async def analyze_with_prompt(slug: str, x_profile_id: Optional[str]=Header(default=None), session: dict=Depends(require_auth)):
"""Run AI analysis with specified prompt template."""
pid = get_pid(x_profile_id)
check_ai_limit(pid)
# Phase 4: Check feature access and ENFORCE
access = check_feature_access(pid, 'ai_calls')
log_feature_usage(pid, 'ai_calls', access, 'analyze')
if not access['allowed']:
logger.warning(
f"[FEATURE-LIMIT] User {pid} blocked: "
f"ai_calls {access['reason']} (used: {access['used']}, limit: {access['limit']})"
)
raise HTTPException(
status_code=403,
detail=f"Limit erreicht: Du hast das Kontingent für KI-Analysen überschritten ({access['used']}/{access['limit']}). "
f"Bitte kontaktiere den Admin oder warte bis zum nächsten Reset."
)
# Get prompt template
with get_db() as conn:
@ -294,14 +311,18 @@ async def analyze_with_prompt(slug: str, x_profile_id: Optional[str]=Header(defa
else:
raise HTTPException(500, "Keine KI-API konfiguriert")
# Save insight
# Save insight (with history - no DELETE)
with get_db() as conn:
cur = get_cursor(conn)
cur.execute("DELETE FROM ai_insights WHERE profile_id=%s AND scope=%s", (pid, slug))
cur.execute("INSERT INTO ai_insights (id, profile_id, scope, content, created) VALUES (%s,%s,%s,%s,CURRENT_TIMESTAMP)",
(str(uuid.uuid4()), pid, slug, content))
# Phase 2: Increment new feature usage counter
increment_feature_usage(pid, 'ai_calls')
# Old usage tracking (keep for now)
inc_ai_usage(pid)
return {"scope": slug, "content": content}
@ -309,7 +330,35 @@ async def analyze_with_prompt(slug: str, x_profile_id: Optional[str]=Header(defa
async def analyze_pipeline(x_profile_id: Optional[str]=Header(default=None), session: dict=Depends(require_auth)):
"""Run 3-stage pipeline analysis."""
pid = get_pid(x_profile_id)
check_ai_limit(pid)
# Phase 4: Check pipeline feature access (boolean - enabled/disabled)
access_pipeline = check_feature_access(pid, 'ai_pipeline')
log_feature_usage(pid, 'ai_pipeline', access_pipeline, 'pipeline')
if not access_pipeline['allowed']:
logger.warning(
f"[FEATURE-LIMIT] User {pid} blocked: "
f"ai_pipeline {access_pipeline['reason']}"
)
raise HTTPException(
status_code=403,
detail=f"Pipeline-Analyse ist nicht verfügbar. Bitte kontaktiere den Admin."
)
# Also check ai_calls (pipeline uses API calls too)
access_calls = check_feature_access(pid, 'ai_calls')
log_feature_usage(pid, 'ai_calls', access_calls, 'pipeline_calls')
if not access_calls['allowed']:
logger.warning(
f"[FEATURE-LIMIT] User {pid} blocked: "
f"ai_calls {access_calls['reason']} (used: {access_calls['used']}, limit: {access_calls['limit']})"
)
raise HTTPException(
status_code=403,
detail=f"Limit erreicht: Du hast das Kontingent für KI-Analysen überschritten ({access_calls['used']}/{access_calls['limit']}). "
f"Bitte kontaktiere den Admin oder warte bis zum nächsten Reset."
)
data = _get_profile_data(pid)
vars = _prepare_template_vars(data)
@ -431,15 +480,20 @@ async def analyze_pipeline(x_profile_id: Optional[str]=Header(default=None), ses
if goals_text:
final_content += "\n\n" + goals_text
# Save as 'gesamt' scope
# Save as 'pipeline' scope (with history - no DELETE)
with get_db() as conn:
cur = get_cursor(conn)
cur.execute("DELETE FROM ai_insights WHERE profile_id=%s AND scope='gesamt'", (pid,))
cur.execute("INSERT INTO ai_insights (id, profile_id, scope, content, created) VALUES (%s,%s,'gesamt',%s,CURRENT_TIMESTAMP)",
cur.execute("INSERT INTO ai_insights (id, profile_id, scope, content, created) VALUES (%s,%s,'pipeline',%s,CURRENT_TIMESTAMP)",
(str(uuid.uuid4()), pid, final_content))
# Phase 2: Increment ai_calls usage (pipeline uses multiple API calls)
# Note: We increment once per pipeline run, not per individual call
increment_feature_usage(pid, 'ai_calls')
# Old usage tracking (keep for now)
inc_ai_usage(pid)
return {"scope": "gesamt", "content": final_content, "stage1": stage1_results}
return {"scope": "pipeline", "content": final_content, "stage1": stage1_results}
@router.get("/ai/usage")

View File

@ -6,16 +6,19 @@ Handles nutrition data, FDDB CSV import, correlations, and weekly aggregates.
import csv
import io
import uuid
import logging
from typing import Optional
from datetime import datetime
from fastapi import APIRouter, HTTPException, UploadFile, File, Header, Depends
from db import get_db, get_cursor, r2d
from auth import require_auth
from auth import require_auth, check_feature_access, increment_feature_usage
from routers.profiles import get_pid
from feature_logger import log_feature_usage
router = APIRouter(prefix="/api/nutrition", tags=["nutrition"])
logger = logging.getLogger(__name__)
# ── Helper ────────────────────────────────────────────────────────────────────
@ -30,6 +33,23 @@ def _pf(s):
async def import_nutrition_csv(file: UploadFile=File(...), x_profile_id: Optional[str]=Header(default=None), session: dict=Depends(require_auth)):
"""Import FDDB nutrition CSV."""
pid = get_pid(x_profile_id)
# Phase 4: Check feature access and ENFORCE
# Note: CSV import can create many entries - we check once before import
access = check_feature_access(pid, 'nutrition_entries')
log_feature_usage(pid, 'nutrition_entries', access, 'import_csv')
if not access['allowed']:
logger.warning(
f"[FEATURE-LIMIT] User {pid} blocked: "
f"nutrition_entries {access['reason']} (used: {access['used']}, limit: {access['limit']})"
)
raise HTTPException(
status_code=403,
detail=f"Limit erreicht: Du hast das Kontingent für Ernährungseinträge überschritten ({access['used']}/{access['limit']}). "
f"Bitte kontaktiere den Admin oder warte bis zum nächsten Reset."
)
raw = await file.read()
try: text = raw.decode('utf-8')
except: text = raw.decode('latin-1')
@ -52,23 +72,88 @@ async def import_nutrition_csv(file: UploadFile=File(...), x_profile_id: Optiona
days[iso]['protein_g'] += _pf(row.get('protein_g',0))
count+=1
inserted=0
new_entries=0
with get_db() as conn:
cur = get_cursor(conn)
for iso,vals in days.items():
kcal=round(vals['kcal'],1); fat=round(vals['fat_g'],1)
carbs=round(vals['carbs_g'],1); prot=round(vals['protein_g'],1)
cur.execute("SELECT id FROM nutrition_log WHERE profile_id=%s AND date=%s",(pid,iso))
if cur.fetchone():
is_new = not cur.fetchone()
if not is_new:
# UPDATE existing
cur.execute("UPDATE nutrition_log SET kcal=%s,protein_g=%s,fat_g=%s,carbs_g=%s WHERE profile_id=%s AND date=%s",
(kcal,prot,fat,carbs,pid,iso))
else:
# INSERT new
cur.execute("INSERT INTO nutrition_log (id,profile_id,date,kcal,protein_g,fat_g,carbs_g,source,created) VALUES (%s,%s,%s,%s,%s,%s,%s,'csv',CURRENT_TIMESTAMP)",
(str(uuid.uuid4()),pid,iso,kcal,prot,fat,carbs))
new_entries += 1
inserted+=1
return {"rows_parsed":count,"days_imported":inserted,
# Phase 2: Increment usage counter for each new entry created
for _ in range(new_entries):
increment_feature_usage(pid, 'nutrition_entries')
return {"rows_parsed":count,"days_imported":inserted,"new_entries":new_entries,
"date_range":{"from":min(days) if days else None,"to":max(days) if days else None}}
@router.post("")
def create_nutrition(date: str, kcal: float, protein_g: float, fat_g: float, carbs_g: float,
x_profile_id: Optional[str]=Header(default=None), session: dict=Depends(require_auth)):
"""Create or update nutrition entry for a specific date."""
pid = get_pid(x_profile_id)
# Validate date format
try:
datetime.strptime(date, '%Y-%m-%d')
except ValueError:
raise HTTPException(400, "Ungültiges Datumsformat. Erwartet: YYYY-MM-DD")
with get_db() as conn:
cur = get_cursor(conn)
# Check if entry exists
cur.execute("SELECT id FROM nutrition_log WHERE profile_id=%s AND date=%s", (pid, date))
existing = cur.fetchone()
if existing:
# UPDATE existing entry
cur.execute("""
UPDATE nutrition_log
SET kcal=%s, protein_g=%s, fat_g=%s, carbs_g=%s, source='manual'
WHERE id=%s AND profile_id=%s
""", (round(kcal,1), round(protein_g,1), round(fat_g,1), round(carbs_g,1), existing['id'], pid))
return {"success": True, "mode": "updated", "id": existing['id']}
else:
# Phase 4: Check feature access before INSERT
access = check_feature_access(pid, 'nutrition_entries')
log_feature_usage(pid, 'nutrition_entries', access, 'create')
if not access['allowed']:
logger.warning(
f"[FEATURE-LIMIT] User {pid} blocked: "
f"nutrition_entries {access['reason']} (used: {access['used']}, limit: {access['limit']})"
)
raise HTTPException(
status_code=403,
detail=f"Limit erreicht: Du hast das Kontingent für Ernährungseinträge überschritten ({access['used']}/{access['limit']}). "
f"Bitte kontaktiere den Admin oder warte bis zum nächsten Reset."
)
# INSERT new entry
new_id = str(uuid.uuid4())
cur.execute("""
INSERT INTO nutrition_log (id, profile_id, date, kcal, protein_g, fat_g, carbs_g, source, created)
VALUES (%s, %s, %s, %s, %s, %s, %s, 'manual', CURRENT_TIMESTAMP)
""", (new_id, pid, date, round(kcal,1), round(protein_g,1), round(fat_g,1), round(carbs_g,1)))
# Phase 2: Increment usage counter
increment_feature_usage(pid, 'nutrition_entries')
return {"success": True, "mode": "created", "id": new_id}
@router.get("")
def list_nutrition(limit: int=365, x_profile_id: Optional[str]=Header(default=None), session: dict=Depends(require_auth)):
"""Get nutrition entries for current profile."""
@ -80,6 +165,17 @@ def list_nutrition(limit: int=365, x_profile_id: Optional[str]=Header(default=No
return [r2d(r) for r in cur.fetchall()]
@router.get("/by-date/{date}")
def get_nutrition_by_date(date: str, x_profile_id: Optional[str]=Header(default=None), session: dict=Depends(require_auth)):
"""Get nutrition entry for a specific date."""
pid = get_pid(x_profile_id)
with get_db() as conn:
cur = get_cursor(conn)
cur.execute("SELECT * FROM nutrition_log WHERE profile_id=%s AND date=%s", (pid, date))
row = cur.fetchone()
return r2d(row) if row else None
@router.get("/correlations")
def nutrition_correlations(x_profile_id: Optional[str]=Header(default=None), session: dict=Depends(require_auth)):
"""Get nutrition data correlated with weight and body fat."""
@ -123,7 +219,9 @@ def nutrition_weekly(weeks: int=16, x_profile_id: Optional[str]=Header(default=N
if not rows: return []
wm={}
for d in rows:
wk=datetime.strptime(d['date'],'%Y-%m-%d').strftime('%Y-W%V')
# Handle both datetime.date objects (from DB) and strings
date_obj = d['date'] if hasattr(d['date'], 'strftime') else datetime.strptime(d['date'],'%Y-%m-%d')
wk = date_obj.strftime('%Y-W%V')
wm.setdefault(wk,[]).append(d)
result=[]
for wk in sorted(wm):
@ -131,3 +229,61 @@ def nutrition_weekly(weeks: int=16, x_profile_id: Optional[str]=Header(default=N
def avg(k): return round(sum(float(e.get(k) or 0) for e in en)/n,1)
result.append({'week':wk,'days':n,'kcal':avg('kcal'),'protein_g':avg('protein_g'),'fat_g':avg('fat_g'),'carbs_g':avg('carbs_g')})
return result
@router.get("/import-history")
def import_history(x_profile_id: Optional[str]=Header(default=None), session: dict=Depends(require_auth)):
"""Get import history by grouping entries by created timestamp."""
pid = get_pid(x_profile_id)
with get_db() as conn:
cur = get_cursor(conn)
cur.execute("""
SELECT
DATE(created) as import_date,
COUNT(*) as count,
MIN(date) as date_from,
MAX(date) as date_to,
MAX(created) as last_created
FROM nutrition_log
WHERE profile_id=%s AND source='csv'
GROUP BY DATE(created)
ORDER BY DATE(created) DESC
""", (pid,))
return [r2d(r) for r in cur.fetchall()]
@router.put("/{entry_id}")
def update_nutrition(entry_id: str, kcal: float, protein_g: float, fat_g: float, carbs_g: float,
x_profile_id: Optional[str]=Header(default=None), session: dict=Depends(require_auth)):
"""Update nutrition entry macros."""
pid = get_pid(x_profile_id)
with get_db() as conn:
cur = get_cursor(conn)
# Verify ownership
cur.execute("SELECT id FROM nutrition_log WHERE id=%s AND profile_id=%s", (entry_id, pid))
if not cur.fetchone():
raise HTTPException(404, "Eintrag nicht gefunden")
cur.execute("""
UPDATE nutrition_log
SET kcal=%s, protein_g=%s, fat_g=%s, carbs_g=%s
WHERE id=%s AND profile_id=%s
""", (round(kcal,1), round(protein_g,1), round(fat_g,1), round(carbs_g,1), entry_id, pid))
return {"success": True}
@router.delete("/{entry_id}")
def delete_nutrition(entry_id: str, x_profile_id: Optional[str]=Header(default=None), session: dict=Depends(require_auth)):
"""Delete nutrition entry."""
pid = get_pid(x_profile_id)
with get_db() as conn:
cur = get_cursor(conn)
# Verify ownership
cur.execute("SELECT id FROM nutrition_log WHERE id=%s AND profile_id=%s", (entry_id, pid))
if not cur.fetchone():
raise HTTPException(404, "Eintrag nicht gefunden")
cur.execute("DELETE FROM nutrition_log WHERE id=%s AND profile_id=%s", (entry_id, pid))
return {"success": True}

View File

@ -5,6 +5,7 @@ Handles progress photo uploads and retrieval.
"""
import os
import uuid
import logging
from pathlib import Path
from typing import Optional
@ -13,10 +14,12 @@ from fastapi.responses import FileResponse
import aiofiles
from db import get_db, get_cursor, r2d
from auth import require_auth, require_auth_flexible
from auth import require_auth, require_auth_flexible, check_feature_access, increment_feature_usage
from routers.profiles import get_pid
from feature_logger import log_feature_usage
router = APIRouter(prefix="/api/photos", tags=["photos"])
logger = logging.getLogger(__name__)
PHOTOS_DIR = Path(os.getenv("PHOTOS_DIR", "./photos"))
PHOTOS_DIR.mkdir(parents=True, exist_ok=True)
@ -27,6 +30,22 @@ async def upload_photo(file: UploadFile=File(...), date: str="",
x_profile_id: Optional[str]=Header(default=None), session: dict=Depends(require_auth)):
"""Upload progress photo."""
pid = get_pid(x_profile_id)
# Phase 4: Check feature access and ENFORCE
access = check_feature_access(pid, 'photos')
log_feature_usage(pid, 'photos', access, 'upload')
if not access['allowed']:
logger.warning(
f"[FEATURE-LIMIT] User {pid} blocked: "
f"photos {access['reason']} (used: {access['used']}, limit: {access['limit']})"
)
raise HTTPException(
status_code=403,
detail=f"Limit erreicht: Du hast das Kontingent für Fotos überschritten ({access['used']}/{access['limit']}). "
f"Bitte kontaktiere den Admin oder warte bis zum nächsten Reset."
)
fid = str(uuid.uuid4())
ext = Path(file.filename).suffix or '.jpg'
path = PHOTOS_DIR / f"{fid}{ext}"
@ -35,6 +54,10 @@ async def upload_photo(file: UploadFile=File(...), date: str="",
cur = get_cursor(conn)
cur.execute("INSERT INTO photos (id,profile_id,date,path,created) VALUES (%s,%s,%s,%s,CURRENT_TIMESTAMP)",
(fid,pid,date,str(path)))
# Phase 2: Increment usage counter
increment_feature_usage(pid, 'photos')
return {"id":fid,"date":date}

View File

@ -29,13 +29,13 @@ def get_tier_limits_matrix(session: dict = Depends(require_admin)):
with get_db() as conn:
cur = get_cursor(conn)
# Get all tiers
cur.execute("SELECT id, name, sort_order FROM tiers WHERE active = true ORDER BY sort_order")
# Get all tiers (including inactive - admin needs to configure all)
cur.execute("SELECT id, name, sort_order FROM tiers ORDER BY sort_order")
tiers = [r2d(r) for r in cur.fetchall()]
# Get all features
cur.execute("""
SELECT id, name, category, limit_type, default_limit
SELECT id, name, category, limit_type, default_limit, reset_period
FROM features
WHERE active = true
ORDER BY category, name

View File

@ -4,16 +4,19 @@ Weight Tracking Endpoints for Mitai Jinkendo
Handles weight log CRUD operations and statistics.
"""
import uuid
import logging
from typing import Optional
from fastapi import APIRouter, Header, Depends
from fastapi import APIRouter, Header, Depends, HTTPException
from db import get_db, get_cursor, r2d
from auth import require_auth
from auth import require_auth, check_feature_access, increment_feature_usage
from models import WeightEntry
from routers.profiles import get_pid
from feature_logger import log_feature_usage
router = APIRouter(prefix="/api/weight", tags=["weight"])
logger = logging.getLogger(__name__)
@router.get("")
@ -31,17 +34,44 @@ def list_weight(limit: int=365, x_profile_id: Optional[str]=Header(default=None)
def upsert_weight(e: WeightEntry, x_profile_id: Optional[str]=Header(default=None), session: dict=Depends(require_auth)):
"""Create or update weight entry (upsert by date)."""
pid = get_pid(x_profile_id)
# Phase 4: Check feature access and ENFORCE
access = check_feature_access(pid, 'weight_entries')
# Structured logging (always)
log_feature_usage(pid, 'weight_entries', access, 'create')
# BLOCK if limit exceeded
if not access['allowed']:
logger.warning(
f"[FEATURE-LIMIT] User {pid} blocked: "
f"weight_entries {access['reason']} (used: {access['used']}, limit: {access['limit']})"
)
raise HTTPException(
status_code=403,
detail=f"Limit erreicht: Du hast das Kontingent für Gewichtseinträge überschritten ({access['used']}/{access['limit']}). "
f"Bitte kontaktiere den Admin oder warte bis zum nächsten Reset."
)
with get_db() as conn:
cur = get_cursor(conn)
cur.execute("SELECT id FROM weight_log WHERE profile_id=%s AND date=%s", (pid,e.date))
ex = cur.fetchone()
is_new_entry = not ex
if ex:
# UPDATE existing entry
cur.execute("UPDATE weight_log SET weight=%s,note=%s WHERE id=%s", (e.weight,e.note,ex['id']))
wid = ex['id']
else:
# INSERT new entry
wid = str(uuid.uuid4())
cur.execute("INSERT INTO weight_log (id,profile_id,date,weight,note,created) VALUES (%s,%s,%s,%s,%s,CURRENT_TIMESTAMP)",
(wid,pid,e.date,e.weight,e.note))
# Phase 2: Increment usage counter (only for new entries)
increment_feature_usage(pid, 'weight_entries')
return {"id":wid,"date":e.date,"weight":e.weight}

1058
docs/MEMBERSHIP_SYSTEM.md Normal file

File diff suppressed because it is too large Load Diff

6766
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@ -20,6 +20,12 @@ import ActivityPage from './pages/ActivityPage'
import Analysis from './pages/Analysis'
import SettingsPage from './pages/SettingsPage'
import GuidePage from './pages/GuidePage'
import AdminTierLimitsPage from './pages/AdminTierLimitsPage'
import AdminFeaturesPage from './pages/AdminFeaturesPage'
import AdminTiersPage from './pages/AdminTiersPage'
import AdminCouponsPage from './pages/AdminCouponsPage'
import AdminUserRestrictionsPage from './pages/AdminUserRestrictionsPage'
import SubscriptionPage from './pages/SubscriptionPage'
import './app.css'
function Nav() {
@ -115,6 +121,12 @@ function AppShell() {
<Route path="/analysis" element={<Analysis/>}/>
<Route path="/settings" element={<SettingsPage/>}/>
<Route path="/guide" element={<GuidePage/>}/>
<Route path="/admin/tier-limits" element={<AdminTierLimitsPage/>}/>
<Route path="/admin/features" element={<AdminFeaturesPage/>}/>
<Route path="/admin/tiers" element={<AdminTiersPage/>}/>
<Route path="/admin/coupons" element={<AdminCouponsPage/>}/>
<Route path="/admin/user-restrictions" element={<AdminUserRestrictionsPage/>}/>
<Route path="/subscription" element={<SubscriptionPage/>}/>
</Routes>
</main>
<Nav/>

View File

@ -0,0 +1,163 @@
/**
* FeatureUsageOverview Styles
* Phase 3: Frontend Display
*/
.feature-usage-overview {
display: flex;
flex-direction: column;
gap: 16px;
}
.feature-usage-loading,
.feature-usage-error,
.feature-usage-empty {
padding: 20px;
text-align: center;
color: var(--text2);
font-size: 14px;
}
.feature-usage-loading {
display: flex;
align-items: center;
justify-content: center;
gap: 10px;
}
.feature-usage-error {
background: rgba(216, 90, 48, 0.1);
color: var(--danger);
border-radius: 8px;
}
/* Category grouping */
.feature-category {
display: flex;
flex-direction: column;
gap: 8px;
}
.feature-category-label {
font-size: 11px;
font-weight: 600;
color: var(--text3);
text-transform: uppercase;
letter-spacing: 0.04em;
}
.feature-list {
display: flex;
flex-direction: column;
gap: 8px;
}
/* Feature item */
.feature-item {
padding: 12px;
background: var(--surface2);
border-radius: 8px;
border: 1px solid var(--border);
transition: all 0.2s;
}
.feature-item--exceeded {
border-color: var(--danger);
background: rgba(216, 90, 48, 0.05);
}
.feature-item--warning {
border-color: #d97706;
background: rgba(217, 119, 6, 0.05);
}
.feature-item--ok {
border-color: var(--border);
}
.feature-main {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.feature-name {
font-size: 14px;
font-weight: 500;
color: var(--text1);
}
.feature-usage {
font-size: 14px;
white-space: nowrap;
}
.usage-unlimited {
color: var(--accent);
font-weight: 500;
}
.usage-boolean {
font-weight: 500;
padding: 2px 8px;
border-radius: 4px;
font-size: 12px;
}
.usage-boolean.enabled {
color: var(--accent);
background: rgba(29, 158, 117, 0.1);
}
.usage-boolean.disabled {
color: var(--text3);
background: rgba(136, 135, 128, 0.1);
}
.usage-count {
color: var(--text1);
font-variant-numeric: tabular-nums;
}
.usage-count strong {
font-weight: 600;
}
.feature-item--exceeded .usage-count {
color: var(--danger);
}
.feature-item--warning .usage-count {
color: #d97706;
}
/* Meta info */
.feature-meta {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-top: 8px;
font-size: 11px;
color: var(--text3);
}
.meta-reset,
.meta-next-reset {
padding: 2px 6px;
background: var(--surface);
border-radius: 4px;
}
/* Responsive */
@media (max-width: 640px) {
.feature-main {
flex-direction: column;
align-items: flex-start;
gap: 6px;
}
.feature-usage {
width: 100%;
}
}

View File

@ -0,0 +1,142 @@
/**
* FeatureUsageOverview - Full feature usage table for Settings page
*
* Shows all features with usage, limits, reset period, and next reset date
* Phase 3: Frontend Display
*/
import { useState, useEffect } from 'react'
import { api } from '../utils/api'
import './FeatureUsageOverview.css'
const RESET_PERIOD_LABELS = {
'never': 'Niemals',
'daily': 'Täglich',
'monthly': 'Monatlich'
}
const CATEGORY_LABELS = {
'data': 'Daten',
'ai': 'KI',
'export': 'Export',
'import': 'Import',
'integration': 'Integration'
}
export default function FeatureUsageOverview() {
const [features, setFeatures] = useState([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState(null)
useEffect(() => {
loadFeatures()
}, [])
const loadFeatures = async () => {
try {
setLoading(true)
const data = await api.getFeatureUsage()
setFeatures(data)
setError(null)
} catch (err) {
console.error('Failed to load feature usage:', err)
setError('Fehler beim Laden der Kontingente')
} finally {
setLoading(false)
}
}
const formatResetDate = (resetAt) => {
if (!resetAt) return '—'
try {
const date = new Date(resetAt)
return date.toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric' })
} catch {
return '—'
}
}
const getStatusClass = (feature) => {
if (!feature.allowed || feature.remaining < 0) return 'exceeded'
if (feature.limit && feature.remaining <= Math.ceil(feature.limit * 0.2)) return 'warning'
return 'ok'
}
// Group by category
const byCategory = features.reduce((acc, f) => {
const cat = f.category || 'other'
if (!acc[cat]) acc[cat] = []
acc[cat].push(f)
return acc
}, {})
if (loading) {
return (
<div className="feature-usage-loading">
<div className="spinner" />
Lade Kontingente...
</div>
)
}
if (error) {
return (
<div className="feature-usage-error">
{error}
</div>
)
}
if (features.length === 0) {
return (
<div className="feature-usage-empty">
Keine Features gefunden
</div>
)
}
return (
<div className="feature-usage-overview">
{Object.entries(byCategory).map(([category, items]) => (
<div key={category} className="feature-category">
<div className="feature-category-label">
{CATEGORY_LABELS[category] || category}
</div>
<div className="feature-list">
{items.map(feature => (
<div key={feature.feature_id} className={`feature-item feature-item--${getStatusClass(feature)}`}>
<div className="feature-main">
<div className="feature-name">{feature.name}</div>
<div className="feature-usage">
{feature.limit === null ? (
<span className="usage-unlimited">Unbegrenzt</span>
) : feature.limit_type === 'boolean' ? (
<span className={`usage-boolean ${feature.allowed ? 'enabled' : 'disabled'}`}>
{feature.allowed ? '✓ Aktiviert' : '✗ Deaktiviert'}
</span>
) : (
<span className="usage-count">
<strong>{feature.used}</strong> / {feature.limit}
</span>
)}
</div>
</div>
{feature.limit_type === 'count' && feature.limit !== null && (
<div className="feature-meta">
<span className="meta-reset">
Reset: {RESET_PERIOD_LABELS[feature.reset_period] || feature.reset_period}
</span>
{feature.reset_at && (
<span className="meta-next-reset">
Nächster Reset: {formatResetDate(feature.reset_at)}
</span>
)}
</div>
)}
</div>
))}
</div>
</div>
))}
</div>
)
}

View File

@ -0,0 +1,103 @@
/**
* UsageBadge Styles - Dezente Version
*
* Sehr kleine, subtile Badge für Usage-Anzeige
* Phase 3: Frontend Display
*/
.usage-badge {
display: inline-block;
font-size: 0.65rem;
font-weight: 500;
padding: 2px 5px;
border-radius: 3px;
margin-left: 10px;
opacity: 0.6;
font-variant-numeric: tabular-nums;
white-space: nowrap;
transition: opacity 0.2s;
}
.usage-badge:hover {
opacity: 0.9;
}
.usage-badge--ok {
color: var(--text3, #888);
background: rgba(136, 135, 128, 0.08);
}
.usage-badge--warning {
color: #d97706;
background: rgba(217, 119, 6, 0.08);
}
.usage-badge--exceeded {
color: var(--danger, #D85A30);
background: rgba(216, 90, 48, 0.1);
font-weight: 600;
opacity: 0.75;
}
.usage-badge--exceeded:hover {
opacity: 1;
}
/* Responsive: Even smaller on mobile */
@media (max-width: 640px) {
.usage-badge {
font-size: 0.6rem;
padding: 1px 4px;
margin-left: 8px;
}
}
/* ============================================================================
Badge Container Styles - Positioning
============================================================================
Zentrale Konfiguration für Badge-Platzierung in verschiedenen Kontexten.
Alle Anpassungen an Layout/Spacing hier vornehmen!
*/
/* Badge rechts außen (für Headings/Titles) */
.badge-container-right {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
/* Badge rechts im Button (mit Beschreibung darunter) */
.badge-button-layout {
display: flex;
flex-direction: column;
align-items: stretch;
gap: 2px;
}
.badge-button-header {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
}
.badge-button-description {
font-size: 11px;
opacity: 0.8;
display: block;
margin-top: 2px;
text-align: left;
}
/* Mobile Anpassungen */
@media (max-width: 640px) {
.badge-container-right {
gap: 8px;
}
.badge-button-description {
font-size: 10px;
}
}

View File

@ -0,0 +1,31 @@
/**
* UsageBadge - Small inline usage indicator
*
* Shows usage quota in format: (used/limit)
* Color-coded: green (ok), yellow (warning), red (exceeded)
*
* Phase 3: Frontend Display
*/
import './UsageBadge.css'
export default function UsageBadge({ used, limit, remaining, allowed }) {
// Don't show badge if unlimited
if (limit === null || limit === undefined) {
return null
}
// Determine status for color coding
let status = 'ok'
if (!allowed || remaining < 0) {
status = 'exceeded'
} else if (limit > 0 && remaining <= Math.ceil(limit * 0.2)) {
// Warning at 20% or less remaining
status = 'warning'
}
return (
<span className={`usage-badge usage-badge--${status}`}>
({used}/{limit})
</span>
)
}

View File

@ -2,6 +2,7 @@ import { useState, useEffect, useRef } from 'react'
import { Upload, Pencil, Trash2, Check, X, CheckCircle } from 'lucide-react'
import { BarChart, Bar, XAxis, YAxis, Tooltip, ResponsiveContainer, CartesianGrid } from 'recharts'
import { api } from '../utils/api'
import UsageBadge from '../components/UsageBadge'
import dayjs from 'dayjs'
import 'dayjs/locale/de'
dayjs.locale('de')
@ -79,7 +80,7 @@ function ImportPanel({ onImported }) {
}
// Manual Entry
function EntryForm({ form, setForm, onSave, onCancel, saveLabel='Speichern' }) {
function EntryForm({ form, setForm, onSave, onCancel, saveLabel='Speichern', saving=false, error=null, usage=null }) {
const set = (k,v) => setForm(f=>({...f,[k]:v}))
return (
<div>
@ -130,8 +131,25 @@ function EntryForm({ form, setForm, onSave, onCancel, saveLabel='Speichern' }) {
value={form.notes||''} onChange={e=>set('notes',e.target.value)}/>
<span className="form-unit"/>
</div>
{error && (
<div style={{padding:'10px',background:'var(--danger-bg)',border:'1px solid var(--danger)',borderRadius:8,fontSize:13,color:'var(--danger)',marginBottom:8}}>
{error}
</div>
)}
<div style={{display:'flex',gap:6,marginTop:8}}>
<button className="btn btn-primary" style={{flex:1}} onClick={onSave}>{saveLabel}</button>
<div
title={usage && !usage.allowed ? `Limit erreicht (${usage.used}/${usage.limit}). Kontaktiere den Admin oder warte bis zum nächsten Reset.` : ''}
style={{flex:1,display:'inline-block'}}
>
<button
className="btn btn-primary"
style={{width:'100%', cursor: (usage && !usage.allowed) ? 'not-allowed' : 'pointer'}}
onClick={onSave}
disabled={saving || (usage && !usage.allowed)}
>
{(usage && !usage.allowed) ? '🔒 Limit erreicht' : saveLabel}
</button>
</div>
{onCancel && <button className="btn btn-secondary" style={{flex:1}} onClick={onCancel}><X size={13}/> Abbrechen</button>}
</div>
</div>
@ -145,25 +163,51 @@ export default function ActivityPage() {
const [tab, setTab] = useState('list')
const [form, setForm] = useState(empty())
const [editing, setEditing] = useState(null)
const [saving, setSaving] = useState(false)
const [saved, setSaved] = useState(false)
const [error, setError] = useState(null)
const [activityUsage, setActivityUsage] = useState(null) // Phase 4: Usage badge
const load = async () => {
const [e, s] = await Promise.all([api.listActivity(), api.activityStats()])
setEntries(e); setStats(s)
}
useEffect(()=>{ load() },[])
const loadUsage = () => {
api.getFeatureUsage().then(features => {
const activityFeature = features.find(f => f.feature_id === 'activity_entries')
setActivityUsage(activityFeature)
}).catch(err => console.error('Failed to load usage:', err))
}
useEffect(()=>{
load()
loadUsage()
},[])
const handleSave = async () => {
const payload = {...form}
if(payload.duration_min) payload.duration_min = parseFloat(payload.duration_min)
if(payload.kcal_active) payload.kcal_active = parseFloat(payload.kcal_active)
if(payload.hr_avg) payload.hr_avg = parseFloat(payload.hr_avg)
if(payload.hr_max) payload.hr_max = parseFloat(payload.hr_max)
if(payload.rpe) payload.rpe = parseInt(payload.rpe)
payload.source = 'manual'
await api.createActivity(payload)
setSaved(true); await load()
setTimeout(()=>{ setSaved(false); setForm(empty()) }, 1500)
setSaving(true)
setError(null)
try {
const payload = {...form}
if(payload.duration_min) payload.duration_min = parseFloat(payload.duration_min)
if(payload.kcal_active) payload.kcal_active = parseFloat(payload.kcal_active)
if(payload.hr_avg) payload.hr_avg = parseFloat(payload.hr_avg)
if(payload.hr_max) payload.hr_max = parseFloat(payload.hr_max)
if(payload.rpe) payload.rpe = parseInt(payload.rpe)
payload.source = 'manual'
await api.createActivity(payload)
setSaved(true)
await load()
await loadUsage() // Reload usage after save
setTimeout(()=>{ setSaved(false); setForm(empty()) }, 1500)
} catch (err) {
console.error('Save failed:', err)
setError(err.message || 'Fehler beim Speichern')
setTimeout(()=>setError(null), 5000)
} finally {
setSaving(false)
}
}
const handleUpdate = async () => {
@ -225,9 +269,13 @@ export default function ActivityPage() {
{tab==='add' && (
<div className="card section-gap">
<div className="card-title">Training eintragen</div>
<div className="card-title badge-container-right">
<span>Training eintragen</span>
{activityUsage && <UsageBadge {...activityUsage} />}
</div>
<EntryForm form={form} setForm={setForm}
onSave={handleSave} saveLabel={saved?'✓ Gespeichert!':'Speichern'}/>
onSave={handleSave} saveLabel={saved?'✓ Gespeichert!':'Speichern'}
saving={saving} error={error} usage={activityUsage}/>
</div>
)}

View File

@ -0,0 +1,523 @@
import { useState, useEffect } from 'react'
import { Save, Plus, Edit2, Trash2, X, Eye, Gift, Ticket } from 'lucide-react'
import { api } from '../utils/api'
export default function AdminCouponsPage() {
const [loading, setLoading] = useState(true)
const [error, setError] = useState('')
const [success, setSuccess] = useState('')
const [coupons, setCoupons] = useState([])
const [editingId, setEditingId] = useState(null)
const [showAddForm, setShowAddForm] = useState(false)
const [viewingRedemptions, setViewingRedemptions] = useState(null)
const [redemptions, setRedemptions] = useState([])
const [formData, setFormData] = useState({
code: '',
type: 'single_use',
valid_from: '',
valid_until: '',
grants_tier: 'premium',
duration_days: 30,
max_redemptions: 1,
notes: '',
active: true
})
useEffect(() => {
loadCoupons()
}, [])
async function loadCoupons() {
try {
setLoading(true)
const data = await api.listCoupons()
setCoupons(data)
setError('')
} catch (e) {
setError(e.message)
} finally {
setLoading(false)
}
}
async function loadRedemptions(couponId) {
try {
const data = await api.getCouponRedemptions(couponId)
setRedemptions(data)
setViewingRedemptions(couponId)
} catch (e) {
setError(e.message)
}
}
function resetForm() {
setFormData({
code: '',
type: 'single_use',
valid_from: '',
valid_until: '',
grants_tier: 'premium',
duration_days: 30,
max_redemptions: 1,
notes: '',
active: true
})
setEditingId(null)
setShowAddForm(false)
}
function startEdit(coupon) {
setFormData({
code: coupon.code,
type: coupon.type,
valid_from: coupon.valid_from ? coupon.valid_from.split('T')[0] : '',
valid_until: coupon.valid_until ? coupon.valid_until.split('T')[0] : '',
grants_tier: coupon.grants_tier,
duration_days: coupon.duration_days || 30,
max_redemptions: coupon.max_redemptions || 1,
notes: coupon.notes || '',
active: coupon.active
})
setEditingId(coupon.id)
setShowAddForm(false)
}
function startAdd() {
// Generate random code
const randomCode = `${formData.type === 'gift' ? 'GIFT' : 'PROMO'}-${Math.random().toString(36).substr(2, 8).toUpperCase()}`
setFormData({ ...formData, code: randomCode })
setShowAddForm(true)
setEditingId(null)
}
async function handleSave() {
try {
setError('')
setSuccess('')
if (!formData.code.trim()) {
setError('Code erforderlich')
return
}
const payload = {
code: formData.code.trim().toUpperCase(),
type: formData.type,
valid_from: formData.valid_from || null,
valid_until: formData.valid_until || null,
grants_tier: formData.grants_tier,
duration_days: parseInt(formData.duration_days) || null,
max_redemptions: formData.type === 'single_use' ? 1 : (parseInt(formData.max_redemptions) || null),
notes: formData.notes.trim(),
active: formData.active
}
if (editingId) {
await api.updateCoupon(editingId, payload)
setSuccess('Coupon aktualisiert')
} else {
await api.createCoupon(payload)
setSuccess('Coupon erstellt')
}
await loadCoupons()
resetForm()
} catch (e) {
setError(e.message)
}
}
async function handleDelete(couponId) {
if (!confirm('Coupon wirklich löschen?')) return
try {
setError('')
await api.deleteCoupon(couponId)
setSuccess('Coupon gelöscht')
await loadCoupons()
} catch (e) {
setError(e.message)
}
}
function formatDate(dateStr) {
if (!dateStr) return '-'
return new Date(dateStr).toLocaleDateString('de-DE')
}
if (loading) return (
<div style={{ display: 'flex', justifyContent: 'center', padding: 40 }}>
<div className="spinner" />
</div>
)
const couponTypes = [
{ value: 'single_use', label: 'Single-Use (einmalig)', icon: '🎟️' },
{ value: 'multi_use_period', label: 'Multi-Use Period (Zeitraum)', icon: '🔄' },
{ value: 'gift', label: 'Geschenk-Coupon', icon: '🎁' }
]
const tierOptions = [
{ value: 'free', label: 'Free' },
{ value: 'basic', label: 'Basic' },
{ value: 'premium', label: 'Premium' }
]
return (
<div style={{ paddingBottom: 80 }}>
{/* Header */}
<div style={{
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
marginBottom: 20
}}>
<div>
<div style={{ fontSize: 20, fontWeight: 700, color: 'var(--text1)' }}>
Coupon-Verwaltung
</div>
<div style={{ fontSize: 13, color: 'var(--text3)', marginTop: 4 }}>
Trial-Codes, Wellpass-Integration, Geschenk-Coupons
</div>
</div>
{!showAddForm && !editingId && (
<button className="btn btn-primary" onClick={startAdd}>
<Plus size={16} /> Neuer Coupon
</button>
)}
</div>
{/* Messages */}
{error && (
<div style={{
padding: 12, background: 'var(--danger)', color: 'white',
borderRadius: 8, marginBottom: 16, fontSize: 14
}}>
{error}
</div>
)}
{success && (
<div style={{
padding: 12, background: 'var(--accent)', color: 'white',
borderRadius: 8, marginBottom: 16, fontSize: 14
}}>
{success}
</div>
)}
{/* Add/Edit Form */}
{(showAddForm || editingId) && (
<div className="card" style={{ padding: 20, marginBottom: 20 }}>
<div style={{
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
marginBottom: 16
}}>
<div style={{ fontSize: 16, fontWeight: 600 }}>
{editingId ? 'Coupon bearbeiten' : 'Neuer Coupon'}
</div>
<button
className="btn btn-secondary"
onClick={resetForm}
style={{ padding: '6px 12px' }}
>
<X size={16} />
</button>
</div>
<div style={{ display: 'grid', gap: 16 }}>
{/* Code */}
<div>
<label className="form-label" style={{ display: 'block', marginBottom: 6 }}>
Coupon-Code *
</label>
<input
className="form-input"
style={{ width: '100%', fontFamily: 'monospace', textTransform: 'uppercase' }}
value={formData.code}
onChange={(e) => setFormData({ ...formData, code: e.target.value })}
placeholder="Z.B. PROMO-2026"
/>
<div style={{ fontSize: 11, color: 'var(--text3)', marginTop: 4 }}>
Wird automatisch in Großbuchstaben konvertiert
</div>
</div>
{/* Type */}
<div>
<label className="form-label" style={{ display: 'block', marginBottom: 6 }}>
Coupon-Typ
</label>
<select
className="form-input"
style={{ width: '100%' }}
value={formData.type}
onChange={(e) => setFormData({ ...formData, type: e.target.value })}
>
{couponTypes.map(t => (
<option key={t.value} value={t.value}>
{t.icon} {t.label}
</option>
))}
</select>
<div style={{ fontSize: 11, color: 'var(--text3)', marginTop: 4 }}>
{formData.type === 'single_use' && 'Kann nur einmal pro User eingelöst werden'}
{formData.type === 'multi_use_period' && 'Unbegrenzte Einlösungen im Gültigkeitszeitraum (z.B. Wellpass)'}
{formData.type === 'gift' && 'Geschenk-Coupon für Bonus-System'}
</div>
</div>
{/* Tier */}
<div>
<label className="form-label" style={{ display: 'block', marginBottom: 6 }}>
Gewährt Tier
</label>
<select
className="form-input"
style={{ width: '100%' }}
value={formData.grants_tier}
onChange={(e) => setFormData({ ...formData, grants_tier: e.target.value })}
>
{tierOptions.map(t => (
<option key={t.value} value={t.value}>{t.label}</option>
))}
</select>
</div>
{/* Duration (only for single_use and gift) */}
{(formData.type === 'single_use' || formData.type === 'gift') && (
<div>
<label className="form-label" style={{ display: 'block', marginBottom: 6 }}>
Gültigkeitsdauer (Tage)
</label>
<input
className="form-input"
style={{ width: '100%' }}
type="number"
value={formData.duration_days}
onChange={(e) => setFormData({ ...formData, duration_days: e.target.value })}
/>
<div style={{ fontSize: 11, color: 'var(--text3)', marginTop: 4 }}>
Wie lange ist der gewährte Zugriff gültig?
</div>
</div>
)}
{/* Valid From/Until (for multi_use_period) */}
{formData.type === 'multi_use_period' && (
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12 }}>
<div>
<label className="form-label" style={{ display: 'block', marginBottom: 6 }}>
Gültig ab
</label>
<input
className="form-input"
style={{ width: '100%' }}
type="date"
value={formData.valid_from}
onChange={(e) => setFormData({ ...formData, valid_from: e.target.value })}
/>
</div>
<div>
<label className="form-label" style={{ display: 'block', marginBottom: 6 }}>
Gültig bis
</label>
<input
className="form-input"
style={{ width: '100%' }}
type="date"
value={formData.valid_until}
onChange={(e) => setFormData({ ...formData, valid_until: e.target.value })}
/>
</div>
</div>
)}
{/* Max Redemptions (not for single_use) */}
{formData.type !== 'single_use' && (
<div>
<label className="form-label" style={{ display: 'block', marginBottom: 6 }}>
Maximale Einlösungen (optional)
</label>
<input
className="form-input"
style={{ width: '100%' }}
type="number"
value={formData.max_redemptions}
onChange={(e) => setFormData({ ...formData, max_redemptions: e.target.value })}
placeholder="Leer = unbegrenzt"
/>
</div>
)}
{/* Notes */}
<div>
<label className="form-label" style={{ display: 'block', marginBottom: 6 }}>
Notizen (optional)
</label>
<input
className="form-input"
style={{ width: '100%' }}
value={formData.notes}
onChange={(e) => setFormData({ ...formData, notes: e.target.value })}
placeholder="Z.B. 'Für Marketing-Kampagne März 2026'"
/>
</div>
{/* Active */}
<div>
<label style={{ display: 'flex', alignItems: 'center', gap: 6, fontSize: 13 }}>
<input
type="checkbox"
checked={formData.active}
onChange={(e) => setFormData({ ...formData, active: e.target.checked })}
/>
Coupon aktiviert (kann eingelöst werden)
</label>
</div>
{/* Actions */}
<div style={{ display: 'flex', gap: 8, marginTop: 8 }}>
<button className="btn btn-primary" onClick={handleSave}>
<Save size={14} /> {editingId ? 'Aktualisieren' : 'Erstellen'}
</button>
<button className="btn btn-secondary" onClick={resetForm}>
Abbrechen
</button>
</div>
</div>
</div>
)}
{/* Redemptions Modal */}
{viewingRedemptions && (
<div style={{
position: 'fixed', top: 0, left: 0, right: 0, bottom: 0,
background: 'rgba(0,0,0,0.5)', zIndex: 1000,
display: 'flex', alignItems: 'center', justifyContent: 'center',
padding: 20
}} onClick={() => setViewingRedemptions(null)}>
<div className="card" style={{ padding: 20, maxWidth: 600, width: '100%', maxHeight: '80vh', overflow: 'auto' }}
onClick={(e) => e.stopPropagation()}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 16 }}>
<div style={{ fontSize: 16, fontWeight: 600 }}>Einlösungen</div>
<button className="btn btn-secondary" onClick={() => setViewingRedemptions(null)} style={{ padding: '6px 12px' }}>
<X size={16} />
</button>
</div>
{redemptions.length === 0 ? (
<div style={{ textAlign: 'center', padding: 40, color: 'var(--text3)' }}>
Noch keine Einlösungen
</div>
) : (
<div style={{ display: 'grid', gap: 8 }}>
{redemptions.map(r => (
<div key={r.id} className="card" style={{ padding: 12, background: 'var(--surface)' }}>
<div style={{ fontWeight: 500 }}>{r.profile_name || `User #${r.profile_id}`}</div>
<div style={{ fontSize: 12, color: 'var(--text3)', marginTop: 2 }}>
Eingelöst am {formatDate(r.redeemed_at)}
</div>
</div>
))}
</div>
)}
</div>
</div>
)}
{/* Coupons List */}
<div style={{ display: 'grid', gap: 12 }}>
{coupons.length === 0 && (
<div className="card" style={{ padding: 40, textAlign: 'center', color: 'var(--text3)' }}>
Keine Coupons vorhanden
</div>
)}
{coupons.map(coupon => (
<div
key={coupon.id}
className="card"
style={{
padding: 16,
opacity: coupon.active ? 1 : 0.6,
border: coupon.active ? '1px solid var(--border)' : '1px dashed var(--border)'
}}
>
<div style={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', gap: 12 }}>
<div style={{ flex: 1 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 4 }}>
<div style={{
fontSize: 16, fontWeight: 700, color: 'var(--text1)',
fontFamily: 'monospace', background: 'var(--surface2)',
padding: '4px 8px', borderRadius: 4
}}>
{coupon.code}
</div>
<span style={{
padding: '2px 8px', borderRadius: 4, fontSize: 11,
background: 'var(--accent-light)', color: 'var(--accent-dark)', fontWeight: 600
}}>
{couponTypes.find(t => t.value === coupon.type)?.icon} {couponTypes.find(t => t.value === coupon.type)?.label}
</span>
{!coupon.active && (
<span style={{
padding: '2px 8px', borderRadius: 4, fontSize: 10,
background: 'var(--danger)', color: 'white', fontWeight: 600
}}>
INAKTIV
</span>
)}
</div>
<div style={{ fontSize: 13, color: 'var(--text2)', marginBottom: 8 }}>
Gewährt: <strong>{coupon.grants_tier}</strong>
{coupon.duration_days && ` für ${coupon.duration_days} Tage`}
</div>
{coupon.notes && (
<div style={{ fontSize: 12, color: 'var(--text3)', marginBottom: 8 }}>
📝 {coupon.notes}
</div>
)}
<div style={{ display: 'flex', gap: 16, fontSize: 12, color: 'var(--text3)' }}>
{coupon.valid_from && (
<div><strong>Gültig ab:</strong> {formatDate(coupon.valid_from)}</div>
)}
{coupon.valid_until && (
<div><strong>Gültig bis:</strong> {formatDate(coupon.valid_until)}</div>
)}
<div>
<strong>Einlösungen:</strong> {coupon.current_redemptions || 0}
{coupon.max_redemptions ? ` / ${coupon.max_redemptions}` : ' / ∞'}
</div>
</div>
</div>
<div style={{ display: 'flex', gap: 4, flexShrink: 0 }}>
<button
className="btn btn-secondary"
onClick={() => loadRedemptions(coupon.id)}
style={{ padding: '6px 12px', fontSize: 12 }}
title="Einlösungen anzeigen"
>
<Eye size={14} />
</button>
<button
className="btn btn-secondary"
onClick={() => startEdit(coupon)}
style={{ padding: '6px 12px', fontSize: 12 }}
>
<Edit2 size={14} />
</button>
<button
className="btn btn-secondary"
onClick={() => handleDelete(coupon.id)}
style={{ padding: '6px 12px', fontSize: 12, color: 'var(--danger)' }}
>
<Trash2 size={14} />
</button>
</div>
</div>
</div>
))}
</div>
</div>
)
}

View File

@ -0,0 +1,480 @@
import { useState, useEffect } from 'react'
import { Save, Edit2, X, Info } from 'lucide-react'
import { api } from '../utils/api'
export default function AdminFeaturesPage() {
const [loading, setLoading] = useState(true)
const [error, setError] = useState('')
const [success, setSuccess] = useState('')
const [features, setFeatures] = useState([])
const [editingId, setEditingId] = useState(null)
const [formData, setFormData] = useState({
name: '',
category: 'data',
description: '',
limit_type: 'count',
default_limit: '',
reset_period: 'never',
visible_in_admin: true,
sort_order: 50,
active: true
})
useEffect(() => {
loadFeatures()
}, [])
async function loadFeatures() {
try {
setLoading(true)
const data = await api.listFeatures()
setFeatures(data)
setError('')
} catch (e) {
setError(e.message)
} finally {
setLoading(false)
}
}
function resetForm() {
setFormData({
name: '',
category: 'data',
description: '',
limit_type: 'count',
default_limit: '',
reset_period: 'never',
visible_in_admin: true,
sort_order: 50,
active: true
})
setEditingId(null)
}
function startEdit(feature) {
setFormData({
name: feature.name,
category: feature.category,
description: feature.description || '',
limit_type: feature.limit_type,
default_limit: feature.default_limit === null ? '' : feature.default_limit,
reset_period: feature.reset_period,
visible_in_admin: feature.visible_in_admin,
sort_order: feature.sort_order || 50,
active: feature.active
})
setEditingId(feature.id)
}
async function handleSave() {
try {
setError('')
setSuccess('')
if (!formData.name.trim()) {
setError('Name erforderlich')
return
}
const payload = {
name: formData.name.trim(),
category: formData.category,
description: formData.description.trim(),
limit_type: formData.limit_type,
default_limit: formData.default_limit === '' ? null : parseInt(formData.default_limit),
reset_period: formData.reset_period,
visible_in_admin: formData.visible_in_admin,
sort_order: formData.sort_order,
active: formData.active
}
await api.updateFeature(editingId, payload)
setSuccess('Feature aktualisiert')
await loadFeatures()
resetForm()
} catch (e) {
setError(e.message)
}
}
if (loading) return (
<div style={{ display: 'flex', justifyContent: 'center', padding: 40 }}>
<div className="spinner" />
</div>
)
const categoryOptions = [
{ value: 'data', label: 'Daten' },
{ value: 'ai', label: 'KI' },
{ value: 'export', label: 'Export' },
{ value: 'integration', label: 'Integrationen' }
]
const resetPeriodOptions = [
{ value: 'never', label: 'Nie (akkumuliert)' },
{ value: 'daily', label: 'Täglich' },
{ value: 'monthly', label: 'Monatlich' }
]
const limitTypeOptions = [
{ value: 'count', label: 'Anzahl (Count)' },
{ value: 'boolean', label: 'Ja/Nein (Boolean)' }
]
return (
<div style={{ paddingBottom: 80 }}>
{/* Header */}
<div style={{ marginBottom: 20 }}>
<div style={{ fontSize: 20, fontWeight: 700, color: 'var(--text1)' }}>
Feature-Konfiguration
</div>
<div style={{ fontSize: 13, color: 'var(--text3)', marginTop: 4 }}>
Limitierungs-Einstellungen für registrierte Features
</div>
</div>
{/* Info Box */}
<div style={{
padding: 12, background: 'var(--accent-light)', borderRadius: 8,
marginBottom: 16, fontSize: 12, color: 'var(--accent-dark)',
display: 'flex', gap: 8, alignItems: 'flex-start'
}}>
<Info size={16} style={{ marginTop: 2, flexShrink: 0 }} />
<div>
<strong>Hinweis:</strong> Features werden automatisch via Code registriert.
Hier können nur Basis-Einstellungen (Limit-Typ, Reset-Periode, Standards) angepasst werden.
Neue Features hinzuzufügen erfordert Code-Änderungen im Backend.
</div>
</div>
{/* Messages */}
{error && (
<div style={{
padding: 12, background: 'var(--danger)', color: 'white',
borderRadius: 8, marginBottom: 16, fontSize: 14
}}>
{error}
</div>
)}
{success && (
<div style={{
padding: 12, background: 'var(--accent)', color: 'white',
borderRadius: 8, marginBottom: 16, fontSize: 14
}}>
{success}
</div>
)}
{/* Edit Form */}
{editingId && (
<div className="card" style={{ padding: 20, marginBottom: 20 }}>
<div style={{
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
marginBottom: 16
}}>
<div style={{ fontSize: 16, fontWeight: 600 }}>
Feature konfigurieren
</div>
<button
className="btn btn-secondary"
onClick={resetForm}
style={{ padding: '6px 12px' }}
>
<X size={16} />
</button>
</div>
<div style={{ display: 'grid', gap: 16 }}>
{/* Feature ID (read-only) */}
<div>
<label className="form-label" style={{ display: 'block', marginBottom: 6 }}>
Feature ID
</label>
<input
className="form-input"
value={editingId}
disabled
style={{
width: '100%',
background: 'var(--surface2)',
color: 'var(--text3)',
cursor: 'not-allowed',
fontFamily: 'monospace'
}}
/>
</div>
{/* Name */}
<div>
<label className="form-label" style={{ display: 'block', marginBottom: 6 }}>
Name *
</label>
<input
className="form-input"
style={{ width: '100%' }}
value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
placeholder="z.B. Gewichtseinträge"
/>
</div>
{/* Description */}
<div>
<label className="form-label" style={{ display: 'block', marginBottom: 6 }}>
Beschreibung (optional)
</label>
<input
className="form-input"
style={{ width: '100%' }}
value={formData.description}
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
placeholder="Kurze Erklärung was dieses Feature limitiert"
/>
</div>
{/* Category + Limit Type */}
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12 }}>
<div>
<label className="form-label" style={{ display: 'block', marginBottom: 6 }}>
Kategorie
</label>
<select
className="form-input"
style={{ width: '100%' }}
value={formData.category}
onChange={(e) => setFormData({ ...formData, category: e.target.value })}
>
{categoryOptions.map(o => (
<option key={o.value} value={o.value}>{o.label}</option>
))}
</select>
</div>
<div>
<label className="form-label" style={{ display: 'block', marginBottom: 6 }}>
Limit-Typ
</label>
<select
className="form-input"
style={{ width: '100%' }}
value={formData.limit_type}
onChange={(e) => setFormData({ ...formData, limit_type: e.target.value })}
>
{limitTypeOptions.map(o => (
<option key={o.value} value={o.value}>{o.label}</option>
))}
</select>
</div>
</div>
{/* Count-specific fields (only for limit_type='count') */}
{formData.limit_type === 'count' && (
<>
{/* Reset Period */}
<div>
<label className="form-label" style={{ display: 'block', marginBottom: 6 }}>
Reset-Periode
</label>
<select
className="form-input"
style={{ width: '100%' }}
value={formData.reset_period}
onChange={(e) => setFormData({ ...formData, reset_period: e.target.value })}
>
{resetPeriodOptions.map(o => (
<option key={o.value} value={o.value}>{o.label}</option>
))}
</select>
<div style={{ fontSize: 11, color: 'var(--text3)', marginTop: 4 }}>
Wann wird der Nutzungszähler zurückgesetzt?
</div>
</div>
{/* Default Limit */}
<div>
<label className="form-label" style={{ display: 'block', marginBottom: 6 }}>
Standard-Limit
</label>
<input
className="form-input"
style={{ width: '100%' }}
type="number"
value={formData.default_limit}
onChange={(e) => setFormData({ ...formData, default_limit: e.target.value })}
placeholder="Leer = unbegrenzt"
/>
<div style={{ fontSize: 11, color: 'var(--text3)', marginTop: 4 }}>
Fallback-Wert wenn kein Tier-spezifisches Limit gesetzt ist
</div>
</div>
</>
)}
{/* Boolean info */}
{formData.limit_type === 'boolean' && (
<div style={{
padding: 12, background: 'var(--accent-light)', borderRadius: 8,
fontSize: 12, color: 'var(--accent-dark)'
}}>
<strong>Boolean-Feature:</strong> Ist entweder verfügbar (AN) oder nicht verfügbar (AUS).
Keine Zähler oder Reset-Perioden notwendig.
</div>
)}
{/* Sort Order */}
<div>
<label className="form-label" style={{ display: 'block', marginBottom: 6 }}>
Anzeigereihenfolge
</label>
<input
className="form-input"
style={{ width: '100%' }}
type="number"
value={formData.sort_order}
onChange={(e) => setFormData({ ...formData, sort_order: parseInt(e.target.value) || 50 })}
/>
<div style={{ fontSize: 11, color: 'var(--text3)', marginTop: 4 }}>
Niedrigere Werte erscheinen weiter oben in Listen (Standard: 50)
</div>
</div>
{/* Checkboxes */}
<div style={{ display: 'flex', gap: 16, paddingTop: 8 }}>
<label style={{ display: 'flex', alignItems: 'center', gap: 6, fontSize: 13 }}>
<input
type="checkbox"
checked={formData.visible_in_admin}
onChange={(e) => setFormData({ ...formData, visible_in_admin: e.target.checked })}
/>
Im Admin sichtbar
</label>
<label style={{ display: 'flex', alignItems: 'center', gap: 6, fontSize: 13 }}>
<input
type="checkbox"
checked={formData.active}
onChange={(e) => setFormData({ ...formData, active: e.target.checked })}
/>
Feature aktiviert
</label>
</div>
{/* Actions */}
<div style={{ display: 'flex', gap: 8, marginTop: 8 }}>
<button className="btn btn-primary" onClick={handleSave}>
<Save size={14} /> Speichern
</button>
<button className="btn btn-secondary" onClick={resetForm}>
Abbrechen
</button>
</div>
</div>
</div>
)}
{/* Features List */}
<div className="card" style={{ padding: 0, overflow: 'auto' }}>
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 13 }}>
<thead>
<tr style={{ background: 'var(--surface2)' }}>
<th style={{ textAlign: 'left', padding: '12px 16px', fontWeight: 600 }}>Feature</th>
<th style={{ textAlign: 'left', padding: '12px 16px', fontWeight: 600 }}>Kategorie</th>
<th style={{ textAlign: 'center', padding: '12px 16px', fontWeight: 600 }}>Limit-Typ</th>
<th style={{ textAlign: 'center', padding: '12px 16px', fontWeight: 600 }}>Reset</th>
<th style={{ textAlign: 'center', padding: '12px 16px', fontWeight: 600 }}>Standard</th>
<th style={{ textAlign: 'center', padding: '12px 16px', fontWeight: 600 }}>Status</th>
<th style={{ textAlign: 'right', padding: '12px 16px', fontWeight: 600 }}>Aktion</th>
</tr>
</thead>
<tbody>
{features.length === 0 && (
<tr>
<td colSpan={7} style={{ textAlign: 'center', padding: 40, color: 'var(--text3)' }}>
Keine Features registriert
</td>
</tr>
)}
{features.map((feature, idx) => (
<tr
key={feature.id}
style={{
borderBottom: idx === features.length - 1 ? 'none' : '1px solid var(--border)',
background: feature.active ? 'transparent' : 'var(--surface)',
opacity: feature.active ? 1 : 0.6
}}
>
<td style={{ padding: '12px 16px' }}>
<div style={{ fontWeight: 500 }}>{feature.name}</div>
<div style={{ fontSize: 11, color: 'var(--text3)', marginTop: 2, fontFamily: 'monospace' }}>
{feature.id}
</div>
{feature.description && (
<div style={{ fontSize: 11, color: 'var(--text3)', marginTop: 2 }}>
{feature.description}
</div>
)}
</td>
<td style={{ padding: '12px 16px' }}>
<span style={{
padding: '2px 8px', borderRadius: 4, fontSize: 11,
background: 'var(--accent-light)', color: 'var(--accent-dark)', fontWeight: 600
}}>
{feature.category}
</span>
</td>
<td style={{ padding: '12px 16px', textAlign: 'center' }}>
<span style={{
padding: '2px 8px', borderRadius: 4, fontSize: 11,
background: feature.limit_type === 'boolean' ? 'var(--surface2)' : 'var(--surface2)',
fontWeight: 500
}}>
{feature.limit_type === 'boolean' ? '✓/✗' : '123'}
</span>
</td>
<td style={{ padding: '12px 16px', textAlign: 'center', fontSize: 11, color: 'var(--text3)' }}>
{feature.reset_period === 'never' ? '∞' : feature.reset_period === 'daily' ? '1d' : '1m'}
</td>
<td style={{ padding: '12px 16px', textAlign: 'center', fontWeight: 500 }}>
{feature.default_limit === null ? '∞' : feature.default_limit}
</td>
<td style={{ padding: '12px 16px', textAlign: 'center' }}>
{feature.active ? (
<span style={{ color: 'var(--accent)', fontWeight: 600 }}> Aktiv</span>
) : (
<span style={{ color: 'var(--text3)' }}> Inaktiv</span>
)}
</td>
<td style={{ padding: '12px 16px', textAlign: 'right' }}>
<button
className="btn btn-secondary"
onClick={() => startEdit(feature)}
style={{ padding: '6px 12px', fontSize: 12 }}
>
<Edit2 size={14} /> Konfigurieren
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
{/* Legend */}
<div style={{
marginTop: 16, padding: 12, background: 'var(--surface2)',
borderRadius: 8, fontSize: 12, color: 'var(--text3)'
}}>
<strong>Limit-Typ:</strong>
<div style={{ marginTop: 4 }}>
<strong>Boolean (/):</strong> Feature ist entweder verfügbar oder nicht (z.B. "KI aktiviert")
</div>
<div style={{ marginTop: 2 }}>
<strong>Count (123):</strong> Feature hat ein Nutzungs-Limit (z.B. "max. 50 Einträge")
</div>
<div style={{ marginTop: 8 }}>
<strong>Reset-Periode:</strong> = nie, 1d = täglich, 1m = monatlich
</div>
</div>
</div>
)
}

View File

@ -1,5 +1,6 @@
import { useState, useEffect } from 'react'
import { Plus, Trash2, Pencil, Check, X, Shield, Key } from 'lucide-react'
import { Plus, Trash2, Pencil, Check, X, Shield, Key, Settings } from 'lucide-react'
import { Link } from 'react-router-dom'
import { useAuth } from '../context/AuthContext'
import { api } from '../utils/api'
@ -142,10 +143,7 @@ function EmailEditor({ profileId, currentEmail, onSaved }) {
function ProfileCard({ profile, currentId, onRefresh }) {
const [expanded, setExpanded] = useState(false)
const [perms, setPerms] = useState({
ai_enabled: profile.ai_enabled ?? 1,
ai_limit_day: profile.ai_limit_day || '',
export_enabled: profile.export_enabled ?? 1,
role: profile.role || 'user',
role: profile.role || 'user',
})
const [saving, setSaving] = useState(false)
const [newPin, setNewPin] = useState('')
@ -156,10 +154,7 @@ function ProfileCard({ profile, currentId, onRefresh }) {
setSaving(true)
try {
await api.adminSetPermissions(profile.id, {
ai_enabled: perms.ai_enabled,
ai_limit_day: perms.ai_limit_day ? parseInt(perms.ai_limit_day) : null,
export_enabled: perms.export_enabled,
role: perms.role,
role: perms.role,
})
await onRefresh()
} finally { setSaving(false) }
@ -195,9 +190,8 @@ function ProfileCard({ profile, currentId, onRefresh }) {
{isSelf && <span style={{fontSize:10,color:'var(--text3)'}}>Du</span>}
</div>
<div style={{fontSize:11,color:'var(--text3)'}}>
KI: {profile.ai_enabled?`${profile.ai_limit_day?` (max ${profile.ai_limit_day}/Tag)`:''}` : '✗'} ·
Export: {profile.export_enabled?'✓':'✗'} ·
Calls heute: {profile.ai_calls_today||0}
Tier: {profile.tier || 'free'} ·
Email: {profile.email || 'nicht gesetzt'}
</div>
</div>
<div style={{display:'flex',gap:6}}>
@ -232,23 +226,19 @@ function ProfileCard({ profile, currentId, onRefresh }) {
</button>
))}
</div>
<button className="btn btn-primary btn-full" style={{marginTop:8}} onClick={savePerms} disabled={saving}>
{saving?'Speichern…':'Rolle speichern'}
</button>
</div>
<Toggle value={!!perms.ai_enabled} onChange={v=>setPerms(p=>({...p,ai_enabled:v?1:0}))} label="KI-Analysen erlaubt"/>
{!!perms.ai_enabled && (
<div className="form-row" style={{paddingTop:6}}>
<label className="form-label" style={{fontSize:12}}>Max. KI-Calls/Tag</label>
<input type="number" className="form-input" style={{width:70}} min={1} max={100}
placeholder="∞" value={perms.ai_limit_day}
onChange={e=>setPerms(p=>({...p,ai_limit_day:e.target.value}))}/>
<span className="form-unit" style={{fontSize:11}}>/Tag</span>
</div>
)}
<Toggle value={!!perms.export_enabled} onChange={v=>setPerms(p=>({...p,export_enabled:v?1:0}))} label="Daten-Export erlaubt"/>
<button className="btn btn-primary btn-full" style={{marginTop:10}} onClick={savePerms} disabled={saving}>
{saving?'Speichern…':'Berechtigungen speichern'}
</button>
{/* Feature-Overrides */}
<div style={{marginBottom:12,padding:10,background:'var(--accent-light)',borderRadius:6,fontSize:12}}>
<strong>Feature-Limits:</strong> Nutze die neue{' '}
<Link to="/admin/user-restrictions" style={{color:'var(--accent-dark)',fontWeight:600}}>
User Feature-Overrides
</Link>{' '}
Seite um individuelle Limits zu setzen.
</div>
{/* Email */}
<div style={{marginTop:12,paddingTop:12,borderTop:'1px solid var(--border)'}}>
@ -397,6 +387,43 @@ export default function AdminPanel() {
{/* Email Settings */}
<EmailSettings/>
{/* v9c Subscription Management */}
<div className="card section-gap" style={{marginTop:16}}>
<div style={{fontWeight:700,fontSize:14,marginBottom:12,display:'flex',alignItems:'center',gap:6}}>
<Settings size={16} color="var(--accent)"/> Subscription-System (v9c)
</div>
<div style={{fontSize:12,color:'var(--text3)',marginBottom:12,lineHeight:1.5}}>
Verwalte Tiers, Features und Limits für das neue Freemium-System.
</div>
<div style={{display:'grid',gap:8}}>
<Link to="/admin/tiers">
<button className="btn btn-secondary btn-full">
🎯 Tiers verwalten
</button>
</Link>
<Link to="/admin/features">
<button className="btn btn-secondary btn-full">
🔧 Feature-Registry verwalten
</button>
</Link>
<Link to="/admin/tier-limits">
<button className="btn btn-secondary btn-full">
📊 Tier Limits Matrix bearbeiten
</button>
</Link>
<Link to="/admin/coupons">
<button className="btn btn-secondary btn-full">
🎟 Coupons verwalten
</button>
</Link>
<Link to="/admin/user-restrictions">
<button className="btn btn-secondary btn-full">
👤 User Feature-Overrides
</button>
</Link>
</div>
</div>
</div>
)
}

View File

@ -0,0 +1,499 @@
import { useState, useEffect } from 'react'
import { Save, RotateCcw, ChevronDown, ChevronUp } from 'lucide-react'
import { api } from '../utils/api'
export default function AdminTierLimitsPage() {
const [loading, setLoading] = useState(true)
const [error, setError] = useState('')
const [success, setSuccess] = useState('')
const [matrix, setMatrix] = useState({ tiers: [], features: [], limits: {} })
const [changes, setChanges] = useState({})
const [saving, setSaving] = useState(false)
const [isMobile, setIsMobile] = useState(window.innerWidth < 768)
useEffect(() => {
loadMatrix()
const handleResize = () => setIsMobile(window.innerWidth < 768)
window.addEventListener('resize', handleResize)
return () => window.removeEventListener('resize', handleResize)
}, [])
async function loadMatrix() {
try {
setLoading(true)
const data = await api.getTierLimitsMatrix()
setMatrix(data)
setChanges({})
setError('')
} catch (e) {
setError(e.message)
} finally {
setLoading(false)
}
}
function handleChange(tierId, featureId, value) {
const key = `${tierId}:${featureId}`
const newChanges = { ...changes }
// Allow temporary empty input for better UX
if (value === '') {
newChanges[key] = { tierId, featureId, value: '', tempValue: '' }
setChanges(newChanges)
return
}
// Parse value
let parsedValue = null
if (value === 'unlimited' || value === '∞') {
parsedValue = null // unlimited
} else if (value === '0' || value === 'disabled') {
parsedValue = 0 // disabled
} else {
const num = parseInt(value)
if (!isNaN(num) && num >= 0) {
parsedValue = num
} else {
return // invalid input, ignore
}
}
newChanges[key] = { tierId, featureId, value: parsedValue, tempValue: value }
setChanges(newChanges)
}
async function saveChanges() {
// Filter out empty temporary values
const validChanges = Object.values(changes).filter(c => c.value !== '')
if (validChanges.length === 0) {
setSuccess('Keine Änderungen')
return
}
try {
setSaving(true)
setError('')
setSuccess('')
const updates = validChanges.map(c => ({
tier_id: c.tierId,
feature_id: c.featureId,
limit_value: c.value
}))
await api.updateTierLimitsBatch(updates)
setSuccess(`${updates.length} Limits gespeichert`)
await loadMatrix()
} catch (e) {
setError(e.message)
} finally {
setSaving(false)
}
}
function getCurrentValue(tierId, featureId) {
const key = `${tierId}:${featureId}`
if (key in changes) {
// Return temp value for display
return changes[key].tempValue !== undefined ? changes[key].tempValue : changes[key].value
}
return matrix.limits[key] ?? null
}
function formatValue(val) {
if (val === '' || val === null || val === undefined) return ''
if (val === '∞' || val === 'unlimited') return '∞'
if (val === 0 || val === '0') return '0'
return val.toString()
}
function groupFeaturesByCategory() {
const groups = {}
matrix.features.forEach(f => {
if (!groups[f.category]) groups[f.category] = []
groups[f.category].push(f)
})
return groups
}
if (loading) return (
<div style={{ display: 'flex', justifyContent: 'center', padding: 40 }}>
<div className="spinner" />
</div>
)
const hasChanges = Object.keys(changes).filter(k => changes[k].value !== '').length > 0
const categoryGroups = groupFeaturesByCategory()
const categoryIcons = { data: '📊', ai: '🤖', export: '📤', integration: '🔗' }
const categoryNames = { data: 'DATEN', ai: 'KI', export: 'EXPORT', integration: 'INTEGRATIONEN' }
// Mobile: Card-based view
if (isMobile) {
return (
<div style={{ paddingBottom: 100 }}>
{/* Header */}
<div style={{ marginBottom: 20 }}>
<div style={{ fontSize: 20, fontWeight: 700, color: 'var(--text1)' }}>
Tier Limits
</div>
<div style={{ fontSize: 13, color: 'var(--text3)', marginTop: 4 }}>
Limits pro Tier konfigurieren
</div>
</div>
{/* Messages */}
{error && (
<div style={{
padding: 12, background: 'var(--danger)', color: 'white',
borderRadius: 8, marginBottom: 16, fontSize: 13
}}>
{error}
</div>
)}
{success && (
<div style={{
padding: 12, background: 'var(--accent)', color: 'white',
borderRadius: 8, marginBottom: 16, fontSize: 13
}}>
{success}
</div>
)}
{/* Mobile: Feature Cards */}
{Object.entries(categoryGroups).map(([category, features]) => (
<div key={category} style={{ marginBottom: 20 }}>
{/* Category Header */}
<div style={{
padding: '6px 12px', background: 'var(--accent-light)', borderRadius: 8,
fontWeight: 600, fontSize: 11, textTransform: 'uppercase',
color: 'var(--accent-dark)', marginBottom: 8
}}>
{categoryIcons[category]} {categoryNames[category] || category}
</div>
{/* Features */}
{features.map(feature => (
<FeatureMobileCard
key={feature.id}
feature={feature}
tiers={matrix.tiers}
getCurrentValue={getCurrentValue}
handleChange={handleChange}
changes={changes}
/>
))}
</div>
))}
{/* Fixed Bottom Bar */}
<div style={{
position: 'fixed', bottom: 0, left: 0, right: 0,
background: 'var(--bg)', borderTop: '1px solid var(--border)',
padding: 16, display: 'flex', gap: 8, zIndex: 100,
boxShadow: '0 -2px 10px rgba(0,0,0,0.1)'
}}>
{hasChanges && (
<button
className="btn btn-secondary"
onClick={() => { setChanges({}); setSuccess(''); setError('') }}
disabled={saving}
style={{ flex: 1 }}
>
<RotateCcw size={14}/> Zurück
</button>
)}
<button
className="btn btn-primary"
onClick={saveChanges}
disabled={saving || !hasChanges}
style={{ flex: hasChanges ? 2 : 1 }}
>
{saving ? '...' : hasChanges ? <><Save size={14}/> {Object.keys(changes).filter(k=>changes[k].value!=='').length} Speichern</> : 'Keine Änderungen'}
</button>
</div>
</div>
)
}
// Desktop: Table view
return (
<div style={{ paddingBottom: 80 }}>
{/* Header */}
<div style={{
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
marginBottom: 20
}}>
<div>
<div style={{ fontSize: 20, fontWeight: 700, color: 'var(--text1)' }}>
Tier Limits Matrix
</div>
<div style={{ fontSize: 13, color: 'var(--text3)', marginTop: 4 }}>
Feature-Limits pro Tier (leer = unbegrenzt, 0 = deaktiviert)
</div>
</div>
<div style={{ display: 'flex', gap: 8 }}>
{hasChanges && (
<button
className="btn btn-secondary"
onClick={() => { setChanges({}); setSuccess(''); setError('') }}
disabled={saving}
>
<RotateCcw size={14}/> Zurücksetzen
</button>
)}
<button
className="btn btn-primary"
onClick={saveChanges}
disabled={saving || !hasChanges}
>
{saving ? 'Speichern...' : hasChanges ? `${Object.keys(changes).filter(k=>changes[k].value!=='').length} Änderungen speichern` : <><Save size={14}/> Speichern</>}
</button>
</div>
</div>
{/* Messages */}
{error && (
<div style={{
padding: 12, background: 'var(--danger)', color: 'white',
borderRadius: 8, marginBottom: 16, fontSize: 14
}}>
{error}
</div>
)}
{success && (
<div style={{
padding: 12, background: 'var(--accent)', color: 'white',
borderRadius: 8, marginBottom: 16, fontSize: 14
}}>
{success}
</div>
)}
{/* Matrix Table */}
<div className="card" style={{ padding: 0, overflow: 'auto' }}>
<table style={{
width: '100%', borderCollapse: 'collapse', fontSize: 13,
minWidth: 800
}}>
<thead>
<tr style={{ background: 'var(--surface2)' }}>
<th style={{
textAlign: 'left', padding: '12px 16px', fontWeight: 600,
position: 'sticky', left: 0, background: 'var(--surface2)', zIndex: 10,
borderRight: '1px solid var(--border)'
}}>
Feature
</th>
{matrix.tiers.map(tier => (
<th key={tier.id} style={{
textAlign: 'center', padding: '12px 16px', fontWeight: 600,
minWidth: 100
}}>
<div>{tier.name}</div>
<div style={{ fontSize: 10, fontWeight: 400, color: 'var(--text3)', marginTop: 2 }}>
{tier.id}
</div>
</th>
))}
</tr>
</thead>
<tbody>
{Object.entries(categoryGroups).map(([category, features]) => (
<>
{/* Category Header */}
<tr key={`cat-${category}`} style={{ background: 'var(--accent-light)' }}>
<td colSpan={matrix.tiers.length + 1} style={{
padding: '8px 16px', fontWeight: 600, fontSize: 11,
textTransform: 'uppercase', letterSpacing: '0.5px',
color: 'var(--accent-dark)'
}}>
{categoryIcons[category]} {categoryNames[category] || category}
</td>
</tr>
{/* Feature Rows */}
{features.map((feature, idx) => (
<tr key={feature.id} style={{
borderBottom: idx === features.length - 1 ? '2px solid var(--border)' : '1px solid var(--border)'
}}>
<td style={{
padding: '8px 16px', fontWeight: 500,
position: 'sticky', left: 0, background: 'var(--bg)', zIndex: 5,
borderRight: '1px solid var(--border)'
}}>
<div>{feature.name}</div>
<div style={{ fontSize: 11, color: 'var(--text3)', marginTop: 2 }}>
{feature.limit_type === 'boolean' ? '(ja/nein)' : `(count, reset: ${feature.reset_period})`}
</div>
</td>
{matrix.tiers.map(tier => {
const currentValue = getCurrentValue(tier.id, feature.id)
const isChanged = `${tier.id}:${feature.id}` in changes && changes[`${tier.id}:${feature.id}`].value !== ''
// Boolean features: Toggle button
if (feature.limit_type === 'boolean') {
const isEnabled = currentValue !== 0 && currentValue !== '0'
return (
<td key={`${tier.id}-${feature.id}`} style={{
textAlign: 'center', padding: 8,
background: isChanged ? 'var(--accent-light)' : 'transparent'
}}>
<button
onClick={() => handleChange(tier.id, feature.id, isEnabled ? '0' : '1')}
style={{
padding: '6px 16px',
border: `2px solid ${isEnabled ? 'var(--accent)' : 'var(--border)'}`,
borderRadius: 20,
background: isEnabled ? 'var(--accent)' : 'var(--surface)',
color: isEnabled ? 'white' : 'var(--text3)',
fontSize: 12,
fontWeight: 600,
cursor: 'pointer',
transition: 'all 0.2s',
minWidth: 70
}}
>
{isEnabled ? '✓ AN' : '✗ AUS'}
</button>
</td>
)
}
// Count features: Text input
return (
<td key={`${tier.id}-${feature.id}`} style={{
textAlign: 'center', padding: 8,
background: isChanged ? 'var(--accent-light)' : 'transparent'
}}>
<input
type="text"
value={formatValue(currentValue)}
onChange={(e) => handleChange(tier.id, feature.id, e.target.value)}
placeholder="∞"
style={{
width: '80px',
padding: '6px 8px',
border: `1.5px solid ${isChanged ? 'var(--accent)' : 'var(--border)'}`,
borderRadius: 6,
textAlign: 'center',
fontSize: 13,
fontWeight: isChanged ? 600 : 400,
background: 'var(--bg)',
color: currentValue === 0 || currentValue === '0' ? 'var(--danger)' :
currentValue === null || currentValue === '' || currentValue === '∞' ? 'var(--accent)' : 'var(--text1)'
}}
/>
</td>
)
})}
</tr>
))}
</>
))}
</tbody>
</table>
</div>
{/* Legend */}
<div style={{
marginTop: 16, padding: 12, background: 'var(--surface2)',
borderRadius: 8, fontSize: 12, color: 'var(--text3)'
}}>
<strong>Eingabe:</strong>
<div style={{ marginTop: 8, display: 'flex', gap: 24, flexWrap: 'wrap' }}>
<span><strong style={{ color: 'var(--accent)' }}>leer oder </strong> = Unbegrenzt</span>
<span><strong style={{ color: 'var(--danger)' }}>0</strong> = Deaktiviert</span>
<span><strong>1-999999</strong> = Limit-Wert</span>
</div>
</div>
</div>
)
}
// Mobile Card Component
function FeatureMobileCard({ feature, tiers, getCurrentValue, handleChange, changes }) {
const [expanded, setExpanded] = useState(false)
return (
<div className="card" style={{ marginBottom: 8, padding: 12 }}>
{/* Feature Header */}
<div
onClick={() => setExpanded(!expanded)}
style={{
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
cursor: 'pointer', padding: '4px 0'
}}
>
<div>
<div style={{ fontWeight: 600, fontSize: 13 }}>{feature.name}</div>
<div style={{ fontSize: 11, color: 'var(--text3)' }}>
{feature.limit_type === 'boolean' ? '(ja/nein)' : `(${feature.reset_period})`}
</div>
</div>
{expanded ? <ChevronUp size={16}/> : <ChevronDown size={16}/>}
</div>
{/* Tier Inputs (Expanded) */}
{expanded && (
<div style={{ marginTop: 12, paddingTop: 12, borderTop: '1px solid var(--border)' }}>
{tiers.map(tier => {
const currentValue = getCurrentValue(tier.id, feature.id)
const isChanged = `${tier.id}:${feature.id}` in changes && changes[`${tier.id}:${feature.id}`].value !== ''
// Boolean features: Toggle button
if (feature.limit_type === 'boolean') {
const isEnabled = currentValue !== 0 && currentValue !== '0'
return (
<div key={tier.id} className="form-row" style={{ marginBottom: 8, alignItems: 'center' }}>
<label className="form-label" style={{ fontSize: 12 }}>{tier.name}</label>
<button
onClick={() => handleChange(tier.id, feature.id, isEnabled ? '0' : '1')}
style={{
flex: 1,
padding: '8px 16px',
border: `2px solid ${isEnabled ? 'var(--accent)' : 'var(--border)'}`,
borderRadius: 20,
background: isEnabled ? 'var(--accent)' : 'var(--surface)',
color: isEnabled ? 'white' : 'var(--text3)',
fontSize: 13,
fontWeight: 600,
cursor: 'pointer',
transition: 'all 0.2s'
}}
>
{isEnabled ? '✓ Aktiviert' : '✗ Deaktiviert'}
</button>
</div>
)
}
// Count features: Text input
return (
<div key={tier.id} className="form-row" style={{ marginBottom: 8 }}>
<label className="form-label" style={{ fontSize: 12 }}>{tier.name}</label>
<input
type="text"
className="form-input"
value={currentValue === null || currentValue === undefined ? '' : currentValue.toString()}
onChange={(e) => handleChange(tier.id, feature.id, e.target.value)}
placeholder="∞"
style={{
border: `1.5px solid ${isChanged ? 'var(--accent)' : 'var(--border)'}`,
background: isChanged ? 'var(--accent-light)' : 'var(--bg)',
color: currentValue === 0 ? 'var(--danger)' :
currentValue === null || currentValue === '' ? 'var(--accent)' : 'var(--text1)',
fontWeight: isChanged ? 600 : 400
}}
/>
<span className="form-unit" style={{ fontSize: 11 }}>
{currentValue === null || currentValue === '' ? '∞' : currentValue === 0 ? '❌' : '✓'}
</span>
</div>
)
})}
</div>
)}
</div>
)
}

View File

@ -0,0 +1,392 @@
import { useState, useEffect } from 'react'
import { Save, Plus, Edit2, Trash2, X } from 'lucide-react'
import { api } from '../utils/api'
export default function AdminTiersPage() {
const [loading, setLoading] = useState(true)
const [error, setError] = useState('')
const [success, setSuccess] = useState('')
const [tiers, setTiers] = useState([])
const [editingId, setEditingId] = useState(null)
const [showAddForm, setShowAddForm] = useState(false)
const [formData, setFormData] = useState({
id: '',
name: '',
description: '',
price_monthly_cents: '',
price_yearly_cents: '',
sort_order: 50,
active: true
})
useEffect(() => {
loadTiers()
}, [])
async function loadTiers() {
try {
setLoading(true)
const data = await api.listTiers()
setTiers(data)
setError('')
} catch (e) {
setError(e.message)
} finally {
setLoading(false)
}
}
function resetForm() {
setFormData({
id: '',
name: '',
description: '',
price_monthly_cents: '',
price_yearly_cents: '',
sort_order: 50,
active: true
})
setEditingId(null)
setShowAddForm(false)
}
function startEdit(tier) {
setFormData({
id: tier.id,
name: tier.name,
description: tier.description || '',
price_monthly_cents: tier.price_monthly_cents === null ? '' : tier.price_monthly_cents,
price_yearly_cents: tier.price_yearly_cents === null ? '' : tier.price_yearly_cents,
sort_order: tier.sort_order || 50,
active: tier.active
})
setEditingId(tier.id)
setShowAddForm(false)
}
async function handleSave() {
try {
setError('')
setSuccess('')
// Validation
if (!formData.name.trim()) {
setError('Name erforderlich')
return
}
const payload = {
name: formData.name.trim(),
description: formData.description.trim(),
price_monthly_cents: formData.price_monthly_cents === '' ? null : parseInt(formData.price_monthly_cents),
price_yearly_cents: formData.price_yearly_cents === '' ? null : parseInt(formData.price_yearly_cents),
sort_order: formData.sort_order,
active: formData.active
}
if (editingId) {
// Update existing
await api.updateTier(editingId, payload)
setSuccess('Tier aktualisiert')
} else {
// Create new
if (!formData.id.trim()) {
setError('ID erforderlich')
return
}
payload.id = formData.id.trim()
await api.createTier(payload)
setSuccess('Tier erstellt')
}
await loadTiers()
resetForm()
} catch (e) {
setError(e.message)
}
}
async function handleDelete(tierId) {
if (!confirm('Tier wirklich deaktivieren?')) return
try {
setError('')
await api.deleteTier(tierId)
setSuccess('Tier deaktiviert')
await loadTiers()
} catch (e) {
setError(e.message)
}
}
function formatPrice(cents) {
if (cents === null || cents === undefined) return 'Kostenlos'
return `${(cents / 100).toFixed(2)}`
}
if (loading) return (
<div style={{ display: 'flex', justifyContent: 'center', padding: 40 }}>
<div className="spinner" />
</div>
)
return (
<div style={{ paddingBottom: 80 }}>
{/* Header */}
<div style={{
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
marginBottom: 20
}}>
<div>
<div style={{ fontSize: 20, fontWeight: 700, color: 'var(--text1)' }}>
Tier-Verwaltung
</div>
<div style={{ fontSize: 13, color: 'var(--text3)', marginTop: 4 }}>
Subscription-Tiers konfigurieren
</div>
</div>
{!showAddForm && !editingId && (
<button
className="btn btn-primary"
onClick={() => setShowAddForm(true)}
>
<Plus size={16} /> Neuer Tier
</button>
)}
</div>
{/* Messages */}
{error && (
<div style={{
padding: 12, background: 'var(--danger)', color: 'white',
borderRadius: 8, marginBottom: 16, fontSize: 14
}}>
{error}
</div>
)}
{success && (
<div style={{
padding: 12, background: 'var(--accent)', color: 'white',
borderRadius: 8, marginBottom: 16, fontSize: 14
}}>
{success}
</div>
)}
{/* Add/Edit Form */}
{(showAddForm || editingId) && (
<div className="card" style={{ padding: 20, marginBottom: 20 }}>
<div style={{
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
marginBottom: 16
}}>
<div style={{ fontSize: 16, fontWeight: 600 }}>
{editingId ? 'Tier bearbeiten' : 'Neuen Tier erstellen'}
</div>
<button
className="btn btn-secondary"
onClick={resetForm}
style={{ padding: '6px 12px' }}
>
<X size={16} />
</button>
</div>
<div style={{ display: 'grid', gap: 12 }}>
{/* ID (nur bei Neuanlage) */}
{!editingId && (
<div className="form-row">
<label className="form-label">ID (Slug) *</label>
<input
className="form-input"
value={formData.id}
onChange={(e) => setFormData({ ...formData, id: e.target.value })}
placeholder="z.B. enterprise"
/>
<span className="form-unit" style={{ fontSize: 11, color: 'var(--text3)' }}>
Kleinbuchstaben, keine Leerzeichen
</span>
</div>
)}
{/* Name */}
<div className="form-row">
<label className="form-label">Name *</label>
<input
className="form-input"
value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
placeholder="z.B. Enterprise"
/>
</div>
{/* Description */}
<div className="form-row">
<label className="form-label">Beschreibung</label>
<input
className="form-input"
value={formData.description}
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
placeholder="z.B. Für Teams und Unternehmen"
/>
</div>
{/* Pricing */}
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12 }}>
<div className="form-row">
<label className="form-label">Monatspreis (Cent)</label>
<input
className="form-input"
type="number"
value={formData.price_monthly_cents}
onChange={(e) => setFormData({ ...formData, price_monthly_cents: e.target.value })}
placeholder="Leer = kostenlos"
/>
<span className="form-unit" style={{ fontSize: 11 }}>
{formData.price_monthly_cents ? formatPrice(parseInt(formData.price_monthly_cents)) : '-'}
</span>
</div>
<div className="form-row">
<label className="form-label">Jahrespreis (Cent)</label>
<input
className="form-input"
type="number"
value={formData.price_yearly_cents}
onChange={(e) => setFormData({ ...formData, price_yearly_cents: e.target.value })}
placeholder="Leer = kostenlos"
/>
<span className="form-unit" style={{ fontSize: 11 }}>
{formData.price_yearly_cents ? formatPrice(parseInt(formData.price_yearly_cents)) : '-'}
</span>
</div>
</div>
{/* Sort Order + Active */}
<div style={{ display: 'grid', gridTemplateColumns: '1fr auto', gap: 12, alignItems: 'end' }}>
<div className="form-row">
<label className="form-label">Sortierung</label>
<input
className="form-input"
type="number"
value={formData.sort_order}
onChange={(e) => setFormData({ ...formData, sort_order: parseInt(e.target.value) || 50 })}
/>
</div>
<label style={{ display: 'flex', alignItems: 'center', gap: 6, fontSize: 13, paddingBottom: 8 }}>
<input
type="checkbox"
checked={formData.active}
onChange={(e) => setFormData({ ...formData, active: e.target.checked })}
/>
Aktiv
</label>
</div>
{/* Actions */}
<div style={{ display: 'flex', gap: 8, marginTop: 8 }}>
<button className="btn btn-primary" onClick={handleSave}>
<Save size={14} /> {editingId ? 'Aktualisieren' : 'Erstellen'}
</button>
<button className="btn btn-secondary" onClick={resetForm}>
Abbrechen
</button>
</div>
</div>
</div>
)}
{/* Tiers List */}
<div style={{ display: 'grid', gap: 12 }}>
{tiers.length === 0 && (
<div className="card" style={{ padding: 40, textAlign: 'center', color: 'var(--text3)' }}>
Keine Tiers vorhanden
</div>
)}
{tiers.map(tier => (
<div
key={tier.id}
className="card"
style={{
padding: 16,
opacity: tier.active ? 1 : 0.6,
border: tier.active ? '1px solid var(--border)' : '1px dashed var(--border)'
}}
>
<div style={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between' }}>
<div style={{ flex: 1 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 4 }}>
<div style={{ fontSize: 16, fontWeight: 700, color: 'var(--text1)' }}>
{tier.name}
</div>
<span style={{
padding: '2px 8px', borderRadius: 4, fontSize: 10,
background: 'var(--surface2)', color: 'var(--text3)', fontFamily: 'monospace'
}}>
{tier.id}
</span>
{!tier.active && (
<span style={{
padding: '2px 8px', borderRadius: 4, fontSize: 10,
background: 'var(--danger)', color: 'white', fontWeight: 600
}}>
INAKTIV
</span>
)}
</div>
{tier.description && (
<div style={{ fontSize: 13, color: 'var(--text2)', marginBottom: 8 }}>
{tier.description}
</div>
)}
<div style={{ display: 'flex', gap: 16, fontSize: 12, color: 'var(--text3)' }}>
<div>
<strong>Monatlich:</strong> {formatPrice(tier.price_monthly_cents)}
</div>
<div>
<strong>Jährlich:</strong> {formatPrice(tier.price_yearly_cents)}
</div>
<div>
<strong>Sortierung:</strong> {tier.sort_order}
</div>
</div>
</div>
<div style={{ display: 'flex', gap: 4 }}>
<button
className="btn btn-secondary"
onClick={() => startEdit(tier)}
style={{ padding: '6px 12px', fontSize: 12 }}
>
<Edit2 size={14} />
</button>
<button
className="btn btn-secondary"
onClick={() => handleDelete(tier.id)}
style={{ padding: '6px 12px', fontSize: 12, color: 'var(--danger)' }}
disabled={!tier.active}
>
<Trash2 size={14} />
</button>
</div>
</div>
</div>
))}
</div>
{/* Info */}
<div style={{
marginTop: 16, padding: 12, background: 'var(--surface2)',
borderRadius: 8, fontSize: 12, color: 'var(--text3)'
}}>
<strong>Hinweis:</strong> Limits für jeden Tier können in der{' '}
<a href="/admin/tier-limits" style={{ color: 'var(--accent)', textDecoration: 'none' }}>
Tier Limits Matrix
</a>{' '}
konfiguriert werden.
</div>
</div>
)
}

View File

@ -0,0 +1,538 @@
import { useState, useEffect } from 'react'
import { Save, AlertCircle, X, RotateCcw } from 'lucide-react'
import { api } from '../utils/api'
export default function AdminUserRestrictionsPage() {
const [loading, setLoading] = useState(true)
const [error, setError] = useState('')
const [success, setSuccess] = useState('')
const [users, setUsers] = useState([])
const [features, setFeatures] = useState([])
const [selectedUserId, setSelectedUserId] = useState('')
const [selectedUser, setSelectedUser] = useState(null)
const [restrictions, setRestrictions] = useState([])
const [tierLimits, setTierLimits] = useState({})
const [changes, setChanges] = useState({})
const [saving, setSaving] = useState(false)
useEffect(() => {
loadInitialData()
}, [])
useEffect(() => {
if (selectedUserId) {
loadUserData(selectedUserId)
} else {
setSelectedUser(null)
setRestrictions([])
setChanges({})
}
}, [selectedUserId])
async function loadInitialData() {
try {
setLoading(true)
const [usersData, featuresData] = await Promise.all([
api.adminListProfiles(),
api.listFeatures()
])
setUsers(usersData)
setFeatures(featuresData.filter(f => f.active))
setError('')
} catch (e) {
setError(e.message)
} finally {
setLoading(false)
}
}
async function loadUserData(userId) {
try {
const [user, restrictionsData, limitsMatrix] = await Promise.all([
api.adminListProfiles().then(users => users.find(u => u.id === userId)),
api.listUserRestrictions(userId),
api.getTierLimitsMatrix()
])
setSelectedUser(user)
setRestrictions(restrictionsData)
// Build tier limits lookup for this user's tier
const userTier = user.tier || 'free'
const limits = {}
features.forEach(feature => {
const key = `${userTier}:${feature.id}`
// Use same fallback logic as TierLimitsPage: undefined null (unlimited)
limits[feature.id] = limitsMatrix.limits[key] ?? null
})
setTierLimits(limits)
setChanges({})
setError('')
setSuccess('')
} catch (e) {
setError(e.message)
}
}
function handleChange(featureId, value) {
const newChanges = { ...changes }
const tierLimit = tierLimits[featureId]
// Parse value (EXACTLY like TierLimitsPage)
let parsedValue = null
if (value === 'unlimited' || value === '∞') {
parsedValue = null // unlimited
} else if (value === '0' || value === 'disabled') {
parsedValue = 0 // disabled
} else if (value === '') {
parsedValue = null // empty unlimited
} else {
const num = parseInt(value)
if (!isNaN(num) && num >= 0) {
parsedValue = num
} else {
return // invalid input, ignore
}
}
// Check if value equals tier limit remove override
if (parsedValue === tierLimit) {
newChanges[featureId] = { action: 'remove', tempValue: value }
} else {
// Different from tier default set override
newChanges[featureId] = { action: 'set', value: parsedValue, tempValue: value }
}
setChanges(newChanges)
}
function handleToggle(featureId) {
// Get current state
const restriction = restrictions.find(r => r.feature_id === featureId)
let currentValue = restriction?.limit_value ?? null
// Check if there's a pending change
if (featureId in changes && changes[featureId].action === 'set') {
currentValue = changes[featureId].value
}
// Toggle between 1 (enabled) and 0 (disabled)
const isCurrentlyEnabled = currentValue !== 0 && currentValue !== '0'
const newValue = isCurrentlyEnabled ? 0 : 1
const newChanges = { ...changes }
newChanges[featureId] = { action: 'set', value: newValue, tempValue: newValue.toString() }
setChanges(newChanges)
}
async function handleSave() {
if (!selectedUserId) return
try {
setSaving(true)
setError('')
setSuccess('')
let changeCount = 0
for (const [featureId, change] of Object.entries(changes)) {
const existingRestriction = restrictions.find(r => r.feature_id === featureId)
if (change.action === 'remove') {
// Remove restriction if exists
if (existingRestriction) {
await api.deleteUserRestriction(existingRestriction.id)
changeCount++
}
} else if (change.action === 'set') {
// Create or update
if (existingRestriction) {
await api.updateUserRestriction(existingRestriction.id, {
limit_value: change.value,
enabled: true
})
} else {
await api.createUserRestriction({
profile_id: selectedUserId,
feature_id: featureId,
limit_value: change.value,
enabled: true,
reason: 'Admin override'
})
}
changeCount++
}
}
setSuccess(`${changeCount} Änderung(en) gespeichert`)
await loadUserData(selectedUserId)
} catch (e) {
setError(e.message)
} finally {
setSaving(false)
}
}
function getDisplayValue(featureId) {
// Check pending changes first
if (featureId in changes) {
const change = changes[featureId]
if (change.action === 'remove') {
// Returning to tier default
return formatValue(tierLimits[featureId])
}
if (change.action === 'set') {
// Use tempValue for display if available, otherwise format the value
return change.tempValue !== undefined ? change.tempValue : formatValue(change.value)
}
}
// Show override if exists, otherwise tier limit (= effective value)
const restriction = restrictions.find(r => r.feature_id === featureId)
if (restriction) {
return formatValue(restriction.limit_value)
}
// No override: show tier limit as default
return formatValue(tierLimits[featureId])
}
function formatValue(val) {
if (val === null || val === undefined) return 'unlimited'
if (val === '' ) return ''
if (val === '∞' || val === 'unlimited') return 'unlimited'
if (val === 0 || val === '0') return '0'
return val.toString()
}
function getToggleState(featureId) {
// Check pending changes first
if (featureId in changes && changes[featureId].action === 'set') {
const val = changes[featureId].value
return val !== 0 && val !== '0'
}
// Check existing restriction
const restriction = restrictions.find(r => r.feature_id === featureId)
if (!restriction) {
// No override: use tier default
const tierLimit = tierLimits[featureId]
return tierLimit !== 0 && tierLimit !== '0'
}
// For boolean features: limit_value determines state
return restriction.limit_value !== 0 && restriction.limit_value !== '0'
}
function hasOverride(featureId) {
return restrictions.some(r => r.feature_id === featureId)
}
function isChanged(featureId) {
return featureId in changes
}
if (loading) return (
<div style={{ display: 'flex', justifyContent: 'center', padding: 40 }}>
<div className="spinner" />
</div>
)
const hasChanges = Object.keys(changes).length > 0
const categoryGroups = {}
features.forEach(f => {
if (!categoryGroups[f.category]) categoryGroups[f.category] = []
categoryGroups[f.category].push(f)
})
const categoryIcons = { data: '📊', ai: '🤖', export: '📤', integration: '🔗' }
const categoryNames = { data: 'DATEN', ai: 'KI', export: 'EXPORT', integration: 'INTEGRATIONEN' }
return (
<div style={{ paddingBottom: 80 }}>
{/* Header */}
<div style={{ marginBottom: 20 }}>
<div style={{ fontSize: 20, fontWeight: 700, color: 'var(--text1)' }}>
User Feature-Overrides
</div>
<div style={{ fontSize: 13, color: 'var(--text3)', marginTop: 4 }}>
Individuelle Feature-Limits für einzelne User setzen
</div>
</div>
{/* Info Box */}
<div style={{
padding: 12, background: 'var(--accent-light)', borderRadius: 8,
marginBottom: 16, fontSize: 12, color: 'var(--accent-dark)',
display: 'flex', gap: 8, alignItems: 'flex-start'
}}>
<AlertCircle size={16} style={{ marginTop: 2, flexShrink: 0 }} />
<div>
<strong>Hinweis:</strong> Felder zeigen effektive Werte (Override falls gesetzt, sonst Tier-Standard).
Wert ändern Override wird gesetzt. Wert = Tier-Standard Override wird entfernt.
</div>
</div>
{/* Messages */}
{error && (
<div style={{
padding: 12, background: 'var(--danger)', color: 'white',
borderRadius: 8, marginBottom: 16, fontSize: 14
}}>
{error}
</div>
)}
{success && (
<div style={{
padding: 12, background: 'var(--accent)', color: 'white',
borderRadius: 8, marginBottom: 16, fontSize: 14
}}>
{success}
</div>
)}
{/* User Selection */}
<div className="card" style={{ padding: 16, marginBottom: 16 }}>
<label className="form-label" style={{ display: 'block', marginBottom: 8 }}>
User auswählen
</label>
<select
className="form-input"
style={{ width: '100%' }}
value={selectedUserId}
onChange={(e) => setSelectedUserId(e.target.value)}
>
<option value="">-- User auswählen --</option>
{users.map(u => (
<option key={u.id} value={u.id}>
{u.name} ({u.email || u.id}) - Tier: {u.tier || 'free'}
</option>
))}
</select>
</div>
{/* User Info + Features */}
{selectedUser && (
<>
{/* Action Buttons */}
<div style={{
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
marginBottom: 16
}}>
<div style={{ fontSize: 16, fontWeight: 600 }}>
Feature-Overrides für {selectedUser.name}
</div>
<div style={{ display: 'flex', gap: 8 }}>
{hasChanges && (
<button
className="btn btn-secondary"
onClick={() => setChanges({})}
disabled={saving}
>
<X size={14} /> Abbrechen
</button>
)}
<button
className="btn btn-primary"
onClick={handleSave}
disabled={!hasChanges || saving}
>
{saving ? 'Speichern...' : hasChanges ? `${Object.keys(changes).length} Änderung(en) speichern` : 'Keine Änderungen'}
</button>
</div>
</div>
{/* User Info Card */}
<div className="card" style={{ padding: 16, marginBottom: 16 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
<div style={{
width: 40, height: 40, borderRadius: '50%',
background: selectedUser.avatar_color || 'var(--accent)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
color: 'white', fontWeight: 700, fontSize: 18
}}>
{selectedUser.name?.charAt(0).toUpperCase()}
</div>
<div style={{ flex: 1 }}>
<div style={{ fontWeight: 600, fontSize: 14 }}>{selectedUser.name}</div>
<div style={{ fontSize: 12, color: 'var(--text3)' }}>
{selectedUser.email || `ID: ${selectedUser.id}`}
</div>
</div>
<div style={{
padding: '4px 12px', borderRadius: 6,
background: 'var(--accent-light)', color: 'var(--accent-dark)',
fontSize: 12, fontWeight: 600
}}>
Tier: {selectedUser.tier || 'free'}
</div>
</div>
</div>
{/* Features Table */}
<div className="card" style={{ padding: 0, overflow: 'auto', marginBottom: 16 }}>
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 13 }}>
<thead>
<tr style={{ background: 'var(--surface2)' }}>
<th style={{ textAlign: 'left', padding: '12px 16px', fontWeight: 600 }}>
Feature
</th>
<th style={{ textAlign: 'center', padding: '12px 16px', fontWeight: 600 }}>
Tier-Limit
</th>
<th style={{ textAlign: 'center', padding: '12px 16px', fontWeight: 600 }}>
Override-Wert
</th>
<th style={{ textAlign: 'right', padding: '12px 16px', fontWeight: 600 }}>
Aktion
</th>
</tr>
</thead>
<tbody>
{Object.entries(categoryGroups).map(([category, categoryFeatures]) => (
<>
{/* Category Header */}
<tr key={`cat-${category}`} style={{ background: 'var(--accent-light)' }}>
<td colSpan={4} style={{
padding: '8px 16px', fontWeight: 600, fontSize: 11,
textTransform: 'uppercase', letterSpacing: '0.5px',
color: 'var(--accent-dark)'
}}>
{categoryIcons[category]} {categoryNames[category] || category}
</td>
</tr>
{/* Feature Rows */}
{categoryFeatures.map(feature => {
const displayValue = getDisplayValue(feature.id)
const toggleState = getToggleState(feature.id)
const override = hasOverride(feature.id)
const changed = isChanged(feature.id)
return (
<tr key={feature.id} style={{
borderBottom: '1px solid var(--border)',
background: changed ? 'var(--accent-light)' : 'transparent'
}}>
{/* Feature Name */}
<td style={{ padding: '12px 16px' }}>
<div style={{ fontWeight: 500 }}>{feature.name}</div>
<div style={{ fontSize: 11, color: 'var(--text3)', marginTop: 2 }}>
{feature.limit_type === 'boolean' ? '(ja/nein)' : `(${feature.reset_period})`}
</div>
</td>
{/* Tier-Limit */}
<td style={{ padding: '12px 16px', textAlign: 'center' }}>
{feature.limit_type === 'boolean' ? (
<span style={{
padding: '6px 12px', borderRadius: 20,
background: tierLimits[feature.id] !== 0 ? 'var(--accent-light)' : 'var(--surface2)',
color: tierLimits[feature.id] !== 0 ? 'var(--accent-dark)' : 'var(--text3)',
fontSize: 12, fontWeight: 600
}}>
{tierLimits[feature.id] !== 0 ? '✓ AN' : '✗ AUS'}
</span>
) : (
<span style={{ fontWeight: 500, color: 'var(--text2)' }}>
{tierLimits[feature.id] === null ? '∞' : tierLimits[feature.id]}
</span>
)}
</td>
{/* Override Input */}
<td style={{ padding: '12px 16px', textAlign: 'center' }}>
{feature.limit_type === 'boolean' ? (
<button
onClick={() => handleToggle(feature.id)}
style={{
padding: '6px 16px',
border: `2px solid ${toggleState ? 'var(--accent)' : 'var(--border)'}`,
borderRadius: 20,
background: toggleState ? 'var(--accent)' : 'var(--surface)',
color: toggleState ? 'white' : 'var(--text3)',
fontSize: 12,
fontWeight: 600,
cursor: 'pointer',
transition: 'all 0.2s',
minWidth: 80
}}
>
{toggleState ? '✓ AN' : '✗ AUS'}
</button>
) : (
<input
type="text"
value={displayValue}
onChange={(e) => handleChange(feature.id, e.target.value)}
placeholder=""
style={{
width: '120px',
padding: '6px 8px',
border: `1.5px solid ${changed ? 'var(--accent)' : override ? 'var(--accent)' : 'var(--border)'}`,
borderRadius: 6,
textAlign: 'center',
fontSize: 13,
fontWeight: override || changed ? 600 : 400,
background: override || changed ? 'var(--accent-light)' : 'var(--bg)',
color: displayValue === '0' ? 'var(--danger)' :
displayValue === 'unlimited' ? 'var(--accent)' : 'var(--text1)'
}}
/>
)}
</td>
{/* Action */}
<td style={{ padding: '12px 16px', textAlign: 'right' }}>
<button
className="btn btn-secondary"
onClick={() => {
// Reset to tier default
const tierValue = tierLimits[feature.id]
handleChange(feature.id, formatValue(tierValue))
}}
disabled={!override}
style={{
padding: '4px 8px',
fontSize: 11,
opacity: override ? 1 : 0.4,
cursor: override ? 'pointer' : 'not-allowed'
}}
>
Zurück
</button>
</td>
</tr>
)
})}
</>
))}
</tbody>
</table>
</div>
{/* Legend */}
<div style={{
marginTop: 16, padding: 12, background: 'var(--surface2)',
borderRadius: 8, fontSize: 12, color: 'var(--text3)'
}}>
<strong>Eingabe:</strong>
<div style={{ marginTop: 8, display: 'flex', gap: 16, flexWrap: 'wrap' }}>
<span><strong>unlimited</strong> = Unbegrenzt</span>
<span><strong style={{ color: 'var(--danger)' }}>0</strong> = Feature deaktiviert</span>
<span><strong>1+</strong> = Limit-Wert</span>
</div>
<div style={{ marginTop: 8, fontSize: 11, opacity: 0.8 }}>
Feld zeigt effektiven Wert (Override falls gesetzt, sonst Tier-Standard)<br />
Wert ändern Override wird gesetzt<br />
Wert = Tier-Standard Override wird entfernt
</div>
</div>
</>
)}
</div>
)
}

View File

@ -3,6 +3,7 @@ import { Brain, Pencil, Trash2, ChevronDown, ChevronUp, Check, X } from 'lucide-
import { api } from '../utils/api'
import { useAuth } from '../context/AuthContext'
import Markdown from '../utils/Markdown'
import UsageBadge from '../components/UsageBadge'
import dayjs from 'dayjs'
import 'dayjs/locale/de'
dayjs.locale('de')
@ -114,6 +115,7 @@ export default function Analysis() {
const [tab, setTab] = useState('run')
const [newResult, setNewResult] = useState(null)
const [pipelineLoading, setPipelineLoading] = useState(false)
const [aiUsage, setAiUsage] = useState(null) // Phase 3: Usage badge
const loadAll = async () => {
const [p, i] = await Promise.all([
@ -123,7 +125,15 @@ export default function Analysis() {
setPrompts(Array.isArray(p)?p:[])
setAllInsights(Array.isArray(i)?i:[])
}
useEffect(()=>{ loadAll() },[])
useEffect(()=>{
loadAll()
// Load feature usage for badges
api.getFeatureUsage().then(features => {
const aiFeature = features.find(f => f.feature_id === 'ai_calls')
setAiUsage(aiFeature)
}).catch(err => console.error('Failed to load usage:', err))
},[])
const runPipeline = async () => {
setPipelineLoading(true); setError(null); setNewResult(null)
@ -177,7 +187,7 @@ export default function Analysis() {
grouped[key].push(ins)
})
const activePrompts = prompts.filter(p=>p.active && !p.slug.startsWith('pipeline_'))
const activePrompts = prompts.filter(p=>p.active && !p.slug.startsWith('pipeline_') && p.slug !== 'pipeline')
// Pipeline is available if the "pipeline" prompt is active
const pipelinePrompt = prompts.find(p=>p.slug==='pipeline')
@ -230,7 +240,10 @@ export default function Analysis() {
<div className="card" style={{marginBottom:16,borderColor:'var(--accent)',borderWidth:2}}>
<div style={{display:'flex',alignItems:'flex-start',gap:12}}>
<div style={{flex:1}}>
<div style={{fontWeight:700,fontSize:15,color:'var(--accent)'}}>🔬 Mehrstufige Gesamtanalyse</div>
<div className="badge-container-right" style={{fontWeight:700,fontSize:15,color:'var(--accent)'}}>
<span>🔬 Mehrstufige Gesamtanalyse</span>
{aiUsage && <UsageBadge {...aiUsage} />}
</div>
<div style={{fontSize:12,color:'var(--text2)',marginTop:3,lineHeight:1.5}}>
3 spezialisierte KI-Calls parallel (Körper + Ernährung + Aktivität),
dann Synthese + Zielabgleich. Detaillierteste Auswertung.
@ -241,12 +254,22 @@ export default function Analysis() {
</div>
)}
</div>
<button className="btn btn-primary" style={{flexShrink:0,minWidth:100}}
onClick={runPipeline} disabled={!!loading||pipelineLoading}>
{pipelineLoading
? <><div className="spinner" style={{width:13,height:13}}/> Läuft</>
: <><Brain size={13}/> Starten</>}
</button>
<div
title={aiUsage && !aiUsage.allowed ? `Limit erreicht (${aiUsage.used}/${aiUsage.limit}). Kontaktiere den Admin oder warte bis zum nächsten Reset.` : ''}
style={{display:'inline-block'}}
>
<button
className="btn btn-primary"
style={{flexShrink:0,minWidth:100, cursor: (aiUsage && !aiUsage.allowed) ? 'not-allowed' : 'pointer'}}
onClick={runPipeline}
disabled={!!loading||pipelineLoading||(aiUsage && !aiUsage.allowed)}
>
{pipelineLoading
? <><div className="spinner" style={{width:13,height:13}}/> Läuft</>
: (aiUsage && !aiUsage.allowed) ? '🔒 Limit'
: <><Brain size={13}/> Starten</>}
</button>
</div>
{!canUseAI && <div style={{fontSize:11,color:'#D85A30',marginTop:4}}>🔒 KI nicht freigeschaltet</div>}
</div>
{pipelineLoading && (
@ -282,7 +305,10 @@ export default function Analysis() {
<div key={p.id} className="card section-gap">
<div style={{display:'flex',alignItems:'flex-start',gap:12}}>
<div style={{flex:1}}>
<div style={{fontWeight:600,fontSize:15}}>{SLUG_LABELS[p.slug]||p.name}</div>
<div className="badge-container-right" style={{fontWeight:600,fontSize:15}}>
<span>{SLUG_LABELS[p.slug]||p.name}</span>
{aiUsage && <UsageBadge {...aiUsage} />}
</div>
{p.description && <div style={{fontSize:12,color:'var(--text3)',marginTop:2}}>{p.description}</div>}
{existing && (
<div style={{fontSize:11,color:'var(--text3)',marginTop:3}}>
@ -290,12 +316,22 @@ export default function Analysis() {
</div>
)}
</div>
<button className="btn btn-primary" style={{flexShrink:0,minWidth:90}}
onClick={()=>runPrompt(p.slug)} disabled={!!loading||!canUseAI}>
{loading===p.slug
? <><div className="spinner" style={{width:13,height:13}}/> Läuft</>
: <><Brain size={13}/> {existing?'Neu erstellen':'Starten'}</>}
</button>
<div
title={aiUsage && !aiUsage.allowed ? `Limit erreicht (${aiUsage.used}/${aiUsage.limit}). Kontaktiere den Admin oder warte bis zum nächsten Reset.` : ''}
style={{display:'inline-block'}}
>
<button
className="btn btn-primary"
style={{flexShrink:0,minWidth:90, cursor: (aiUsage && !aiUsage.allowed) ? 'not-allowed' : 'pointer'}}
onClick={()=>runPrompt(p.slug)}
disabled={!!loading||!canUseAI||(aiUsage && !aiUsage.allowed)}
>
{loading===p.slug
? <><div className="spinner" style={{width:13,height:13}}/> Läuft</>
: (aiUsage && !aiUsage.allowed) ? '🔒 Limit'
: <><Brain size={13}/> {existing?'Neu erstellen':'Starten'}</>}
</button>
</div>
</div>
{/* Show existing result collapsed */}
{existing && newResult?.id !== existing.id && (

View File

@ -4,6 +4,7 @@ import { useNavigate } from 'react-router-dom'
import { api } from '../utils/api'
import { calcBodyFat, getBfCategory, METHOD_POINTS } from '../utils/calc'
import { CALIPER_POINTS, CALIPER_METHODS } from '../utils/guideData'
import UsageBadge from '../components/UsageBadge'
import dayjs from 'dayjs'
function emptyForm() {
@ -15,7 +16,7 @@ function emptyForm() {
}
}
function CaliperForm({ form, setForm, profile, onSave, onCancel, saveLabel='Speichern' }) {
function CaliperForm({ form, setForm, profile, onSave, onCancel, saveLabel='Speichern', saving=false, error=null, usage=null }) {
const sex = profile?.sex||'m'
const age = profile?.dob ? Math.floor((Date.now()-new Date(profile.dob))/(365.25*24*3600*1000)) : 30
const weight = form.weight || 80
@ -65,8 +66,25 @@ function CaliperForm({ form, setForm, profile, onSave, onCancel, saveLabel='Spei
<input type="text" className="form-input" placeholder="optional" value={form.notes||''} onChange={e=>set('notes',e.target.value)}/>
<span className="form-unit"/>
</div>
{error && (
<div style={{padding:'10px',background:'var(--danger-bg)',border:'1px solid var(--danger)',borderRadius:8,fontSize:13,color:'var(--danger)',marginBottom:8}}>
{error}
</div>
)}
<div style={{display:'flex',gap:6,marginTop:8}}>
<button className="btn btn-primary" style={{flex:1}} onClick={()=>onSave(bfPct, sex)}>{saveLabel}</button>
<div
title={usage && !usage.allowed ? `Limit erreicht (${usage.used}/${usage.limit}). Kontaktiere den Admin oder warte bis zum nächsten Reset.` : ''}
style={{flex:1,display:'inline-block'}}
>
<button
className="btn btn-primary"
style={{width:'100%', cursor: (usage && !usage.allowed) ? 'not-allowed' : 'pointer'}}
onClick={()=>onSave(bfPct, sex)}
disabled={saving || (usage && !usage.allowed)}
>
{(usage && !usage.allowed) ? '🔒 Limit erreicht' : saveLabel}
</button>
</div>
{onCancel && <button className="btn btn-secondary" style={{flex:1}} onClick={onCancel}><X size={13}/> Abbrechen</button>}
</div>
</div>
@ -78,12 +96,26 @@ export default function CaliperScreen() {
const [profile, setProfile] = useState(null)
const [form, setForm] = useState(emptyForm())
const [editing, setEditing] = useState(null)
const [saving, setSaving] = useState(false)
const [saved, setSaved] = useState(false)
const [error, setError] = useState(null)
const [caliperUsage, setCaliperUsage] = useState(null) // Phase 4: Usage badge
const nav = useNavigate()
const load = () => Promise.all([api.listCaliper(), api.getProfile()])
.then(([e,p])=>{ setEntries(e); setProfile(p) })
useEffect(()=>{ load() },[])
const loadUsage = () => {
api.getFeatureUsage().then(features => {
const caliperFeature = features.find(f => f.feature_id === 'caliper_entries')
setCaliperUsage(caliperFeature)
}).catch(err => console.error('Failed to load usage:', err))
}
useEffect(()=>{
load()
loadUsage()
},[])
const buildPayload = (f, bfPct, sex) => {
const weight = profile?.weight || null
@ -97,11 +129,23 @@ export default function CaliperScreen() {
}
const handleSave = async (bfPct, sex) => {
const payload = buildPayload(form, bfPct, sex)
await api.upsertCaliper(payload)
setSaved(true); await load()
setTimeout(()=>setSaved(false),2000)
setForm(emptyForm())
setSaving(true)
setError(null)
try {
const payload = buildPayload(form, bfPct, sex)
await api.upsertCaliper(payload)
setSaved(true)
await load()
await loadUsage() // Reload usage after save
setTimeout(()=>setSaved(false),2000)
setForm(emptyForm())
} catch (err) {
console.error('Save failed:', err)
setError(err.message || 'Fehler beim Speichern')
setTimeout(()=>setError(null), 5000)
} finally {
setSaving(false)
}
}
const handleUpdate = async (bfPct, sex) => {
@ -125,9 +169,13 @@ export default function CaliperScreen() {
</div>
<div className="card section-gap">
<div className="card-title">Neue Messung</div>
<div className="card-title badge-container-right">
<span>Neue Messung</span>
{caliperUsage && <UsageBadge {...caliperUsage} />}
</div>
<CaliperForm form={form} setForm={setForm} profile={profile}
onSave={handleSave} saveLabel={saved?'✓ Gespeichert!':'Speichern'}/>
onSave={handleSave} saveLabel={saved?'✓ Gespeichert!':'Speichern'}
saving={saving} error={error} usage={caliperUsage}/>
</div>
<div className="section-gap">

View File

@ -3,6 +3,7 @@ import { Pencil, Trash2, Check, X, Camera, BookOpen } from 'lucide-react'
import { useNavigate } from 'react-router-dom'
import { api } from '../utils/api'
import { CIRCUMFERENCE_POINTS } from '../utils/guideData'
import UsageBadge from '../components/UsageBadge'
import dayjs from 'dayjs'
const FIELDS = ['c_neck','c_chest','c_waist','c_belly','c_hip','c_thigh','c_calf','c_arm']
@ -16,18 +17,32 @@ export default function CircumScreen() {
const [editing, setEditing] = useState(null)
const [saving, setSaving] = useState(false)
const [saved, setSaved] = useState(false)
const [error, setError] = useState(null)
const [photoFile, setPhotoFile] = useState(null)
const [photoPreview, setPhotoPreview] = useState(null)
const [circumUsage, setCircumUsage] = useState(null) // Phase 4: Usage badge
const fileRef = useRef()
const nav = useNavigate()
const load = () => api.listCirc().then(setEntries)
useEffect(()=>{ load() },[])
const loadUsage = () => {
api.getFeatureUsage().then(features => {
const circumFeature = features.find(f => f.feature_id === 'circumference_entries')
setCircumUsage(circumFeature)
}).catch(err => console.error('Failed to load usage:', err))
}
useEffect(()=>{
load()
loadUsage()
},[])
const set = (k,v) => setForm(f=>({...f,[k]:v}))
const handleSave = async () => {
setSaving(true)
setError(null)
try {
const payload = {}
payload.date = form.date
@ -38,10 +53,18 @@ export default function CircumScreen() {
payload.photo_id = pr.id
}
await api.upsertCirc(payload)
setSaved(true); await load()
setSaved(true)
await load()
await loadUsage() // Reload usage after save
setTimeout(()=>setSaved(false),2000)
setForm(empty()); setPhotoFile(null); setPhotoPreview(null)
} finally { setSaving(false) }
} catch (err) {
console.error('Save failed:', err)
setError(err.message || 'Fehler beim Speichern')
setTimeout(()=>setError(null), 5000)
} finally {
setSaving(false)
}
}
const startEdit = (e) => setEditing({...e})
@ -72,7 +95,10 @@ export default function CircumScreen() {
{/* Eingabe */}
<div className="card section-gap">
<div className="card-title">Neue Messung</div>
<div className="card-title badge-container-right">
<span>Neue Messung</span>
{circumUsage && <UsageBadge {...circumUsage} />}
</div>
<div className="form-row">
<label className="form-label">Datum</label>
<input type="date" className="form-input" style={{width:140}} value={form.date} onChange={e=>set('date',e.target.value)}/>
@ -99,9 +125,27 @@ export default function CircumScreen() {
<button className="btn btn-secondary btn-full" onClick={()=>fileRef.current.click()}>
<Camera size={14}/> {photoPreview?'Foto ändern':'Foto hinzufügen'}
</button>
<button className="btn btn-primary btn-full" style={{marginTop:8}} onClick={handleSave} disabled={saving}>
{saved ? <><Check size={14}/> Gespeichert!</> : saving ? '…' : 'Speichern'}
</button>
{error && (
<div style={{padding:'10px',background:'var(--danger-bg)',border:'1px solid var(--danger)',borderRadius:8,fontSize:13,color:'var(--danger)',marginTop:8}}>
{error}
</div>
)}
<div
title={circumUsage && !circumUsage.allowed ? `Limit erreicht (${circumUsage.used}/${circumUsage.limit}). Kontaktiere den Admin oder warte bis zum nächsten Reset.` : ''}
style={{display:'inline-block',width:'100%',marginTop:8}}
>
<button
className="btn btn-primary btn-full"
style={{cursor: (circumUsage && !circumUsage.allowed) ? 'not-allowed' : 'pointer'}}
onClick={handleSave}
disabled={saving || (circumUsage && !circumUsage.allowed)}
>
{saved ? <><Check size={14}/> Gespeichert!</>
: saving ? '…'
: (circumUsage && !circumUsage.allowed) ? '🔒 Limit erreicht'
: 'Speichern'}
</button>
</div>
</div>
{/* Liste */}

View File

@ -27,32 +27,75 @@ function QuickWeight({ onSaved }) {
const [input, setInput] = useState('')
const [saving, setSaving] = useState(false)
const [saved, setSaved] = useState(false)
const [error, setError] = useState(null)
const [weightUsage, setWeightUsage] = useState(null)
const today = dayjs().format('YYYY-MM-DD')
const loadUsage = () => {
api.getFeatureUsage().then(features => {
const weightFeature = features.find(f => f.feature_id === 'weight_entries')
setWeightUsage(weightFeature)
}).catch(err => console.error('Failed to load usage:', err))
}
useEffect(()=>{
api.weightStats().then(s=>{
if(s?.latest?.date===today) setInput(String(s.latest.weight))
})
loadUsage()
},[])
const handleSave = async () => {
const w=parseFloat(input); if(!w||w<20||w>300) return
setSaving(true)
try{ await api.upsertWeight(today,w); setSaved(true); onSaved?.(); setTimeout(()=>setSaved(false),2000) }
finally{ setSaving(false) }
setError(null)
try{
await api.upsertWeight(today,w)
setSaved(true)
await loadUsage() // Reload usage after save
onSaved?.()
setTimeout(()=>setSaved(false),2000)
} catch(err) {
console.error('Save failed:', err)
setError(err.message || 'Fehler beim Speichern')
setTimeout(()=>setError(null), 5000)
} finally {
setSaving(false)
}
}
const isDisabled = saving || !input || (weightUsage && !weightUsage.allowed)
const tooltipText = weightUsage && !weightUsage.allowed
? `Limit erreicht (${weightUsage.used}/${weightUsage.limit}). Kontaktiere den Admin oder warte bis zum nächsten Reset.`
: ''
return (
<div style={{display:'flex',gap:8,alignItems:'center'}}>
<input type="number" min={20} max={300} step={0.1} className="form-input"
style={{flex:1,fontSize:17,fontWeight:600,textAlign:'center'}}
placeholder="kg eingeben" value={input} onChange={e=>setInput(e.target.value)}
onKeyDown={e=>e.key==='Enter'&&handleSave()}/>
<span style={{fontSize:13,color:'var(--text3)'}}>kg</span>
<button className="btn btn-primary" style={{padding:'8px 14px'}}
onClick={handleSave} disabled={saving||!input}>
{saved?<Check size={15}/>:saving?<div className="spinner" style={{width:14,height:14}}/>:'Speichern'}
</button>
<div>
{error && (
<div style={{padding:'8px 10px',background:'var(--danger-bg)',border:'1px solid var(--danger)',borderRadius:8,fontSize:12,color:'var(--danger)',marginBottom:8}}>
{error}
</div>
)}
<div style={{display:'flex',gap:8,alignItems:'center'}}>
<input type="number" min={20} max={300} step={0.1} className="form-input"
style={{flex:1,fontSize:17,fontWeight:600,textAlign:'center'}}
placeholder="kg eingeben" value={input} onChange={e=>setInput(e.target.value)}
onKeyDown={e=>e.key==='Enter'&&!isDisabled&&handleSave()}/>
<span style={{fontSize:13,color:'var(--text3)'}}>kg</span>
<div title={tooltipText} style={{display:'inline-block'}}>
<button
className="btn btn-primary"
style={{padding:'8px 14px', cursor: isDisabled ? 'not-allowed' : 'pointer'}}
onClick={handleSave}
disabled={isDisabled}
>
{saved ? <Check size={15}/>
: saving ? <div className="spinner" style={{width:14,height:14}}/>
: (weightUsage && !weightUsage.allowed) ? '🔒 Limit'
: 'Speichern'}
</button>
</div>
</div>
</div>
)
}

View File

@ -18,6 +18,419 @@ function rollingAvg(arr, key, window=7) {
})
}
// Entry Form (Create/Update)
function EntryForm({ onSaved }) {
const [date, setDate] = useState(dayjs().format('YYYY-MM-DD'))
const [values, setValues] = useState({ kcal: '', protein_g: '', fat_g: '', carbs_g: '' })
const [existingId, setExistingId] = useState(null)
const [loading, setLoading] = useState(false)
const [saving, setSaving] = useState(false)
const [error, setError] = useState(null)
const [success, setSuccess] = useState(null)
// Load data for selected date
useEffect(() => {
const load = async () => {
if (!date) return
setLoading(true)
setError(null)
try {
const data = await nutritionApi.getNutritionByDate(date)
if (data) {
setValues({
kcal: data.kcal || '',
protein_g: data.protein_g || '',
fat_g: data.fat_g || '',
carbs_g: data.carbs_g || ''
})
setExistingId(data.id)
} else {
setValues({ kcal: '', protein_g: '', fat_g: '', carbs_g: '' })
setExistingId(null)
}
} catch(e) {
console.error('Failed to load entry:', e)
} finally {
setLoading(false)
}
}
load()
}, [date])
const handleSave = async () => {
if (!date || !values.kcal) {
setError('Datum und Kalorien sind Pflichtfelder')
return
}
setSaving(true)
setError(null)
setSuccess(null)
try {
const result = await nutritionApi.createNutrition(
date,
parseFloat(values.kcal) || 0,
parseFloat(values.protein_g) || 0,
parseFloat(values.fat_g) || 0,
parseFloat(values.carbs_g) || 0
)
setSuccess(result.mode === 'created' ? 'Eintrag hinzugefügt' : 'Eintrag aktualisiert')
setTimeout(() => setSuccess(null), 3000)
onSaved()
} catch(e) {
if (e.message.includes('Limit erreicht')) {
setError(e.message)
} else {
setError('Speichern fehlgeschlagen: ' + e.message)
}
setTimeout(() => setError(null), 5000)
} finally {
setSaving(false)
}
}
return (
<div className="card section-gap">
<div className="card-title">Eintrag hinzufügen / bearbeiten</div>
{error && (
<div style={{padding:'8px 12px',background:'#FCEBEB',borderRadius:8,fontSize:13,color:'#D85A30',marginBottom:12}}>
{error}
</div>
)}
{success && (
<div style={{padding:'8px 12px',background:'var(--accent-light)',borderRadius:8,fontSize:13,color:'var(--accent-dark)',marginBottom:12}}>
{success}
</div>
)}
<div style={{display:'grid',gridTemplateColumns:'1fr 1fr',gap:12,marginBottom:12}}>
<div style={{gridColumn:'1 / -1'}}>
<label className="form-label">Datum</label>
<input
type="date"
className="form-input"
value={date}
onChange={e => setDate(e.target.value)}
max={dayjs().format('YYYY-MM-DD')}
style={{width:'100%'}}
/>
{existingId && !loading && (
<div style={{fontSize:11,color:'var(--accent)',marginTop:4}}>
Eintrag existiert bereits wird beim Speichern aktualisiert
</div>
)}
</div>
<div>
<label className="form-label">Kalorien *</label>
<input
type="number"
className="form-input"
value={values.kcal}
onChange={e => setValues({...values, kcal: e.target.value})}
placeholder="z.B. 2000"
disabled={loading}
style={{width:'100%'}}
/>
</div>
<div>
<label className="form-label">Protein (g)</label>
<input
type="number"
className="form-input"
value={values.protein_g}
onChange={e => setValues({...values, protein_g: e.target.value})}
placeholder="z.B. 150"
disabled={loading}
style={{width:'100%'}}
/>
</div>
<div>
<label className="form-label">Fett (g)</label>
<input
type="number"
className="form-input"
value={values.fat_g}
onChange={e => setValues({...values, fat_g: e.target.value})}
placeholder="z.B. 80"
disabled={loading}
style={{width:'100%'}}
/>
</div>
<div>
<label className="form-label">Kohlenhydrate (g)</label>
<input
type="number"
className="form-input"
value={values.carbs_g}
onChange={e => setValues({...values, carbs_g: e.target.value})}
placeholder="z.B. 200"
disabled={loading}
style={{width:'100%'}}
/>
</div>
</div>
<button
className="btn btn-primary btn-full"
onClick={handleSave}
disabled={saving || loading || !date || !values.kcal}>
{saving ? (
<><div className="spinner" style={{width:14,height:14}}/> Speichere</>
) : existingId ? (
'📝 Eintrag aktualisieren'
) : (
' Eintrag hinzufügen'
)}
</button>
</div>
)
}
// Data Tab (Editable Entry List)
function DataTab({ entries, onUpdate }) {
const [editId, setEditId] = useState(null)
const [editValues, setEditValues] = useState({})
const [saving, setSaving] = useState(false)
const [error, setError] = useState(null)
const [filter, setFilter] = useState('30') // days to show (7, 30, 90, 'all')
const startEdit = (e) => {
setEditId(e.id)
setEditValues({
kcal: e.kcal || 0,
protein_g: e.protein_g || 0,
fat_g: e.fat_g || 0,
carbs_g: e.carbs_g || 0
})
}
const cancelEdit = () => {
setEditId(null)
setEditValues({})
setError(null)
}
const saveEdit = async (id) => {
setSaving(true)
setError(null)
try {
await nutritionApi.updateNutrition(
id,
editValues.kcal,
editValues.protein_g,
editValues.fat_g,
editValues.carbs_g
)
setEditId(null)
setEditValues({})
onUpdate()
} catch(e) {
setError('Speichern fehlgeschlagen: ' + e.message)
} finally {
setSaving(false)
}
}
const deleteEntry = async (id, date) => {
if (!confirm(`Eintrag vom ${dayjs(date).format('DD.MM.YYYY')} wirklich löschen?`)) return
try {
await nutritionApi.deleteNutrition(id)
onUpdate()
} catch(e) {
setError('Löschen fehlgeschlagen: ' + e.message)
}
}
// Filter entries by date range
const filteredEntries = filter === 'all'
? entries
: entries.filter(e => {
const daysDiff = dayjs().diff(dayjs(e.date), 'day')
return daysDiff <= parseInt(filter)
})
if (entries.length === 0) {
return (
<div className="card section-gap">
<div className="card-title">Alle Einträge (0)</div>
<p className="muted">Noch keine Ernährungsdaten. Importiere FDDB CSV oben.</p>
</div>
)
}
return (
<div className="card section-gap">
<div style={{display:'flex',justifyContent:'space-between',alignItems:'center',marginBottom:16}}>
<div className="card-title" style={{margin:0}}>
Alle Einträge ({filteredEntries.length}{filteredEntries.length !== entries.length ? ` von ${entries.length}` : ''})
</div>
<select
value={filter}
onChange={e => setFilter(e.target.value)}
style={{
padding:'6px 10px',fontSize:12,borderRadius:8,border:'1.5px solid var(--border2)',
background:'var(--surface)',color:'var(--text2)',cursor:'pointer',fontFamily:'var(--font)'
}}>
<option value="7">Letzte 7 Tage</option>
<option value="30">Letzte 30 Tage</option>
<option value="90">Letzte 90 Tage</option>
<option value="365">Letztes Jahr</option>
<option value="all">Alle anzeigen</option>
</select>
</div>
{error && (
<div style={{padding:'8px 12px',background:'#FCEBEB',borderRadius:8,fontSize:13,color:'#D85A30',marginBottom:12}}>
{error}
</div>
)}
{filteredEntries.map((e, i) => {
const isEditing = editId === e.id
return (
<div key={e.id || i} style={{
borderBottom: i < filteredEntries.length - 1 ? '1px solid var(--border)' : 'none',
padding: '12px 0'
}}>
{!isEditing ? (
<>
<div style={{display:'flex', justifyContent:'space-between', alignItems:'center', marginBottom:6}}>
<strong style={{fontSize:14}}>{dayjs(e.date).format('dd, DD. MMMM YYYY')}</strong>
<div style={{display:'flex',gap:6}}>
<button onClick={() => startEdit(e)}
style={{padding:'4px 10px',fontSize:11,borderRadius:6,border:'1px solid var(--border2)',
background:'var(--surface)',color:'var(--text2)',cursor:'pointer'}}>
Bearbeiten
</button>
<button onClick={() => deleteEntry(e.id, e.date)}
style={{padding:'4px 10px',fontSize:11,borderRadius:6,border:'1px solid #D85A30',
background:'#FCEBEB',color:'#D85A30',cursor:'pointer'}}>
🗑
</button>
</div>
</div>
<div style={{fontSize:13, fontWeight:600, color:'#EF9F27',marginBottom:6}}>
{Math.round(e.kcal || 0)} kcal
</div>
<div style={{display:'flex', gap:12, fontSize:12, color:'var(--text2)'}}>
<span>🥩 Protein: <strong>{Math.round(e.protein_g || 0)}g</strong></span>
<span>🫙 Fett: <strong>{Math.round(e.fat_g || 0)}g</strong></span>
<span>🍞 Kohlenhydrate: <strong>{Math.round(e.carbs_g || 0)}g</strong></span>
</div>
{e.source && (
<div style={{fontSize:10, color:'var(--text3)', marginTop:4}}>
Quelle: {e.source}
</div>
)}
</>
) : (
<>
<div style={{marginBottom:8}}>
<strong style={{fontSize:14}}>{dayjs(e.date).format('dd, DD. MMMM YYYY')}</strong>
</div>
<div style={{display:'grid',gridTemplateColumns:'1fr 1fr',gap:8,marginBottom:10}}>
<div>
<label style={{fontSize:11,color:'var(--text3)',display:'block',marginBottom:4}}>Kalorien</label>
<input type="number" className="form-input" value={editValues.kcal}
onChange={e => setEditValues({...editValues, kcal: parseFloat(e.target.value)||0})}
style={{width:'100%'}}/>
</div>
<div>
<label style={{fontSize:11,color:'var(--text3)',display:'block',marginBottom:4}}>Protein (g)</label>
<input type="number" className="form-input" value={editValues.protein_g}
onChange={e => setEditValues({...editValues, protein_g: parseFloat(e.target.value)||0})}
style={{width:'100%'}}/>
</div>
<div>
<label style={{fontSize:11,color:'var(--text3)',display:'block',marginBottom:4}}>Fett (g)</label>
<input type="number" className="form-input" value={editValues.fat_g}
onChange={e => setEditValues({...editValues, fat_g: parseFloat(e.target.value)||0})}
style={{width:'100%'}}/>
</div>
<div>
<label style={{fontSize:11,color:'var(--text3)',display:'block',marginBottom:4}}>Kohlenhydrate (g)</label>
<input type="number" className="form-input" value={editValues.carbs_g}
onChange={e => setEditValues({...editValues, carbs_g: parseFloat(e.target.value)||0})}
style={{width:'100%'}}/>
</div>
</div>
<div style={{display:'flex',gap:8}}>
<button onClick={() => saveEdit(e.id)} disabled={saving}
className="btn btn-primary" style={{flex:1}}>
{saving ? 'Speichere…' : '✓ Speichern'}
</button>
<button onClick={cancelEdit} disabled={saving}
className="btn btn-secondary" style={{flex:1}}>
Abbrechen
</button>
</div>
</>
)}
</div>
)
})}
</div>
)
}
// Import History
function ImportHistory() {
const [history, setHistory] = useState([])
const [loading, setLoading] = useState(true)
useEffect(() => {
const load = async () => {
try {
const data = await nutritionApi.nutritionImportHistory()
setHistory(Array.isArray(data) ? data : [])
} catch(e) {
console.error('Failed to load import history:', e)
} finally {
setLoading(false)
}
}
load()
}, [])
if (loading) return null
if (!history.length) return null
return (
<div className="card section-gap">
<div className="card-title">Import-Historie</div>
<div style={{display:'flex',flexDirection:'column',gap:8}}>
{history.map((h, i) => (
<div key={i} style={{
padding: '10px 12px',
background: 'var(--surface2)',
borderRadius: 8,
borderLeft: '3px solid var(--accent)',
fontSize: 13
}}>
<div style={{display:'flex',justifyContent:'space-between',marginBottom:4}}>
<strong>{dayjs(h.import_date).format('DD.MM.YYYY')}</strong>
<span style={{color:'var(--text3)',fontSize:11}}>
{dayjs(h.last_created).format('HH:mm')} Uhr
</span>
</div>
<div style={{color:'var(--text2)',fontSize:12}}>
<span>{h.count} {h.count === 1 ? 'Eintrag' : 'Einträge'}</span>
{h.date_from && h.date_to && (
<span style={{marginLeft:8,color:'var(--text3)'}}>
({dayjs(h.date_from).format('DD.MM.YY')} {dayjs(h.date_to).format('DD.MM.YY')})
</span>
)}
</div>
</div>
))}
</div>
</div>
)
}
// Import Panel
function ImportPanel({ onImported }) {
const fileRef = useRef()
@ -322,9 +735,11 @@ function CalorieBalance({ data, profile }) {
// Main Page
export default function NutritionPage() {
const [tab, setTab] = useState('overview')
const [inputTab, setInputTab] = useState('entry') // 'entry' or 'import'
const [analysisTab,setAnalysisTab] = useState('data')
const [corrData, setCorr] = useState([])
const [weekly, setWeekly] = useState([])
const [entries, setEntries]= useState([])
const [profile, setProf] = useState(null)
const [loading, setLoad] = useState(true)
const [hasData, setHasData]= useState(false)
@ -332,13 +747,15 @@ export default function NutritionPage() {
const load = async () => {
setLoad(true)
try {
const [corr, wkly, prof] = await Promise.all([
const [corr, wkly, ent, prof] = await Promise.all([
nutritionApi.nutritionCorrelations(),
nutritionApi.nutritionWeekly(16),
api.getActiveProfile(),
nutritionApi.listNutrition(365), // BUG-002 fix: load raw entries
nutritionApi.getActiveProfile(),
])
setCorr(Array.isArray(corr)?corr:[])
setWeekly(Array.isArray(wkly)?wkly:[])
setEntries(Array.isArray(ent)?ent:[]) // BUG-002 fix
setProf(prof)
setHasData(Array.isArray(corr) && corr.some(d=>d.kcal))
} catch(e) { console.error('load error:', e) }
@ -351,29 +768,52 @@ export default function NutritionPage() {
<div>
<h1 className="page-title">Ernährung</h1>
<ImportPanel onImported={load}/>
{/* Input Method Tabs */}
<div className="tabs section-gap" style={{marginBottom:0}}>
<button className={'tab'+(inputTab==='entry'?' active':'')} onClick={()=>setInputTab('entry')}>
Einzelerfassung
</button>
<button className={'tab'+(inputTab==='import'?' active':'')} onClick={()=>setInputTab('import')}>
📥 Import
</button>
</div>
{/* Entry Form */}
{inputTab==='entry' && <EntryForm onSaved={load}/>}
{/* Import Panel + History */}
{inputTab==='import' && (
<>
<ImportPanel onImported={load}/>
<ImportHistory/>
</>
)}
{loading && <div className="empty-state"><div className="spinner"/></div>}
{!loading && !hasData && (
<div className="empty-state">
<h3>Noch keine Ernährungsdaten</h3>
<p>Importiere deinen FDDB-Export oben um Auswertungen zu sehen.</p>
<p>Erfasse Daten über Einzelerfassung oder importiere deinen FDDB-Export.</p>
</div>
)}
{/* Analysis Section */}
{!loading && hasData && (
<>
<OverviewCards data={corrData}/>
<div className="tabs section-gap" style={{overflowX:'auto',flexWrap:'nowrap'}}>
<button className={'tab'+(tab==='overview'?' active':'')} onClick={()=>setTab('overview')}>Übersicht</button>
<button className={'tab'+(tab==='weight'?' active':'')} onClick={()=>setTab('weight')}>Kcal vs. Gewicht</button>
<button className={'tab'+(tab==='protein'?' active':'')} onClick={()=>setTab('protein')}>Protein vs. Mager</button>
<button className={'tab'+(tab==='balance'?' active':'')} onClick={()=>setTab('balance')}>Bilanz</button>
<button className={'tab'+(analysisTab==='data'?' active':'')} onClick={()=>setAnalysisTab('data')}>Daten</button>
<button className={'tab'+(analysisTab==='overview'?' active':'')} onClick={()=>setAnalysisTab('overview')}>Übersicht</button>
<button className={'tab'+(analysisTab==='weight'?' active':'')} onClick={()=>setAnalysisTab('weight')}>Kcal vs. Gewicht</button>
<button className={'tab'+(analysisTab==='protein'?' active':'')} onClick={()=>setAnalysisTab('protein')}>Protein vs. Mager</button>
<button className={'tab'+(analysisTab==='balance'?' active':'')} onClick={()=>setAnalysisTab('balance')}>Bilanz</button>
</div>
{tab==='overview' && (
{analysisTab==='data' && <DataTab entries={entries} onUpdate={load}/>}
{analysisTab==='overview' && (
<div className="card section-gap">
<div className="card-title">Makro-Verteilung pro Woche (Ø g/Tag)</div>
<WeeklyMacros weekly={weekly}/>
@ -385,7 +825,7 @@ export default function NutritionPage() {
</div>
)}
{tab==='weight' && (
{analysisTab==='weight' && (
<div className="card section-gap">
<div className="card-title">Kalorien vs. Gewichtsverlauf</div>
<div style={{fontSize:11,color:'var(--text3)',marginBottom:8}}>
@ -401,7 +841,7 @@ export default function NutritionPage() {
</div>
)}
{tab==='protein' && (
{analysisTab==='protein' && (
<div className="card section-gap">
<div className="card-title">Protein vs. Magermasse</div>
<div style={{fontSize:11,color:'var(--text3)',marginBottom:8}}>
@ -417,7 +857,7 @@ export default function NutritionPage() {
</div>
)}
{tab==='balance' && (
{analysisTab==='balance' && (
<div className="card section-gap">
<div className="card-title">Kaloriendefizit / -überschuss</div>
<CalorieBalance data={corrData} profile={profile}/>

View File

@ -1,10 +1,12 @@
import { useState } from 'react'
import { Save, Download, Upload, Trash2, Plus, Check, Pencil, X, LogOut, Shield, Key } from 'lucide-react'
import { useState, useEffect } from 'react'
import { Save, Download, Upload, Trash2, Plus, Check, Pencil, X, LogOut, Shield, Key, BarChart3 } from 'lucide-react'
import { useProfile } from '../context/ProfileContext'
import { useAuth } from '../context/AuthContext'
import { Avatar } from './ProfileSelect'
import { api } from '../utils/api'
import AdminPanel from './AdminPanel'
import FeatureUsageOverview from '../components/FeatureUsageOverview'
import UsageBadge from '../components/UsageBadge'
const COLORS = ['#1D9E75','#378ADD','#D85A30','#EF9F27','#7F77DD','#D4537E','#639922','#888780']
@ -99,6 +101,15 @@ export default function SettingsPage() {
const [pinOpen, setPinOpen] = useState(false)
const [newPin, setNewPin] = useState('')
const [pinMsg, setPinMsg] = useState(null)
const [exportUsage, setExportUsage] = useState(null) // Phase 3: Usage badge
// Load feature usage for export badges
useEffect(() => {
api.getFeatureUsage().then(features => {
const exportFeature = features.find(f => f.feature_id === 'data_export')
setExportUsage(exportFeature)
}).catch(err => console.error('Failed to load usage:', err))
}, [])
const handleLogout = async () => {
if (!confirm('Ausloggen?')) return
@ -326,6 +337,17 @@ export default function SettingsPage() {
</div>
</div>
{/* Feature Usage Overview (Phase 3) */}
<div className="card section-gap">
<div className="card-title" style={{display:'flex',alignItems:'center',gap:6}}>
<BarChart3 size={15} color="var(--accent)"/> Kontingente
</div>
<p style={{fontSize:13,color:'var(--text2)',marginBottom:12,lineHeight:1.6}}>
Übersicht über deine Feature-Nutzung und verfügbare Kontingente.
</p>
<FeatureUsageOverview />
</div>
{/* Admin Panel */}
{isAdmin && (
<div className="card section-gap">
@ -359,13 +381,23 @@ export default function SettingsPage() {
{canExport && <>
<button className="btn btn-primary btn-full"
onClick={()=>api.exportZip()}>
<Download size={14}/> ZIP exportieren
<span style={{fontSize:11,opacity:0.8,marginLeft:6}}> je eine CSV pro Kategorie</span>
<div className="badge-button-layout">
<div className="badge-button-header">
<span><Download size={14}/> ZIP exportieren</span>
{exportUsage && <UsageBadge {...exportUsage} />}
</div>
<span className="badge-button-description">je eine CSV pro Kategorie</span>
</div>
</button>
<button className="btn btn-secondary btn-full"
onClick={()=>api.exportJson()}>
<Download size={14}/> JSON exportieren
<span style={{fontSize:11,opacity:0.8,marginLeft:6}}> maschinenlesbar, alles in einer Datei</span>
<div className="badge-button-layout">
<div className="badge-button-header">
<span><Download size={14}/> JSON exportieren</span>
{exportUsage && <UsageBadge {...exportUsage} />}
</div>
<span className="badge-button-description">maschinenlesbar, alles in einer Datei</span>
</div>
</button>
</>}
</div>

View File

@ -0,0 +1,241 @@
import { useState, useEffect } from 'react'
import { Gift, AlertCircle, TrendingUp, Award } from 'lucide-react'
import { api } from '../utils/api'
export default function SubscriptionPage() {
const [loading, setLoading] = useState(true)
const [error, setError] = useState('')
const [subscription, setSubscription] = useState(null)
const [usage, setUsage] = useState([])
const [limits, setLimits] = useState([])
const [couponCode, setCouponCode] = useState('')
const [redeeming, setRedeeming] = useState(false)
const [couponSuccess, setCouponSuccess] = useState('')
useEffect(() => {
loadData()
}, [])
async function loadData() {
try {
setLoading(true)
const [subData, usageData, limitsData] = await Promise.all([
api.getMySubscription(),
api.getMyUsage(),
api.getMyLimits()
])
setSubscription(subData)
setUsage(usageData)
setLimits(limitsData)
setError('')
} catch (e) {
setError(e.message)
} finally {
setLoading(false)
}
}
async function handleRedeemCoupon() {
if (!couponCode.trim()) return
try {
setRedeeming(true)
setError('')
setCouponSuccess('')
await api.redeemCoupon(couponCode.trim().toUpperCase())
setCouponSuccess('Coupon erfolgreich eingelöst!')
setCouponCode('')
await loadData()
} catch (e) {
setError(e.message)
} finally {
setRedeeming(false)
}
}
if (loading) return (
<div style={{ display: 'flex', justifyContent: 'center', padding: 40 }}>
<div className="spinner" />
</div>
)
const tierColors = {
free: { bg: 'var(--surface2)', color: 'var(--text2)', icon: '🆓' },
basic: { bg: '#E3F2FD', color: '#1565C0', icon: '⭐' },
premium: { bg: '#F3E5F5', color: '#6A1B9A', icon: '👑' },
selfhosted: { bg: 'var(--accent-light)', color: 'var(--accent-dark)', icon: '🏠' }
}
const currentTier = subscription?.current_tier || 'free'
const tierStyle = tierColors[currentTier] || tierColors.free
return (
<div style={{ paddingBottom: 40 }}>
{/* Header */}
<div style={{ marginBottom: 20 }}>
<div style={{ fontSize: 20, fontWeight: 700, color: 'var(--text1)' }}>
Mein Abo
</div>
<div style={{ fontSize: 13, color: 'var(--text3)', marginTop: 4 }}>
Tier, Limits und Nutzung
</div>
</div>
{/* Messages */}
{error && (
<div style={{
padding: 12, background: 'var(--danger)', color: 'white',
borderRadius: 8, marginBottom: 16, fontSize: 14
}}>
{error}
</div>
)}
{couponSuccess && (
<div style={{
padding: 12, background: 'var(--accent)', color: 'white',
borderRadius: 8, marginBottom: 16, fontSize: 14
}}>
{couponSuccess}
</div>
)}
{/* Current Tier Card */}
<div className="card" style={{ padding: 20, marginBottom: 16 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: 16 }}>
<div style={{
width: 48, height: 48, borderRadius: 12,
background: tierStyle.bg,
display: 'flex', alignItems: 'center', justifyContent: 'center',
fontSize: 24
}}>
{tierStyle.icon}
</div>
<div style={{ flex: 1 }}>
<div style={{ fontSize: 12, color: 'var(--text3)', textTransform: 'uppercase', fontWeight: 600 }}>
Aktueller Tier
</div>
<div style={{ fontSize: 20, fontWeight: 700, color: tierStyle.color }}>
{currentTier.charAt(0).toUpperCase() + currentTier.slice(1)}
</div>
</div>
</div>
{subscription?.trial_ends_at && new Date(subscription.trial_ends_at) > new Date() && (
<div style={{
padding: 10, background: '#FFF3CD', borderRadius: 8,
fontSize: 13, color: '#856404', display: 'flex', gap: 8, alignItems: 'center'
}}>
<AlertCircle size={16} />
<div>
<strong>Trial aktiv:</strong> Endet am {new Date(subscription.trial_ends_at).toLocaleDateString('de-DE')}
</div>
</div>
)}
{subscription?.access_until && (
<div style={{
padding: 10, background: 'var(--accent-light)', borderRadius: 8,
fontSize: 13, color: 'var(--accent-dark)', display: 'flex', gap: 8, alignItems: 'center',
marginTop: 12
}}>
<Award size={16} />
<div>
<strong>Zugriff bis:</strong> {new Date(subscription.access_until).toLocaleDateString('de-DE')}
</div>
</div>
)}
</div>
{/* Feature Limits */}
<div className="card" style={{ padding: 20, marginBottom: 16 }}>
<div style={{
fontSize: 14, fontWeight: 600, marginBottom: 12,
display: 'flex', alignItems: 'center', gap: 6
}}>
<TrendingUp size={16} color="var(--accent)" />
Feature-Limits & Nutzung
</div>
{limits.length === 0 ? (
<div style={{ textAlign: 'center', padding: 20, color: 'var(--text3)', fontSize: 13 }}>
Keine Limits konfiguriert
</div>
) : (
<div style={{ display: 'grid', gap: 12 }}>
{limits.map(limit => {
const usageEntry = usage.find(u => u.feature_id === limit.feature_id)
const used = usageEntry?.usage_count || 0
const limitValue = limit.limit_value
const percentage = limitValue ? Math.min((used / limitValue) * 100, 100) : 0
return (
<div key={limit.feature_id} style={{
padding: 12, background: 'var(--surface)', borderRadius: 8
}}>
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 8 }}>
<div style={{ fontWeight: 500, fontSize: 13 }}>{limit.feature_name}</div>
<div style={{ fontSize: 13, color: 'var(--text3)' }}>
{used} / {limitValue === null ? '∞' : limitValue}
</div>
</div>
{limitValue !== null && (
<div style={{
height: 6, background: 'var(--border)', borderRadius: 3, overflow: 'hidden'
}}>
<div style={{
width: `${percentage}%`,
height: '100%',
background: percentage > 90 ? 'var(--danger)' : percentage > 70 ? '#FFA726' : 'var(--accent)',
transition: 'width 0.3s'
}} />
</div>
)}
{limit.reset_period !== 'never' && (
<div style={{ fontSize: 11, color: 'var(--text3)', marginTop: 4 }}>
Reset: {limit.reset_period === 'daily' ? 'Täglich' : 'Monatlich'}
</div>
)}
</div>
)
})}
</div>
)}
</div>
{/* Coupon Redemption */}
<div className="card" style={{ padding: 20 }}>
<div style={{
fontSize: 14, fontWeight: 600, marginBottom: 12,
display: 'flex', alignItems: 'center', gap: 6
}}>
<Gift size={16} color="var(--accent)" />
Coupon einlösen
</div>
<div style={{ fontSize: 12, color: 'var(--text3)', marginBottom: 12 }}>
Hast du einen Coupon-Code? Löse ihn hier ein um Zugriff auf Premium-Features zu erhalten.
</div>
<div style={{ display: 'flex', gap: 8 }}>
<input
className="form-input"
style={{ flex: 1, textTransform: 'uppercase', fontFamily: 'monospace' }}
value={couponCode}
onChange={(e) => setCouponCode(e.target.value)}
placeholder="Z.B. PROMO-2026"
onKeyPress={(e) => e.key === 'Enter' && handleRedeemCoupon()}
/>
<button
className="btn btn-primary"
onClick={handleRedeemCoupon}
disabled={!couponCode.trim() || redeeming}
>
{redeeming ? 'Prüfen...' : 'Einlösen'}
</button>
</div>
</div>
</div>
)
}

View File

@ -2,6 +2,7 @@ import { useState, useEffect, useRef } from 'react'
import { Pencil, Trash2, Check, X } from 'lucide-react'
import { api } from '../utils/api'
import { LineChart, Line, XAxis, YAxis, Tooltip, ResponsiveContainer, CartesianGrid, ReferenceLine } from 'recharts'
import UsageBadge from '../components/UsageBadge'
import dayjs from 'dayjs'
import 'dayjs/locale/de'
dayjs.locale('de')
@ -21,19 +22,42 @@ export default function WeightScreen() {
const [newNote, setNewNote] = useState('')
const [saving, setSaving] = useState(false)
const [saved, setSaved] = useState(false)
const [error, setError] = useState(null)
const [weightUsage, setWeightUsage] = useState(null) // Phase 3: Usage badge
const load = () => api.listWeight(365).then(data => setEntries(data))
useEffect(()=>{ load() },[])
const loadUsage = () => {
// Load feature usage for badge
api.getFeatureUsage().then(features => {
const weightFeature = features.find(f => f.feature_id === 'weight_entries')
setWeightUsage(weightFeature)
}).catch(err => console.error('Failed to load usage:', err))
}
useEffect(()=>{
load()
loadUsage()
},[])
const handleSave = async () => {
if (!newWeight) return
setSaving(true)
setError(null)
try {
await api.upsertWeight(newDate, parseFloat(newWeight), newNote)
setSaved(true); await load()
setSaved(true)
await load()
await loadUsage() // Reload usage after save
setTimeout(()=>setSaved(false), 2000)
setNewWeight(''); setNewNote('')
} finally { setSaving(false) }
} catch (err) {
console.error('Save failed:', err)
setError(err.message || 'Fehler beim Speichern')
setTimeout(()=>setError(null), 5000)
} finally {
setSaving(false)
}
}
const handleUpdate = async () => {
@ -59,7 +83,10 @@ export default function WeightScreen() {
{/* Eingabe */}
<div className="card section-gap">
<div className="card-title">Eintrag hinzufügen</div>
<div className="card-title badge-container-right">
<span>Eintrag hinzufügen</span>
{weightUsage && <UsageBadge {...weightUsage} />}
</div>
<div className="form-row">
<label className="form-label">Datum</label>
<input type="date" className="form-input" style={{width:140}}
@ -79,11 +106,27 @@ export default function WeightScreen() {
value={newNote} onChange={e=>setNewNote(e.target.value)}/>
<span className="form-unit"/>
</div>
<button className="btn btn-primary btn-full" onClick={handleSave} disabled={saving||!newWeight}>
{saved ? <><Check size={15}/> Gespeichert!</>
: saving ? <><div className="spinner" style={{width:14,height:14}}/> </>
: 'Speichern'}
</button>
{error && (
<div style={{padding:'10px',background:'var(--danger-bg)',border:'1px solid var(--danger)',borderRadius:8,fontSize:13,color:'var(--danger)',marginBottom:12}}>
{error}
</div>
)}
<div
title={weightUsage && !weightUsage.allowed ? `Limit erreicht (${weightUsage.used}/${weightUsage.limit}). Kontaktiere den Admin oder warte bis zum nächsten Reset.` : ''}
style={{display:'inline-block',width:'100%'}}
>
<button
className="btn btn-primary btn-full"
onClick={handleSave}
disabled={saving || !newWeight || (weightUsage && !weightUsage.allowed)}
style={{cursor: (weightUsage && !weightUsage.allowed) ? 'not-allowed' : 'pointer'}}
>
{saved ? <><Check size={15}/> Gespeichert!</>
: saving ? <><div className="spinner" style={{width:14,height:14}}/> </>
: (weightUsage && !weightUsage.allowed) ? '🔒 Limit erreicht'
: 'Speichern'}
</button>
</div>
</div>
{/* Chart */}

View File

@ -82,6 +82,11 @@ export const api = {
listNutrition: (l=365) => req(`/nutrition?limit=${l}`),
nutritionCorrelations: () => req('/nutrition/correlations'),
nutritionWeekly: (w=16) => req(`/nutrition/weekly?weeks=${w}`),
nutritionImportHistory: () => req('/nutrition/import-history'),
getNutritionByDate: (date) => req(`/nutrition/by-date/${date}`),
createNutrition: (date,kcal,protein,fat,carbs) => req(`/nutrition?date=${date}&kcal=${kcal}&protein_g=${protein}&fat_g=${fat}&carbs_g=${carbs}`,{method:'POST'}),
updateNutrition: (id,kcal,protein,fat,carbs) => req(`/nutrition/${id}?kcal=${kcal}&protein_g=${protein}&fat_g=${fat}&carbs_g=${carbs}`,{method:'PUT'}),
deleteNutrition: (id) => req(`/nutrition/${id}`,{method:'DELETE'}),
// Stats & AI
getStats: () => req('/stats'),
@ -137,4 +142,48 @@ export const api = {
adminDeleteProfile: (id) => req(`/admin/profiles/${id}`,{method:'DELETE'}),
adminSetPermissions: (id,d) => req(`/admin/profiles/${id}/permissions`,jput(d)),
changePin: (pin) => req('/auth/pin',json({pin})),
// v9c Subscription System
// User-facing
getMySubscription: () => req('/subscription/me'),
getMyUsage: () => req('/subscription/usage'),
getMyLimits: () => req('/subscription/limits'),
redeemCoupon: (code) => req('/coupons/redeem',json({code})),
getFeatureUsage: () => req('/features/usage'), // Phase 3: Usage overview
// Admin: Features
listFeatures: () => req('/features'),
createFeature: (d) => req('/features',json(d)),
updateFeature: (id,d) => req(`/features/${id}`,jput(d)),
deleteFeature: (id) => req(`/features/${id}`,{method:'DELETE'}),
// Admin: Tiers
listTiers: () => req('/tiers'),
createTier: (d) => req('/tiers',json(d)),
updateTier: (id,d) => req(`/tiers/${id}`,jput(d)),
deleteTier: (id) => req(`/tiers/${id}`,{method:'DELETE'}),
// Admin: Tier Limits (Matrix)
getTierLimitsMatrix: () => req('/tier-limits'),
updateTierLimit: (d) => req('/tier-limits',jput(d)),
updateTierLimitsBatch:(updates) => req('/tier-limits/batch',jput({updates})),
// Admin: User Restrictions
listUserRestrictions: (pid) => req(`/user-restrictions${pid?'?profile_id='+pid:''}`),
createUserRestriction:(d) => req('/user-restrictions',json(d)),
updateUserRestriction:(id,d) => req(`/user-restrictions/${id}`,jput(d)),
deleteUserRestriction:(id) => req(`/user-restrictions/${id}`,{method:'DELETE'}),
// Admin: Coupons
listCoupons: () => req('/coupons'),
createCoupon: (d) => req('/coupons',json(d)),
updateCoupon: (id,d) => req(`/coupons/${id}`,jput(d)),
deleteCoupon: (id) => req(`/coupons/${id}`,{method:'DELETE'}),
getCouponRedemptions: (id) => req(`/coupons/${id}/redemptions`),
// Admin: Access Grants
listAccessGrants: (pid,active)=> req(`/access-grants${pid?'?profile_id='+pid:''}${active?'&active_only=true':''}`),
createAccessGrant: (d) => req('/access-grants',json(d)),
updateAccessGrant: (id,d) => req(`/access-grants/${id}`,jput(d)),
revokeAccessGrant: (id) => req(`/access-grants/${id}`,{method:'DELETE'}),
}