Compare commits
No commits in common. "3ff2a1bf454f326207efe90c8e20bf917e942125" and "51aa57f304fc38d72d9a11ecc48399d4a141fe48" have entirely different histories.
3ff2a1bf45
...
51aa57f304
50
CLAUDE.md
50
CLAUDE.md
|
|
@ -49,7 +49,7 @@ frontend/src/
|
||||||
└── technical/ # MEMBERSHIP_SYSTEM.md
|
└── technical/ # MEMBERSHIP_SYSTEM.md
|
||||||
```
|
```
|
||||||
|
|
||||||
## Aktuelle Version: v9c (komplett) 🚀 Production seit 21.03.2026
|
## Aktuelle Version: v9c (komplett)
|
||||||
|
|
||||||
### Implementiert ✅
|
### Implementiert ✅
|
||||||
- Login (E-Mail + bcrypt), Auth-Middleware alle Endpoints, Rate Limiting
|
- Login (E-Mail + bcrypt), Auth-Middleware alle Endpoints, Rate Limiting
|
||||||
|
|
@ -81,55 +81,15 @@ frontend/src/
|
||||||
- ✅ **BUG-002:** Ernährungs-Daten Tab fehlte – importierte Einträge nicht sichtbar
|
- ✅ **BUG-002:** Ernährungs-Daten Tab fehlte – importierte Einträge nicht sichtbar
|
||||||
- ✅ **BUG-003:** Korrelations-Chart Extrapolation (gestrichelte Linien für fehlende Werte)
|
- ✅ **BUG-003:** Korrelations-Chart Extrapolation (gestrichelte Linien für fehlende Werte)
|
||||||
- ✅ **BUG-004:** Import-Historie Refresh (Force remount via key prop)
|
- ✅ **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 ✅ (Deployed to Production 21.03.2026)
|
### v9c Finalisierung ✅
|
||||||
- ✅ **Selbst-Registrierung:** POST /api/auth/register, E-Mail-Verifizierung, Auto-Login
|
- ✅ **Selbst-Registrierung:** POST /api/auth/register, E-Mail-Verifizierung, Auto-Login
|
||||||
- ✅ **Trial-System UI:** Countdown-Banner im Dashboard (3 Urgency-Level)
|
- ✅ **Trial-System UI:** Countdown-Banner im Dashboard (3 Urgency-Level)
|
||||||
- ✅ **Migrations-System:** Automatische Schema-Migrationen beim Start (db_init.py)
|
- ✅ **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
|
|
||||||
|
|
||||||
### Auf develop (bereit für Prod) 🚀
|
### Offen v9d 🔲
|
||||||
**v9d Phase 1b - Feature-komplett, ready for deployment**
|
- Schlaf-Modul
|
||||||
|
- Trainingstypen + Herzfrequenz
|
||||||
- ✅ **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`
|
📚 Details: `.claude/docs/technical/MEMBERSHIP_SYSTEM.md` · `.claude/docs/architecture/FEATURE_ENFORCEMENT.md`
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -19,8 +19,7 @@ from routers import auth, profiles, weight, circumference, caliper
|
||||||
from routers import activity, nutrition, photos, insights, prompts
|
from routers import activity, nutrition, photos, insights, prompts
|
||||||
from routers import admin, stats, exportdata, importdata
|
from routers import admin, stats, exportdata, importdata
|
||||||
from routers import subscription, coupons, features, tiers_mgmt, tier_limits
|
from routers import subscription, coupons, features, tiers_mgmt, tier_limits
|
||||||
from routers import user_restrictions, access_grants, training_types, admin_training_types
|
from routers import user_restrictions, access_grants
|
||||||
from routers import admin_activity_mappings
|
|
||||||
|
|
||||||
# ── App Configuration ─────────────────────────────────────────────────────────
|
# ── App Configuration ─────────────────────────────────────────────────────────
|
||||||
DATA_DIR = Path(os.getenv("DATA_DIR", "./data"))
|
DATA_DIR = Path(os.getenv("DATA_DIR", "./data"))
|
||||||
|
|
@ -86,11 +85,6 @@ app.include_router(tier_limits.router) # /api/tier-limits (admin)
|
||||||
app.include_router(user_restrictions.router) # /api/user-restrictions (admin)
|
app.include_router(user_restrictions.router) # /api/user-restrictions (admin)
|
||||||
app.include_router(access_grants.router) # /api/access-grants (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 ──────────────────────────────────────────────────────────────
|
# ── Health Check ──────────────────────────────────────────────────────────────
|
||||||
@app.get("/")
|
@app.get("/")
|
||||||
def root():
|
def root():
|
||||||
|
|
|
||||||
|
|
@ -1,86 +0,0 @@
|
||||||
-- 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';
|
|
||||||
|
|
@ -1,24 +0,0 @@
|
||||||
-- 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';
|
|
||||||
|
|
@ -1,29 +0,0 @@
|
||||||
-- 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';
|
|
||||||
|
|
@ -1,121 +0,0 @@
|
||||||
-- 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,9 +84,6 @@ class ActivityEntry(BaseModel):
|
||||||
rpe: Optional[int] = None
|
rpe: Optional[int] = None
|
||||||
source: Optional[str] = 'manual'
|
source: Optional[str] = 'manual'
|
||||||
notes: Optional[str] = None
|
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):
|
class NutritionDay(BaseModel):
|
||||||
|
|
|
||||||
|
|
@ -113,126 +113,9 @@ def activity_stats(x_profile_id: Optional[str]=Header(default=None), session: di
|
||||||
return {"count":len(rows),"total_kcal":round(total_kcal),"total_min":round(total_min),"by_type":by_type}
|
return {"count":len(rows),"total_kcal":round(total_kcal),"total_min":round(total_min),"by_type":by_type}
|
||||||
|
|
||||||
|
|
||||||
def get_training_type_for_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")
|
@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)):
|
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 with automatic training type mapping."""
|
"""Import Apple Health workout CSV."""
|
||||||
pid = get_pid(x_profile_id)
|
pid = get_pid(x_profile_id)
|
||||||
raw = await file.read()
|
raw = await file.read()
|
||||||
try: text = raw.decode('utf-8')
|
try: text = raw.decode('utf-8')
|
||||||
|
|
@ -262,58 +145,16 @@ async def import_activity_csv(file: UploadFile=File(...), x_profile_id: Optional
|
||||||
def tf(v):
|
def tf(v):
|
||||||
try: return round(float(v),1) if v else None
|
try: return round(float(v),1) if v else None
|
||||||
except: return None
|
except: return None
|
||||||
# Map activity_type to training_type_id using database mappings
|
|
||||||
training_type_id, training_category, training_subcategory = get_training_type_for_activity(wtype, pid)
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Check if entry already exists (duplicate detection by date + start_time)
|
|
||||||
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
|
cur.execute("""INSERT INTO activity_log
|
||||||
(id,profile_id,date,start_time,end_time,activity_type,duration_min,kcal_active,kcal_resting,
|
(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)
|
hr_avg,hr_max,distance_km,source,created)
|
||||||
VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,'apple_health',%s,%s,%s,CURRENT_TIMESTAMP)""",
|
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,
|
(str(uuid.uuid4()),pid,date,start,row.get('End',''),wtype,duration_min,
|
||||||
kj(row.get('Aktive Energie (kJ)','')),kj(row.get('Ruheeinträge (kJ)','')),
|
kj(row.get('Aktive Energie (kJ)','')),kj(row.get('Ruheeinträge (kJ)','')),
|
||||||
tf(row.get('Durchschn. Herzfrequenz (count/min)','')),
|
tf(row.get('Durchschn. Herzfrequenz (count/min)','')),
|
||||||
tf(row.get('Max. Herzfrequenz (count/min)','')),
|
tf(row.get('Max. Herzfrequenz (count/min)','')),
|
||||||
tf(row.get('Distanz (km)','')),
|
tf(row.get('Distanz (km)',''))))
|
||||||
training_type_id,training_category,training_subcategory))
|
|
||||||
inserted+=1
|
inserted+=1
|
||||||
except Exception as e:
|
except: skipped+=1
|
||||||
logger.warning(f"Import row failed: {e}")
|
|
||||||
skipped+=1
|
|
||||||
return {"inserted":inserted,"skipped":skipped,"message":f"{inserted} Trainings importiert"}
|
return {"inserted":inserted,"skipped":skipped,"message":f"{inserted} Trainings importiert"}
|
||||||
|
|
|
||||||
|
|
@ -1,219 +0,0 @@
|
||||||
"""
|
|
||||||
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
|
|
||||||
|
|
@ -1,281 +0,0 @@
|
||||||
"""
|
|
||||||
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
|
|
||||||
|
|
@ -1,129 +0,0 @@
|
||||||
"""
|
|
||||||
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 { useEffect } from 'react'
|
||||||
import { BrowserRouter, Routes, Route, NavLink, useNavigate } from 'react-router-dom'
|
import { BrowserRouter, Routes, Route, NavLink, useNavigate } from 'react-router-dom'
|
||||||
import { LayoutDashboard, PlusSquare, TrendingUp, BarChart2, Settings, LogOut } from 'lucide-react'
|
import { LayoutDashboard, PlusSquare, TrendingUp, BarChart2, Settings } from 'lucide-react'
|
||||||
import { ProfileProvider, useProfile } from './context/ProfileContext'
|
import { ProfileProvider, useProfile } from './context/ProfileContext'
|
||||||
import { AuthProvider, useAuth } from './context/AuthContext'
|
import { AuthProvider, useAuth } from './context/AuthContext'
|
||||||
import { setProfileId } from './utils/api'
|
import { setProfileId } from './utils/api'
|
||||||
|
|
@ -27,8 +27,6 @@ import AdminFeaturesPage from './pages/AdminFeaturesPage'
|
||||||
import AdminTiersPage from './pages/AdminTiersPage'
|
import AdminTiersPage from './pages/AdminTiersPage'
|
||||||
import AdminCouponsPage from './pages/AdminCouponsPage'
|
import AdminCouponsPage from './pages/AdminCouponsPage'
|
||||||
import AdminUserRestrictionsPage from './pages/AdminUserRestrictionsPage'
|
import AdminUserRestrictionsPage from './pages/AdminUserRestrictionsPage'
|
||||||
import AdminTrainingTypesPage from './pages/AdminTrainingTypesPage'
|
|
||||||
import AdminActivityMappingsPage from './pages/AdminActivityMappingsPage'
|
|
||||||
import SubscriptionPage from './pages/SubscriptionPage'
|
import SubscriptionPage from './pages/SubscriptionPage'
|
||||||
import './app.css'
|
import './app.css'
|
||||||
|
|
||||||
|
|
@ -52,17 +50,10 @@ function Nav() {
|
||||||
}
|
}
|
||||||
|
|
||||||
function AppShell() {
|
function AppShell() {
|
||||||
const { session, loading: authLoading, needsSetup, logout } = useAuth()
|
const { session, loading: authLoading, needsSetup } = useAuth()
|
||||||
const { activeProfile, loading: profileLoading } = useProfile()
|
const { activeProfile, loading: profileLoading } = useProfile()
|
||||||
const nav = useNavigate()
|
const nav = useNavigate()
|
||||||
|
|
||||||
const handleLogout = () => {
|
|
||||||
if (confirm('Wirklich abmelden?')) {
|
|
||||||
logout()
|
|
||||||
window.location.href = '/'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
useEffect(()=>{
|
useEffect(()=>{
|
||||||
if (session?.profile_id) {
|
if (session?.profile_id) {
|
||||||
setProfileId(session.profile_id)
|
setProfileId(session.profile_id)
|
||||||
|
|
@ -128,32 +119,12 @@ function AppShell() {
|
||||||
<div className="app-shell">
|
<div className="app-shell">
|
||||||
<header className="app-header">
|
<header className="app-header">
|
||||||
<span className="app-logo">Mitai Jinkendo</span>
|
<span className="app-logo">Mitai Jinkendo</span>
|
||||||
<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'}}>
|
<NavLink to="/settings" style={{textDecoration:'none'}}>
|
||||||
{activeProfile
|
{activeProfile
|
||||||
? <Avatar profile={activeProfile} size={30}/>
|
? <Avatar profile={activeProfile} size={30}/>
|
||||||
: <div style={{width:30,height:30,borderRadius:'50%',background:'var(--accent)'}}/>
|
: <div style={{width:30,height:30,borderRadius:'50%',background:'var(--accent)'}}/>
|
||||||
}
|
}
|
||||||
</NavLink>
|
</NavLink>
|
||||||
</div>
|
|
||||||
</header>
|
</header>
|
||||||
<main className="app-main">
|
<main className="app-main">
|
||||||
<Routes>
|
<Routes>
|
||||||
|
|
@ -174,8 +145,6 @@ function AppShell() {
|
||||||
<Route path="/admin/tiers" element={<AdminTiersPage/>}/>
|
<Route path="/admin/tiers" element={<AdminTiersPage/>}/>
|
||||||
<Route path="/admin/coupons" element={<AdminCouponsPage/>}/>
|
<Route path="/admin/coupons" element={<AdminCouponsPage/>}/>
|
||||||
<Route path="/admin/user-restrictions" element={<AdminUserRestrictionsPage/>}/>
|
<Route path="/admin/user-restrictions" element={<AdminUserRestrictionsPage/>}/>
|
||||||
<Route path="/admin/training-types" element={<AdminTrainingTypesPage/>}/>
|
|
||||||
<Route path="/admin/activity-mappings" element={<AdminActivityMappingsPage/>}/>
|
|
||||||
<Route path="/subscription" element={<SubscriptionPage/>}/>
|
<Route path="/subscription" element={<SubscriptionPage/>}/>
|
||||||
</Routes>
|
</Routes>
|
||||||
</main>
|
</main>
|
||||||
|
|
|
||||||
|
|
@ -1,191 +0,0 @@
|
||||||
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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
@ -1,120 +0,0 @@
|
||||||
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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
@ -1,114 +0,0 @@
|
||||||
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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<a
|
<Link
|
||||||
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"
|
to="/settings?tab=subscription"
|
||||||
style={{
|
style={{
|
||||||
padding: '10px 20px',
|
padding: '10px 20px',
|
||||||
borderRadius: 8,
|
borderRadius: 8,
|
||||||
|
|
@ -76,12 +76,11 @@ export default function TrialBanner({ profile }) {
|
||||||
textDecoration: 'none',
|
textDecoration: 'none',
|
||||||
whiteSpace: 'nowrap',
|
whiteSpace: 'nowrap',
|
||||||
border: 'none',
|
border: 'none',
|
||||||
cursor: 'pointer',
|
cursor: 'pointer'
|
||||||
display: 'inline-block'
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{isUrgent ? 'Kontakt aufnehmen' : 'Abo anfragen'}
|
{isUrgent ? 'Jetzt upgraden' : 'Abo wählen'}
|
||||||
</a>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,8 +3,6 @@ import { Upload, Pencil, Trash2, Check, X, CheckCircle } from 'lucide-react'
|
||||||
import { BarChart, Bar, XAxis, YAxis, Tooltip, ResponsiveContainer, CartesianGrid } from 'recharts'
|
import { BarChart, Bar, XAxis, YAxis, Tooltip, ResponsiveContainer, CartesianGrid } from 'recharts'
|
||||||
import { api } from '../utils/api'
|
import { api } from '../utils/api'
|
||||||
import UsageBadge from '../components/UsageBadge'
|
import UsageBadge from '../components/UsageBadge'
|
||||||
import TrainingTypeSelect from '../components/TrainingTypeSelect'
|
|
||||||
import BulkCategorize from '../components/BulkCategorize'
|
|
||||||
import dayjs from 'dayjs'
|
import dayjs from 'dayjs'
|
||||||
import 'dayjs/locale/de'
|
import 'dayjs/locale/de'
|
||||||
dayjs.locale('de')
|
dayjs.locale('de')
|
||||||
|
|
@ -20,10 +18,7 @@ function empty() {
|
||||||
date: dayjs().format('YYYY-MM-DD'),
|
date: dayjs().format('YYYY-MM-DD'),
|
||||||
activity_type: 'Traditionelles Krafttraining',
|
activity_type: 'Traditionelles Krafttraining',
|
||||||
duration_min: '', kcal_active: '',
|
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
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -94,19 +89,11 @@ 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)}/>
|
<input type="date" className="form-input" style={{width:140}} value={form.date} onChange={e=>set('date',e.target.value)}/>
|
||||||
<span className="form-unit"/>
|
<span className="form-unit"/>
|
||||||
</div>
|
</div>
|
||||||
<div style={{marginBottom:12}}>
|
<div className="form-row">
|
||||||
<TrainingTypeSelect
|
<label className="form-label">Trainingsart</label>
|
||||||
value={form.training_type_id}
|
<select className="form-select" value={form.activity_type} onChange={e=>set('activity_type',e.target.value)}>
|
||||||
onChange={(typeId, category, subcategory) => {
|
{ACTIVITY_TYPES.map(t=><option key={t} value={t}>{t}</option>)}
|
||||||
setForm(f => ({
|
</select>
|
||||||
...f,
|
|
||||||
training_type_id: typeId,
|
|
||||||
training_category: category,
|
|
||||||
training_subcategory: subcategory
|
|
||||||
}))
|
|
||||||
}}
|
|
||||||
required={false}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="form-row">
|
<div className="form-row">
|
||||||
<label className="form-label">Dauer</label>
|
<label className="form-label">Dauer</label>
|
||||||
|
|
@ -180,7 +167,6 @@ export default function ActivityPage() {
|
||||||
const [saved, setSaved] = useState(false)
|
const [saved, setSaved] = useState(false)
|
||||||
const [error, setError] = useState(null)
|
const [error, setError] = useState(null)
|
||||||
const [activityUsage, setActivityUsage] = useState(null) // Phase 4: Usage badge
|
const [activityUsage, setActivityUsage] = useState(null) // Phase 4: Usage badge
|
||||||
const [categories, setCategories] = useState({}) // v9d: Training categories
|
|
||||||
|
|
||||||
const load = async () => {
|
const load = async () => {
|
||||||
const [e, s] = await Promise.all([api.listActivity(), api.activityStats()])
|
const [e, s] = await Promise.all([api.listActivity(), api.activityStats()])
|
||||||
|
|
@ -197,7 +183,6 @@ export default function ActivityPage() {
|
||||||
useEffect(()=>{
|
useEffect(()=>{
|
||||||
load()
|
load()
|
||||||
loadUsage()
|
loadUsage()
|
||||||
api.getTrainingCategories().then(setCategories).catch(err => console.error('Failed to load categories:', err))
|
|
||||||
},[])
|
},[])
|
||||||
|
|
||||||
const handleSave = async () => {
|
const handleSave = async () => {
|
||||||
|
|
@ -261,7 +246,6 @@ export default function ActivityPage() {
|
||||||
<button className={'tab'+(tab==='list'?' active':'')} onClick={()=>setTab('list')}>Verlauf</button>
|
<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==='add'?' active':'')} onClick={()=>setTab('add')}>+ Manuell</button>
|
||||||
<button className={'tab'+(tab==='import'?' active':'')} onClick={()=>setTab('import')}>Import</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>
|
<button className={'tab'+(tab==='stats'?' active':'')} onClick={()=>setTab('stats')}>Statistik</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -283,13 +267,6 @@ export default function ActivityPage() {
|
||||||
|
|
||||||
{tab==='import' && <ImportPanel onImported={load}/>}
|
{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' && (
|
{tab==='add' && (
|
||||||
<div className="card section-gap">
|
<div className="card section-gap">
|
||||||
<div className="card-title badge-container-right">
|
<div className="card-title badge-container-right">
|
||||||
|
|
@ -353,26 +330,7 @@ export default function ActivityPage() {
|
||||||
<div>
|
<div>
|
||||||
<div style={{display:'flex',justifyContent:'space-between',alignItems:'flex-start'}}>
|
<div style={{display:'flex',justifyContent:'space-between',alignItems:'flex-start'}}>
|
||||||
<div style={{flex:1}}>
|
<div style={{flex:1}}>
|
||||||
<div style={{display:'flex',alignItems:'center',gap:6,marginBottom:2}}>
|
|
||||||
<div style={{fontSize:14,fontWeight:600}}>{e.activity_type}</div>
|
<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}}>
|
<div style={{fontSize:11,color:'var(--text3)',marginBottom:4}}>
|
||||||
{dayjs(e.date).format('dd, DD. MMMM YYYY')}
|
{dayjs(e.date).format('dd, DD. MMMM YYYY')}
|
||||||
{e.start_time && e.start_time.length>10 && ` · ${e.start_time.slice(11,16)}`}
|
{e.start_time && e.start_time.length>10 && ` · ${e.start_time.slice(11,16)}`}
|
||||||
|
|
|
||||||
|
|
@ -1,446 +0,0 @@
|
||||||
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,28 +424,6 @@ export default function AdminPanel() {
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</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>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,390 +0,0 @@
|
||||||
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,7 +10,6 @@ import { useProfile } from '../context/ProfileContext'
|
||||||
import { getBfCategory } from '../utils/calc'
|
import { getBfCategory } from '../utils/calc'
|
||||||
import TrialBanner from '../components/TrialBanner'
|
import TrialBanner from '../components/TrialBanner'
|
||||||
import EmailVerificationBanner from '../components/EmailVerificationBanner'
|
import EmailVerificationBanner from '../components/EmailVerificationBanner'
|
||||||
import TrainingTypeDistribution from '../components/TrainingTypeDistribution'
|
|
||||||
import { getInterpretation, getStatusColor, getStatusBg } from '../utils/interpret'
|
import { getInterpretation, getStatusColor, getStatusBg } from '../utils/interpret'
|
||||||
import Markdown from '../utils/Markdown'
|
import Markdown from '../utils/Markdown'
|
||||||
import dayjs from 'dayjs'
|
import dayjs from 'dayjs'
|
||||||
|
|
@ -471,20 +470,6 @@ export default function Dashboard() {
|
||||||
)}
|
)}
|
||||||
</div>
|
</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 */}
|
{/* Latest AI insight */}
|
||||||
<div className="card section-gap">
|
<div className="card section-gap">
|
||||||
<div style={{display:'flex',alignItems:'center',justifyContent:'space-between',marginBottom:8}}>
|
<div style={{display:'flex',alignItems:'center',justifyContent:'space-between',marginBottom:8}}>
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,6 @@ import { api } from '../utils/api'
|
||||||
import { getBfCategory } from '../utils/calc'
|
import { getBfCategory } from '../utils/calc'
|
||||||
import { getInterpretation, getStatusColor, getStatusBg } from '../utils/interpret'
|
import { getInterpretation, getStatusColor, getStatusBg } from '../utils/interpret'
|
||||||
import Markdown from '../utils/Markdown'
|
import Markdown from '../utils/Markdown'
|
||||||
import TrainingTypeDistribution from '../components/TrainingTypeDistribution'
|
|
||||||
import dayjs from 'dayjs'
|
import dayjs from 'dayjs'
|
||||||
import 'dayjs/locale/de'
|
import 'dayjs/locale/de'
|
||||||
dayjs.locale('de')
|
dayjs.locale('de')
|
||||||
|
|
@ -654,10 +653,6 @@ function ActivitySection({ activities, insights, onRequest, loadingSlug, filterA
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</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={{marginBottom:12}}>
|
||||||
<div style={{fontSize:12,fontWeight:600,color:'var(--text3)',marginBottom:8}}>BEWERTUNG</div>
|
<div style={{fontSize:12,fontWeight:600,color:'var(--text3)',marginBottom:8}}>BEWERTUNG</div>
|
||||||
{actRules.map((item,i)=><RuleCard key={i} item={item}/>)}
|
{actRules.map((item,i)=><RuleCard key={i} item={item}/>)}
|
||||||
|
|
|
||||||
|
|
@ -56,8 +56,6 @@ export const api = {
|
||||||
updateActivity: (id,d) => req(`/activity/${id}`,jput(d)),
|
updateActivity: (id,d) => req(`/activity/${id}`,jput(d)),
|
||||||
deleteActivity: (id) => req(`/activity/${id}`,{method:'DELETE'}),
|
deleteActivity: (id) => req(`/activity/${id}`,{method:'DELETE'}),
|
||||||
activityStats: () => req('/activity/stats'),
|
activityStats: () => req('/activity/stats'),
|
||||||
listUncategorizedActivities: () => req('/activity/uncategorized'),
|
|
||||||
bulkCategorizeActivities: (d) => req('/activity/bulk-categorize', json(d)),
|
|
||||||
importActivityCsv: async(file)=>{
|
importActivityCsv: async(file)=>{
|
||||||
const fd=new FormData();fd.append('file',file)
|
const fd=new FormData();fd.append('file',file)
|
||||||
const r=await fetch(`${BASE}/activity/import-csv`,{method:'POST',body:fd,headers:hdrs()})
|
const r=await fetch(`${BASE}/activity/import-csv`,{method:'POST',body:fd,headers:hdrs()})
|
||||||
|
|
@ -191,25 +189,4 @@ export const api = {
|
||||||
createAccessGrant: (d) => req('/access-grants',json(d)),
|
createAccessGrant: (d) => req('/access-grants',json(d)),
|
||||||
updateAccessGrant: (id,d) => req(`/access-grants/${id}`,jput(d)),
|
updateAccessGrant: (id,d) => req(`/access-grants/${id}`,jput(d)),
|
||||||
revokeAccessGrant: (id) => req(`/access-grants/${id}`,{method:'DELETE'}),
|
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