Abschluss 9c #11
50
CLAUDE.md
50
CLAUDE.md
|
|
@ -49,7 +49,7 @@ frontend/src/
|
|||
└── technical/ # MEMBERSHIP_SYSTEM.md
|
||||
```
|
||||
|
||||
## Aktuelle Version: v9c (komplett)
|
||||
## Aktuelle Version: v9c (komplett) 🚀 Production seit 21.03.2026
|
||||
|
||||
### Implementiert ✅
|
||||
- Login (E-Mail + bcrypt), Auth-Middleware alle Endpoints, Rate Limiting
|
||||
|
|
@ -81,15 +81,55 @@ frontend/src/
|
|||
- ✅ **BUG-002:** Ernährungs-Daten Tab fehlte – importierte Einträge nicht sichtbar
|
||||
- ✅ **BUG-003:** Korrelations-Chart Extrapolation (gestrichelte Linien für fehlende Werte)
|
||||
- ✅ **BUG-004:** Import-Historie Refresh (Force remount via key prop)
|
||||
- ✅ **BUG-005:** Login → leere Seite (window.location.href='/' nach login)
|
||||
- ✅ **BUG-006:** Email-Verifizierung → leere Seite (window.location.href='/' statt navigate)
|
||||
- ✅ **BUG-007:** Doppelklick Verifizierungslink → generischer JSON-Fehler (Error-Parsing + bessere Backend-Meldung)
|
||||
- ✅ **BUG-008:** Dashboard infinite loading bei API-Fehlern (.catch() handler in load())
|
||||
|
||||
### v9c Finalisierung ✅
|
||||
### v9c Finalisierung ✅ (Deployed to Production 21.03.2026)
|
||||
- ✅ **Selbst-Registrierung:** POST /api/auth/register, E-Mail-Verifizierung, Auto-Login
|
||||
- ✅ **Trial-System UI:** Countdown-Banner im Dashboard (3 Urgency-Level)
|
||||
- ✅ **Migrations-System:** Automatische Schema-Migrationen beim Start (db_init.py)
|
||||
- ✅ **Navigation-Fixes:** Alle Login/Verify-Flows funktionieren korrekt
|
||||
- ✅ **Error-Handling:** JSON-Fehler sauber formatiert, Dashboard robust bei API-Fehlern
|
||||
|
||||
### Offen v9d 🔲
|
||||
- Schlaf-Modul
|
||||
- Trainingstypen + Herzfrequenz
|
||||
### Auf develop (bereit für Prod) 🚀
|
||||
**v9d Phase 1b - Feature-komplett, ready for deployment**
|
||||
|
||||
- ✅ **Trainingstypen-System (komplett):**
|
||||
- 29 Trainingstypen (7 Kategorien)
|
||||
- Admin-CRUD mit vollständiger UI
|
||||
- Automatisches Apple Health Mapping (23 Workout-Typen)
|
||||
- Bulk-Kategorisierung für bestehende Aktivitäten
|
||||
- Farbige Typ-Badges in Aktivitätsliste
|
||||
- TrainingTypeDistribution Chart in History-Seite
|
||||
- TrainingTypeSelect in ActivityPage
|
||||
|
||||
- ✅ **Weitere Verbesserungen:**
|
||||
- TrialBanner mailto (Vorbereitung zentrales Abo-System)
|
||||
- Admin-Formular UX-Optimierung (Full-width inputs, größere Textareas)
|
||||
|
||||
- 📚 **Dokumentation:**
|
||||
- `.claude/docs/technical/CENTRAL_SUBSCRIPTION_SYSTEM.md`
|
||||
- `.claude/docs/functional/AI_PROMPTS.md` (erweitert um Fähigkeiten-Mapping)
|
||||
|
||||
### v9d – Phase 1 ✅ (Deployed 21.03.2026)
|
||||
- ✅ **Trainingstypen Basis:** DB-Schema, 23 Typen, API-Endpoints
|
||||
- ✅ **Logout-Button:** Im Header neben Avatar, mit Bestätigung
|
||||
- ✅ **Components:** TrainingTypeSelect, TrainingTypeDistribution
|
||||
|
||||
### v9d – Phase 1b ✅ (Abgeschlossen, auf develop)
|
||||
- ✅ ActivityPage: TrainingTypeSelect eingebunden
|
||||
- ✅ History: TrainingTypeDistribution Chart + Typ-Badges bei Aktivitäten
|
||||
- ✅ Apple Health Import: Automatisches Mapping (29 Typen)
|
||||
- ✅ Bulk-Kategorisierung: UI + Endpoints
|
||||
- ✅ Admin-CRUD: Vollständige Verwaltung inkl. UX-Optimierungen
|
||||
|
||||
### v9d – Phase 2+ 🔲 (Später)
|
||||
- 🔲 Ruhetage erfassen (rest_days Tabelle)
|
||||
- 🔲 Ruhepuls erfassen (vitals_log Tabelle)
|
||||
- 🔲 HF-Zonen + Erholungsstatus
|
||||
- 🔲 Schlaf-Modul
|
||||
|
||||
📚 Details: `.claude/docs/technical/MEMBERSHIP_SYSTEM.md` · `.claude/docs/architecture/FEATURE_ENFORCEMENT.md`
|
||||
|
||||
|
|
|
|||
|
|
@ -19,7 +19,8 @@ from routers import auth, profiles, weight, circumference, caliper
|
|||
from routers import activity, nutrition, photos, insights, prompts
|
||||
from routers import admin, stats, exportdata, importdata
|
||||
from routers import subscription, coupons, features, tiers_mgmt, tier_limits
|
||||
from routers import user_restrictions, access_grants
|
||||
from routers import user_restrictions, access_grants, training_types, admin_training_types
|
||||
from routers import admin_activity_mappings
|
||||
|
||||
# ── App Configuration ─────────────────────────────────────────────────────────
|
||||
DATA_DIR = Path(os.getenv("DATA_DIR", "./data"))
|
||||
|
|
@ -85,6 +86,11 @@ app.include_router(tier_limits.router) # /api/tier-limits (admin)
|
|||
app.include_router(user_restrictions.router) # /api/user-restrictions (admin)
|
||||
app.include_router(access_grants.router) # /api/access-grants (admin)
|
||||
|
||||
# v9d 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_activity_mappings.router) # /api/admin/activity-mappings/*
|
||||
|
||||
# ── Health Check ──────────────────────────────────────────────────────────────
|
||||
@app.get("/")
|
||||
def root():
|
||||
|
|
|
|||
86
backend/migrations/004_training_types.sql
Normal file
86
backend/migrations/004_training_types.sql
Normal file
|
|
@ -0,0 +1,86 @@
|
|||
-- Migration 004: Training Types & Categories
|
||||
-- Part of v9d: Schlaf + Sport-Vertiefung
|
||||
-- Created: 2026-03-21
|
||||
|
||||
-- ========================================
|
||||
-- 1. Create training_types table
|
||||
-- ========================================
|
||||
CREATE TABLE IF NOT EXISTS training_types (
|
||||
id SERIAL PRIMARY KEY,
|
||||
category VARCHAR(50) NOT NULL, -- Main category: 'cardio', 'strength', 'hiit', etc.
|
||||
subcategory VARCHAR(50), -- Optional: 'running', 'hypertrophy', etc.
|
||||
name_de VARCHAR(100) NOT NULL, -- German display name
|
||||
name_en VARCHAR(100) NOT NULL, -- English display name
|
||||
icon VARCHAR(10), -- Emoji icon
|
||||
sort_order INTEGER DEFAULT 0, -- For UI ordering
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- ========================================
|
||||
-- 2. Add training type columns to activity_log
|
||||
-- ========================================
|
||||
ALTER TABLE activity_log
|
||||
ADD COLUMN IF NOT EXISTS training_type_id INTEGER REFERENCES training_types(id),
|
||||
ADD COLUMN IF NOT EXISTS training_category VARCHAR(50), -- Denormalized for fast queries
|
||||
ADD COLUMN IF NOT EXISTS training_subcategory VARCHAR(50); -- Denormalized
|
||||
|
||||
-- ========================================
|
||||
-- 3. Create indexes
|
||||
-- ========================================
|
||||
CREATE INDEX IF NOT EXISTS idx_activity_training_type ON activity_log(training_type_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_activity_training_category ON activity_log(training_category);
|
||||
CREATE INDEX IF NOT EXISTS idx_training_types_category ON training_types(category);
|
||||
|
||||
-- ========================================
|
||||
-- 4. Seed training types data
|
||||
-- ========================================
|
||||
|
||||
-- Cardio (Ausdauer)
|
||||
INSERT INTO training_types (category, subcategory, name_de, name_en, icon, sort_order) VALUES
|
||||
('cardio', 'running', 'Laufen', 'Running', '🏃', 100),
|
||||
('cardio', 'cycling', 'Radfahren', 'Cycling', '🚴', 101),
|
||||
('cardio', 'swimming', 'Schwimmen', 'Swimming', '🏊', 102),
|
||||
('cardio', 'rowing', 'Rudern', 'Rowing', '🚣', 103),
|
||||
('cardio', 'other', 'Sonstiges Cardio', 'Other Cardio', '❤️', 104);
|
||||
|
||||
-- Kraft
|
||||
INSERT INTO training_types (category, subcategory, name_de, name_en, icon, sort_order) VALUES
|
||||
('strength', 'hypertrophy', 'Hypertrophie', 'Hypertrophy', '💪', 200),
|
||||
('strength', 'maxstrength', 'Maximalkraft', 'Max Strength', '🏋️', 201),
|
||||
('strength', 'endurance', 'Kraftausdauer', 'Strength Endurance', '🔁', 202),
|
||||
('strength', 'functional', 'Funktionell', 'Functional', '⚡', 203);
|
||||
|
||||
-- Schnellkraft / HIIT
|
||||
INSERT INTO training_types (category, subcategory, name_de, name_en, icon, sort_order) VALUES
|
||||
('hiit', 'hiit', 'HIIT', 'HIIT', '🔥', 300),
|
||||
('hiit', 'explosive', 'Explosiv', 'Explosive', '💥', 301),
|
||||
('hiit', 'circuit', 'Circuit Training', 'Circuit Training', '🔄', 302);
|
||||
|
||||
-- Kampfsport / Technikkraft
|
||||
INSERT INTO training_types (category, subcategory, name_de, name_en, icon, sort_order) VALUES
|
||||
('martial_arts', 'technique', 'Techniktraining', 'Technique Training', '🥋', 400),
|
||||
('martial_arts', 'sparring', 'Sparring / Wettkampf', 'Sparring / Competition', '🥊', 401),
|
||||
('martial_arts', 'strength', 'Kraft für Kampfsport', 'Martial Arts Strength', '⚔️', 402);
|
||||
|
||||
-- Mobility & Dehnung
|
||||
INSERT INTO training_types (category, subcategory, name_de, name_en, icon, sort_order) VALUES
|
||||
('mobility', 'static', 'Statisches Dehnen', 'Static Stretching', '🧘', 500),
|
||||
('mobility', 'dynamic', 'Dynamisches Dehnen', 'Dynamic Stretching', '🤸', 501),
|
||||
('mobility', 'yoga', 'Yoga', 'Yoga', '🕉️', 502),
|
||||
('mobility', 'fascia', 'Faszienarbeit', 'Fascia Work', '🎯', 503);
|
||||
|
||||
-- Erholung (aktiv)
|
||||
INSERT INTO training_types (category, subcategory, name_de, name_en, icon, sort_order) VALUES
|
||||
('recovery', 'walk', 'Spaziergang', 'Walk', '🚶', 600),
|
||||
('recovery', 'swim_light', 'Leichtes Schwimmen', 'Light Swimming', '🏊', 601),
|
||||
('recovery', 'regeneration', 'Regenerationseinheit', 'Regeneration', '💆', 602);
|
||||
|
||||
-- General / Uncategorized
|
||||
INSERT INTO training_types (category, subcategory, name_de, name_en, icon, sort_order) VALUES
|
||||
('other', NULL, 'Sonstiges', 'Other', '📝', 900);
|
||||
|
||||
-- ========================================
|
||||
-- 5. Add comment
|
||||
-- ========================================
|
||||
COMMENT ON TABLE training_types IS 'v9d: Training type categories and subcategories';
|
||||
COMMENT ON TABLE activity_log IS 'Extended in v9d with training_type_id for categorization';
|
||||
24
backend/migrations/005_training_types_extended.sql
Normal file
24
backend/migrations/005_training_types_extended.sql
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
-- Migration 005: Extended Training Types
|
||||
-- Add: Cardio (Gehen, Tanzen), Mind & Meditation category
|
||||
-- Created: 2026-03-21
|
||||
|
||||
-- ========================================
|
||||
-- Add new cardio subcategories
|
||||
-- ========================================
|
||||
INSERT INTO training_types (category, subcategory, name_de, name_en, icon, sort_order) VALUES
|
||||
('cardio', 'walk', 'Gehen', 'Walking', '🚶', 105),
|
||||
('cardio', 'dance', 'Tanzen', 'Dance', '💃', 106);
|
||||
|
||||
-- ========================================
|
||||
-- Add new category: Geist & Meditation
|
||||
-- ========================================
|
||||
INSERT INTO training_types (category, subcategory, name_de, name_en, icon, sort_order) VALUES
|
||||
('mind', 'meditation', 'Meditation', 'Meditation', '🧘♂️', 700),
|
||||
('mind', 'breathwork', 'Atemarbeit', 'Breathwork', '🫁', 701),
|
||||
('mind', 'mindfulness', 'Achtsamkeit', 'Mindfulness', '☮️', 702),
|
||||
('mind', 'visualization', 'Visualisierung', 'Visualization', '🎨', 703);
|
||||
|
||||
-- ========================================
|
||||
-- Add comment
|
||||
-- ========================================
|
||||
COMMENT ON TABLE training_types IS 'v9d Phase 1b: Extended with cardio walk/dance and mind category';
|
||||
29
backend/migrations/006_training_types_abilities.sql
Normal file
29
backend/migrations/006_training_types_abilities.sql
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
-- Migration 006: Training Types - Abilities Mapping
|
||||
-- Add abilities JSONB column for future AI analysis
|
||||
-- Maps to: koordinativ, konditionell, kognitiv, psychisch, taktisch
|
||||
-- Created: 2026-03-21
|
||||
|
||||
-- ========================================
|
||||
-- Add abilities column
|
||||
-- ========================================
|
||||
ALTER TABLE training_types
|
||||
ADD COLUMN IF NOT EXISTS abilities JSONB DEFAULT '{}';
|
||||
|
||||
-- ========================================
|
||||
-- Add description columns for better documentation
|
||||
-- ========================================
|
||||
ALTER TABLE training_types
|
||||
ADD COLUMN IF NOT EXISTS description_de TEXT,
|
||||
ADD COLUMN IF NOT EXISTS description_en TEXT;
|
||||
|
||||
-- ========================================
|
||||
-- Add index for abilities queries
|
||||
-- ========================================
|
||||
CREATE INDEX IF NOT EXISTS idx_training_types_abilities ON training_types USING GIN (abilities);
|
||||
|
||||
-- ========================================
|
||||
-- Comment
|
||||
-- ========================================
|
||||
COMMENT ON COLUMN training_types.abilities IS 'JSONB: Maps to athletic abilities for AI analysis (koordinativ, konditionell, kognitiv, psychisch, taktisch)';
|
||||
COMMENT ON COLUMN training_types.description_de IS 'German description for admin UI and AI context';
|
||||
COMMENT ON COLUMN training_types.description_en IS 'English description for admin UI and AI context';
|
||||
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.';
|
||||
|
|
@ -84,6 +84,9 @@ class ActivityEntry(BaseModel):
|
|||
rpe: Optional[int] = None
|
||||
source: Optional[str] = 'manual'
|
||||
notes: Optional[str] = None
|
||||
training_type_id: Optional[int] = None # v9d: Training type categorization
|
||||
training_category: Optional[str] = None # v9d: Denormalized category
|
||||
training_subcategory: Optional[str] = None # v9d: Denormalized subcategory
|
||||
|
||||
|
||||
class NutritionDay(BaseModel):
|
||||
|
|
|
|||
|
|
@ -113,9 +113,126 @@ 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}
|
||||
|
||||
|
||||
def get_training_type_for_activity(activity_type: str, profile_id: str = None):
|
||||
"""
|
||||
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)
|
||||
"""
|
||||
with get_db() as conn:
|
||||
cur = get_cursor(conn)
|
||||
|
||||
# Try user-specific mapping first
|
||||
if profile_id:
|
||||
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 = %s
|
||||
LIMIT 1
|
||||
""", (activity_type, profile_id))
|
||||
row = cur.fetchone()
|
||||
if row:
|
||||
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)
|
||||
|
||||
|
||||
@router.get("/uncategorized")
|
||||
def list_uncategorized_activities(x_profile_id: Optional[str]=Header(default=None), session: dict=Depends(require_auth)):
|
||||
"""Get activities without assigned training type, grouped by activity_type."""
|
||||
pid = get_pid(x_profile_id)
|
||||
with get_db() as conn:
|
||||
cur = get_cursor(conn)
|
||||
cur.execute("""
|
||||
SELECT activity_type, COUNT(*) as count,
|
||||
MIN(date) as first_date, MAX(date) as last_date
|
||||
FROM activity_log
|
||||
WHERE profile_id=%s AND training_type_id IS NULL
|
||||
GROUP BY activity_type
|
||||
ORDER BY count DESC
|
||||
""", (pid,))
|
||||
return [r2d(r) for r in cur.fetchall()]
|
||||
|
||||
|
||||
@router.post("/bulk-categorize")
|
||||
def bulk_categorize_activities(
|
||||
data: dict,
|
||||
x_profile_id: Optional[str]=Header(default=None),
|
||||
session: dict=Depends(require_auth)
|
||||
):
|
||||
"""
|
||||
Bulk update training type for activities.
|
||||
|
||||
Also saves the mapping to activity_type_mappings for future imports.
|
||||
|
||||
Body: {
|
||||
"activity_type": "Running",
|
||||
"training_type_id": 1,
|
||||
"training_category": "cardio",
|
||||
"training_subcategory": "running"
|
||||
}
|
||||
"""
|
||||
pid = get_pid(x_profile_id)
|
||||
activity_type = data.get('activity_type')
|
||||
training_type_id = data.get('training_type_id')
|
||||
training_category = data.get('training_category')
|
||||
training_subcategory = data.get('training_subcategory')
|
||||
|
||||
if not activity_type or not training_type_id:
|
||||
raise HTTPException(400, "activity_type and training_type_id required")
|
||||
|
||||
with get_db() as conn:
|
||||
cur = get_cursor(conn)
|
||||
|
||||
# Update existing activities
|
||||
cur.execute("""
|
||||
UPDATE activity_log
|
||||
SET training_type_id = %s,
|
||||
training_category = %s,
|
||||
training_subcategory = %s
|
||||
WHERE profile_id = %s
|
||||
AND activity_type = %s
|
||||
AND training_type_id IS NULL
|
||||
""", (training_type_id, training_category, training_subcategory, pid, activity_type))
|
||||
updated_count = cur.rowcount
|
||||
|
||||
# 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")
|
||||
async def import_activity_csv(file: UploadFile=File(...), x_profile_id: Optional[str]=Header(default=None), session: dict=Depends(require_auth)):
|
||||
"""Import Apple Health workout CSV."""
|
||||
"""Import Apple Health workout CSV with automatic training type mapping."""
|
||||
pid = get_pid(x_profile_id)
|
||||
raw = await file.read()
|
||||
try: text = raw.decode('utf-8')
|
||||
|
|
@ -145,16 +262,58 @@ async def import_activity_csv(file: UploadFile=File(...), x_profile_id: Optional
|
|||
def tf(v):
|
||||
try: return round(float(v),1) if v else None
|
||||
except: return None
|
||||
# Map activity_type to training_type_id using database mappings
|
||||
training_type_id, training_category, training_subcategory = get_training_type_for_activity(wtype, pid)
|
||||
|
||||
try:
|
||||
cur.execute("""INSERT INTO activity_log
|
||||
(id,profile_id,date,start_time,end_time,activity_type,duration_min,kcal_active,kcal_resting,
|
||||
hr_avg,hr_max,distance_km,source,created)
|
||||
VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,'apple_health',CURRENT_TIMESTAMP)""",
|
||||
(str(uuid.uuid4()),pid,date,start,row.get('End',''),wtype,duration_min,
|
||||
kj(row.get('Aktive Energie (kJ)','')),kj(row.get('Ruheeinträge (kJ)','')),
|
||||
tf(row.get('Durchschn. Herzfrequenz (count/min)','')),
|
||||
tf(row.get('Max. Herzfrequenz (count/min)','')),
|
||||
tf(row.get('Distanz (km)',''))))
|
||||
inserted+=1
|
||||
except: skipped+=1
|
||||
# Check if entry already exists (duplicate detection by date + start_time)
|
||||
cur.execute("""
|
||||
SELECT id FROM activity_log
|
||||
WHERE profile_id = %s AND date = %s AND start_time = %s
|
||||
""", (pid, date, start))
|
||||
existing = cur.fetchone()
|
||||
|
||||
if existing:
|
||||
# Update existing entry (e.g., to add training type mapping)
|
||||
cur.execute("""
|
||||
UPDATE activity_log
|
||||
SET end_time = %s,
|
||||
activity_type = %s,
|
||||
duration_min = %s,
|
||||
kcal_active = %s,
|
||||
kcal_resting = %s,
|
||||
hr_avg = %s,
|
||||
hr_max = %s,
|
||||
distance_km = %s,
|
||||
training_type_id = %s,
|
||||
training_category = %s,
|
||||
training_subcategory = %s
|
||||
WHERE id = %s
|
||||
""", (
|
||||
row.get('End',''), wtype, duration_min,
|
||||
kj(row.get('Aktive Energie (kJ)','')),
|
||||
kj(row.get('Ruheeinträge (kJ)','')),
|
||||
tf(row.get('Durchschn. Herzfrequenz (count/min)','')),
|
||||
tf(row.get('Max. Herzfrequenz (count/min)','')),
|
||||
tf(row.get('Distanz (km)','')),
|
||||
training_type_id, training_category, training_subcategory,
|
||||
existing['id']
|
||||
))
|
||||
skipped += 1 # Count as skipped (not newly inserted)
|
||||
else:
|
||||
# Insert new entry
|
||||
cur.execute("""INSERT INTO activity_log
|
||||
(id,profile_id,date,start_time,end_time,activity_type,duration_min,kcal_active,kcal_resting,
|
||||
hr_avg,hr_max,distance_km,source,training_type_id,training_category,training_subcategory,created)
|
||||
VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,'apple_health',%s,%s,%s,CURRENT_TIMESTAMP)""",
|
||||
(str(uuid.uuid4()),pid,date,start,row.get('End',''),wtype,duration_min,
|
||||
kj(row.get('Aktive Energie (kJ)','')),kj(row.get('Ruheeinträge (kJ)','')),
|
||||
tf(row.get('Durchschn. Herzfrequenz (count/min)','')),
|
||||
tf(row.get('Max. Herzfrequenz (count/min)','')),
|
||||
tf(row.get('Distanz (km)','')),
|
||||
training_type_id,training_category,training_subcategory))
|
||||
inserted+=1
|
||||
except Exception as e:
|
||||
logger.warning(f"Import row failed: {e}")
|
||||
skipped+=1
|
||||
return {"inserted":inserted,"skipped":skipped,"message":f"{inserted} Trainings importiert"}
|
||||
|
|
|
|||
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
|
||||
281
backend/routers/admin_training_types.py
Normal file
281
backend/routers/admin_training_types.py
Normal file
|
|
@ -0,0 +1,281 @@
|
|||
"""
|
||||
Admin Training Types Management - v9d Phase 1b
|
||||
|
||||
CRUD operations for training types with abilities mapping preparation.
|
||||
"""
|
||||
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_auth, require_admin
|
||||
|
||||
router = APIRouter(prefix="/api/admin/training-types", tags=["admin", "training-types"])
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class TrainingTypeCreate(BaseModel):
|
||||
category: str
|
||||
subcategory: Optional[str] = None
|
||||
name_de: str
|
||||
name_en: str
|
||||
icon: Optional[str] = None
|
||||
description_de: Optional[str] = None
|
||||
description_en: Optional[str] = None
|
||||
sort_order: int = 0
|
||||
abilities: Optional[dict] = None
|
||||
|
||||
|
||||
class TrainingTypeUpdate(BaseModel):
|
||||
category: Optional[str] = None
|
||||
subcategory: Optional[str] = None
|
||||
name_de: Optional[str] = None
|
||||
name_en: Optional[str] = None
|
||||
icon: Optional[str] = None
|
||||
description_de: Optional[str] = None
|
||||
description_en: Optional[str] = None
|
||||
sort_order: Optional[int] = None
|
||||
abilities: Optional[dict] = None
|
||||
|
||||
|
||||
@router.get("")
|
||||
def list_training_types_admin(session: dict = Depends(require_admin)):
|
||||
"""
|
||||
Get all training types for admin management.
|
||||
Returns full details including abilities.
|
||||
"""
|
||||
with get_db() as conn:
|
||||
cur = get_cursor(conn)
|
||||
cur.execute("""
|
||||
SELECT id, category, subcategory, name_de, name_en, icon,
|
||||
description_de, description_en, sort_order, abilities,
|
||||
created_at
|
||||
FROM training_types
|
||||
ORDER BY sort_order, category, subcategory
|
||||
""")
|
||||
rows = cur.fetchall()
|
||||
|
||||
return [r2d(r) for r in rows]
|
||||
|
||||
|
||||
@router.get("/{type_id}")
|
||||
def get_training_type(type_id: int, session: dict = Depends(require_admin)):
|
||||
"""Get single training type by ID."""
|
||||
with get_db() as conn:
|
||||
cur = get_cursor(conn)
|
||||
cur.execute("""
|
||||
SELECT id, category, subcategory, name_de, name_en, icon,
|
||||
description_de, description_en, sort_order, abilities,
|
||||
created_at
|
||||
FROM training_types
|
||||
WHERE id = %s
|
||||
""", (type_id,))
|
||||
row = cur.fetchone()
|
||||
|
||||
if not row:
|
||||
raise HTTPException(404, "Training type not found")
|
||||
|
||||
return r2d(row)
|
||||
|
||||
|
||||
@router.post("")
|
||||
def create_training_type(data: TrainingTypeCreate, session: dict = Depends(require_admin)):
|
||||
"""Create new training type."""
|
||||
with get_db() as conn:
|
||||
cur = get_cursor(conn)
|
||||
|
||||
# Convert abilities dict to JSONB
|
||||
abilities_json = data.abilities if data.abilities else {}
|
||||
|
||||
cur.execute("""
|
||||
INSERT INTO training_types
|
||||
(category, subcategory, name_de, name_en, icon,
|
||||
description_de, description_en, sort_order, abilities)
|
||||
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s)
|
||||
RETURNING id
|
||||
""", (
|
||||
data.category,
|
||||
data.subcategory,
|
||||
data.name_de,
|
||||
data.name_en,
|
||||
data.icon,
|
||||
data.description_de,
|
||||
data.description_en,
|
||||
data.sort_order,
|
||||
abilities_json
|
||||
))
|
||||
|
||||
new_id = cur.fetchone()['id']
|
||||
|
||||
logger.info(f"[ADMIN] Training type created: {new_id} - {data.name_de} ({data.category}/{data.subcategory})")
|
||||
|
||||
return {"id": new_id, "message": "Training type created"}
|
||||
|
||||
|
||||
@router.put("/{type_id}")
|
||||
def update_training_type(
|
||||
type_id: int,
|
||||
data: TrainingTypeUpdate,
|
||||
session: dict = Depends(require_admin)
|
||||
):
|
||||
"""Update existing training type."""
|
||||
with get_db() as conn:
|
||||
cur = get_cursor(conn)
|
||||
|
||||
# Build update query dynamically
|
||||
updates = []
|
||||
values = []
|
||||
|
||||
if data.category is not None:
|
||||
updates.append("category = %s")
|
||||
values.append(data.category)
|
||||
if data.subcategory is not None:
|
||||
updates.append("subcategory = %s")
|
||||
values.append(data.subcategory)
|
||||
if data.name_de is not None:
|
||||
updates.append("name_de = %s")
|
||||
values.append(data.name_de)
|
||||
if data.name_en is not None:
|
||||
updates.append("name_en = %s")
|
||||
values.append(data.name_en)
|
||||
if data.icon is not None:
|
||||
updates.append("icon = %s")
|
||||
values.append(data.icon)
|
||||
if data.description_de is not None:
|
||||
updates.append("description_de = %s")
|
||||
values.append(data.description_de)
|
||||
if data.description_en is not None:
|
||||
updates.append("description_en = %s")
|
||||
values.append(data.description_en)
|
||||
if data.sort_order is not None:
|
||||
updates.append("sort_order = %s")
|
||||
values.append(data.sort_order)
|
||||
if data.abilities is not None:
|
||||
updates.append("abilities = %s")
|
||||
values.append(data.abilities)
|
||||
|
||||
if not updates:
|
||||
raise HTTPException(400, "No fields to update")
|
||||
|
||||
values.append(type_id)
|
||||
|
||||
cur.execute(f"""
|
||||
UPDATE training_types
|
||||
SET {', '.join(updates)}
|
||||
WHERE id = %s
|
||||
""", values)
|
||||
|
||||
if cur.rowcount == 0:
|
||||
raise HTTPException(404, "Training type not found")
|
||||
|
||||
logger.info(f"[ADMIN] Training type updated: {type_id}")
|
||||
|
||||
return {"id": type_id, "message": "Training type updated"}
|
||||
|
||||
|
||||
@router.delete("/{type_id}")
|
||||
def delete_training_type(type_id: int, session: dict = Depends(require_admin)):
|
||||
"""
|
||||
Delete training type.
|
||||
|
||||
WARNING: This will fail if any activities reference this type.
|
||||
Consider adding a soft-delete or archive mechanism if needed.
|
||||
"""
|
||||
with get_db() as conn:
|
||||
cur = get_cursor(conn)
|
||||
|
||||
# Check if any activities use this type
|
||||
cur.execute("""
|
||||
SELECT COUNT(*) as count
|
||||
FROM activity_log
|
||||
WHERE training_type_id = %s
|
||||
""", (type_id,))
|
||||
|
||||
count = cur.fetchone()['count']
|
||||
if count > 0:
|
||||
raise HTTPException(
|
||||
400,
|
||||
f"Cannot delete: {count} activities are using this training type. "
|
||||
"Please reassign or delete those activities first."
|
||||
)
|
||||
|
||||
cur.execute("DELETE FROM training_types WHERE id = %s", (type_id,))
|
||||
|
||||
if cur.rowcount == 0:
|
||||
raise HTTPException(404, "Training type not found")
|
||||
|
||||
logger.info(f"[ADMIN] Training type deleted: {type_id}")
|
||||
|
||||
return {"message": "Training type deleted"}
|
||||
|
||||
|
||||
@router.get("/taxonomy/abilities")
|
||||
def get_abilities_taxonomy(session: dict = Depends(require_auth)):
|
||||
"""
|
||||
Get abilities taxonomy for UI and AI analysis.
|
||||
|
||||
This defines the 5 dimensions of athletic development.
|
||||
"""
|
||||
taxonomy = {
|
||||
"koordinativ": {
|
||||
"name_de": "Koordinative Fähigkeiten",
|
||||
"name_en": "Coordination Abilities",
|
||||
"icon": "🎯",
|
||||
"abilities": [
|
||||
{"key": "orientierung", "name_de": "Orientierung", "name_en": "Orientation"},
|
||||
{"key": "differenzierung", "name_de": "Differenzierung", "name_en": "Differentiation"},
|
||||
{"key": "kopplung", "name_de": "Kopplung", "name_en": "Coupling"},
|
||||
{"key": "gleichgewicht", "name_de": "Gleichgewicht", "name_en": "Balance"},
|
||||
{"key": "rhythmus", "name_de": "Rhythmisierung", "name_en": "Rhythm"},
|
||||
{"key": "reaktion", "name_de": "Reaktion", "name_en": "Reaction"},
|
||||
{"key": "umstellung", "name_de": "Umstellung", "name_en": "Adaptation"}
|
||||
]
|
||||
},
|
||||
"konditionell": {
|
||||
"name_de": "Konditionelle Fähigkeiten",
|
||||
"name_en": "Conditional Abilities",
|
||||
"icon": "💪",
|
||||
"abilities": [
|
||||
{"key": "kraft", "name_de": "Kraft", "name_en": "Strength"},
|
||||
{"key": "ausdauer", "name_de": "Ausdauer", "name_en": "Endurance"},
|
||||
{"key": "schnelligkeit", "name_de": "Schnelligkeit", "name_en": "Speed"},
|
||||
{"key": "flexibilitaet", "name_de": "Flexibilität", "name_en": "Flexibility"}
|
||||
]
|
||||
},
|
||||
"kognitiv": {
|
||||
"name_de": "Kognitive Fähigkeiten",
|
||||
"name_en": "Cognitive Abilities",
|
||||
"icon": "🧠",
|
||||
"abilities": [
|
||||
{"key": "konzentration", "name_de": "Konzentration", "name_en": "Concentration"},
|
||||
{"key": "aufmerksamkeit", "name_de": "Aufmerksamkeit", "name_en": "Attention"},
|
||||
{"key": "wahrnehmung", "name_de": "Wahrnehmung", "name_en": "Perception"},
|
||||
{"key": "entscheidung", "name_de": "Entscheidungsfindung", "name_en": "Decision Making"}
|
||||
]
|
||||
},
|
||||
"psychisch": {
|
||||
"name_de": "Psychische Fähigkeiten",
|
||||
"name_en": "Psychological Abilities",
|
||||
"icon": "🎭",
|
||||
"abilities": [
|
||||
{"key": "motivation", "name_de": "Motivation", "name_en": "Motivation"},
|
||||
{"key": "willenskraft", "name_de": "Willenskraft", "name_en": "Willpower"},
|
||||
{"key": "stressresistenz", "name_de": "Stressresistenz", "name_en": "Stress Resistance"},
|
||||
{"key": "selbstvertrauen", "name_de": "Selbstvertrauen", "name_en": "Self-Confidence"}
|
||||
]
|
||||
},
|
||||
"taktisch": {
|
||||
"name_de": "Taktische Fähigkeiten",
|
||||
"name_en": "Tactical Abilities",
|
||||
"icon": "♟️",
|
||||
"abilities": [
|
||||
{"key": "timing", "name_de": "Timing", "name_en": "Timing"},
|
||||
{"key": "strategie", "name_de": "Strategie", "name_en": "Strategy"},
|
||||
{"key": "antizipation", "name_de": "Antizipation", "name_en": "Anticipation"},
|
||||
{"key": "situationsanalyse", "name_de": "Situationsanalyse", "name_en": "Situation Analysis"}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
return taxonomy
|
||||
129
backend/routers/training_types.py
Normal file
129
backend/routers/training_types.py
Normal file
|
|
@ -0,0 +1,129 @@
|
|||
"""
|
||||
Training Types API - v9d
|
||||
|
||||
Provides hierarchical list of training categories and subcategories
|
||||
for activity classification.
|
||||
"""
|
||||
from fastapi import APIRouter, Depends
|
||||
from db import get_db, get_cursor
|
||||
from auth import require_auth
|
||||
|
||||
router = APIRouter(prefix="/api/training-types", tags=["training-types"])
|
||||
|
||||
|
||||
@router.get("")
|
||||
def list_training_types(session: dict = Depends(require_auth)):
|
||||
"""
|
||||
Get all training types, grouped by category.
|
||||
|
||||
Returns hierarchical structure:
|
||||
{
|
||||
"cardio": [
|
||||
{"id": 1, "subcategory": "running", "name_de": "Laufen", ...},
|
||||
...
|
||||
],
|
||||
"strength": [...],
|
||||
...
|
||||
}
|
||||
"""
|
||||
with get_db() as conn:
|
||||
cur = get_cursor(conn)
|
||||
cur.execute("""
|
||||
SELECT id, category, subcategory, name_de, name_en, icon, sort_order
|
||||
FROM training_types
|
||||
ORDER BY sort_order, category, subcategory
|
||||
""")
|
||||
rows = cur.fetchall()
|
||||
|
||||
# Group by category
|
||||
grouped = {}
|
||||
for row in rows:
|
||||
cat = row['category']
|
||||
if cat not in grouped:
|
||||
grouped[cat] = []
|
||||
grouped[cat].append({
|
||||
'id': row['id'],
|
||||
'category': row['category'],
|
||||
'subcategory': row['subcategory'],
|
||||
'name_de': row['name_de'],
|
||||
'name_en': row['name_en'],
|
||||
'icon': row['icon'],
|
||||
'sort_order': row['sort_order']
|
||||
})
|
||||
|
||||
return grouped
|
||||
|
||||
|
||||
@router.get("/flat")
|
||||
def list_training_types_flat(session: dict = Depends(require_auth)):
|
||||
"""
|
||||
Get all training types as flat list (for simple dropdown).
|
||||
"""
|
||||
with get_db() as conn:
|
||||
cur = get_cursor(conn)
|
||||
cur.execute("""
|
||||
SELECT id, category, subcategory, name_de, name_en, icon
|
||||
FROM training_types
|
||||
ORDER BY sort_order
|
||||
""")
|
||||
rows = cur.fetchall()
|
||||
|
||||
return [dict(row) for row in rows]
|
||||
|
||||
|
||||
@router.get("/categories")
|
||||
def list_categories(session: dict = Depends(require_auth)):
|
||||
"""
|
||||
Get list of unique categories with metadata.
|
||||
"""
|
||||
categories = {
|
||||
'cardio': {
|
||||
'name_de': 'Cardio (Ausdauer)',
|
||||
'name_en': 'Cardio (Endurance)',
|
||||
'icon': '❤️',
|
||||
'color': '#EF4444'
|
||||
},
|
||||
'strength': {
|
||||
'name_de': 'Kraft',
|
||||
'name_en': 'Strength',
|
||||
'icon': '💪',
|
||||
'color': '#3B82F6'
|
||||
},
|
||||
'hiit': {
|
||||
'name_de': 'Schnellkraft / HIIT',
|
||||
'name_en': 'Power / HIIT',
|
||||
'icon': '🔥',
|
||||
'color': '#F59E0B'
|
||||
},
|
||||
'martial_arts': {
|
||||
'name_de': 'Kampfsport',
|
||||
'name_en': 'Martial Arts',
|
||||
'icon': '🥋',
|
||||
'color': '#8B5CF6'
|
||||
},
|
||||
'mobility': {
|
||||
'name_de': 'Mobility & Dehnung',
|
||||
'name_en': 'Mobility & Stretching',
|
||||
'icon': '🧘',
|
||||
'color': '#10B981'
|
||||
},
|
||||
'recovery': {
|
||||
'name_de': 'Erholung (aktiv)',
|
||||
'name_en': 'Recovery (active)',
|
||||
'icon': '💆',
|
||||
'color': '#6B7280'
|
||||
},
|
||||
'mind': {
|
||||
'name_de': 'Geist & Meditation',
|
||||
'name_en': 'Mind & Meditation',
|
||||
'icon': '🧘♂️',
|
||||
'color': '#A78BFA'
|
||||
},
|
||||
'other': {
|
||||
'name_de': 'Sonstiges',
|
||||
'name_en': 'Other',
|
||||
'icon': '📝',
|
||||
'color': '#9CA3AF'
|
||||
}
|
||||
}
|
||||
return categories
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
import { useEffect } from 'react'
|
||||
import { BrowserRouter, Routes, Route, NavLink, useNavigate } from 'react-router-dom'
|
||||
import { LayoutDashboard, PlusSquare, TrendingUp, BarChart2, Settings } from 'lucide-react'
|
||||
import { LayoutDashboard, PlusSquare, TrendingUp, BarChart2, Settings, LogOut } from 'lucide-react'
|
||||
import { ProfileProvider, useProfile } from './context/ProfileContext'
|
||||
import { AuthProvider, useAuth } from './context/AuthContext'
|
||||
import { setProfileId } from './utils/api'
|
||||
|
|
@ -27,6 +27,8 @@ import AdminFeaturesPage from './pages/AdminFeaturesPage'
|
|||
import AdminTiersPage from './pages/AdminTiersPage'
|
||||
import AdminCouponsPage from './pages/AdminCouponsPage'
|
||||
import AdminUserRestrictionsPage from './pages/AdminUserRestrictionsPage'
|
||||
import AdminTrainingTypesPage from './pages/AdminTrainingTypesPage'
|
||||
import AdminActivityMappingsPage from './pages/AdminActivityMappingsPage'
|
||||
import SubscriptionPage from './pages/SubscriptionPage'
|
||||
import './app.css'
|
||||
|
||||
|
|
@ -50,10 +52,17 @@ function Nav() {
|
|||
}
|
||||
|
||||
function AppShell() {
|
||||
const { session, loading: authLoading, needsSetup } = useAuth()
|
||||
const { session, loading: authLoading, needsSetup, logout } = useAuth()
|
||||
const { activeProfile, loading: profileLoading } = useProfile()
|
||||
const nav = useNavigate()
|
||||
|
||||
const handleLogout = () => {
|
||||
if (confirm('Wirklich abmelden?')) {
|
||||
logout()
|
||||
window.location.href = '/'
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(()=>{
|
||||
if (session?.profile_id) {
|
||||
setProfileId(session.profile_id)
|
||||
|
|
@ -119,12 +128,32 @@ function AppShell() {
|
|||
<div className="app-shell">
|
||||
<header className="app-header">
|
||||
<span className="app-logo">Mitai Jinkendo</span>
|
||||
<NavLink to="/settings" style={{textDecoration:'none'}}>
|
||||
{activeProfile
|
||||
? <Avatar profile={activeProfile} size={30}/>
|
||||
: <div style={{width:30,height:30,borderRadius:'50%',background:'var(--accent)'}}/>
|
||||
}
|
||||
</NavLink>
|
||||
<div style={{display:'flex', gap:12, alignItems:'center'}}>
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
title="Abmelden"
|
||||
style={{
|
||||
background:'none',
|
||||
border:'none',
|
||||
cursor:'pointer',
|
||||
padding:6,
|
||||
display:'flex',
|
||||
alignItems:'center',
|
||||
color:'var(--text2)',
|
||||
transition:'color 0.15s'
|
||||
}}
|
||||
onMouseEnter={e => e.currentTarget.style.color = '#D85A30'}
|
||||
onMouseLeave={e => e.currentTarget.style.color = 'var(--text2)'}
|
||||
>
|
||||
<LogOut size={18}/>
|
||||
</button>
|
||||
<NavLink to="/settings" style={{textDecoration:'none'}}>
|
||||
{activeProfile
|
||||
? <Avatar profile={activeProfile} size={30}/>
|
||||
: <div style={{width:30,height:30,borderRadius:'50%',background:'var(--accent)'}}/>
|
||||
}
|
||||
</NavLink>
|
||||
</div>
|
||||
</header>
|
||||
<main className="app-main">
|
||||
<Routes>
|
||||
|
|
@ -145,6 +174,8 @@ function AppShell() {
|
|||
<Route path="/admin/tiers" element={<AdminTiersPage/>}/>
|
||||
<Route path="/admin/coupons" element={<AdminCouponsPage/>}/>
|
||||
<Route path="/admin/user-restrictions" element={<AdminUserRestrictionsPage/>}/>
|
||||
<Route path="/admin/training-types" element={<AdminTrainingTypesPage/>}/>
|
||||
<Route path="/admin/activity-mappings" element={<AdminActivityMappingsPage/>}/>
|
||||
<Route path="/subscription" element={<SubscriptionPage/>}/>
|
||||
</Routes>
|
||||
</main>
|
||||
|
|
|
|||
191
frontend/src/components/BulkCategorize.jsx
Normal file
191
frontend/src/components/BulkCategorize.jsx
Normal file
|
|
@ -0,0 +1,191 @@
|
|||
import { useState, useEffect } from 'react'
|
||||
import { api } from '../utils/api'
|
||||
import TrainingTypeSelect from './TrainingTypeSelect'
|
||||
|
||||
/**
|
||||
* BulkCategorize - UI for categorizing existing activities without training type
|
||||
*
|
||||
* Shows uncategorized activities grouped by activity_type,
|
||||
* allows bulk assignment of training type to all activities of same type.
|
||||
*/
|
||||
export default function BulkCategorize({ onComplete }) {
|
||||
const [uncategorized, setUncategorized] = useState([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [assignments, setAssignments] = useState({})
|
||||
const [saving, setSaving] = useState(null)
|
||||
|
||||
useEffect(() => {
|
||||
loadUncategorized()
|
||||
}, [])
|
||||
|
||||
const loadUncategorized = () => {
|
||||
setLoading(true)
|
||||
api.listUncategorizedActivities()
|
||||
.then(data => {
|
||||
setUncategorized(data)
|
||||
setLoading(false)
|
||||
})
|
||||
.catch(err => {
|
||||
console.error('Failed to load uncategorized activities:', err)
|
||||
setLoading(false)
|
||||
})
|
||||
}
|
||||
|
||||
const handleAssignment = (activityType, typeId, category, subcategory) => {
|
||||
setAssignments(prev => ({
|
||||
...prev,
|
||||
[activityType]: {
|
||||
training_type_id: typeId,
|
||||
training_category: category,
|
||||
training_subcategory: subcategory
|
||||
}
|
||||
}))
|
||||
}
|
||||
|
||||
const handleSave = async (activityType) => {
|
||||
const assignment = assignments[activityType]
|
||||
if (!assignment || !assignment.training_type_id) {
|
||||
alert('Bitte wähle einen Trainingstyp aus')
|
||||
return
|
||||
}
|
||||
|
||||
setSaving(activityType)
|
||||
try {
|
||||
const result = await api.bulkCategorizeActivities({
|
||||
activity_type: activityType,
|
||||
...assignment
|
||||
})
|
||||
|
||||
// Remove from list
|
||||
setUncategorized(prev => prev.filter(u => u.activity_type !== activityType))
|
||||
setAssignments(prev => {
|
||||
const newAssignments = { ...prev }
|
||||
delete newAssignments[activityType]
|
||||
return newAssignments
|
||||
})
|
||||
|
||||
// Show success message
|
||||
console.log(`✓ ${result.updated} activities categorized`)
|
||||
|
||||
} catch (err) {
|
||||
console.error('Failed to categorize:', err)
|
||||
alert('Kategorisierung fehlgeschlagen: ' + err.message)
|
||||
} finally {
|
||||
setSaving(null)
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div style={{ textAlign: 'center', padding: 20 }}>
|
||||
<div className="spinner" style={{ width: 24, height: 24, margin: '0 auto' }} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (uncategorized.length === 0) {
|
||||
return (
|
||||
<div style={{
|
||||
textAlign: 'center',
|
||||
padding: 40,
|
||||
color: 'var(--text3)',
|
||||
fontSize: 14
|
||||
}}>
|
||||
<div style={{ fontSize: 32, marginBottom: 8 }}>✓</div>
|
||||
<div>Alle Aktivitäten sind kategorisiert</div>
|
||||
{onComplete && (
|
||||
<button
|
||||
onClick={onComplete}
|
||||
className="btn btn-secondary"
|
||||
style={{ marginTop: 16 }}
|
||||
>
|
||||
Schließen
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ maxWidth: 600, margin: '0 auto' }}>
|
||||
<div style={{
|
||||
marginBottom: 20,
|
||||
padding: 16,
|
||||
background: 'var(--surface)',
|
||||
borderRadius: 8,
|
||||
fontSize: 13,
|
||||
color: 'var(--text2)'
|
||||
}}>
|
||||
<strong style={{ color: 'var(--text1)' }}>
|
||||
{uncategorized.reduce((sum, u) => sum + u.count, 0)} Aktivitäten
|
||||
</strong> ohne Trainingstyp gefunden. Weise jedem Aktivitätstyp einen Trainingstyp zu.
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||
{uncategorized.map(item => (
|
||||
<div
|
||||
key={item.activity_type}
|
||||
className="card"
|
||||
style={{ padding: 16 }}
|
||||
>
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'flex-start',
|
||||
marginBottom: 12
|
||||
}}>
|
||||
<div>
|
||||
<div style={{ fontWeight: 600, fontSize: 14, marginBottom: 4 }}>
|
||||
{item.activity_type}
|
||||
</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--text3)' }}>
|
||||
{item.count} Einheiten
|
||||
{item.first_date && item.last_date && (
|
||||
<> · {item.first_date} bis {item.last_date}</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<TrainingTypeSelect
|
||||
value={assignments[item.activity_type]?.training_type_id || null}
|
||||
onChange={(typeId, category, subcategory) =>
|
||||
handleAssignment(item.activity_type, typeId, category, subcategory)
|
||||
}
|
||||
required={false}
|
||||
/>
|
||||
|
||||
<button
|
||||
onClick={() => handleSave(item.activity_type)}
|
||||
disabled={
|
||||
!assignments[item.activity_type]?.training_type_id ||
|
||||
saving === item.activity_type
|
||||
}
|
||||
className="btn btn-primary btn-full"
|
||||
style={{ marginTop: 12 }}
|
||||
>
|
||||
{saving === item.activity_type ? (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, justifyContent: 'center' }}>
|
||||
<div className="spinner" style={{ width: 14, height: 14 }} />
|
||||
Speichere...
|
||||
</div>
|
||||
) : (
|
||||
`${item.count} Einheiten kategorisieren`
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{onComplete && (
|
||||
<button
|
||||
onClick={onComplete}
|
||||
className="btn btn-secondary btn-full"
|
||||
style={{ marginTop: 16 }}
|
||||
>
|
||||
Später fortsetzen
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
120
frontend/src/components/TrainingTypeDistribution.jsx
Normal file
120
frontend/src/components/TrainingTypeDistribution.jsx
Normal file
|
|
@ -0,0 +1,120 @@
|
|||
import { useState, useEffect } from 'react'
|
||||
import { PieChart, Pie, Cell, ResponsiveContainer, Legend, Tooltip } from 'recharts'
|
||||
import { api } from '../utils/api'
|
||||
|
||||
/**
|
||||
* TrainingTypeDistribution - Pie chart showing activity distribution by type
|
||||
*
|
||||
* @param {number} days - Number of days to analyze (default: 28)
|
||||
*/
|
||||
export default function TrainingTypeDistribution({ days = 28 }) {
|
||||
const [data, setData] = useState([])
|
||||
const [categories, setCategories] = useState({})
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
Promise.all([
|
||||
api.listActivity(days),
|
||||
api.getTrainingCategories()
|
||||
]).then(([activities, cats]) => {
|
||||
setCategories(cats)
|
||||
|
||||
// Group by training_category
|
||||
const grouped = {}
|
||||
activities.forEach(act => {
|
||||
const cat = act.training_category || 'other'
|
||||
if (!grouped[cat]) grouped[cat] = 0
|
||||
grouped[cat]++
|
||||
})
|
||||
|
||||
// Convert to chart data
|
||||
const chartData = Object.entries(grouped).map(([cat, count]) => ({
|
||||
name: cats[cat]?.name_de || 'Sonstiges',
|
||||
value: count,
|
||||
color: cats[cat]?.color || '#9CA3AF',
|
||||
icon: cats[cat]?.icon || '📝'
|
||||
}))
|
||||
|
||||
setData(chartData.sort((a, b) => b.value - a.value))
|
||||
setLoading(false)
|
||||
}).catch(err => {
|
||||
console.error('Failed to load training type distribution:', err)
|
||||
setLoading(false)
|
||||
})
|
||||
}, [days])
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div style={{textAlign:'center', padding:20}}>
|
||||
<div className="spinner" style={{width:24, height:24, margin:'0 auto'}}/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (data.length === 0) {
|
||||
return (
|
||||
<div style={{textAlign:'center', padding:20, color:'var(--text3)', fontSize:13}}>
|
||||
Keine Aktivitäten in den letzten {days} Tagen
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const total = data.reduce((sum, d) => sum + d.value, 0)
|
||||
|
||||
return (
|
||||
<div>
|
||||
<ResponsiveContainer width="100%" height={200}>
|
||||
<PieChart>
|
||||
<Pie
|
||||
data={data}
|
||||
cx="50%"
|
||||
cy="50%"
|
||||
innerRadius={50}
|
||||
outerRadius={80}
|
||||
paddingAngle={2}
|
||||
dataKey="value"
|
||||
>
|
||||
{data.map((entry, index) => (
|
||||
<Cell key={`cell-${index}`} fill={entry.color} />
|
||||
))}
|
||||
</Pie>
|
||||
<Tooltip
|
||||
formatter={(value) => `${value} Einheiten (${Math.round(value/total*100)}%)`}
|
||||
contentStyle={{
|
||||
background:'var(--surface)',
|
||||
border:'1px solid var(--border)',
|
||||
borderRadius:8,
|
||||
fontSize:12
|
||||
}}
|
||||
/>
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
|
||||
{/* Legend */}
|
||||
<div style={{display:'grid', gridTemplateColumns:'1fr 1fr', gap:8, marginTop:12}}>
|
||||
{data.map((entry, i) => (
|
||||
<div key={i} style={{display:'flex', alignItems:'center', gap:6, fontSize:12}}>
|
||||
<div style={{
|
||||
width:12, height:12, borderRadius:'50%',
|
||||
background:entry.color, flexShrink:0
|
||||
}}/>
|
||||
<span style={{color:'var(--text2)'}}>
|
||||
{entry.icon} {entry.name}
|
||||
</span>
|
||||
<span style={{marginLeft:'auto', fontWeight:600, color:'var(--text1)'}}>
|
||||
{entry.value}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div style={{
|
||||
marginTop:12, padding:'8px 12px',
|
||||
background:'var(--surface)', borderRadius:8,
|
||||
fontSize:12, color:'var(--text3)', textAlign:'center'
|
||||
}}>
|
||||
Gesamt: <strong style={{color:'var(--text1)'}}>{total}</strong> Einheiten in {days} Tagen
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
114
frontend/src/components/TrainingTypeSelect.jsx
Normal file
114
frontend/src/components/TrainingTypeSelect.jsx
Normal file
|
|
@ -0,0 +1,114 @@
|
|||
import { useState, useEffect } from 'react'
|
||||
import { api } from '../utils/api'
|
||||
|
||||
/**
|
||||
* TrainingTypeSelect - Two-level dropdown for training type selection
|
||||
*
|
||||
* @param {number|null} value - Selected training_type_id
|
||||
* @param {function} onChange - Callback (training_type_id, category, subcategory) => void
|
||||
* @param {boolean} required - Is selection required?
|
||||
*/
|
||||
export default function TrainingTypeSelect({ value, onChange, required = false }) {
|
||||
const [types, setTypes] = useState({}) // Grouped by category
|
||||
const [categories, setCategories] = useState({}) // Category metadata
|
||||
const [selectedCategory, setSelectedCategory] = useState('')
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
Promise.all([
|
||||
api.listTrainingTypes(),
|
||||
api.getTrainingCategories()
|
||||
]).then(([typesData, catsData]) => {
|
||||
setTypes(typesData)
|
||||
setCategories(catsData)
|
||||
|
||||
// If value is set, find and select the category
|
||||
if (value) {
|
||||
for (const cat in typesData) {
|
||||
const found = typesData[cat].find(t => t.id === value)
|
||||
if (found) {
|
||||
setSelectedCategory(cat)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
setLoading(false)
|
||||
}).catch(err => {
|
||||
console.error('Failed to load training types:', err)
|
||||
setLoading(false)
|
||||
})
|
||||
}, [value])
|
||||
|
||||
const handleCategoryChange = (cat) => {
|
||||
setSelectedCategory(cat)
|
||||
// Auto-select first subcategory if available
|
||||
if (types[cat] && types[cat].length > 0) {
|
||||
const firstType = types[cat][0]
|
||||
onChange(firstType.id, firstType.category, firstType.subcategory)
|
||||
} else {
|
||||
onChange(null, null, null)
|
||||
}
|
||||
}
|
||||
|
||||
const handleTypeChange = (typeId) => {
|
||||
const type = Object.values(types)
|
||||
.flat()
|
||||
.find(t => t.id === parseInt(typeId))
|
||||
|
||||
if (type) {
|
||||
onChange(type.id, type.category, type.subcategory)
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return <div style={{fontSize:13, color:'var(--text3)'}}>Lade Trainingstypen...</div>
|
||||
}
|
||||
|
||||
const availableCategories = Object.keys(categories)
|
||||
const availableTypes = selectedCategory ? (types[selectedCategory] || []) : []
|
||||
|
||||
return (
|
||||
<div style={{display:'flex', gap:8, flexDirection:'column'}}>
|
||||
{/* Category dropdown */}
|
||||
<div>
|
||||
<label className="form-label">Kategorie</label>
|
||||
<select
|
||||
className="form-input"
|
||||
value={selectedCategory}
|
||||
onChange={(e) => handleCategoryChange(e.target.value)}
|
||||
required={required}
|
||||
style={{width:'100%', boxSizing:'border-box'}}
|
||||
>
|
||||
<option value="">-- Wähle Kategorie --</option>
|
||||
{availableCategories.map(cat => (
|
||||
<option key={cat} value={cat}>
|
||||
{categories[cat].icon} {categories[cat].name_de}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Subcategory dropdown (conditional) */}
|
||||
{selectedCategory && availableTypes.length > 0 && (
|
||||
<div>
|
||||
<label className="form-label">Untertyp</label>
|
||||
<select
|
||||
className="form-input"
|
||||
value={value || ''}
|
||||
onChange={(e) => handleTypeChange(e.target.value)}
|
||||
required={required}
|
||||
style={{width:'100%', boxSizing:'border-box'}}
|
||||
>
|
||||
<option value="">-- Wähle Untertyp --</option>
|
||||
{availableTypes.map(type => (
|
||||
<option key={type.id} value={type.id}>
|
||||
{type.icon} {type.name_de}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -64,8 +64,8 @@ export default function TrialBanner({ profile }) {
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<Link
|
||||
to="/settings?tab=subscription"
|
||||
<a
|
||||
href="mailto:mitai@jinkendo.de?subject=Abo-Anfrage%20für%20Mitai%20Jinkendo&body=Hallo,%0A%0Aich%20möchte%20gerne%20ein%20Abo%20für%20Mitai%20Jinkendo%20abschließen.%0A%0AMein%20Profil:%20"
|
||||
style={{
|
||||
padding: '10px 20px',
|
||||
borderRadius: 8,
|
||||
|
|
@ -76,11 +76,12 @@ export default function TrialBanner({ profile }) {
|
|||
textDecoration: 'none',
|
||||
whiteSpace: 'nowrap',
|
||||
border: 'none',
|
||||
cursor: 'pointer'
|
||||
cursor: 'pointer',
|
||||
display: 'inline-block'
|
||||
}}
|
||||
>
|
||||
{isUrgent ? 'Jetzt upgraden' : 'Abo wählen'}
|
||||
</Link>
|
||||
{isUrgent ? 'Kontakt aufnehmen' : 'Abo anfragen'}
|
||||
</a>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,6 +3,8 @@ 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 TrainingTypeSelect from '../components/TrainingTypeSelect'
|
||||
import BulkCategorize from '../components/BulkCategorize'
|
||||
import dayjs from 'dayjs'
|
||||
import 'dayjs/locale/de'
|
||||
dayjs.locale('de')
|
||||
|
|
@ -18,7 +20,10 @@ function empty() {
|
|||
date: dayjs().format('YYYY-MM-DD'),
|
||||
activity_type: 'Traditionelles Krafttraining',
|
||||
duration_min: '', kcal_active: '',
|
||||
hr_avg: '', hr_max: '', rpe: '', notes: ''
|
||||
hr_avg: '', hr_max: '', rpe: '', notes: '',
|
||||
training_type_id: null,
|
||||
training_category: null,
|
||||
training_subcategory: null
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -89,11 +94,19 @@ function EntryForm({ form, setForm, onSave, onCancel, saveLabel='Speichern', sav
|
|||
<input type="date" className="form-input" style={{width:140}} value={form.date} onChange={e=>set('date',e.target.value)}/>
|
||||
<span className="form-unit"/>
|
||||
</div>
|
||||
<div className="form-row">
|
||||
<label className="form-label">Trainingsart</label>
|
||||
<select className="form-select" value={form.activity_type} onChange={e=>set('activity_type',e.target.value)}>
|
||||
{ACTIVITY_TYPES.map(t=><option key={t} value={t}>{t}</option>)}
|
||||
</select>
|
||||
<div style={{marginBottom:12}}>
|
||||
<TrainingTypeSelect
|
||||
value={form.training_type_id}
|
||||
onChange={(typeId, category, subcategory) => {
|
||||
setForm(f => ({
|
||||
...f,
|
||||
training_type_id: typeId,
|
||||
training_category: category,
|
||||
training_subcategory: subcategory
|
||||
}))
|
||||
}}
|
||||
required={false}
|
||||
/>
|
||||
</div>
|
||||
<div className="form-row">
|
||||
<label className="form-label">Dauer</label>
|
||||
|
|
@ -167,6 +180,7 @@ export default function ActivityPage() {
|
|||
const [saved, setSaved] = useState(false)
|
||||
const [error, setError] = useState(null)
|
||||
const [activityUsage, setActivityUsage] = useState(null) // Phase 4: Usage badge
|
||||
const [categories, setCategories] = useState({}) // v9d: Training categories
|
||||
|
||||
const load = async () => {
|
||||
const [e, s] = await Promise.all([api.listActivity(), api.activityStats()])
|
||||
|
|
@ -183,6 +197,7 @@ export default function ActivityPage() {
|
|||
useEffect(()=>{
|
||||
load()
|
||||
loadUsage()
|
||||
api.getTrainingCategories().then(setCategories).catch(err => console.error('Failed to load categories:', err))
|
||||
},[])
|
||||
|
||||
const handleSave = async () => {
|
||||
|
|
@ -246,6 +261,7 @@ export default function ActivityPage() {
|
|||
<button className={'tab'+(tab==='list'?' active':'')} onClick={()=>setTab('list')}>Verlauf</button>
|
||||
<button className={'tab'+(tab==='add'?' active':'')} onClick={()=>setTab('add')}>+ Manuell</button>
|
||||
<button className={'tab'+(tab==='import'?' active':'')} onClick={()=>setTab('import')}>Import</button>
|
||||
<button className={'tab'+(tab==='categorize'?' active':'')} onClick={()=>setTab('categorize')}>Kategorisieren</button>
|
||||
<button className={'tab'+(tab==='stats'?' active':'')} onClick={()=>setTab('stats')}>Statistik</button>
|
||||
</div>
|
||||
|
||||
|
|
@ -267,6 +283,13 @@ export default function ActivityPage() {
|
|||
|
||||
{tab==='import' && <ImportPanel onImported={load}/>}
|
||||
|
||||
{tab==='categorize' && (
|
||||
<div className="card section-gap">
|
||||
<div className="card-title">🏷️ Aktivitäten kategorisieren</div>
|
||||
<BulkCategorize onComplete={() => { load(); setTab('list'); }} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{tab==='add' && (
|
||||
<div className="card section-gap">
|
||||
<div className="card-title badge-container-right">
|
||||
|
|
@ -330,7 +353,26 @@ export default function ActivityPage() {
|
|||
<div>
|
||||
<div style={{display:'flex',justifyContent:'space-between',alignItems:'flex-start'}}>
|
||||
<div style={{flex:1}}>
|
||||
<div style={{fontSize:14,fontWeight:600}}>{e.activity_type}</div>
|
||||
<div style={{display:'flex',alignItems:'center',gap:6,marginBottom:2}}>
|
||||
<div style={{fontSize:14,fontWeight:600}}>{e.activity_type}</div>
|
||||
{e.training_category && categories[e.training_category] && (
|
||||
<div style={{
|
||||
display:'inline-flex',
|
||||
alignItems:'center',
|
||||
gap:3,
|
||||
padding:'2px 6px',
|
||||
background:categories[e.training_category].color + '22',
|
||||
border:`1px solid ${categories[e.training_category].color}`,
|
||||
borderRadius:4,
|
||||
fontSize:10,
|
||||
fontWeight:600,
|
||||
color:categories[e.training_category].color
|
||||
}}>
|
||||
<span>{categories[e.training_category].icon}</span>
|
||||
<span>{categories[e.training_category].name_de}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div style={{fontSize:11,color:'var(--text3)',marginBottom:4}}>
|
||||
{dayjs(e.date).format('dd, DD. MMMM YYYY')}
|
||||
{e.start_time && e.start_time.length>10 && ` · ${e.start_time.slice(11,16)}`}
|
||||
|
|
|
|||
446
frontend/src/pages/AdminActivityMappingsPage.jsx
Normal file
446
frontend/src/pages/AdminActivityMappingsPage.jsx
Normal file
|
|
@ -0,0 +1,446 @@
|
|||
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 */}
|
||||
<button
|
||||
onClick={startCreate}
|
||||
className="btn btn-primary btn-full"
|
||||
style={{ marginBottom: 16 }}
|
||||
>
|
||||
<Plus size={16} /> Neues Mapping anlegen
|
||||
</button>
|
||||
|
||||
{/* New mapping form (only shown when creating) */}
|
||||
{editingId === 'new' && formData && (
|
||||
<div className="card" style={{ padding: 16, marginBottom: 16, border: '2px solid var(--accent)' }}>
|
||||
<div style={{ fontWeight: 600, marginBottom: 12 }}>➕ Neues Mapping</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"
|
||||
style={{ width: '100%' }}
|
||||
autoFocus
|
||||
/>
|
||||
<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 with inline editing */}
|
||||
{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 => {
|
||||
const isEditing = editingId === mapping.id
|
||||
|
||||
return (
|
||||
<div
|
||||
key={mapping.id}
|
||||
className="card"
|
||||
style={{
|
||||
padding: 12,
|
||||
border: isEditing ? '2px solid var(--accent)' : undefined
|
||||
}}
|
||||
>
|
||||
{isEditing && formData ? (
|
||||
/* Inline edit form */
|
||||
<div>
|
||||
<div style={{ fontWeight: 600, marginBottom: 12, color: 'var(--accent)' }}>
|
||||
✏️ Mapping bearbeiten
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||
<div>
|
||||
<div className="form-label">Activity Type (nicht änderbar)</div>
|
||||
<input
|
||||
className="form-input"
|
||||
value={formData.activity_type}
|
||||
disabled
|
||||
style={{ width: '100%', background: 'var(--surface2)' }}
|
||||
/>
|
||||
</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>
|
||||
|
||||
<div style={{ display: 'flex', gap: 8, marginTop: 8 }}>
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={saving}
|
||||
className="btn btn-primary"
|
||||
style={{ flex: 1 }}
|
||||
>
|
||||
{saving ? (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6, justifyContent: 'center' }}>
|
||||
<div className="spinner" style={{ width: 14, height: 14 }} />
|
||||
Speichere...
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<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>
|
||||
) : (
|
||||
/* Normal view */
|
||||
<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>
|
||||
)
|
||||
}
|
||||
|
|
@ -424,6 +424,28 @@ export default function AdminPanel() {
|
|||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* v9d Training Types 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)"/> Trainingstypen (v9d)
|
||||
</div>
|
||||
<div style={{fontSize:12,color:'var(--text3)',marginBottom:12,lineHeight:1.5}}>
|
||||
Verwalte Trainingstypen, Kategorien und Activity-Mappings (lernendes System).
|
||||
</div>
|
||||
<div style={{display:'grid',gap:8}}>
|
||||
<Link to="/admin/training-types">
|
||||
<button className="btn btn-secondary btn-full">
|
||||
🏋️ Trainingstypen verwalten
|
||||
</button>
|
||||
</Link>
|
||||
<Link to="/admin/activity-mappings">
|
||||
<button className="btn btn-secondary btn-full">
|
||||
🔗 Activity-Mappings (lernendes System)
|
||||
</button>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
390
frontend/src/pages/AdminTrainingTypesPage.jsx
Normal file
390
frontend/src/pages/AdminTrainingTypesPage.jsx
Normal file
|
|
@ -0,0 +1,390 @@
|
|||
import { useState, useEffect } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { Pencil, Trash2, Plus, Save, X, ArrowLeft } from 'lucide-react'
|
||||
import { api } from '../utils/api'
|
||||
|
||||
/**
|
||||
* AdminTrainingTypesPage - CRUD for training types
|
||||
* v9d Phase 1b - Basic CRUD without abilities mapping
|
||||
*/
|
||||
export default function AdminTrainingTypesPage() {
|
||||
const nav = useNavigate()
|
||||
const [types, setTypes] = useState([])
|
||||
const [categories, setCategories] = useState({})
|
||||
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)
|
||||
|
||||
useEffect(() => {
|
||||
load()
|
||||
}, [])
|
||||
|
||||
const load = () => {
|
||||
setLoading(true)
|
||||
Promise.all([
|
||||
api.adminListTrainingTypes(),
|
||||
api.getTrainingCategories()
|
||||
]).then(([typesData, catsData]) => {
|
||||
setTypes(typesData)
|
||||
setCategories(catsData)
|
||||
setLoading(false)
|
||||
}).catch(err => {
|
||||
console.error('Failed to load training types:', err)
|
||||
setError(err.message)
|
||||
setLoading(false)
|
||||
})
|
||||
}
|
||||
|
||||
const startCreate = () => {
|
||||
setFormData({
|
||||
category: 'cardio',
|
||||
subcategory: '',
|
||||
name_de: '',
|
||||
name_en: '',
|
||||
icon: '',
|
||||
description_de: '',
|
||||
description_en: '',
|
||||
sort_order: 0
|
||||
})
|
||||
setEditingId('new')
|
||||
}
|
||||
|
||||
const startEdit = (type) => {
|
||||
setFormData({
|
||||
category: type.category,
|
||||
subcategory: type.subcategory || '',
|
||||
name_de: type.name_de,
|
||||
name_en: type.name_en,
|
||||
icon: type.icon || '',
|
||||
description_de: type.description_de || '',
|
||||
description_en: type.description_en || '',
|
||||
sort_order: type.sort_order
|
||||
})
|
||||
setEditingId(type.id)
|
||||
}
|
||||
|
||||
const cancelEdit = () => {
|
||||
setEditingId(null)
|
||||
setFormData(null)
|
||||
setError(null)
|
||||
}
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!formData.name_de || !formData.name_en) {
|
||||
setError('Name (DE) und Name (EN) sind Pflichtfelder')
|
||||
return
|
||||
}
|
||||
|
||||
setSaving(true)
|
||||
setError(null)
|
||||
|
||||
try {
|
||||
if (editingId === 'new') {
|
||||
await api.adminCreateTrainingType(formData)
|
||||
} else {
|
||||
await api.adminUpdateTrainingType(editingId, formData)
|
||||
}
|
||||
await load()
|
||||
cancelEdit()
|
||||
} catch (err) {
|
||||
console.error('Save failed:', err)
|
||||
setError(err.message)
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDelete = async (id, name) => {
|
||||
if (!confirm(`Trainingstyp "${name}" wirklich löschen?\n\nHinweis: Löschen ist nur möglich wenn keine Aktivitäten diesen Typ verwenden.`)) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await api.adminDeleteTrainingType(id)
|
||||
await load()
|
||||
} catch (err) {
|
||||
alert('Löschen fehlgeschlagen: ' + err.message)
|
||||
}
|
||||
}
|
||||
|
||||
// Group by category
|
||||
const grouped = {}
|
||||
types.forEach(type => {
|
||||
if (!grouped[type.category]) {
|
||||
grouped[type.category] = []
|
||||
}
|
||||
grouped[type.category].push(type)
|
||||
})
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div style={{ padding: 20, textAlign: 'center' }}>
|
||||
<div className="spinner" style={{ width: 32, height: 32, margin: '0 auto' }} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
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 }}>Trainingstypen verwalten</h1>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="card" style={{ padding: 12, marginBottom: 16, background: '#FCEBEB', color: '#D85A30' }}>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Create new button */}
|
||||
{!editingId && (
|
||||
<button
|
||||
onClick={startCreate}
|
||||
className="btn btn-primary btn-full"
|
||||
style={{ marginBottom: 16 }}
|
||||
>
|
||||
<Plus size={16} /> Neuen Trainingstyp anlegen
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Edit form */}
|
||||
{editingId && formData && (
|
||||
<div className="card" style={{ padding: 16, marginBottom: 16 }}>
|
||||
<div style={{ fontWeight: 600, marginBottom: 12 }}>
|
||||
{editingId === 'new' ? '➕ Neuer Trainingstyp' : '✏️ Trainingstyp bearbeiten'}
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
|
||||
<div>
|
||||
<div className="form-label">Kategorie *</div>
|
||||
<select
|
||||
className="form-input"
|
||||
value={formData.category}
|
||||
onChange={e => setFormData({ ...formData, category: e.target.value })}
|
||||
style={{ width: '100%' }}
|
||||
>
|
||||
{Object.keys(categories).map(cat => (
|
||||
<option key={cat} value={cat}>
|
||||
{categories[cat].icon} {categories[cat].name_de}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="form-label">Subkategorie</div>
|
||||
<input
|
||||
className="form-input"
|
||||
value={formData.subcategory}
|
||||
onChange={e => setFormData({ ...formData, subcategory: e.target.value })}
|
||||
placeholder="z.B. running, hypertrophy, meditation"
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
<div style={{ fontSize: 11, color: 'var(--text3)', marginTop: 4 }}>
|
||||
Kleingeschrieben, ohne Leerzeichen, eindeutig
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="form-label">Name (Deutsch) *</div>
|
||||
<input
|
||||
className="form-input"
|
||||
value={formData.name_de}
|
||||
onChange={e => setFormData({ ...formData, name_de: e.target.value })}
|
||||
placeholder="z.B. Laufen"
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="form-label">Name (English) *</div>
|
||||
<input
|
||||
className="form-input"
|
||||
value={formData.name_en}
|
||||
onChange={e => setFormData({ ...formData, name_en: e.target.value })}
|
||||
placeholder="e.g. Running"
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="form-label">Icon (Emoji)</div>
|
||||
<input
|
||||
className="form-input"
|
||||
value={formData.icon}
|
||||
onChange={e => setFormData({ ...formData, icon: e.target.value })}
|
||||
placeholder="🏃"
|
||||
maxLength={10}
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="form-label">Sortierung</div>
|
||||
<input
|
||||
type="number"
|
||||
className="form-input"
|
||||
value={formData.sort_order}
|
||||
onChange={e => setFormData({ ...formData, sort_order: parseInt(e.target.value) })}
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
<div style={{ fontSize: 11, color: 'var(--text3)', marginTop: 4 }}>
|
||||
Niedrigere Zahlen werden zuerst angezeigt
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="form-label">Beschreibung (Deutsch)</div>
|
||||
<textarea
|
||||
className="form-input"
|
||||
value={formData.description_de}
|
||||
onChange={e => setFormData({ ...formData, description_de: e.target.value })}
|
||||
placeholder="Optional: Beschreibung für KI-Analyse"
|
||||
rows={4}
|
||||
style={{ width: '100%', resize: 'vertical' }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="form-label">Beschreibung (English)</div>
|
||||
<textarea
|
||||
className="form-input"
|
||||
value={formData.description_en}
|
||||
onChange={e => setFormData({ ...formData, description_en: e.target.value })}
|
||||
placeholder="Optional: Description for AI analysis"
|
||||
rows={4}
|
||||
style={{ width: '100%', resize: 'vertical' }}
|
||||
/>
|
||||
</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 grouped by category */}
|
||||
{Object.entries(grouped).sort((a, b) => {
|
||||
const orderA = categories[a[0]]?.sort_order || 999
|
||||
const orderB = categories[b[0]]?.sort_order || 999
|
||||
return orderA - orderB
|
||||
}).map(([cat, catTypes]) => (
|
||||
<div key={cat} className="card" style={{ padding: 16, marginBottom: 12 }}>
|
||||
<div style={{
|
||||
fontWeight: 600,
|
||||
fontSize: 14,
|
||||
marginBottom: 12,
|
||||
color: categories[cat]?.color || 'var(--text1)'
|
||||
}}>
|
||||
{categories[cat]?.icon} {categories[cat]?.name_de}
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
{catTypes.sort((a, b) => a.sort_order - b.sort_order).map(type => (
|
||||
<div
|
||||
key={type.id}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 8,
|
||||
padding: 8,
|
||||
background: 'var(--surface)',
|
||||
borderRadius: 6
|
||||
}}
|
||||
>
|
||||
<div style={{ fontSize: 18 }}>{type.icon}</div>
|
||||
<div style={{ flex: 1 }}>
|
||||
<div style={{ fontSize: 13, fontWeight: 500 }}>
|
||||
{type.name_de} <span style={{ color: 'var(--text3)' }}>/ {type.name_en}</span>
|
||||
</div>
|
||||
{type.subcategory && (
|
||||
<div style={{ fontSize: 11, color: 'var(--text3)' }}>
|
||||
Subkategorie: {type.subcategory}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
onClick={() => startEdit(type)}
|
||||
style={{
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
cursor: 'pointer',
|
||||
padding: 6,
|
||||
color: 'var(--accent)'
|
||||
}}
|
||||
title="Bearbeiten"
|
||||
>
|
||||
<Pencil size={16} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDelete(type.id, type.name_de)}
|
||||
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>Hinweis:</strong> Das Fähigkeiten-Mapping (koordinativ, konditionell, etc.) wird in einer späteren Version hinzugefügt.
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -10,6 +10,7 @@ import { useProfile } from '../context/ProfileContext'
|
|||
import { getBfCategory } from '../utils/calc'
|
||||
import TrialBanner from '../components/TrialBanner'
|
||||
import EmailVerificationBanner from '../components/EmailVerificationBanner'
|
||||
import TrainingTypeDistribution from '../components/TrainingTypeDistribution'
|
||||
import { getInterpretation, getStatusColor, getStatusBg } from '../utils/interpret'
|
||||
import Markdown from '../utils/Markdown'
|
||||
import dayjs from 'dayjs'
|
||||
|
|
@ -470,6 +471,20 @@ export default function Dashboard() {
|
|||
)}
|
||||
</div>
|
||||
|
||||
{/* Training Type Distribution */}
|
||||
{activities.length > 0 && (
|
||||
<div className="card section-gap" style={{marginBottom:16}}>
|
||||
<div style={{display:'flex',justifyContent:'space-between',alignItems:'center',marginBottom:12}}>
|
||||
<div style={{fontWeight:600,fontSize:13}}>🏋️ Trainingstyp-Verteilung</div>
|
||||
<button style={{background:'none',border:'none',fontSize:12,color:'var(--accent)',cursor:'pointer'}}
|
||||
onClick={()=>nav('/activity')}>
|
||||
Details →
|
||||
</button>
|
||||
</div>
|
||||
<TrainingTypeDistribution days={28} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Latest AI insight */}
|
||||
<div className="card section-gap">
|
||||
<div style={{display:'flex',alignItems:'center',justifyContent:'space-between',marginBottom:8}}>
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import { api } from '../utils/api'
|
|||
import { getBfCategory } from '../utils/calc'
|
||||
import { getInterpretation, getStatusColor, getStatusBg } from '../utils/interpret'
|
||||
import Markdown from '../utils/Markdown'
|
||||
import TrainingTypeDistribution from '../components/TrainingTypeDistribution'
|
||||
import dayjs from 'dayjs'
|
||||
import 'dayjs/locale/de'
|
||||
dayjs.locale('de')
|
||||
|
|
@ -653,6 +654,10 @@ function ActivitySection({ activities, insights, onRequest, loadingSlug, filterA
|
|||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="card" style={{marginBottom:12}}>
|
||||
<div style={{fontSize:12,fontWeight:600,color:'var(--text3)',marginBottom:8}}>Trainingstyp-Verteilung</div>
|
||||
<TrainingTypeDistribution days={period === 9999 ? 365 : period} />
|
||||
</div>
|
||||
<div style={{marginBottom:12}}>
|
||||
<div style={{fontSize:12,fontWeight:600,color:'var(--text3)',marginBottom:8}}>BEWERTUNG</div>
|
||||
{actRules.map((item,i)=><RuleCard key={i} item={item}/>)}
|
||||
|
|
|
|||
|
|
@ -56,6 +56,8 @@ export const api = {
|
|||
updateActivity: (id,d) => req(`/activity/${id}`,jput(d)),
|
||||
deleteActivity: (id) => req(`/activity/${id}`,{method:'DELETE'}),
|
||||
activityStats: () => req('/activity/stats'),
|
||||
listUncategorizedActivities: () => req('/activity/uncategorized'),
|
||||
bulkCategorizeActivities: (d) => req('/activity/bulk-categorize', json(d)),
|
||||
importActivityCsv: async(file)=>{
|
||||
const fd=new FormData();fd.append('file',file)
|
||||
const r=await fetch(`${BASE}/activity/import-csv`,{method:'POST',body:fd,headers:hdrs()})
|
||||
|
|
@ -189,4 +191,25 @@ export const api = {
|
|||
createAccessGrant: (d) => req('/access-grants',json(d)),
|
||||
updateAccessGrant: (id,d) => req(`/access-grants/${id}`,jput(d)),
|
||||
revokeAccessGrant: (id) => req(`/access-grants/${id}`,{method:'DELETE'}),
|
||||
|
||||
// v9d: Training Types
|
||||
listTrainingTypes: () => req('/training-types'), // Grouped by category
|
||||
listTrainingTypesFlat:() => req('/training-types/flat'), // Flat list
|
||||
getTrainingCategories:() => req('/training-types/categories'), // Category metadata
|
||||
|
||||
// Admin: Training Types (v9d Phase 1b)
|
||||
adminListTrainingTypes: () => req('/admin/training-types'),
|
||||
adminGetTrainingType: (id) => req(`/admin/training-types/${id}`),
|
||||
adminCreateTrainingType: (d) => req('/admin/training-types', json(d)),
|
||||
adminUpdateTrainingType: (id,d) => req(`/admin/training-types/${id}`, jput(d)),
|
||||
adminDeleteTrainingType: (id) => req(`/admin/training-types/${id}`, {method:'DELETE'}),
|
||||
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