feat: Training Planning (core feature) complete
Backend: - Created migration 006_training_planning.sql - training_units table (planned vs actual, status, notes) - training_unit_exercises M:N (order, duration, modifications) - Created routers/training_planning.py with full CRUD - List with filters (group, date range, status) - Get detail with exercises - Create/Update/Delete with access control - Quick create endpoint (auto-fills from group defaults) - Permission: trainer for own groups, admin for all - Registered training_planning router in main.py Frontend: - Complete TrainingPlanningPage with full planning workflow - Group selector + date range picker - List view with status badges (planned/completed/cancelled) - Create/Edit modal with exercise management - Drag to reorder exercises (▲▼) - Quick create button (one-click with group defaults) - Durchführung section (actual date/time, attendance, status) - Public notes + trainer-only notes - Updated api.js with deleteTrainingUnit and quickCreateTrainingUnit - Added /planning route to App.jsx - Navigation already configured (Calendar icon) This is the CORE feature - trainers can now plan and document training sessions. Next: Admin routes, role system refinement, testing
This commit is contained in:
parent
505a8e5e38
commit
7f156ba085
|
|
@ -70,18 +70,14 @@ def read_root():
|
|||
}
|
||||
|
||||
# Register routers
|
||||
from routers import auth, profiles, exercises, clubs, skills
|
||||
from routers import auth, profiles, exercises, clubs, skills, training_planning
|
||||
|
||||
app.include_router(auth.router)
|
||||
app.include_router(profiles.router)
|
||||
app.include_router(exercises.router)
|
||||
app.include_router(clubs.router)
|
||||
app.include_router(skills.router)
|
||||
|
||||
# TODO: Add more routers as they are created
|
||||
# from routers import training_planning
|
||||
# app.include_router(training_planning.router, prefix="/api")
|
||||
# ... etc
|
||||
app.include_router(training_planning.router)
|
||||
|
||||
if __name__ == "__main__":
|
||||
import uvicorn
|
||||
|
|
|
|||
60
backend/migrations/006_training_planning.sql
Normal file
60
backend/migrations/006_training_planning.sql
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
-- Migration 006: Training Planning (Training Units)
|
||||
-- Erstellt: 2026-04-22
|
||||
-- Beschreibung: Trainingsplanung und -dokumentation
|
||||
|
||||
-- Training Units (Trainingseinheiten)
|
||||
CREATE TABLE training_units (
|
||||
id SERIAL PRIMARY KEY,
|
||||
group_id INT REFERENCES training_groups(id) ON DELETE CASCADE,
|
||||
|
||||
-- Planung
|
||||
planned_date DATE NOT NULL,
|
||||
planned_time_start TIME,
|
||||
planned_time_end TIME,
|
||||
planned_focus VARCHAR(200),
|
||||
|
||||
-- Durchführung
|
||||
actual_date DATE,
|
||||
actual_time_start TIME,
|
||||
actual_time_end TIME,
|
||||
attendance_count INT,
|
||||
|
||||
-- Status & Notizen
|
||||
status VARCHAR(50) DEFAULT 'planned', -- planned, completed, cancelled
|
||||
notes TEXT,
|
||||
trainer_notes TEXT,
|
||||
|
||||
-- Meta
|
||||
created_by INT REFERENCES profiles(id),
|
||||
created_at TIMESTAMP DEFAULT NOW(),
|
||||
updated_at TIMESTAMP DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX idx_training_units_group ON training_units(group_id);
|
||||
CREATE INDEX idx_training_units_date ON training_units(planned_date);
|
||||
CREATE INDEX idx_training_units_status ON training_units(status);
|
||||
CREATE INDEX idx_training_units_created_by ON training_units(created_by);
|
||||
|
||||
-- Training Unit Exercises (M:N - geplante und durchgeführte Übungen)
|
||||
CREATE TABLE training_unit_exercises (
|
||||
id SERIAL PRIMARY KEY,
|
||||
training_unit_id INT REFERENCES training_units(id) ON DELETE CASCADE,
|
||||
exercise_id INT REFERENCES exercises(id),
|
||||
|
||||
-- Reihenfolge
|
||||
order_index INT NOT NULL,
|
||||
|
||||
-- Zeitplanung
|
||||
planned_duration_min INT,
|
||||
actual_duration_min INT,
|
||||
|
||||
-- Notizen
|
||||
notes TEXT,
|
||||
modifications TEXT, -- Anpassungen während der Durchführung
|
||||
|
||||
created_at TIMESTAMP DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX idx_training_unit_exercises_unit ON training_unit_exercises(training_unit_id);
|
||||
CREATE INDEX idx_training_unit_exercises_exercise ON training_unit_exercises(exercise_id);
|
||||
CREATE INDEX idx_training_unit_exercises_order ON training_unit_exercises(training_unit_id, order_index);
|
||||
403
backend/routers/training_planning.py
Normal file
403
backend/routers/training_planning.py
Normal file
|
|
@ -0,0 +1,403 @@
|
|||
"""
|
||||
Training Planning Endpoints for Shinkan Jinkendo
|
||||
|
||||
Handles CRUD operations for training units and their exercises.
|
||||
"""
|
||||
from typing import Optional
|
||||
from datetime import date, time
|
||||
from fastapi import APIRouter, HTTPException, Depends, Query
|
||||
|
||||
from db import get_db, get_cursor, r2d
|
||||
from auth import require_auth
|
||||
|
||||
router = APIRouter(prefix="/api", tags=["training_planning"])
|
||||
|
||||
|
||||
# ── List Training Units ───────────────────────────────────────────────
|
||||
@router.get("/training-units")
|
||||
def list_training_units(
|
||||
group_id: Optional[int] = Query(default=None),
|
||||
start_date: Optional[str] = Query(default=None),
|
||||
end_date: Optional[str] = Query(default=None),
|
||||
status: Optional[str] = Query(default=None),
|
||||
session=Depends(require_auth)
|
||||
):
|
||||
"""
|
||||
List training units with optional filters.
|
||||
|
||||
Filters:
|
||||
- group_id: Filter by training group
|
||||
- start_date: Filter from this date (YYYY-MM-DD)
|
||||
- end_date: Filter to this date (YYYY-MM-DD)
|
||||
- status: planned, completed, cancelled
|
||||
"""
|
||||
profile_id = session['profile_id']
|
||||
role = session.get('role')
|
||||
|
||||
with get_db() as conn:
|
||||
cur = get_cursor(conn)
|
||||
|
||||
query = """
|
||||
SELECT tu.*,
|
||||
tg.name as group_name,
|
||||
tg.weekday as group_weekday,
|
||||
c.name as club_name,
|
||||
p.name as trainer_name
|
||||
FROM training_units tu
|
||||
LEFT JOIN training_groups tg ON tu.group_id = tg.id
|
||||
LEFT JOIN clubs c ON tg.club_id = c.id
|
||||
LEFT JOIN profiles p ON tu.created_by = p.id
|
||||
"""
|
||||
|
||||
where = []
|
||||
params = []
|
||||
|
||||
# Access control: show only own units unless admin
|
||||
if role not in ['admin', 'superadmin']:
|
||||
where.append("(tu.created_by = %s OR tg.trainer_id = %s)")
|
||||
params.extend([profile_id, profile_id])
|
||||
|
||||
if group_id:
|
||||
where.append("tu.group_id = %s")
|
||||
params.append(group_id)
|
||||
|
||||
if start_date:
|
||||
where.append("tu.planned_date >= %s")
|
||||
params.append(start_date)
|
||||
|
||||
if end_date:
|
||||
where.append("tu.planned_date <= %s")
|
||||
params.append(end_date)
|
||||
|
||||
if status:
|
||||
where.append("tu.status = %s")
|
||||
params.append(status)
|
||||
|
||||
if where:
|
||||
query += " WHERE " + " AND ".join(where)
|
||||
|
||||
query += " ORDER BY tu.planned_date DESC, tu.planned_time_start DESC"
|
||||
|
||||
cur.execute(query, params)
|
||||
rows = cur.fetchall()
|
||||
return [r2d(r) for r in rows]
|
||||
|
||||
|
||||
# ── Get Training Unit ─────────────────────────────────────────────────
|
||||
@router.get("/training-units/{unit_id}")
|
||||
def get_training_unit(unit_id: int, session=Depends(require_auth)):
|
||||
"""Get training unit by ID with exercises."""
|
||||
profile_id = session['profile_id']
|
||||
role = session.get('role')
|
||||
|
||||
with get_db() as conn:
|
||||
cur = get_cursor(conn)
|
||||
|
||||
# Get training unit
|
||||
cur.execute("""
|
||||
SELECT tu.*,
|
||||
tg.name as group_name,
|
||||
tg.weekday as group_weekday,
|
||||
tg.time_start as group_time_start,
|
||||
tg.time_end as group_time_end,
|
||||
tg.location as group_location,
|
||||
c.name as club_name,
|
||||
p.name as trainer_name
|
||||
FROM training_units tu
|
||||
LEFT JOIN training_groups tg ON tu.group_id = tg.id
|
||||
LEFT JOIN clubs c ON tg.club_id = c.id
|
||||
LEFT JOIN profiles p ON tu.created_by = p.id
|
||||
WHERE tu.id = %s
|
||||
""", (unit_id,))
|
||||
|
||||
unit = cur.fetchone()
|
||||
|
||||
if not unit:
|
||||
raise HTTPException(404, "Trainingseinheit nicht gefunden")
|
||||
|
||||
unit = r2d(unit)
|
||||
|
||||
# Access control
|
||||
cur.execute("SELECT trainer_id FROM training_groups WHERE id = %s", (unit['group_id'],))
|
||||
group = cur.fetchone()
|
||||
|
||||
if role not in ['admin', 'superadmin']:
|
||||
if unit['created_by'] != profile_id and (not group or group['trainer_id'] != profile_id):
|
||||
raise HTTPException(403, "Keine Berechtigung")
|
||||
|
||||
# Get exercises
|
||||
cur.execute("""
|
||||
SELECT tue.*,
|
||||
e.title as exercise_title,
|
||||
e.summary as exercise_summary,
|
||||
e.focus_area as exercise_focus_area
|
||||
FROM training_unit_exercises tue
|
||||
LEFT JOIN exercises e ON tue.exercise_id = e.id
|
||||
WHERE tue.training_unit_id = %s
|
||||
ORDER BY tue.order_index
|
||||
""", (unit_id,))
|
||||
unit['exercises'] = [r2d(r) for r in cur.fetchall()]
|
||||
|
||||
return unit
|
||||
|
||||
|
||||
# ── Create Training Unit ──────────────────────────────────────────────
|
||||
@router.post("/training-units")
|
||||
def create_training_unit(data: dict, session=Depends(require_auth)):
|
||||
"""Create new training unit."""
|
||||
profile_id = session['profile_id']
|
||||
role = session.get('role')
|
||||
|
||||
group_id = data.get('group_id')
|
||||
planned_date = data.get('planned_date')
|
||||
|
||||
if not group_id or not planned_date:
|
||||
raise HTTPException(400, "group_id und planned_date sind Pflichtfelder")
|
||||
|
||||
with get_db() as conn:
|
||||
cur = get_cursor(conn)
|
||||
|
||||
# Check group exists and access
|
||||
cur.execute("""
|
||||
SELECT trainer_id, co_trainer_ids
|
||||
FROM training_groups
|
||||
WHERE id = %s
|
||||
""", (group_id,))
|
||||
group = cur.fetchone()
|
||||
|
||||
if not group:
|
||||
raise HTTPException(404, "Trainingsgruppe nicht gefunden")
|
||||
|
||||
# Check permission
|
||||
co_trainers = group['co_trainer_ids'] or []
|
||||
if role not in ['admin', 'superadmin', 'trainer']:
|
||||
raise HTTPException(403, "Nur Trainer dürfen Trainingseinheiten erstellen")
|
||||
|
||||
if role not in ['admin', 'superadmin']:
|
||||
if group['trainer_id'] != profile_id and profile_id not in co_trainers:
|
||||
raise HTTPException(403, "Nur der zuständige Trainer darf für diese Gruppe planen")
|
||||
|
||||
# Insert training unit
|
||||
cur.execute("""
|
||||
INSERT INTO training_units (
|
||||
group_id, planned_date, planned_time_start, planned_time_end,
|
||||
planned_focus, status, notes, trainer_notes, created_by
|
||||
) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s)
|
||||
RETURNING id
|
||||
""", (
|
||||
group_id,
|
||||
planned_date,
|
||||
data.get('planned_time_start'),
|
||||
data.get('planned_time_end'),
|
||||
data.get('planned_focus'),
|
||||
data.get('status', 'planned'),
|
||||
data.get('notes'),
|
||||
data.get('trainer_notes'),
|
||||
profile_id
|
||||
))
|
||||
|
||||
unit_id = cur.fetchone()['id']
|
||||
|
||||
# Add exercises if provided
|
||||
exercises = data.get('exercises', [])
|
||||
for idx, ex in enumerate(exercises):
|
||||
cur.execute("""
|
||||
INSERT INTO training_unit_exercises (
|
||||
training_unit_id, exercise_id, order_index,
|
||||
planned_duration_min, notes
|
||||
) VALUES (%s, %s, %s, %s, %s)
|
||||
""", (
|
||||
unit_id,
|
||||
ex.get('exercise_id'),
|
||||
idx,
|
||||
ex.get('planned_duration_min'),
|
||||
ex.get('notes')
|
||||
))
|
||||
|
||||
conn.commit()
|
||||
|
||||
return get_training_unit(unit_id, session)
|
||||
|
||||
|
||||
# ── Update Training Unit ──────────────────────────────────────────────
|
||||
@router.put("/training-units/{unit_id}")
|
||||
def update_training_unit(unit_id: int, data: dict, session=Depends(require_auth)):
|
||||
"""Update training unit."""
|
||||
profile_id = session['profile_id']
|
||||
role = session.get('role')
|
||||
|
||||
with get_db() as conn:
|
||||
cur = get_cursor(conn)
|
||||
|
||||
# Check existence and access
|
||||
cur.execute("""
|
||||
SELECT tu.created_by, tu.group_id, tg.trainer_id, tg.co_trainer_ids
|
||||
FROM training_units tu
|
||||
LEFT JOIN training_groups tg ON tu.group_id = tg.id
|
||||
WHERE tu.id = %s
|
||||
""", (unit_id,))
|
||||
|
||||
unit = cur.fetchone()
|
||||
|
||||
if not unit:
|
||||
raise HTTPException(404, "Trainingseinheit nicht gefunden")
|
||||
|
||||
# Check permission
|
||||
co_trainers = unit['co_trainer_ids'] or []
|
||||
if role not in ['admin', 'superadmin']:
|
||||
if unit['created_by'] != profile_id and unit['trainer_id'] != profile_id and profile_id not in co_trainers:
|
||||
raise HTTPException(403, "Keine Berechtigung")
|
||||
|
||||
# Update training unit
|
||||
cur.execute("""
|
||||
UPDATE training_units SET
|
||||
planned_date = %s,
|
||||
planned_time_start = %s,
|
||||
planned_time_end = %s,
|
||||
planned_focus = %s,
|
||||
actual_date = %s,
|
||||
actual_time_start = %s,
|
||||
actual_time_end = %s,
|
||||
attendance_count = %s,
|
||||
status = %s,
|
||||
notes = %s,
|
||||
trainer_notes = %s,
|
||||
updated_at = NOW()
|
||||
WHERE id = %s
|
||||
""", (
|
||||
data.get('planned_date'),
|
||||
data.get('planned_time_start'),
|
||||
data.get('planned_time_end'),
|
||||
data.get('planned_focus'),
|
||||
data.get('actual_date'),
|
||||
data.get('actual_time_start'),
|
||||
data.get('actual_time_end'),
|
||||
data.get('attendance_count'),
|
||||
data.get('status'),
|
||||
data.get('notes'),
|
||||
data.get('trainer_notes'),
|
||||
unit_id
|
||||
))
|
||||
|
||||
# Update exercises if provided
|
||||
if 'exercises' in data:
|
||||
# Delete existing exercises
|
||||
cur.execute("DELETE FROM training_unit_exercises WHERE training_unit_id = %s", (unit_id,))
|
||||
|
||||
# Add new exercises
|
||||
exercises = data['exercises']
|
||||
for idx, ex in enumerate(exercises):
|
||||
cur.execute("""
|
||||
INSERT INTO training_unit_exercises (
|
||||
training_unit_id, exercise_id, order_index,
|
||||
planned_duration_min, actual_duration_min,
|
||||
notes, modifications
|
||||
) VALUES (%s, %s, %s, %s, %s, %s, %s)
|
||||
""", (
|
||||
unit_id,
|
||||
ex.get('exercise_id'),
|
||||
idx,
|
||||
ex.get('planned_duration_min'),
|
||||
ex.get('actual_duration_min'),
|
||||
ex.get('notes'),
|
||||
ex.get('modifications')
|
||||
))
|
||||
|
||||
conn.commit()
|
||||
|
||||
return get_training_unit(unit_id, session)
|
||||
|
||||
|
||||
# ── Delete Training Unit ──────────────────────────────────────────────
|
||||
@router.delete("/training-units/{unit_id}")
|
||||
def delete_training_unit(unit_id: int, session=Depends(require_auth)):
|
||||
"""Delete training unit."""
|
||||
profile_id = session['profile_id']
|
||||
role = session.get('role')
|
||||
|
||||
with get_db() as conn:
|
||||
cur = get_cursor(conn)
|
||||
|
||||
# Check existence and access
|
||||
cur.execute("""
|
||||
SELECT created_by FROM training_units WHERE id = %s
|
||||
""", (unit_id,))
|
||||
|
||||
unit = cur.fetchone()
|
||||
|
||||
if not unit:
|
||||
raise HTTPException(404, "Trainingseinheit nicht gefunden")
|
||||
|
||||
# Only creator or admin can delete
|
||||
if role not in ['admin', 'superadmin'] and unit['created_by'] != profile_id:
|
||||
raise HTTPException(403, "Keine Berechtigung")
|
||||
|
||||
# Delete (CASCADE handles exercises)
|
||||
cur.execute("DELETE FROM training_units WHERE id = %s", (unit_id,))
|
||||
conn.commit()
|
||||
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
# ── Quick Create from Template ────────────────────────────────────────
|
||||
@router.post("/training-units/quick-create")
|
||||
def quick_create_training_unit(data: dict, session=Depends(require_auth)):
|
||||
"""
|
||||
Quick create training unit with group defaults.
|
||||
Takes group_id and date, auto-fills time from group schedule.
|
||||
"""
|
||||
profile_id = session['profile_id']
|
||||
|
||||
group_id = data.get('group_id')
|
||||
planned_date = data.get('planned_date')
|
||||
|
||||
if not group_id or not planned_date:
|
||||
raise HTTPException(400, "group_id und planned_date sind Pflichtfelder")
|
||||
|
||||
with get_db() as conn:
|
||||
cur = get_cursor(conn)
|
||||
|
||||
# Get group defaults
|
||||
cur.execute("""
|
||||
SELECT weekday, time_start, time_end, trainer_id, co_trainer_ids
|
||||
FROM training_groups
|
||||
WHERE id = %s
|
||||
""", (group_id,))
|
||||
|
||||
group = cur.fetchone()
|
||||
|
||||
if not group:
|
||||
raise HTTPException(404, "Trainingsgruppe nicht gefunden")
|
||||
|
||||
# Check permission
|
||||
role = session.get('role')
|
||||
co_trainers = group['co_trainer_ids'] or []
|
||||
|
||||
if role not in ['admin', 'superadmin', 'trainer']:
|
||||
raise HTTPException(403, "Nur Trainer dürfen Trainingseinheiten erstellen")
|
||||
|
||||
if role not in ['admin', 'superadmin']:
|
||||
if group['trainer_id'] != profile_id and profile_id not in co_trainers:
|
||||
raise HTTPException(403, "Keine Berechtigung für diese Gruppe")
|
||||
|
||||
# Create with group defaults
|
||||
cur.execute("""
|
||||
INSERT INTO training_units (
|
||||
group_id, planned_date,
|
||||
planned_time_start, planned_time_end,
|
||||
status, created_by
|
||||
) VALUES (%s, %s, %s, %s, %s, %s)
|
||||
RETURNING id
|
||||
""", (
|
||||
group_id,
|
||||
planned_date,
|
||||
group['time_start'],
|
||||
group['time_end'],
|
||||
'planned',
|
||||
profile_id
|
||||
))
|
||||
|
||||
unit_id = cur.fetchone()['id']
|
||||
conn.commit()
|
||||
|
||||
return get_training_unit(unit_id, session)
|
||||
|
|
@ -9,6 +9,7 @@ import ProfilePage from './pages/ProfilePage'
|
|||
import ExercisesPage from './pages/ExercisesPage'
|
||||
import ClubsPage from './pages/ClubsPage'
|
||||
import SkillsPage from './pages/SkillsPage'
|
||||
import TrainingPlanningPage from './pages/TrainingPlanningPage'
|
||||
import './app.css'
|
||||
|
||||
// Bottom Navigation (Mobile)
|
||||
|
|
@ -167,6 +168,14 @@ function AppRoutes() {
|
|||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/planning"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<TrainingPlanningPage />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Catch all - redirect to dashboard or login */}
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
|
|
|
|||
701
frontend/src/pages/TrainingPlanningPage.jsx
Normal file
701
frontend/src/pages/TrainingPlanningPage.jsx
Normal file
|
|
@ -0,0 +1,701 @@
|
|||
import React, { useState, useEffect } from 'react'
|
||||
import api from '../utils/api'
|
||||
import { useAuth } from '../context/AuthContext'
|
||||
|
||||
function TrainingPlanningPage() {
|
||||
const { user } = useAuth()
|
||||
const [groups, setGroups] = useState([])
|
||||
const [selectedGroupId, setSelectedGroupId] = useState('')
|
||||
const [units, setUnits] = useState([])
|
||||
const [exercises, setExercises] = useState([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [showModal, setShowModal] = useState(false)
|
||||
const [editingUnit, setEditingUnit] = useState(null)
|
||||
|
||||
// Date range (default: next 30 days)
|
||||
const today = new Date().toISOString().split('T')[0]
|
||||
const thirtyDaysLater = new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString().split('T')[0]
|
||||
|
||||
const [startDate, setStartDate] = useState(today)
|
||||
const [endDate, setEndDate] = useState(thirtyDaysLater)
|
||||
|
||||
// Form state
|
||||
const [formData, setFormData] = useState({
|
||||
group_id: '',
|
||||
planned_date: '',
|
||||
planned_time_start: '',
|
||||
planned_time_end: '',
|
||||
planned_focus: '',
|
||||
actual_date: '',
|
||||
actual_time_start: '',
|
||||
actual_time_end: '',
|
||||
attendance_count: '',
|
||||
status: 'planned',
|
||||
notes: '',
|
||||
trainer_notes: '',
|
||||
exercises: []
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
loadData()
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedGroupId) {
|
||||
loadUnits()
|
||||
}
|
||||
}, [selectedGroupId, startDate, endDate])
|
||||
|
||||
const loadData = async () => {
|
||||
try {
|
||||
const [groupsData, exercisesData] = await Promise.all([
|
||||
api.listTrainingGroups({ status: 'active' }),
|
||||
api.listExercises()
|
||||
])
|
||||
setGroups(groupsData)
|
||||
setExercises(exercisesData)
|
||||
|
||||
// Auto-select first group if trainer owns it
|
||||
if (groupsData.length > 0) {
|
||||
const ownGroup = groupsData.find(g => g.trainer_id === user?.id)
|
||||
if (ownGroup) {
|
||||
setSelectedGroupId(ownGroup.id)
|
||||
} else if (groupsData.length === 1) {
|
||||
setSelectedGroupId(groupsData[0].id)
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to load data:', err)
|
||||
alert('Fehler beim Laden: ' + err.message)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const loadUnits = async () => {
|
||||
if (!selectedGroupId) return
|
||||
|
||||
try {
|
||||
const unitsData = await api.listTrainingUnits({ group_id: selectedGroupId, start_date: startDate, end_date: endDate })
|
||||
setUnits(unitsData)
|
||||
} catch (err) {
|
||||
console.error('Failed to load units:', err)
|
||||
}
|
||||
}
|
||||
|
||||
const handleQuickCreate = async () => {
|
||||
if (!selectedGroupId) {
|
||||
alert('Bitte wähle zuerst eine Trainingsgruppe')
|
||||
return
|
||||
}
|
||||
|
||||
const date = prompt('Datum für neue Trainingseinheit (YYYY-MM-DD):', today)
|
||||
if (!date) return
|
||||
|
||||
try {
|
||||
await api.quickCreateTrainingUnit({
|
||||
group_id: parseInt(selectedGroupId),
|
||||
planned_date: date
|
||||
})
|
||||
await loadUnits()
|
||||
} catch (err) {
|
||||
alert('Fehler beim Erstellen: ' + err.message)
|
||||
}
|
||||
}
|
||||
|
||||
const handleCreate = () => {
|
||||
if (!selectedGroupId) {
|
||||
alert('Bitte wähle zuerst eine Trainingsgruppe')
|
||||
return
|
||||
}
|
||||
|
||||
const group = groups.find(g => g.id === parseInt(selectedGroupId))
|
||||
|
||||
setEditingUnit(null)
|
||||
setFormData({
|
||||
group_id: selectedGroupId,
|
||||
planned_date: today,
|
||||
planned_time_start: group?.time_start?.slice(0, 5) || '',
|
||||
planned_time_end: group?.time_end?.slice(0, 5) || '',
|
||||
planned_focus: '',
|
||||
actual_date: '',
|
||||
actual_time_start: '',
|
||||
actual_time_end: '',
|
||||
attendance_count: '',
|
||||
status: 'planned',
|
||||
notes: '',
|
||||
trainer_notes: '',
|
||||
exercises: []
|
||||
})
|
||||
setShowModal(true)
|
||||
}
|
||||
|
||||
const handleEdit = async (unit) => {
|
||||
try {
|
||||
const fullUnit = await api.getTrainingUnit(unit.id)
|
||||
setEditingUnit(fullUnit)
|
||||
setFormData({
|
||||
group_id: fullUnit.group_id,
|
||||
planned_date: fullUnit.planned_date || '',
|
||||
planned_time_start: fullUnit.planned_time_start?.slice(0, 5) || '',
|
||||
planned_time_end: fullUnit.planned_time_end?.slice(0, 5) || '',
|
||||
planned_focus: fullUnit.planned_focus || '',
|
||||
actual_date: fullUnit.actual_date || '',
|
||||
actual_time_start: fullUnit.actual_time_start?.slice(0, 5) || '',
|
||||
actual_time_end: fullUnit.actual_time_end?.slice(0, 5) || '',
|
||||
attendance_count: fullUnit.attendance_count || '',
|
||||
status: fullUnit.status || 'planned',
|
||||
notes: fullUnit.notes || '',
|
||||
trainer_notes: fullUnit.trainer_notes || '',
|
||||
exercises: fullUnit.exercises || []
|
||||
})
|
||||
setShowModal(true)
|
||||
} catch (err) {
|
||||
alert('Fehler beim Laden: ' + err.message)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDelete = async (unit) => {
|
||||
if (!confirm(`Trainingseinheit vom ${unit.planned_date} wirklich löschen?`)) return
|
||||
|
||||
try {
|
||||
await api.deleteTrainingUnit(unit.id)
|
||||
await loadUnits()
|
||||
} catch (err) {
|
||||
alert('Fehler beim Löschen: ' + err.message)
|
||||
}
|
||||
}
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault()
|
||||
|
||||
if (!formData.group_id || !formData.planned_date) {
|
||||
alert('Gruppe und Datum sind Pflichtfelder')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const payload = {
|
||||
...formData,
|
||||
group_id: parseInt(formData.group_id),
|
||||
attendance_count: formData.attendance_count ? parseInt(formData.attendance_count) : null,
|
||||
exercises: formData.exercises.map((ex, idx) => ({
|
||||
exercise_id: ex.exercise_id,
|
||||
order_index: idx,
|
||||
planned_duration_min: ex.planned_duration_min ? parseInt(ex.planned_duration_min) : null,
|
||||
actual_duration_min: ex.actual_duration_min ? parseInt(ex.actual_duration_min) : null,
|
||||
notes: ex.notes || null,
|
||||
modifications: ex.modifications || null
|
||||
}))
|
||||
}
|
||||
|
||||
if (editingUnit) {
|
||||
await api.updateTrainingUnit(editingUnit.id, payload)
|
||||
} else {
|
||||
await api.createTrainingUnit(payload)
|
||||
}
|
||||
|
||||
setShowModal(false)
|
||||
await loadUnits()
|
||||
} catch (err) {
|
||||
alert('Fehler beim Speichern: ' + err.message)
|
||||
}
|
||||
}
|
||||
|
||||
const updateFormField = (field, value) => {
|
||||
setFormData(prev => ({ ...prev, [field]: value }))
|
||||
}
|
||||
|
||||
const addExercise = () => {
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
exercises: [...prev.exercises, {
|
||||
exercise_id: '',
|
||||
planned_duration_min: '',
|
||||
actual_duration_min: '',
|
||||
notes: '',
|
||||
modifications: ''
|
||||
}]
|
||||
}))
|
||||
}
|
||||
|
||||
const updateExercise = (index, field, value) => {
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
exercises: prev.exercises.map((ex, i) =>
|
||||
i === index ? { ...ex, [field]: value } : ex
|
||||
)
|
||||
}))
|
||||
}
|
||||
|
||||
const removeExercise = (index) => {
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
exercises: prev.exercises.filter((_, i) => i !== index)
|
||||
}))
|
||||
}
|
||||
|
||||
const moveExercise = (index, direction) => {
|
||||
const newExercises = [...formData.exercises]
|
||||
const target = index + direction
|
||||
|
||||
if (target < 0 || target >= newExercises.length) return
|
||||
|
||||
[newExercises[index], newExercises[target]] = [newExercises[target], newExercises[index]]
|
||||
setFormData(prev => ({ ...prev, exercises: newExercises }))
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div style={{ padding: '2rem', textAlign: 'center' }}>
|
||||
<div className="spinner"></div>
|
||||
<p>Laden...</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const selectedGroup = groups.find(g => g.id === parseInt(selectedGroupId))
|
||||
|
||||
return (
|
||||
<div style={{ padding: '2rem' }}>
|
||||
<div style={{ maxWidth: '1200px', margin: '0 auto' }}>
|
||||
<h1 style={{ marginBottom: '1.5rem' }}>Trainingsplanung</h1>
|
||||
|
||||
{/* Group & Date Controls */}
|
||||
<div className="card" style={{ marginBottom: '1.5rem' }}>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(200px, 1fr))', gap: '1rem' }}>
|
||||
<div>
|
||||
<label className="form-label">Trainingsgruppe</label>
|
||||
<select
|
||||
className="form-input"
|
||||
value={selectedGroupId}
|
||||
onChange={(e) => setSelectedGroupId(e.target.value)}
|
||||
>
|
||||
<option value="">Bitte wählen</option>
|
||||
{groups.map(g => (
|
||||
<option key={g.id} value={g.id}>
|
||||
{g.name} ({g.club_name})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="form-label">Von</label>
|
||||
<input
|
||||
type="date"
|
||||
className="form-input"
|
||||
value={startDate}
|
||||
onChange={(e) => setStartDate(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="form-label">Bis</label>
|
||||
<input
|
||||
type="date"
|
||||
className="form-input"
|
||||
value={endDate}
|
||||
onChange={(e) => setEndDate(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{selectedGroup && (
|
||||
<div style={{ marginTop: '1rem', padding: '1rem', background: 'var(--surface2)', borderRadius: '8px' }}>
|
||||
<p style={{ fontSize: '0.875rem', color: 'var(--text2)' }}>
|
||||
📍 {selectedGroup.location || 'Kein Ort angegeben'}
|
||||
{selectedGroup.weekday && ` · ${selectedGroup.weekday}`}
|
||||
{selectedGroup.time_start && ` · ${selectedGroup.time_start.slice(0, 5)} - ${selectedGroup.time_end?.slice(0, 5)}`}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Action Buttons */}
|
||||
{selectedGroupId && (
|
||||
<div style={{ display: 'flex', gap: '0.5rem', marginBottom: '1.5rem' }}>
|
||||
<button className="btn btn-primary" onClick={handleCreate}>
|
||||
+ Neue Trainingseinheit
|
||||
</button>
|
||||
<button className="btn btn-secondary" onClick={handleQuickCreate}>
|
||||
⚡ Schnell erstellen
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Units List */}
|
||||
{!selectedGroupId ? (
|
||||
<div className="card">
|
||||
<p style={{ color: 'var(--text2)', textAlign: 'center' }}>
|
||||
Bitte wähle eine Trainingsgruppe aus
|
||||
</p>
|
||||
</div>
|
||||
) : units.length === 0 ? (
|
||||
<div className="card">
|
||||
<p style={{ color: 'var(--text2)', textAlign: 'center' }}>
|
||||
Keine Trainingseinheiten im gewählten Zeitraum
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ display: 'grid', gap: '1rem' }}>
|
||||
{units.map(unit => (
|
||||
<div key={unit.id} className="card">
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'start', marginBottom: '1rem' }}>
|
||||
<div>
|
||||
<h3 style={{ marginBottom: '0.25rem' }}>
|
||||
{unit.planned_date}
|
||||
{unit.planned_time_start && ` · ${unit.planned_time_start.slice(0, 5)} - ${unit.planned_time_end?.slice(0, 5)}`}
|
||||
</h3>
|
||||
{unit.planned_focus && (
|
||||
<p style={{ color: 'var(--text2)', fontSize: '0.875rem', marginBottom: '0.5rem' }}>
|
||||
🎯 {unit.planned_focus}
|
||||
</p>
|
||||
)}
|
||||
<div style={{ display: 'flex', gap: '0.5rem', flexWrap: 'wrap' }}>
|
||||
<span style={{
|
||||
fontSize: '0.75rem',
|
||||
padding: '0.25rem 0.5rem',
|
||||
borderRadius: '4px',
|
||||
background: unit.status === 'completed' ? '#2ea44f' : unit.status === 'cancelled' ? 'var(--danger)' : 'var(--surface2)',
|
||||
color: unit.status === 'completed' || unit.status === 'cancelled' ? 'white' : 'var(--text2)'
|
||||
}}>
|
||||
{unit.status === 'planned' && '📅 Geplant'}
|
||||
{unit.status === 'completed' && '✓ Durchgeführt'}
|
||||
{unit.status === 'cancelled' && '✗ Abgesagt'}
|
||||
</span>
|
||||
{unit.attendance_count !== null && (
|
||||
<span style={{
|
||||
fontSize: '0.75rem',
|
||||
padding: '0.25rem 0.5rem',
|
||||
borderRadius: '4px',
|
||||
background: 'var(--surface2)',
|
||||
color: 'var(--text2)'
|
||||
}}>
|
||||
👥 {unit.attendance_count} Teilnehmer
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', gap: '0.5rem' }}>
|
||||
<button
|
||||
className="btn btn-secondary"
|
||||
onClick={() => handleEdit(unit)}
|
||||
>
|
||||
Bearbeiten
|
||||
</button>
|
||||
<button
|
||||
className="btn"
|
||||
style={{
|
||||
background: 'var(--danger)',
|
||||
color: 'white',
|
||||
border: 'none'
|
||||
}}
|
||||
onClick={() => handleDelete(unit)}
|
||||
>
|
||||
Löschen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{unit.notes && (
|
||||
<p style={{ color: 'var(--text2)', fontSize: '0.875rem', marginTop: '0.5rem' }}>
|
||||
💬 {unit.notes}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Create/Edit Modal */}
|
||||
{showModal && (
|
||||
<div style={{
|
||||
position: 'fixed',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
background: 'rgba(0,0,0,0.5)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
zIndex: 1000,
|
||||
padding: '1rem',
|
||||
overflowY: 'auto'
|
||||
}}>
|
||||
<div style={{
|
||||
background: 'var(--surface)',
|
||||
borderRadius: '12px',
|
||||
padding: '2rem',
|
||||
maxWidth: '900px',
|
||||
width: '100%',
|
||||
maxHeight: '90vh',
|
||||
overflowY: 'auto',
|
||||
margin: '1rem'
|
||||
}}>
|
||||
<h2 style={{ marginBottom: '1.5rem' }}>
|
||||
{editingUnit ? 'Trainingseinheit bearbeiten' : 'Neue Trainingseinheit'}
|
||||
</h2>
|
||||
|
||||
<form onSubmit={handleSubmit}>
|
||||
{/* Basic Info */}
|
||||
<h3 style={{ marginBottom: '1rem' }}>Planung</h3>
|
||||
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 1fr', gap: '1rem', marginBottom: '1rem' }}>
|
||||
<div className="form-row">
|
||||
<label className="form-label">Datum *</label>
|
||||
<input
|
||||
type="date"
|
||||
className="form-input"
|
||||
value={formData.planned_date}
|
||||
onChange={(e) => updateFormField('planned_date', e.target.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-row">
|
||||
<label className="form-label">Von</label>
|
||||
<input
|
||||
type="time"
|
||||
className="form-input"
|
||||
value={formData.planned_time_start}
|
||||
onChange={(e) => updateFormField('planned_time_start', e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-row">
|
||||
<label className="form-label">Bis</label>
|
||||
<input
|
||||
type="time"
|
||||
className="form-input"
|
||||
value={formData.planned_time_end}
|
||||
onChange={(e) => updateFormField('planned_time_end', e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="form-row">
|
||||
<label className="form-label">Trainingsfokus</label>
|
||||
<input
|
||||
type="text"
|
||||
className="form-input"
|
||||
value={formData.planned_focus}
|
||||
onChange={(e) => updateFormField('planned_focus', e.target.value)}
|
||||
placeholder="z.B. Kihon Grundlagen, Kata Heian Shodan"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Exercises */}
|
||||
<h3 style={{ marginTop: '2rem', marginBottom: '1rem' }}>Übungen</h3>
|
||||
|
||||
{formData.exercises.length === 0 ? (
|
||||
<p style={{ color: 'var(--text2)', marginBottom: '1rem' }}>
|
||||
Noch keine Übungen hinzugefügt
|
||||
</p>
|
||||
) : (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.5rem', marginBottom: '1rem' }}>
|
||||
{formData.exercises.map((ex, idx) => (
|
||||
<div key={idx} style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: '30px 1fr 80px auto',
|
||||
gap: '0.5rem',
|
||||
alignItems: 'center',
|
||||
padding: '0.5rem',
|
||||
background: 'var(--surface2)',
|
||||
borderRadius: '8px'
|
||||
}}>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '2px' }}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => moveExercise(idx, -1)}
|
||||
disabled={idx === 0}
|
||||
style={{
|
||||
padding: '2px',
|
||||
fontSize: '0.75rem',
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
cursor: idx === 0 ? 'not-allowed' : 'pointer',
|
||||
opacity: idx === 0 ? 0.3 : 1
|
||||
}}
|
||||
>
|
||||
▲
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => moveExercise(idx, 1)}
|
||||
disabled={idx === formData.exercises.length - 1}
|
||||
style={{
|
||||
padding: '2px',
|
||||
fontSize: '0.75rem',
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
cursor: idx === formData.exercises.length - 1 ? 'not-allowed' : 'pointer',
|
||||
opacity: idx === formData.exercises.length - 1 ? 0.3 : 1
|
||||
}}
|
||||
>
|
||||
▼
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<select
|
||||
className="form-input"
|
||||
value={ex.exercise_id}
|
||||
onChange={(e) => updateExercise(idx, 'exercise_id', parseInt(e.target.value))}
|
||||
style={{ margin: 0 }}
|
||||
>
|
||||
<option value="">Übung wählen</option>
|
||||
{exercises.map(exercise => (
|
||||
<option key={exercise.id} value={exercise.id}>
|
||||
{exercise.title}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
<input
|
||||
type="number"
|
||||
className="form-input"
|
||||
value={ex.planned_duration_min}
|
||||
onChange={(e) => updateExercise(idx, 'planned_duration_min', e.target.value)}
|
||||
placeholder="min"
|
||||
style={{ margin: 0 }}
|
||||
/>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="btn"
|
||||
onClick={() => removeExercise(idx)}
|
||||
style={{
|
||||
padding: '0.5rem',
|
||||
background: 'var(--danger)',
|
||||
color: 'white',
|
||||
border: 'none'
|
||||
}}
|
||||
>
|
||||
✗
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary"
|
||||
onClick={addExercise}
|
||||
style={{ marginBottom: '2rem' }}
|
||||
>
|
||||
+ Übung hinzufügen
|
||||
</button>
|
||||
|
||||
{/* Durchführung (nur bei Edit) */}
|
||||
{editingUnit && (
|
||||
<>
|
||||
<h3 style={{ marginTop: '2rem', marginBottom: '1rem' }}>Durchführung</h3>
|
||||
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 1fr 1fr', gap: '1rem', marginBottom: '1rem' }}>
|
||||
<div className="form-row">
|
||||
<label className="form-label">Tatsächliches Datum</label>
|
||||
<input
|
||||
type="date"
|
||||
className="form-input"
|
||||
value={formData.actual_date}
|
||||
onChange={(e) => updateFormField('actual_date', e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-row">
|
||||
<label className="form-label">Von</label>
|
||||
<input
|
||||
type="time"
|
||||
className="form-input"
|
||||
value={formData.actual_time_start}
|
||||
onChange={(e) => updateFormField('actual_time_start', e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-row">
|
||||
<label className="form-label">Bis</label>
|
||||
<input
|
||||
type="time"
|
||||
className="form-input"
|
||||
value={formData.actual_time_end}
|
||||
onChange={(e) => updateFormField('actual_time_end', e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-row">
|
||||
<label className="form-label">Teilnehmer</label>
|
||||
<input
|
||||
type="number"
|
||||
className="form-input"
|
||||
value={formData.attendance_count}
|
||||
onChange={(e) => updateFormField('attendance_count', e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="form-row">
|
||||
<label className="form-label">Status</label>
|
||||
<select
|
||||
className="form-input"
|
||||
value={formData.status}
|
||||
onChange={(e) => updateFormField('status', e.target.value)}
|
||||
>
|
||||
<option value="planned">Geplant</option>
|
||||
<option value="completed">Durchgeführt</option>
|
||||
<option value="cancelled">Abgesagt</option>
|
||||
</select>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Notes */}
|
||||
<h3 style={{ marginTop: '2rem', marginBottom: '1rem' }}>Notizen</h3>
|
||||
|
||||
<div className="form-row">
|
||||
<label className="form-label">Öffentliche Notizen</label>
|
||||
<textarea
|
||||
className="form-input"
|
||||
rows={3}
|
||||
value={formData.notes}
|
||||
onChange={(e) => updateFormField('notes', e.target.value)}
|
||||
placeholder="Für alle Teilnehmer sichtbar"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-row">
|
||||
<label className="form-label">Trainernotizen</label>
|
||||
<textarea
|
||||
className="form-input"
|
||||
rows={3}
|
||||
value={formData.trainer_notes}
|
||||
onChange={(e) => updateFormField('trainer_notes', e.target.value)}
|
||||
placeholder="Nur für Trainer sichtbar"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Buttons */}
|
||||
<div style={{ display: 'flex', gap: '0.5rem', marginTop: '1.5rem' }}>
|
||||
<button type="submit" className="btn btn-primary" style={{ flex: 1 }}>
|
||||
{editingUnit ? 'Speichern' : 'Erstellen'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary"
|
||||
onClick={() => setShowModal(false)}
|
||||
>
|
||||
Abbrechen
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default TrainingPlanningPage
|
||||
|
|
@ -256,6 +256,17 @@ export async function updateTrainingUnit(id, data) {
|
|||
})
|
||||
}
|
||||
|
||||
export async function deleteTrainingUnit(id) {
|
||||
return request(`/api/training-units/${id}`, { method: 'DELETE' })
|
||||
}
|
||||
|
||||
export async function quickCreateTrainingUnit(data) {
|
||||
return request('/api/training-units/quick-create', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data)
|
||||
})
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Version & Health
|
||||
// ============================================================================
|
||||
|
|
@ -315,6 +326,8 @@ export const api = {
|
|||
getTrainingUnit,
|
||||
createTrainingUnit,
|
||||
updateTrainingUnit,
|
||||
deleteTrainingUnit,
|
||||
quickCreateTrainingUnit,
|
||||
|
||||
// System
|
||||
getVersion,
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user