feat: learnable activity type mapping system (DB-based, auto-learning)
Replaces hardcoded mappings with database-driven, self-learning system. Backend: - Migration 007: activity_type_mappings table - Supports global and user-specific mappings - Seeded with 40+ default mappings (German + English) - Unique constraint: (activity_type, profile_id) - Refactored: get_training_type_for_activity() queries DB - Priority: user-specific → global → NULL - Bulk categorization now saves mapping automatically - Source: 'bulk' for learned mappings - admin_activity_mappings.py: Full CRUD endpoints - List, Get, Create, Update, Delete - Coverage stats endpoint - CSV import uses DB mappings (no hardcoded logic) Frontend: - AdminActivityMappingsPage: Full mapping management UI - Coverage stats (% mapped, unmapped count) - Filter: All / Global - Create/Edit/Delete mappings - Tip: System learns from bulk categorization - Added route + admin link - API methods: adminList/Get/Create/Update/DeleteActivityMapping Benefits: - No code changes needed for new activity types - System learns from user bulk categorizations - User-specific mappings override global defaults - Admin can manage all mappings via UI - Migration pre-populates 40+ common German/English types Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
a4bd738e6f
commit
829edecbdc
|
|
@ -20,6 +20,7 @@ from routers import activity, nutrition, photos, insights, prompts
|
||||||
from routers import admin, stats, exportdata, importdata
|
from routers import admin, stats, exportdata, importdata
|
||||||
from routers import subscription, coupons, features, tiers_mgmt, tier_limits
|
from routers import subscription, coupons, features, tiers_mgmt, tier_limits
|
||||||
from routers import user_restrictions, access_grants, training_types, admin_training_types
|
from routers import user_restrictions, access_grants, training_types, admin_training_types
|
||||||
|
from routers import admin_activity_mappings
|
||||||
|
|
||||||
# ── App Configuration ─────────────────────────────────────────────────────────
|
# ── App Configuration ─────────────────────────────────────────────────────────
|
||||||
DATA_DIR = Path(os.getenv("DATA_DIR", "./data"))
|
DATA_DIR = Path(os.getenv("DATA_DIR", "./data"))
|
||||||
|
|
@ -88,6 +89,7 @@ app.include_router(access_grants.router) # /api/access-grants (admin)
|
||||||
# v9d Training Types
|
# v9d Training Types
|
||||||
app.include_router(training_types.router) # /api/training-types/*
|
app.include_router(training_types.router) # /api/training-types/*
|
||||||
app.include_router(admin_training_types.router) # /api/admin/training-types/*
|
app.include_router(admin_training_types.router) # /api/admin/training-types/*
|
||||||
|
app.include_router(admin_activity_mappings.router) # /api/admin/activity-mappings/*
|
||||||
|
|
||||||
# ── Health Check ──────────────────────────────────────────────────────────────
|
# ── Health Check ──────────────────────────────────────────────────────────────
|
||||||
@app.get("/")
|
@app.get("/")
|
||||||
|
|
|
||||||
121
backend/migrations/007_activity_type_mappings.sql
Normal file
121
backend/migrations/007_activity_type_mappings.sql
Normal file
|
|
@ -0,0 +1,121 @@
|
||||||
|
-- Migration 007: Activity Type Mappings (Learnable System)
|
||||||
|
-- Replaces hardcoded mappings with DB-based configurable system
|
||||||
|
-- Created: 2026-03-21
|
||||||
|
|
||||||
|
-- ========================================
|
||||||
|
-- 1. Create activity_type_mappings table
|
||||||
|
-- ========================================
|
||||||
|
CREATE TABLE IF NOT EXISTS activity_type_mappings (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
activity_type VARCHAR(100) NOT NULL,
|
||||||
|
training_type_id INTEGER NOT NULL REFERENCES training_types(id) ON DELETE CASCADE,
|
||||||
|
profile_id VARCHAR(36), -- NULL = global mapping, otherwise user-specific
|
||||||
|
source VARCHAR(20) DEFAULT 'manual', -- 'manual', 'bulk', 'admin', 'default'
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
CONSTRAINT unique_activity_type_per_profile UNIQUE(activity_type, profile_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- ========================================
|
||||||
|
-- 2. Create indexes
|
||||||
|
-- ========================================
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_activity_type_mappings_type ON activity_type_mappings(activity_type);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_activity_type_mappings_profile ON activity_type_mappings(profile_id);
|
||||||
|
|
||||||
|
-- ========================================
|
||||||
|
-- 3. Seed default mappings (global)
|
||||||
|
-- ========================================
|
||||||
|
-- Note: These are the German Apple Health workout types
|
||||||
|
-- training_type_id references are based on existing training_types data
|
||||||
|
|
||||||
|
-- Helper function to get training_type_id by subcategory
|
||||||
|
DO $$
|
||||||
|
DECLARE
|
||||||
|
v_running_id INTEGER;
|
||||||
|
v_walk_id INTEGER;
|
||||||
|
v_cycling_id INTEGER;
|
||||||
|
v_swimming_id INTEGER;
|
||||||
|
v_hypertrophy_id INTEGER;
|
||||||
|
v_functional_id INTEGER;
|
||||||
|
v_hiit_id INTEGER;
|
||||||
|
v_yoga_id INTEGER;
|
||||||
|
v_technique_id INTEGER;
|
||||||
|
v_sparring_id INTEGER;
|
||||||
|
v_rowing_id INTEGER;
|
||||||
|
v_dance_id INTEGER;
|
||||||
|
v_static_id INTEGER;
|
||||||
|
v_regeneration_id INTEGER;
|
||||||
|
v_meditation_id INTEGER;
|
||||||
|
v_mindfulness_id INTEGER;
|
||||||
|
BEGIN
|
||||||
|
-- Get training_type IDs
|
||||||
|
SELECT id INTO v_running_id FROM training_types WHERE subcategory = 'running' LIMIT 1;
|
||||||
|
SELECT id INTO v_walk_id FROM training_types WHERE subcategory = 'walk' LIMIT 1;
|
||||||
|
SELECT id INTO v_cycling_id FROM training_types WHERE subcategory = 'cycling' LIMIT 1;
|
||||||
|
SELECT id INTO v_swimming_id FROM training_types WHERE subcategory = 'swimming' LIMIT 1;
|
||||||
|
SELECT id INTO v_hypertrophy_id FROM training_types WHERE subcategory = 'hypertrophy' LIMIT 1;
|
||||||
|
SELECT id INTO v_functional_id FROM training_types WHERE subcategory = 'functional' LIMIT 1;
|
||||||
|
SELECT id INTO v_hiit_id FROM training_types WHERE subcategory = 'hiit' LIMIT 1;
|
||||||
|
SELECT id INTO v_yoga_id FROM training_types WHERE subcategory = 'yoga' LIMIT 1;
|
||||||
|
SELECT id INTO v_technique_id FROM training_types WHERE subcategory = 'technique' LIMIT 1;
|
||||||
|
SELECT id INTO v_sparring_id FROM training_types WHERE subcategory = 'sparring' LIMIT 1;
|
||||||
|
SELECT id INTO v_rowing_id FROM training_types WHERE subcategory = 'rowing' LIMIT 1;
|
||||||
|
SELECT id INTO v_dance_id FROM training_types WHERE subcategory = 'dance' LIMIT 1;
|
||||||
|
SELECT id INTO v_static_id FROM training_types WHERE subcategory = 'static' LIMIT 1;
|
||||||
|
SELECT id INTO v_regeneration_id FROM training_types WHERE subcategory = 'regeneration' LIMIT 1;
|
||||||
|
SELECT id INTO v_meditation_id FROM training_types WHERE subcategory = 'meditation' LIMIT 1;
|
||||||
|
SELECT id INTO v_mindfulness_id FROM training_types WHERE subcategory = 'mindfulness' LIMIT 1;
|
||||||
|
|
||||||
|
-- Insert default mappings (German Apple Health names)
|
||||||
|
INSERT INTO activity_type_mappings (activity_type, training_type_id, profile_id, source) VALUES
|
||||||
|
-- German workout types
|
||||||
|
('Laufen', v_running_id, NULL, 'default'),
|
||||||
|
('Gehen', v_walk_id, NULL, 'default'),
|
||||||
|
('Wandern', v_walk_id, NULL, 'default'),
|
||||||
|
('Outdoor Spaziergang', v_walk_id, NULL, 'default'),
|
||||||
|
('Innenräume Spaziergang', v_walk_id, NULL, 'default'),
|
||||||
|
('Spaziergang', v_walk_id, NULL, 'default'),
|
||||||
|
('Radfahren', v_cycling_id, NULL, 'default'),
|
||||||
|
('Schwimmen', v_swimming_id, NULL, 'default'),
|
||||||
|
('Traditionelles Krafttraining', v_hypertrophy_id, NULL, 'default'),
|
||||||
|
('Funktionelles Krafttraining', v_functional_id, NULL, 'default'),
|
||||||
|
('Hochintensives Intervalltraining', v_hiit_id, NULL, 'default'),
|
||||||
|
('Yoga', v_yoga_id, NULL, 'default'),
|
||||||
|
('Kampfsport', v_technique_id, NULL, 'default'),
|
||||||
|
('Matrial Arts', v_technique_id, NULL, 'default'), -- Common typo
|
||||||
|
('Boxen', v_sparring_id, NULL, 'default'),
|
||||||
|
('Rudern', v_rowing_id, NULL, 'default'),
|
||||||
|
('Tanzen', v_dance_id, NULL, 'default'),
|
||||||
|
('Cardio Dance', v_dance_id, NULL, 'default'),
|
||||||
|
('Flexibilität', v_static_id, NULL, 'default'),
|
||||||
|
('Abwärmen', v_regeneration_id, NULL, 'default'),
|
||||||
|
('Cooldown', v_regeneration_id, NULL, 'default'),
|
||||||
|
('Meditation', v_meditation_id, NULL, 'default'),
|
||||||
|
('Achtsamkeit', v_mindfulness_id, NULL, 'default'),
|
||||||
|
('Geist & Körper', v_yoga_id, NULL, 'default')
|
||||||
|
ON CONFLICT (activity_type, profile_id) DO NOTHING;
|
||||||
|
|
||||||
|
-- English workout types (for compatibility)
|
||||||
|
INSERT INTO activity_type_mappings (activity_type, training_type_id, profile_id, source) VALUES
|
||||||
|
('Running', v_running_id, NULL, 'default'),
|
||||||
|
('Walking', v_walk_id, NULL, 'default'),
|
||||||
|
('Hiking', v_walk_id, NULL, 'default'),
|
||||||
|
('Cycling', v_cycling_id, NULL, 'default'),
|
||||||
|
('Swimming', v_swimming_id, NULL, 'default'),
|
||||||
|
('Traditional Strength Training', v_hypertrophy_id, NULL, 'default'),
|
||||||
|
('Functional Strength Training', v_functional_id, NULL, 'default'),
|
||||||
|
('High Intensity Interval Training', v_hiit_id, NULL, 'default'),
|
||||||
|
('Martial Arts', v_technique_id, NULL, 'default'),
|
||||||
|
('Boxing', v_sparring_id, NULL, 'default'),
|
||||||
|
('Rowing', v_rowing_id, NULL, 'default'),
|
||||||
|
('Dance', v_dance_id, NULL, 'default'),
|
||||||
|
('Core Training', v_functional_id, NULL, 'default'),
|
||||||
|
('Flexibility', v_static_id, NULL, 'default'),
|
||||||
|
('Mindfulness', v_mindfulness_id, NULL, 'default')
|
||||||
|
ON CONFLICT (activity_type, profile_id) DO NOTHING;
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
-- ========================================
|
||||||
|
-- 4. Add comment
|
||||||
|
-- ========================================
|
||||||
|
COMMENT ON TABLE activity_type_mappings IS 'v9d Phase 1b: Learnable activity type to training type mappings. Replaces hardcoded mappings.';
|
||||||
|
|
@ -113,80 +113,44 @@ def activity_stats(x_profile_id: Optional[str]=Header(default=None), session: di
|
||||||
return {"count":len(rows),"total_kcal":round(total_kcal),"total_min":round(total_min),"by_type":by_type}
|
return {"count":len(rows),"total_kcal":round(total_kcal),"total_min":round(total_min),"by_type":by_type}
|
||||||
|
|
||||||
|
|
||||||
def get_training_type_for_apple_health(workout_type: str):
|
def get_training_type_for_activity(activity_type: str, profile_id: str = None):
|
||||||
"""
|
"""
|
||||||
Map Apple Health workout type to training_type_id + category + subcategory.
|
Map activity_type to training_type_id using database mappings.
|
||||||
|
|
||||||
|
Priority:
|
||||||
|
1. User-specific mapping (profile_id)
|
||||||
|
2. Global mapping (profile_id = NULL)
|
||||||
|
3. No mapping found → returns (None, None, None)
|
||||||
|
|
||||||
Returns: (training_type_id, category, subcategory) or (None, None, None)
|
Returns: (training_type_id, category, subcategory) or (None, None, None)
|
||||||
"""
|
"""
|
||||||
with get_db() as conn:
|
with get_db() as conn:
|
||||||
cur = get_cursor(conn)
|
cur = get_cursor(conn)
|
||||||
|
|
||||||
# Mapping: Apple Health Workout Type → training_type subcategory
|
# Try user-specific mapping first
|
||||||
# Supports English and German workout names
|
if profile_id:
|
||||||
mapping = {
|
|
||||||
# English
|
|
||||||
'running': 'running',
|
|
||||||
'walking': 'walk',
|
|
||||||
'hiking': 'walk',
|
|
||||||
'cycling': 'cycling',
|
|
||||||
'swimming': 'swimming',
|
|
||||||
'traditional strength training': 'hypertrophy',
|
|
||||||
'functional strength training': 'functional',
|
|
||||||
'high intensity interval training': 'hiit',
|
|
||||||
'yoga': 'yoga',
|
|
||||||
'martial arts': 'technique',
|
|
||||||
'boxing': 'sparring',
|
|
||||||
'rowing': 'rowing',
|
|
||||||
'dance': 'dance',
|
|
||||||
'core training': 'functional',
|
|
||||||
'flexibility': 'static',
|
|
||||||
'cooldown': 'regeneration',
|
|
||||||
'meditation': 'meditation',
|
|
||||||
'mindfulness': 'mindfulness',
|
|
||||||
|
|
||||||
# German (Deutsch)
|
|
||||||
'laufen': 'running',
|
|
||||||
'gehen': 'walk',
|
|
||||||
'wandern': 'walk',
|
|
||||||
'outdoor spaziergang': 'walk',
|
|
||||||
'innenräume spaziergang': 'walk',
|
|
||||||
'spaziergang': 'walk',
|
|
||||||
'radfahren': 'cycling',
|
|
||||||
'schwimmen': 'swimming',
|
|
||||||
'traditionelles krafttraining': 'hypertrophy',
|
|
||||||
'funktionelles krafttraining': 'functional',
|
|
||||||
'hochintensives intervalltraining': 'hiit',
|
|
||||||
'yoga': 'yoga',
|
|
||||||
'kampfsport': 'technique',
|
|
||||||
'matrial arts': 'technique', # Common typo in Apple Health
|
|
||||||
'boxen': 'sparring',
|
|
||||||
'rudern': 'rowing',
|
|
||||||
'tanzen': 'dance',
|
|
||||||
'cardio dance': 'dance',
|
|
||||||
'core training': 'functional',
|
|
||||||
'flexibilität': 'static',
|
|
||||||
'abwärmen': 'regeneration',
|
|
||||||
'cooldown': 'regeneration',
|
|
||||||
'meditation': 'meditation',
|
|
||||||
'achtsamkeit': 'mindfulness',
|
|
||||||
'geist & körper': 'yoga', # Mind & Body → Yoga category
|
|
||||||
}
|
|
||||||
|
|
||||||
subcategory = mapping.get(workout_type.lower())
|
|
||||||
if not subcategory:
|
|
||||||
return (None, None, None)
|
|
||||||
|
|
||||||
# Find training_type_id by subcategory
|
|
||||||
cur.execute("""
|
cur.execute("""
|
||||||
SELECT id, category, subcategory
|
SELECT m.training_type_id, t.category, t.subcategory
|
||||||
FROM training_types
|
FROM activity_type_mappings m
|
||||||
WHERE LOWER(subcategory) = %s
|
JOIN training_types t ON m.training_type_id = t.id
|
||||||
|
WHERE m.activity_type = %s AND m.profile_id = %s
|
||||||
LIMIT 1
|
LIMIT 1
|
||||||
""", (subcategory,))
|
""", (activity_type, profile_id))
|
||||||
row = cur.fetchone()
|
row = cur.fetchone()
|
||||||
|
|
||||||
if row:
|
if row:
|
||||||
return (row['id'], row['category'], row['subcategory'])
|
return (row['training_type_id'], row['category'], row['subcategory'])
|
||||||
|
|
||||||
|
# Try global mapping
|
||||||
|
cur.execute("""
|
||||||
|
SELECT m.training_type_id, t.category, t.subcategory
|
||||||
|
FROM activity_type_mappings m
|
||||||
|
JOIN training_types t ON m.training_type_id = t.id
|
||||||
|
WHERE m.activity_type = %s AND m.profile_id IS NULL
|
||||||
|
LIMIT 1
|
||||||
|
""", (activity_type,))
|
||||||
|
row = cur.fetchone()
|
||||||
|
if row:
|
||||||
|
return (row['training_type_id'], row['category'], row['subcategory'])
|
||||||
|
|
||||||
return (None, None, None)
|
return (None, None, None)
|
||||||
|
|
||||||
|
|
@ -216,6 +180,9 @@ def bulk_categorize_activities(
|
||||||
):
|
):
|
||||||
"""
|
"""
|
||||||
Bulk update training type for activities.
|
Bulk update training type for activities.
|
||||||
|
|
||||||
|
Also saves the mapping to activity_type_mappings for future imports.
|
||||||
|
|
||||||
Body: {
|
Body: {
|
||||||
"activity_type": "Running",
|
"activity_type": "Running",
|
||||||
"training_type_id": 1,
|
"training_type_id": 1,
|
||||||
|
|
@ -234,6 +201,8 @@ def bulk_categorize_activities(
|
||||||
|
|
||||||
with get_db() as conn:
|
with get_db() as conn:
|
||||||
cur = get_cursor(conn)
|
cur = get_cursor(conn)
|
||||||
|
|
||||||
|
# Update existing activities
|
||||||
cur.execute("""
|
cur.execute("""
|
||||||
UPDATE activity_log
|
UPDATE activity_log
|
||||||
SET training_type_id = %s,
|
SET training_type_id = %s,
|
||||||
|
|
@ -245,7 +214,20 @@ def bulk_categorize_activities(
|
||||||
""", (training_type_id, training_category, training_subcategory, pid, activity_type))
|
""", (training_type_id, training_category, training_subcategory, pid, activity_type))
|
||||||
updated_count = cur.rowcount
|
updated_count = cur.rowcount
|
||||||
|
|
||||||
return {"updated": updated_count, "activity_type": activity_type}
|
# Save mapping for future imports (upsert)
|
||||||
|
cur.execute("""
|
||||||
|
INSERT INTO activity_type_mappings (activity_type, training_type_id, profile_id, source, updated_at)
|
||||||
|
VALUES (%s, %s, %s, 'bulk', CURRENT_TIMESTAMP)
|
||||||
|
ON CONFLICT (activity_type, profile_id)
|
||||||
|
DO UPDATE SET
|
||||||
|
training_type_id = EXCLUDED.training_type_id,
|
||||||
|
source = 'bulk',
|
||||||
|
updated_at = CURRENT_TIMESTAMP
|
||||||
|
""", (activity_type, training_type_id, pid))
|
||||||
|
|
||||||
|
logger.info(f"[MAPPING] Saved bulk mapping: {activity_type} → training_type_id {training_type_id} (profile {pid})")
|
||||||
|
|
||||||
|
return {"updated": updated_count, "activity_type": activity_type, "mapping_saved": True}
|
||||||
|
|
||||||
|
|
||||||
@router.post("/import-csv")
|
@router.post("/import-csv")
|
||||||
|
|
@ -280,8 +262,8 @@ async def import_activity_csv(file: UploadFile=File(...), x_profile_id: Optional
|
||||||
def tf(v):
|
def tf(v):
|
||||||
try: return round(float(v),1) if v else None
|
try: return round(float(v),1) if v else None
|
||||||
except: return None
|
except: return None
|
||||||
# Map Apple Health workout type to training_type_id
|
# Map activity_type to training_type_id using database mappings
|
||||||
training_type_id, training_category, training_subcategory = get_training_type_for_apple_health(wtype)
|
training_type_id, training_category, training_subcategory = get_training_type_for_activity(wtype, pid)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Check if entry already exists (duplicate detection by date + start_time)
|
# Check if entry already exists (duplicate detection by date + start_time)
|
||||||
|
|
|
||||||
219
backend/routers/admin_activity_mappings.py
Normal file
219
backend/routers/admin_activity_mappings.py
Normal file
|
|
@ -0,0 +1,219 @@
|
||||||
|
"""
|
||||||
|
Admin Activity Type Mappings Management - v9d Phase 1b
|
||||||
|
|
||||||
|
CRUD operations for activity_type_mappings (learnable system).
|
||||||
|
"""
|
||||||
|
import logging
|
||||||
|
from typing import Optional
|
||||||
|
from fastapi import APIRouter, HTTPException, Depends
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
from db import get_db, get_cursor, r2d
|
||||||
|
from auth import require_admin
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/admin/activity-mappings", tags=["admin", "activity-mappings"])
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class ActivityMappingCreate(BaseModel):
|
||||||
|
activity_type: str
|
||||||
|
training_type_id: int
|
||||||
|
profile_id: Optional[str] = None
|
||||||
|
source: str = 'admin'
|
||||||
|
|
||||||
|
|
||||||
|
class ActivityMappingUpdate(BaseModel):
|
||||||
|
training_type_id: Optional[int] = None
|
||||||
|
profile_id: Optional[str] = None
|
||||||
|
source: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("")
|
||||||
|
def list_activity_mappings(
|
||||||
|
profile_id: Optional[str] = None,
|
||||||
|
global_only: bool = False,
|
||||||
|
session: dict = Depends(require_admin)
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Get all activity type mappings.
|
||||||
|
|
||||||
|
Filters:
|
||||||
|
- profile_id: Show only mappings for specific profile
|
||||||
|
- global_only: Show only global mappings (profile_id IS NULL)
|
||||||
|
"""
|
||||||
|
with get_db() as conn:
|
||||||
|
cur = get_cursor(conn)
|
||||||
|
|
||||||
|
query = """
|
||||||
|
SELECT m.id, m.activity_type, m.training_type_id, m.profile_id, m.source,
|
||||||
|
m.created_at, m.updated_at,
|
||||||
|
t.name_de as training_type_name_de,
|
||||||
|
t.category, t.subcategory, t.icon
|
||||||
|
FROM activity_type_mappings m
|
||||||
|
JOIN training_types t ON m.training_type_id = t.id
|
||||||
|
"""
|
||||||
|
|
||||||
|
conditions = []
|
||||||
|
params = []
|
||||||
|
|
||||||
|
if global_only:
|
||||||
|
conditions.append("m.profile_id IS NULL")
|
||||||
|
elif profile_id:
|
||||||
|
conditions.append("m.profile_id = %s")
|
||||||
|
params.append(profile_id)
|
||||||
|
|
||||||
|
if conditions:
|
||||||
|
query += " WHERE " + " AND ".join(conditions)
|
||||||
|
|
||||||
|
query += " ORDER BY m.activity_type"
|
||||||
|
|
||||||
|
cur.execute(query, params)
|
||||||
|
rows = cur.fetchall()
|
||||||
|
|
||||||
|
return [r2d(r) for r in rows]
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{mapping_id}")
|
||||||
|
def get_activity_mapping(mapping_id: int, session: dict = Depends(require_admin)):
|
||||||
|
"""Get single activity mapping by ID."""
|
||||||
|
with get_db() as conn:
|
||||||
|
cur = get_cursor(conn)
|
||||||
|
cur.execute("""
|
||||||
|
SELECT m.id, m.activity_type, m.training_type_id, m.profile_id, m.source,
|
||||||
|
m.created_at, m.updated_at,
|
||||||
|
t.name_de as training_type_name_de,
|
||||||
|
t.category, t.subcategory
|
||||||
|
FROM activity_type_mappings m
|
||||||
|
JOIN training_types t ON m.training_type_id = t.id
|
||||||
|
WHERE m.id = %s
|
||||||
|
""", (mapping_id,))
|
||||||
|
row = cur.fetchone()
|
||||||
|
|
||||||
|
if not row:
|
||||||
|
raise HTTPException(404, "Mapping not found")
|
||||||
|
|
||||||
|
return r2d(row)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("")
|
||||||
|
def create_activity_mapping(data: ActivityMappingCreate, session: dict = Depends(require_admin)):
|
||||||
|
"""
|
||||||
|
Create new activity type mapping.
|
||||||
|
|
||||||
|
Note: Duplicate (activity_type, profile_id) will fail with 409 Conflict.
|
||||||
|
"""
|
||||||
|
with get_db() as conn:
|
||||||
|
cur = get_cursor(conn)
|
||||||
|
|
||||||
|
try:
|
||||||
|
cur.execute("""
|
||||||
|
INSERT INTO activity_type_mappings
|
||||||
|
(activity_type, training_type_id, profile_id, source)
|
||||||
|
VALUES (%s, %s, %s, %s)
|
||||||
|
RETURNING id
|
||||||
|
""", (
|
||||||
|
data.activity_type,
|
||||||
|
data.training_type_id,
|
||||||
|
data.profile_id,
|
||||||
|
data.source
|
||||||
|
))
|
||||||
|
|
||||||
|
new_id = cur.fetchone()['id']
|
||||||
|
|
||||||
|
logger.info(f"[ADMIN] Mapping created: {data.activity_type} → training_type_id {data.training_type_id} (profile: {data.profile_id})")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
if 'unique_activity_type_per_profile' in str(e):
|
||||||
|
raise HTTPException(409, f"Mapping for '{data.activity_type}' already exists (profile: {data.profile_id})")
|
||||||
|
raise HTTPException(400, f"Failed to create mapping: {str(e)}")
|
||||||
|
|
||||||
|
return {"id": new_id, "message": "Mapping created"}
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/{mapping_id}")
|
||||||
|
def update_activity_mapping(
|
||||||
|
mapping_id: int,
|
||||||
|
data: ActivityMappingUpdate,
|
||||||
|
session: dict = Depends(require_admin)
|
||||||
|
):
|
||||||
|
"""Update existing activity type mapping."""
|
||||||
|
with get_db() as conn:
|
||||||
|
cur = get_cursor(conn)
|
||||||
|
|
||||||
|
# Build update query dynamically
|
||||||
|
updates = []
|
||||||
|
values = []
|
||||||
|
|
||||||
|
if data.training_type_id is not None:
|
||||||
|
updates.append("training_type_id = %s")
|
||||||
|
values.append(data.training_type_id)
|
||||||
|
if data.profile_id is not None:
|
||||||
|
updates.append("profile_id = %s")
|
||||||
|
values.append(data.profile_id)
|
||||||
|
if data.source is not None:
|
||||||
|
updates.append("source = %s")
|
||||||
|
values.append(data.source)
|
||||||
|
|
||||||
|
if not updates:
|
||||||
|
raise HTTPException(400, "No fields to update")
|
||||||
|
|
||||||
|
updates.append("updated_at = CURRENT_TIMESTAMP")
|
||||||
|
values.append(mapping_id)
|
||||||
|
|
||||||
|
cur.execute(f"""
|
||||||
|
UPDATE activity_type_mappings
|
||||||
|
SET {', '.join(updates)}
|
||||||
|
WHERE id = %s
|
||||||
|
""", values)
|
||||||
|
|
||||||
|
if cur.rowcount == 0:
|
||||||
|
raise HTTPException(404, "Mapping not found")
|
||||||
|
|
||||||
|
logger.info(f"[ADMIN] Mapping updated: {mapping_id}")
|
||||||
|
|
||||||
|
return {"id": mapping_id, "message": "Mapping updated"}
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{mapping_id}")
|
||||||
|
def delete_activity_mapping(mapping_id: int, session: dict = Depends(require_admin)):
|
||||||
|
"""
|
||||||
|
Delete activity type mapping.
|
||||||
|
|
||||||
|
This will cause future imports to NOT auto-assign training type for this activity_type.
|
||||||
|
Existing activities with this mapping remain unchanged.
|
||||||
|
"""
|
||||||
|
with get_db() as conn:
|
||||||
|
cur = get_cursor(conn)
|
||||||
|
|
||||||
|
cur.execute("DELETE FROM activity_type_mappings WHERE id = %s", (mapping_id,))
|
||||||
|
|
||||||
|
if cur.rowcount == 0:
|
||||||
|
raise HTTPException(404, "Mapping not found")
|
||||||
|
|
||||||
|
logger.info(f"[ADMIN] Mapping deleted: {mapping_id}")
|
||||||
|
|
||||||
|
return {"message": "Mapping deleted"}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/stats/coverage")
|
||||||
|
def get_mapping_coverage(session: dict = Depends(require_admin)):
|
||||||
|
"""
|
||||||
|
Get statistics about mapping coverage.
|
||||||
|
|
||||||
|
Returns how many activities are mapped vs unmapped across all profiles.
|
||||||
|
"""
|
||||||
|
with get_db() as conn:
|
||||||
|
cur = get_cursor(conn)
|
||||||
|
|
||||||
|
cur.execute("""
|
||||||
|
SELECT
|
||||||
|
COUNT(*) as total_activities,
|
||||||
|
COUNT(training_type_id) as mapped_activities,
|
||||||
|
COUNT(*) - COUNT(training_type_id) as unmapped_activities,
|
||||||
|
COUNT(DISTINCT activity_type) as unique_activity_types,
|
||||||
|
COUNT(DISTINCT CASE WHEN training_type_id IS NULL THEN activity_type END) as unmapped_types
|
||||||
|
FROM activity_log
|
||||||
|
""")
|
||||||
|
stats = r2d(cur.fetchone())
|
||||||
|
|
||||||
|
return stats
|
||||||
|
|
@ -28,6 +28,7 @@ import AdminTiersPage from './pages/AdminTiersPage'
|
||||||
import AdminCouponsPage from './pages/AdminCouponsPage'
|
import AdminCouponsPage from './pages/AdminCouponsPage'
|
||||||
import AdminUserRestrictionsPage from './pages/AdminUserRestrictionsPage'
|
import AdminUserRestrictionsPage from './pages/AdminUserRestrictionsPage'
|
||||||
import AdminTrainingTypesPage from './pages/AdminTrainingTypesPage'
|
import AdminTrainingTypesPage from './pages/AdminTrainingTypesPage'
|
||||||
|
import AdminActivityMappingsPage from './pages/AdminActivityMappingsPage'
|
||||||
import SubscriptionPage from './pages/SubscriptionPage'
|
import SubscriptionPage from './pages/SubscriptionPage'
|
||||||
import './app.css'
|
import './app.css'
|
||||||
|
|
||||||
|
|
@ -174,6 +175,7 @@ function AppShell() {
|
||||||
<Route path="/admin/coupons" element={<AdminCouponsPage/>}/>
|
<Route path="/admin/coupons" element={<AdminCouponsPage/>}/>
|
||||||
<Route path="/admin/user-restrictions" element={<AdminUserRestrictionsPage/>}/>
|
<Route path="/admin/user-restrictions" element={<AdminUserRestrictionsPage/>}/>
|
||||||
<Route path="/admin/training-types" element={<AdminTrainingTypesPage/>}/>
|
<Route path="/admin/training-types" element={<AdminTrainingTypesPage/>}/>
|
||||||
|
<Route path="/admin/activity-mappings" element={<AdminActivityMappingsPage/>}/>
|
||||||
<Route path="/subscription" element={<SubscriptionPage/>}/>
|
<Route path="/subscription" element={<SubscriptionPage/>}/>
|
||||||
</Routes>
|
</Routes>
|
||||||
</main>
|
</main>
|
||||||
|
|
|
||||||
366
frontend/src/pages/AdminActivityMappingsPage.jsx
Normal file
366
frontend/src/pages/AdminActivityMappingsPage.jsx
Normal file
|
|
@ -0,0 +1,366 @@
|
||||||
|
import { useState, useEffect } from 'react'
|
||||||
|
import { useNavigate } from 'react-router-dom'
|
||||||
|
import { Pencil, Trash2, Plus, Save, X, ArrowLeft, TrendingUp } from 'lucide-react'
|
||||||
|
import { api } from '../utils/api'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* AdminActivityMappingsPage - Manage activity_type → training_type mappings
|
||||||
|
* v9d Phase 1b - Learnable system (replaces hardcoded mappings)
|
||||||
|
*/
|
||||||
|
export default function AdminActivityMappingsPage() {
|
||||||
|
const nav = useNavigate()
|
||||||
|
const [mappings, setMappings] = useState([])
|
||||||
|
const [trainingTypes, setTrainingTypes] = useState([])
|
||||||
|
const [coverage, setCoverage] = useState(null)
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [editingId, setEditingId] = useState(null)
|
||||||
|
const [formData, setFormData] = useState(null)
|
||||||
|
const [error, setError] = useState(null)
|
||||||
|
const [saving, setSaving] = useState(false)
|
||||||
|
const [filter, setFilter] = useState('all') // 'all', 'global', 'user'
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
load()
|
||||||
|
}, [filter])
|
||||||
|
|
||||||
|
const load = () => {
|
||||||
|
setLoading(true)
|
||||||
|
Promise.all([
|
||||||
|
api.adminListActivityMappings(null, filter === 'global'),
|
||||||
|
api.listTrainingTypesFlat(),
|
||||||
|
api.adminGetMappingCoverage()
|
||||||
|
]).then(([mappingsData, typesData, coverageData]) => {
|
||||||
|
setMappings(mappingsData)
|
||||||
|
setTrainingTypes(typesData)
|
||||||
|
setCoverage(coverageData)
|
||||||
|
setLoading(false)
|
||||||
|
}).catch(err => {
|
||||||
|
console.error('Failed to load mappings:', err)
|
||||||
|
setError(err.message)
|
||||||
|
setLoading(false)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const startCreate = () => {
|
||||||
|
setFormData({
|
||||||
|
activity_type: '',
|
||||||
|
training_type_id: trainingTypes[0]?.id || null,
|
||||||
|
profile_id: '',
|
||||||
|
source: 'admin'
|
||||||
|
})
|
||||||
|
setEditingId('new')
|
||||||
|
}
|
||||||
|
|
||||||
|
const startEdit = (mapping) => {
|
||||||
|
setFormData({
|
||||||
|
activity_type: mapping.activity_type,
|
||||||
|
training_type_id: mapping.training_type_id,
|
||||||
|
profile_id: mapping.profile_id || '',
|
||||||
|
source: mapping.source
|
||||||
|
})
|
||||||
|
setEditingId(mapping.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
const cancelEdit = () => {
|
||||||
|
setEditingId(null)
|
||||||
|
setFormData(null)
|
||||||
|
setError(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
if (!formData.activity_type || !formData.training_type_id) {
|
||||||
|
setError('Activity Type und Training Type sind Pflichtfelder')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setSaving(true)
|
||||||
|
setError(null)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const payload = {
|
||||||
|
...formData,
|
||||||
|
profile_id: formData.profile_id || null
|
||||||
|
}
|
||||||
|
|
||||||
|
if (editingId === 'new') {
|
||||||
|
await api.adminCreateActivityMapping(payload)
|
||||||
|
} else {
|
||||||
|
await api.adminUpdateActivityMapping(editingId, {
|
||||||
|
training_type_id: payload.training_type_id,
|
||||||
|
profile_id: payload.profile_id,
|
||||||
|
source: payload.source
|
||||||
|
})
|
||||||
|
}
|
||||||
|
await load()
|
||||||
|
cancelEdit()
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Save failed:', err)
|
||||||
|
setError(err.message)
|
||||||
|
} finally {
|
||||||
|
setSaving(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDelete = async (id, activityType) => {
|
||||||
|
if (!confirm(`Mapping für "${activityType}" wirklich löschen?\n\nZukünftige Imports werden diesen Typ nicht mehr automatisch zuordnen.`)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await api.adminDeleteActivityMapping(id)
|
||||||
|
await load()
|
||||||
|
} catch (err) {
|
||||||
|
alert('Löschen fehlgeschlagen: ' + err.message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div style={{ padding: 20, textAlign: 'center' }}>
|
||||||
|
<div className="spinner" style={{ width: 32, height: 32, margin: '0 auto' }} />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const coveragePercent = coverage ? Math.round((coverage.mapped_activities / coverage.total_activities) * 100) : 0
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ padding: '16px 16px 80px' }}>
|
||||||
|
<div style={{ marginBottom: 20, display: 'flex', alignItems: 'center', gap: 12 }}>
|
||||||
|
<button
|
||||||
|
onClick={() => nav('/settings')}
|
||||||
|
style={{
|
||||||
|
background: 'none',
|
||||||
|
border: 'none',
|
||||||
|
cursor: 'pointer',
|
||||||
|
padding: 6,
|
||||||
|
display: 'flex',
|
||||||
|
color: 'var(--text2)'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ArrowLeft size={20} />
|
||||||
|
</button>
|
||||||
|
<h1 style={{ margin: 0, fontSize: 20, fontWeight: 700 }}>Activity-Mappings</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="card" style={{ padding: 12, marginBottom: 16, background: '#FCEBEB', color: '#D85A30' }}>
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Coverage Stats */}
|
||||||
|
{coverage && (
|
||||||
|
<div className="card" style={{ padding: 16, marginBottom: 16 }}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 8 }}>
|
||||||
|
<TrendingUp size={16} color="var(--accent)" />
|
||||||
|
<div style={{ fontWeight: 600, fontSize: 14 }}>Mapping-Abdeckung</div>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 1fr', gap: 8, fontSize: 12 }}>
|
||||||
|
<div style={{ textAlign: 'center', padding: 8, background: 'var(--surface)', borderRadius: 6 }}>
|
||||||
|
<div style={{ fontSize: 18, fontWeight: 700, color: 'var(--accent)' }}>{coveragePercent}%</div>
|
||||||
|
<div style={{ color: 'var(--text3)' }}>Zugeordnet</div>
|
||||||
|
</div>
|
||||||
|
<div style={{ textAlign: 'center', padding: 8, background: 'var(--surface)', borderRadius: 6 }}>
|
||||||
|
<div style={{ fontSize: 18, fontWeight: 700 }}>{coverage.mapped_activities}</div>
|
||||||
|
<div style={{ color: 'var(--text3)' }}>Mit Typ</div>
|
||||||
|
</div>
|
||||||
|
<div style={{ textAlign: 'center', padding: 8, background: 'var(--surface)', borderRadius: 6 }}>
|
||||||
|
<div style={{ fontSize: 18, fontWeight: 700, color: '#D85A30' }}>{coverage.unmapped_activities}</div>
|
||||||
|
<div style={{ color: 'var(--text3)' }}>Ohne Typ</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style={{ marginTop: 8, fontSize: 11, color: 'var(--text3)' }}>
|
||||||
|
{coverage.unmapped_types} verschiedene Activity-Types noch nicht gemappt
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Filter */}
|
||||||
|
<div style={{ display: 'flex', gap: 8, marginBottom: 16 }}>
|
||||||
|
<button
|
||||||
|
onClick={() => setFilter('all')}
|
||||||
|
className={filter === 'all' ? 'btn btn-primary' : 'btn btn-secondary'}
|
||||||
|
style={{ flex: 1, fontSize: 12 }}
|
||||||
|
>
|
||||||
|
Alle ({mappings.length})
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setFilter('global')}
|
||||||
|
className={filter === 'global' ? 'btn btn-primary' : 'btn btn-secondary'}
|
||||||
|
style={{ flex: 1, fontSize: 12 }}
|
||||||
|
>
|
||||||
|
Global
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Create new button */}
|
||||||
|
{!editingId && (
|
||||||
|
<button
|
||||||
|
onClick={startCreate}
|
||||||
|
className="btn btn-primary btn-full"
|
||||||
|
style={{ marginBottom: 16 }}
|
||||||
|
>
|
||||||
|
<Plus size={16} /> Neues Mapping anlegen
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Edit form */}
|
||||||
|
{editingId && formData && (
|
||||||
|
<div className="card" style={{ padding: 16, marginBottom: 16 }}>
|
||||||
|
<div style={{ fontWeight: 600, marginBottom: 12 }}>
|
||||||
|
{editingId === 'new' ? '➕ Neues Mapping' : '✏️ Mapping bearbeiten'}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
|
||||||
|
<div>
|
||||||
|
<div className="form-label">Activity Type * (exakt wie in CSV)</div>
|
||||||
|
<input
|
||||||
|
className="form-input"
|
||||||
|
value={formData.activity_type}
|
||||||
|
onChange={e => setFormData({ ...formData, activity_type: e.target.value })}
|
||||||
|
placeholder="z.B. Traditionelles Krafttraining"
|
||||||
|
disabled={editingId !== 'new'}
|
||||||
|
style={{ width: '100%' }}
|
||||||
|
/>
|
||||||
|
<div style={{ fontSize: 11, color: 'var(--text3)', marginTop: 4 }}>
|
||||||
|
Groß-/Kleinschreibung beachten! Muss exakt mit CSV übereinstimmen.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<div className="form-label">Training Type *</div>
|
||||||
|
<select
|
||||||
|
className="form-input"
|
||||||
|
value={formData.training_type_id}
|
||||||
|
onChange={e => setFormData({ ...formData, training_type_id: parseInt(e.target.value) })}
|
||||||
|
style={{ width: '100%' }}
|
||||||
|
>
|
||||||
|
{trainingTypes.map(type => (
|
||||||
|
<option key={type.id} value={type.id}>
|
||||||
|
{type.icon} {type.name_de} ({type.category})
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<div className="form-label">Profil-ID (leer = global)</div>
|
||||||
|
<input
|
||||||
|
className="form-input"
|
||||||
|
value={formData.profile_id}
|
||||||
|
onChange={e => setFormData({ ...formData, profile_id: e.target.value })}
|
||||||
|
placeholder="Leer lassen für globales Mapping"
|
||||||
|
style={{ width: '100%' }}
|
||||||
|
/>
|
||||||
|
<div style={{ fontSize: 11, color: 'var(--text3)', marginTop: 4 }}>
|
||||||
|
Global = für alle User, sonst user-spezifisch
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ display: 'flex', gap: 8, marginTop: 8 }}>
|
||||||
|
<button
|
||||||
|
onClick={handleSave}
|
||||||
|
disabled={saving}
|
||||||
|
className="btn btn-primary"
|
||||||
|
style={{ flex: 1 }}
|
||||||
|
>
|
||||||
|
{saving ? (
|
||||||
|
<>
|
||||||
|
<div className="spinner" style={{ width: 14, height: 14 }} />
|
||||||
|
Speichere...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Save size={16} /> Speichern
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={cancelEdit}
|
||||||
|
disabled={saving}
|
||||||
|
className="btn btn-secondary"
|
||||||
|
style={{ flex: 1 }}
|
||||||
|
>
|
||||||
|
<X size={16} /> Abbrechen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* List */}
|
||||||
|
{mappings.length === 0 ? (
|
||||||
|
<div className="card" style={{ padding: 40, textAlign: 'center', color: 'var(--text3)' }}>
|
||||||
|
Keine Mappings gefunden
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||||
|
{mappings.map(mapping => (
|
||||||
|
<div
|
||||||
|
key={mapping.id}
|
||||||
|
className="card"
|
||||||
|
style={{ padding: 12 }}
|
||||||
|
>
|
||||||
|
<div style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 8
|
||||||
|
}}>
|
||||||
|
<div style={{ fontSize: 18 }}>{mapping.icon}</div>
|
||||||
|
<div style={{ flex: 1 }}>
|
||||||
|
<div style={{ fontSize: 13, fontWeight: 600 }}>
|
||||||
|
{mapping.activity_type}
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: 11, color: 'var(--text3)' }}>
|
||||||
|
→ {mapping.training_type_name_de}
|
||||||
|
{mapping.profile_id && <> · User-spezifisch</>}
|
||||||
|
{!mapping.profile_id && <> · Global</>}
|
||||||
|
{mapping.source && <> · {mapping.source}</>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => startEdit(mapping)}
|
||||||
|
style={{
|
||||||
|
background: 'none',
|
||||||
|
border: 'none',
|
||||||
|
cursor: 'pointer',
|
||||||
|
padding: 6,
|
||||||
|
color: 'var(--accent)'
|
||||||
|
}}
|
||||||
|
title="Bearbeiten"
|
||||||
|
>
|
||||||
|
<Pencil size={16} />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => handleDelete(mapping.id, mapping.activity_type)}
|
||||||
|
style={{
|
||||||
|
background: 'none',
|
||||||
|
border: 'none',
|
||||||
|
cursor: 'pointer',
|
||||||
|
padding: 6,
|
||||||
|
color: '#D85A30'
|
||||||
|
}}
|
||||||
|
title="Löschen"
|
||||||
|
>
|
||||||
|
<Trash2 size={16} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div style={{
|
||||||
|
marginTop: 20,
|
||||||
|
padding: 12,
|
||||||
|
background: 'var(--surface2)',
|
||||||
|
borderRadius: 8,
|
||||||
|
fontSize: 12,
|
||||||
|
color: 'var(--text3)'
|
||||||
|
}}>
|
||||||
|
<strong>💡 Tipp:</strong> Das System lernt automatisch! Wenn du im Tab "Kategorisieren" Aktivitäten zuordnest, wird das Mapping gespeichert und beim nächsten Import automatisch angewendet.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -431,7 +431,7 @@ export default function AdminPanel() {
|
||||||
<Settings size={16} color="var(--accent)"/> Trainingstypen (v9d)
|
<Settings size={16} color="var(--accent)"/> Trainingstypen (v9d)
|
||||||
</div>
|
</div>
|
||||||
<div style={{fontSize:12,color:'var(--text3)',marginBottom:12,lineHeight:1.5}}>
|
<div style={{fontSize:12,color:'var(--text3)',marginBottom:12,lineHeight:1.5}}>
|
||||||
Verwalte Trainingstypen, Kategorien und Fähigkeiten-Mapping.
|
Verwalte Trainingstypen, Kategorien und Activity-Mappings (lernendes System).
|
||||||
</div>
|
</div>
|
||||||
<div style={{display:'grid',gap:8}}>
|
<div style={{display:'grid',gap:8}}>
|
||||||
<Link to="/admin/training-types">
|
<Link to="/admin/training-types">
|
||||||
|
|
@ -439,6 +439,11 @@ export default function AdminPanel() {
|
||||||
🏋️ Trainingstypen verwalten
|
🏋️ Trainingstypen verwalten
|
||||||
</button>
|
</button>
|
||||||
</Link>
|
</Link>
|
||||||
|
<Link to="/admin/activity-mappings">
|
||||||
|
<button className="btn btn-secondary btn-full">
|
||||||
|
🔗 Activity-Mappings (lernendes System)
|
||||||
|
</button>
|
||||||
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -204,4 +204,12 @@ export const api = {
|
||||||
adminUpdateTrainingType: (id,d) => req(`/admin/training-types/${id}`, jput(d)),
|
adminUpdateTrainingType: (id,d) => req(`/admin/training-types/${id}`, jput(d)),
|
||||||
adminDeleteTrainingType: (id) => req(`/admin/training-types/${id}`, {method:'DELETE'}),
|
adminDeleteTrainingType: (id) => req(`/admin/training-types/${id}`, {method:'DELETE'}),
|
||||||
getAbilitiesTaxonomy: () => req('/admin/training-types/taxonomy/abilities'),
|
getAbilitiesTaxonomy: () => req('/admin/training-types/taxonomy/abilities'),
|
||||||
|
|
||||||
|
// Admin: Activity Type Mappings (v9d Phase 1b - Learnable System)
|
||||||
|
adminListActivityMappings: (profileId, globalOnly) => req(`/admin/activity-mappings${profileId?'?profile_id='+profileId:''}${globalOnly?'?global_only=true':''}`),
|
||||||
|
adminGetActivityMapping: (id) => req(`/admin/activity-mappings/${id}`),
|
||||||
|
adminCreateActivityMapping: (d) => req('/admin/activity-mappings', json(d)),
|
||||||
|
adminUpdateActivityMapping: (id,d) => req(`/admin/activity-mappings/${id}`, jput(d)),
|
||||||
|
adminDeleteActivityMapping: (id) => req(`/admin/activity-mappings/${id}`, {method:'DELETE'}),
|
||||||
|
adminGetMappingCoverage: () => req('/admin/activity-mappings/stats/coverage'),
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user