Verbesserung Darstellung Rahmenprogramme #42
|
|
@ -0,0 +1,36 @@
|
|||
-- Geplante Gesamt- und Abschnittsdauer; Rahmenprogramm: Fokus/Stil als M:N (wie Trainingsarten/Zielgruppen)
|
||||
|
||||
ALTER TABLE training_units
|
||||
ADD COLUMN IF NOT EXISTS planned_duration_min INT;
|
||||
|
||||
ALTER TABLE training_unit_sections
|
||||
ADD COLUMN IF NOT EXISTS planned_duration_min INT;
|
||||
|
||||
ALTER TABLE training_plan_template_sections
|
||||
ADD COLUMN IF NOT EXISTS planned_duration_min INT;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS training_framework_program_focus_areas (
|
||||
framework_program_id INT NOT NULL REFERENCES training_framework_programs(id) ON DELETE CASCADE,
|
||||
focus_area_id INT NOT NULL REFERENCES focus_areas(id) ON DELETE CASCADE,
|
||||
PRIMARY KEY (framework_program_id, focus_area_id)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_tfpfa_focus ON training_framework_program_focus_areas(focus_area_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS training_framework_program_style_directions (
|
||||
framework_program_id INT NOT NULL REFERENCES training_framework_programs(id) ON DELETE CASCADE,
|
||||
style_direction_id INT NOT NULL REFERENCES style_directions(id) ON DELETE CASCADE,
|
||||
PRIMARY KEY (framework_program_id, style_direction_id)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_tfpsd_style ON training_framework_program_style_directions(style_direction_id);
|
||||
|
||||
INSERT INTO training_framework_program_focus_areas (framework_program_id, focus_area_id)
|
||||
SELECT id, focus_area_id FROM training_framework_programs
|
||||
WHERE focus_area_id IS NOT NULL
|
||||
ON CONFLICT DO NOTHING;
|
||||
|
||||
INSERT INTO training_framework_program_style_directions (framework_program_id, style_direction_id)
|
||||
SELECT id, style_direction_id FROM training_framework_programs
|
||||
WHERE style_direction_id IS NOT NULL
|
||||
ON CONFLICT DO NOTHING;
|
||||
|
|
@ -99,6 +99,32 @@ def _target_group_ids(cur, framework_id: int) -> List[int]:
|
|||
return [r["target_group_id"] for r in cur.fetchall()]
|
||||
|
||||
|
||||
def _focus_area_ids(cur, framework_id: int) -> List[int]:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT focus_area_id
|
||||
FROM training_framework_program_focus_areas
|
||||
WHERE framework_program_id = %s
|
||||
ORDER BY focus_area_id
|
||||
""",
|
||||
(framework_id,),
|
||||
)
|
||||
return [r["focus_area_id"] for r in cur.fetchall()]
|
||||
|
||||
|
||||
def _style_direction_ids(cur, framework_id: int) -> List[int]:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT style_direction_id
|
||||
FROM training_framework_program_style_directions
|
||||
WHERE framework_program_id = %s
|
||||
ORDER BY style_direction_id
|
||||
""",
|
||||
(framework_id,),
|
||||
)
|
||||
return [r["style_direction_id"] for r in cur.fetchall()]
|
||||
|
||||
|
||||
def _hydrate_framework(cur, row: Dict[str, Any]) -> Dict[str, Any]:
|
||||
fid = row["id"]
|
||||
cur.execute(
|
||||
|
|
@ -136,6 +162,14 @@ def _hydrate_framework(cur, row: Dict[str, Any]) -> Dict[str, Any]:
|
|||
uid = row_b["id"]
|
||||
s["blueprint_training_unit_id"] = uid
|
||||
unit_min: Dict[str, Any] = {"id": uid}
|
||||
cur.execute(
|
||||
"SELECT planned_duration_min FROM training_units WHERE id = %s",
|
||||
(uid,),
|
||||
)
|
||||
urow = cur.fetchone()
|
||||
s["planned_duration_min"] = (
|
||||
urow["planned_duration_min"] if urow else None
|
||||
)
|
||||
_hydrate_training_unit_payload(cur, unit_min)
|
||||
s["phases"] = unit_min.get("phases", [])
|
||||
s["sections"] = unit_min.get("sections", [])
|
||||
|
|
@ -143,6 +177,8 @@ def _hydrate_framework(cur, row: Dict[str, Any]) -> Dict[str, Any]:
|
|||
row["slots"] = slots
|
||||
row["training_type_ids"] = _training_type_ids(cur, fid)
|
||||
row["target_group_ids"] = _target_group_ids(cur, fid)
|
||||
row["focus_area_ids"] = _focus_area_ids(cur, fid)
|
||||
row["style_direction_ids"] = _style_direction_ids(cur, fid)
|
||||
return row
|
||||
|
||||
|
||||
|
|
@ -209,6 +245,53 @@ def _replace_target_groups(cur, framework_id: int, ids: Sequence[int]) -> None:
|
|||
)
|
||||
|
||||
|
||||
def _replace_focus_areas(cur, framework_id: int, ids: Sequence[int]) -> None:
|
||||
cur.execute(
|
||||
"DELETE FROM training_framework_program_focus_areas WHERE framework_program_id = %s",
|
||||
(framework_id,),
|
||||
)
|
||||
for fid in ids:
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO training_framework_program_focus_areas (framework_program_id, focus_area_id)
|
||||
VALUES (%s, %s)
|
||||
ON CONFLICT DO NOTHING
|
||||
""",
|
||||
(framework_id, fid),
|
||||
)
|
||||
|
||||
|
||||
def _replace_style_directions(cur, framework_id: int, ids: Sequence[int]) -> None:
|
||||
cur.execute(
|
||||
"DELETE FROM training_framework_program_style_directions WHERE framework_program_id = %s",
|
||||
(framework_id,),
|
||||
)
|
||||
for sid in ids:
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO training_framework_program_style_directions (framework_program_id, style_direction_id)
|
||||
VALUES (%s, %s)
|
||||
ON CONFLICT DO NOTHING
|
||||
""",
|
||||
(framework_id, sid),
|
||||
)
|
||||
|
||||
|
||||
def _parse_context_ids_from_payload(data: dict) -> tuple:
|
||||
"""focus_area_ids / style_direction_ids (M:N); Legacy focus_area_id / style_direction_id."""
|
||||
fa_ids = _parse_positive_int_ids(data.get("focus_area_ids"), "focus_area_ids")
|
||||
if not fa_ids and data.get("focus_area_id") not in (None, ""):
|
||||
one = _optional_positive_int(data.get("focus_area_id"), "focus_area_id")
|
||||
if one is not None:
|
||||
fa_ids = [one]
|
||||
sd_ids = _parse_positive_int_ids(data.get("style_direction_ids"), "style_direction_ids")
|
||||
if not sd_ids and data.get("style_direction_id") not in (None, ""):
|
||||
one = _optional_positive_int(data.get("style_direction_id"), "style_direction_id")
|
||||
if one is not None:
|
||||
sd_ids = [one]
|
||||
return fa_ids, sd_ids
|
||||
|
||||
|
||||
def _insert_goal_rows(cur, framework_id: int, goals_in: List[Any]) -> None:
|
||||
if not goals_in:
|
||||
raise HTTPException(status_code=400, detail="Mindestens ein Entwicklungsziel (goals) ist erforderlich")
|
||||
|
|
@ -277,22 +360,25 @@ def _insert_slots_and_blueprints(
|
|||
)
|
||||
sid = cur.fetchone()["id"]
|
||||
|
||||
slot_pdur = _optional_positive_int(
|
||||
slot.get("planned_duration_min"), "planned_duration_min"
|
||||
)
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO training_units (
|
||||
group_id, planned_date,
|
||||
planned_time_start, planned_time_end, planned_focus,
|
||||
planned_time_start, planned_time_end, planned_duration_min, planned_focus,
|
||||
status, notes, trainer_notes,
|
||||
created_by, plan_template_id, framework_slot_id
|
||||
) VALUES (
|
||||
NULL, NULL,
|
||||
NULL, NULL, NULL,
|
||||
NULL, NULL, %s, NULL,
|
||||
'planned', NULL, NULL,
|
||||
%s, NULL, %s
|
||||
)
|
||||
RETURNING id
|
||||
""",
|
||||
(profile_id, sid),
|
||||
(slot_pdur, profile_id, sid),
|
||||
)
|
||||
bid = cur.fetchone()["id"]
|
||||
|
||||
|
|
@ -327,8 +413,6 @@ def list_training_framework_programs(tenant: TenantContext = Depends(get_tenant_
|
|||
cur = get_cursor(conn)
|
||||
base_sel = """
|
||||
SELECT fp.*,
|
||||
fa.name AS focus_area_name,
|
||||
sd.name AS style_direction_name,
|
||||
(SELECT COUNT(*)::int FROM training_framework_goals g WHERE g.framework_program_id = fp.id)
|
||||
AS goals_count,
|
||||
(SELECT COUNT(*)::int FROM training_framework_slots s WHERE s.framework_program_id = fp.id)
|
||||
|
|
@ -337,6 +421,18 @@ def list_training_framework_programs(tenant: TenantContext = Depends(get_tenant_
|
|||
WHERE t.framework_program_id = fp.id) AS training_types_count,
|
||||
(SELECT COUNT(*)::int FROM training_framework_program_target_groups tg
|
||||
WHERE tg.framework_program_id = fp.id) AS target_groups_count,
|
||||
(
|
||||
SELECT STRING_AGG(fa.name::text, ', ' ORDER BY fa.name)
|
||||
FROM training_framework_program_focus_areas j
|
||||
JOIN focus_areas fa ON fa.id = j.focus_area_id
|
||||
WHERE j.framework_program_id = fp.id
|
||||
) AS focus_area_names_agg,
|
||||
(
|
||||
SELECT STRING_AGG(sd.name::text, ', ' ORDER BY sd.name)
|
||||
FROM training_framework_program_style_directions j
|
||||
JOIN style_directions sd ON sd.id = j.style_direction_id
|
||||
WHERE j.framework_program_id = fp.id
|
||||
) AS style_direction_names_agg,
|
||||
(
|
||||
SELECT STRING_AGG(typ.name::text, ', ' ORDER BY typ.sort_order NULLS LAST, typ.name)
|
||||
FROM training_framework_program_training_types j
|
||||
|
|
@ -350,8 +446,6 @@ def list_training_framework_programs(tenant: TenantContext = Depends(get_tenant_
|
|||
WHERE j.framework_program_id = fp.id
|
||||
) AS target_group_names_agg
|
||||
FROM training_framework_programs fp
|
||||
LEFT JOIN focus_areas fa ON fa.id = fp.focus_area_id
|
||||
LEFT JOIN style_directions sd ON sd.id = fp.style_direction_id
|
||||
"""
|
||||
vis_clause, vis_params = library_content_visibility_sql(
|
||||
alias="fp",
|
||||
|
|
@ -403,10 +497,11 @@ def create_training_framework_program(
|
|||
if not isinstance(goals_in, list) or not goals_in:
|
||||
raise HTTPException(status_code=400, detail="goals als Liste mit mindestens einem Eintrag ist Pflicht")
|
||||
|
||||
fa_id = _optional_positive_int(data.get("focus_area_id"), "focus_area_id")
|
||||
sd_id = _optional_positive_int(data.get("style_direction_id"), "style_direction_id")
|
||||
fa_ids, sd_ids = _parse_context_ids_from_payload(data)
|
||||
tt_ids = _parse_positive_int_ids(data.get("training_type_ids"), "training_type_ids")
|
||||
tg_ids = _parse_positive_int_ids(data.get("target_group_ids"), "target_group_ids")
|
||||
fa_legacy = fa_ids[0] if len(fa_ids) == 1 else None
|
||||
sd_legacy = sd_ids[0] if len(sd_ids) == 1 else None
|
||||
|
||||
with get_db() as conn:
|
||||
cur = get_cursor(conn)
|
||||
|
|
@ -418,19 +513,17 @@ def create_training_framework_program(
|
|||
planned_period_start, planned_period_end,
|
||||
visibility, club_id, created_by,
|
||||
focus_area_id, style_direction_id
|
||||
) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s)
|
||||
) VALUES (%s, %s, NULL, NULL, %s, %s, %s, %s, %s)
|
||||
RETURNING id
|
||||
""",
|
||||
(
|
||||
title[:200],
|
||||
data.get("description"),
|
||||
data.get("planned_period_start"),
|
||||
data.get("planned_period_end"),
|
||||
vis,
|
||||
club_id,
|
||||
profile_id,
|
||||
fa_id,
|
||||
sd_id,
|
||||
fa_legacy,
|
||||
sd_legacy,
|
||||
),
|
||||
)
|
||||
fid = cur.fetchone()["id"]
|
||||
|
|
@ -438,6 +531,8 @@ def create_training_framework_program(
|
|||
_insert_slots_and_blueprints(cur, fid, slots_in, profile_id, role)
|
||||
_replace_training_types(cur, fid, tt_ids)
|
||||
_replace_target_groups(cur, fid, tg_ids)
|
||||
_replace_focus_areas(cur, fid, fa_ids)
|
||||
_replace_style_directions(cur, fid, sd_ids)
|
||||
conn.commit()
|
||||
|
||||
return _response_framework_detail(fid, profile_id, role)
|
||||
|
|
@ -489,13 +584,6 @@ def update_training_framework_program(
|
|||
if "description" in data:
|
||||
header_fields.append("description = %s")
|
||||
header_params.append(data.get("description"))
|
||||
if "planned_period_start" in data:
|
||||
header_fields.append("planned_period_start = %s")
|
||||
header_params.append(data.get("planned_period_start"))
|
||||
if "planned_period_end" in data:
|
||||
header_fields.append("planned_period_end = %s")
|
||||
header_params.append(data.get("planned_period_end"))
|
||||
|
||||
if "visibility" in data:
|
||||
header_fields.append("visibility = %s")
|
||||
header_params.append(merged_vis)
|
||||
|
|
@ -503,18 +591,26 @@ def update_training_framework_program(
|
|||
header_fields.append("club_id = %s")
|
||||
header_params.append(merged_club)
|
||||
|
||||
if "focus_area_id" in data:
|
||||
fidv = data.get("focus_area_id")
|
||||
if "focus_area_ids" in data or "focus_area_id" in data:
|
||||
fa_ids = _parse_positive_int_ids(data.get("focus_area_ids"), "focus_area_ids")
|
||||
if not fa_ids and data.get("focus_area_id") not in (None, ""):
|
||||
one = _optional_positive_int(data.get("focus_area_id"), "focus_area_id")
|
||||
if one is not None:
|
||||
fa_ids = [one]
|
||||
header_fields.append("focus_area_id = %s")
|
||||
header_params.append(
|
||||
None if fidv in (None, "") else _optional_positive_int(fidv, "focus_area_id")
|
||||
header_params.append(fa_ids[0] if len(fa_ids) == 1 else None)
|
||||
_replace_focus_areas(cur, framework_id, fa_ids)
|
||||
if "style_direction_ids" in data or "style_direction_id" in data:
|
||||
sd_ids = _parse_positive_int_ids(
|
||||
data.get("style_direction_ids"), "style_direction_ids"
|
||||
)
|
||||
if "style_direction_id" in data:
|
||||
sidv = data.get("style_direction_id")
|
||||
if not sd_ids and data.get("style_direction_id") not in (None, ""):
|
||||
one = _optional_positive_int(data.get("style_direction_id"), "style_direction_id")
|
||||
if one is not None:
|
||||
sd_ids = [one]
|
||||
header_fields.append("style_direction_id = %s")
|
||||
header_params.append(
|
||||
None if sidv in (None, "") else _optional_positive_int(sidv, "style_direction_id")
|
||||
)
|
||||
header_params.append(sd_ids[0] if len(sd_ids) == 1 else None)
|
||||
_replace_style_directions(cur, framework_id, sd_ids)
|
||||
|
||||
if header_fields:
|
||||
header_fields.append("updated_at = NOW()")
|
||||
|
|
@ -555,7 +651,17 @@ def update_training_framework_program(
|
|||
cur, framework_id, data.get("slots") or [], profile_id, role
|
||||
)
|
||||
|
||||
if header_fields or "goals" in data or "slots" in data or "training_type_ids" in data or "target_group_ids" in data:
|
||||
if (
|
||||
header_fields
|
||||
or "goals" in data
|
||||
or "slots" in data
|
||||
or "training_type_ids" in data
|
||||
or "target_group_ids" in data
|
||||
or "focus_area_ids" in data
|
||||
or "focus_area_id" in data
|
||||
or "style_direction_ids" in data
|
||||
or "style_direction_id" in data
|
||||
):
|
||||
cur.execute(
|
||||
"UPDATE training_framework_programs SET updated_at = NOW() WHERE id = %s",
|
||||
(framework_id,),
|
||||
|
|
|
|||
|
|
@ -648,6 +648,7 @@ def _ensure_default_whole_group_phase(cur, unit_id: int, *, order_index: int = 0
|
|||
|
||||
_SECTION_ROWS_SQL = """
|
||||
SELECT tus.id, tus.training_unit_id, tus.order_index, tus.title, tus.guidance_notes,
|
||||
tus.planned_duration_min,
|
||||
tus.source_template_section_id, tus.phase_id, tus.parallel_stream_id
|
||||
FROM training_unit_sections tus
|
||||
LEFT JOIN training_unit_phases ph ON ph.id = tus.phase_id
|
||||
|
|
@ -740,7 +741,7 @@ def _fetch_phases_nested(cur, unit_id: int) -> List[Dict[str, Any]]:
|
|||
cur.execute(
|
||||
"""
|
||||
SELECT id, training_unit_id, order_index, title, guidance_notes,
|
||||
source_template_section_id, phase_id, parallel_stream_id
|
||||
planned_duration_min, source_template_section_id, phase_id, parallel_stream_id
|
||||
FROM training_unit_sections
|
||||
WHERE phase_id = %s
|
||||
ORDER BY order_index
|
||||
|
|
@ -771,7 +772,7 @@ def _fetch_phases_nested(cur, unit_id: int) -> List[Dict[str, Any]]:
|
|||
cur.execute(
|
||||
"""
|
||||
SELECT id, training_unit_id, order_index, title, guidance_notes,
|
||||
source_template_section_id, phase_id, parallel_stream_id
|
||||
planned_duration_min, source_template_section_id, phase_id, parallel_stream_id
|
||||
FROM training_unit_sections
|
||||
WHERE parallel_stream_id = %s
|
||||
ORDER BY order_index
|
||||
|
|
@ -831,6 +832,7 @@ def _clone_section_payload_dict(sec: Dict[str, Any]) -> Dict[str, Any]:
|
|||
"title": sec.get("title"),
|
||||
"order_index": sec.get("order_index"),
|
||||
"guidance_notes": sec.get("guidance_notes"),
|
||||
"planned_duration_min": sec.get("planned_duration_min"),
|
||||
"items": items_clean,
|
||||
}
|
||||
stid = sec.get("source_template_section_id")
|
||||
|
|
@ -974,6 +976,7 @@ def _copy_blueprint_into_scheduled_unit(
|
|||
planned_date,
|
||||
planned_time_start,
|
||||
planned_time_end,
|
||||
planned_duration_min,
|
||||
planned_focus,
|
||||
actual_date,
|
||||
actual_time_start,
|
||||
|
|
@ -992,6 +995,7 @@ def _copy_blueprint_into_scheduled_unit(
|
|||
%s,
|
||||
planned_time_start,
|
||||
planned_time_end,
|
||||
planned_duration_min,
|
||||
planned_focus,
|
||||
NULL::DATE,
|
||||
NULL::TIME WITHOUT TIME ZONE,
|
||||
|
|
@ -1281,8 +1285,9 @@ def _insert_one_replacement_section(
|
|||
cur.execute(
|
||||
"""
|
||||
INSERT INTO training_unit_sections (
|
||||
training_unit_id, phase_id, parallel_stream_id, order_index, title, guidance_notes, source_template_section_id
|
||||
) VALUES (%s, %s, %s, %s, %s, %s, %s)
|
||||
training_unit_id, phase_id, parallel_stream_id, order_index, title, guidance_notes,
|
||||
planned_duration_min, source_template_section_id
|
||||
) VALUES (%s, %s, %s, %s, %s, %s, %s, %s)
|
||||
RETURNING id
|
||||
""",
|
||||
(
|
||||
|
|
@ -1292,6 +1297,7 @@ def _insert_one_replacement_section(
|
|||
order_ix,
|
||||
title,
|
||||
sec.get("guidance_notes"),
|
||||
sec.get("planned_duration_min"),
|
||||
src_tsec,
|
||||
),
|
||||
)
|
||||
|
|
@ -1629,10 +1635,12 @@ def _normalize_training_plan_template_section_payload(sec: Any, si: int) -> Dict
|
|||
p_so = int(raw_so) if raw_so is not None and raw_so != "" else 0
|
||||
except (TypeError, ValueError):
|
||||
p_so = 0
|
||||
pdur = _optional_positive_int(sec.get("planned_duration_min"), "planned_duration_min")
|
||||
return {
|
||||
"title": title,
|
||||
"order_index": order_ix,
|
||||
"guidance_text": sec.get("guidance_text"),
|
||||
"planned_duration_min": pdur,
|
||||
"phase_kind": pk,
|
||||
"phase_order_index": p_oi,
|
||||
"parallel_stream_order_index": p_so,
|
||||
|
|
@ -1645,15 +1653,16 @@ def _insert_training_plan_template_sections(cur, template_id: int, sections_in:
|
|||
cur.execute(
|
||||
"""
|
||||
INSERT INTO training_plan_template_sections (
|
||||
template_id, order_index, title, guidance_text,
|
||||
template_id, order_index, title, guidance_text, planned_duration_min,
|
||||
phase_kind, phase_order_index, parallel_stream_order_index
|
||||
) VALUES (%s, %s, %s, %s, %s, %s, %s)
|
||||
) VALUES (%s, %s, %s, %s, %s, %s, %s, %s)
|
||||
""",
|
||||
(
|
||||
template_id,
|
||||
row["order_index"],
|
||||
row["title"],
|
||||
row["guidance_text"],
|
||||
row["planned_duration_min"],
|
||||
row["phase_kind"],
|
||||
row["phase_order_index"],
|
||||
row["parallel_stream_order_index"],
|
||||
|
|
@ -1700,6 +1709,7 @@ def _template_rows_to_phases_payload(rows: List[Dict[str, Any]]) -> List[Dict[st
|
|||
"title": rr.get("title"),
|
||||
"order_index": j,
|
||||
"guidance_notes": rr.get("guidance_text"),
|
||||
"planned_duration_min": rr.get("planned_duration_min"),
|
||||
"items": [],
|
||||
**(
|
||||
{"source_template_section_id": int(tid)}
|
||||
|
|
@ -1743,6 +1753,7 @@ def _template_rows_to_phases_payload(rows: List[Dict[str, Any]]) -> List[Dict[st
|
|||
"title": rr.get("title"),
|
||||
"order_index": j,
|
||||
"guidance_notes": rr.get("guidance_text"),
|
||||
"planned_duration_min": rr.get("planned_duration_min"),
|
||||
"items": [],
|
||||
**(
|
||||
{"source_template_section_id": int(tid)}
|
||||
|
|
@ -1775,7 +1786,7 @@ def _instantiate_from_template(
|
|||
) -> None:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT id, title, guidance_text, order_index, phase_kind, phase_order_index, parallel_stream_order_index
|
||||
SELECT id, title, guidance_text, planned_duration_min, order_index, phase_kind, phase_order_index, parallel_stream_order_index
|
||||
FROM training_plan_template_sections
|
||||
WHERE template_id = %s
|
||||
ORDER BY order_index
|
||||
|
|
@ -2591,11 +2602,13 @@ def create_training_unit(data: dict, tenant: TenantContext = Depends(get_tenant_
|
|||
eff_lead_for_co,
|
||||
)
|
||||
|
||||
pdur = _optional_positive_int(data.get("planned_duration_min"), "planned_duration_min")
|
||||
base_params = (
|
||||
group_id,
|
||||
planned_date,
|
||||
data.get("planned_time_start"),
|
||||
data.get("planned_time_end"),
|
||||
pdur,
|
||||
data.get("planned_focus"),
|
||||
data.get("status", "planned"),
|
||||
data.get("notes"),
|
||||
|
|
@ -2610,11 +2623,11 @@ def create_training_unit(data: dict, tenant: TenantContext = Depends(get_tenant_
|
|||
"""
|
||||
INSERT INTO training_units (
|
||||
group_id, planned_date, planned_time_start, planned_time_end,
|
||||
planned_focus, status, notes, trainer_notes, created_by,
|
||||
planned_duration_min, planned_focus, status, notes, trainer_notes, created_by,
|
||||
plan_template_id,
|
||||
lead_trainer_profile_id,
|
||||
assistant_trainer_profile_ids
|
||||
) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
|
||||
) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
|
||||
RETURNING id
|
||||
""",
|
||||
base_params + (av_db,),
|
||||
|
|
@ -2624,10 +2637,10 @@ def create_training_unit(data: dict, tenant: TenantContext = Depends(get_tenant_
|
|||
"""
|
||||
INSERT INTO training_units (
|
||||
group_id, planned_date, planned_time_start, planned_time_end,
|
||||
planned_focus, status, notes, trainer_notes, created_by,
|
||||
planned_duration_min, planned_focus, status, notes, trainer_notes, created_by,
|
||||
plan_template_id,
|
||||
lead_trainer_profile_id
|
||||
) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
|
||||
) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
|
||||
RETURNING id
|
||||
""",
|
||||
base_params,
|
||||
|
|
@ -2717,6 +2730,11 @@ def update_training_unit(unit_id: int, data: dict, tenant: TenantContext = Depen
|
|||
if "planned_time_end" in data:
|
||||
blueprint_fields.append("planned_time_end = %s")
|
||||
blueprint_params.append(data.get("planned_time_end"))
|
||||
if "planned_duration_min" in data:
|
||||
blueprint_fields.append("planned_duration_min = %s")
|
||||
blueprint_params.append(
|
||||
_optional_positive_int(data.get("planned_duration_min"), "planned_duration_min")
|
||||
)
|
||||
if "notes" in data:
|
||||
blueprint_fields.append("notes = %s")
|
||||
blueprint_params.append(data.get("notes"))
|
||||
|
|
@ -2782,6 +2800,7 @@ def update_training_unit(unit_id: int, data: dict, tenant: TenantContext = Depen
|
|||
planned_date = COALESCE(%s, planned_date),
|
||||
planned_time_start = %s,
|
||||
planned_time_end = %s,
|
||||
planned_duration_min = %s,
|
||||
planned_focus = %s,
|
||||
actual_date = %s,
|
||||
actual_time_start = %s,
|
||||
|
|
@ -2801,6 +2820,9 @@ def update_training_unit(unit_id: int, data: dict, tenant: TenantContext = Depen
|
|||
data.get("planned_date"),
|
||||
data.get("planned_time_start"),
|
||||
data.get("planned_time_end"),
|
||||
_optional_positive_int(data.get("planned_duration_min"), "planned_duration_min")
|
||||
if "planned_duration_min" in data
|
||||
else unit_row.get("planned_duration_min"),
|
||||
data.get("planned_focus"),
|
||||
data.get("actual_date"),
|
||||
data.get("actual_time_start"),
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
# Shinkan Jinkendo Version Information
|
||||
|
||||
APP_VERSION = "0.8.149"
|
||||
BUILD_DATE = "2026-05-19"
|
||||
DB_SCHEMA_VERSION = "20260516065"
|
||||
APP_VERSION = "0.8.150"
|
||||
BUILD_DATE = "2026-05-20"
|
||||
DB_SCHEMA_VERSION = "20260520066"
|
||||
|
||||
MODULE_VERSIONS = {
|
||||
"legal_documents": "1.4.0", # Admin: Live-Vorschau pro Abschnitt + modale Vollvorschau (Editor + Dokumentenliste)
|
||||
|
|
|
|||
|
|
@ -25,6 +25,7 @@ import {
|
|||
swapAdjacentPhaseRuns,
|
||||
reorderBlocksImmutableWithPlanLoc,
|
||||
reorderSectionBeforeParallelRunAsWholeGroup,
|
||||
reorderSectionAfterParallelRunAsWholeGroup,
|
||||
reorderSectionAsFirstInParallelStream,
|
||||
reorderBlockIntoParallelStreamEnd,
|
||||
globalInsertBeforeIndexForParallelStreamEnd,
|
||||
|
|
@ -37,6 +38,7 @@ import {
|
|||
noteRow,
|
||||
sectionPlannedMinutes,
|
||||
} from '../utils/trainingUnitSectionsForm'
|
||||
import { formatDurationDisplay, parseDurationInput } from '../utils/trainingDurationUtils'
|
||||
import api from '../utils/api'
|
||||
import { isCompactTagLegendMode } from '../config/planningModuleUx'
|
||||
import { useAuth } from '../context/AuthContext'
|
||||
|
|
@ -274,6 +276,19 @@ export default function TrainingUnitSectionsEditor({
|
|||
user?.training_planning_prefs?.module_display_mode
|
||||
)
|
||||
|
||||
const [useCompactPhaseMoves, setUseCompactPhaseMoves] = useState(() =>
|
||||
typeof window !== 'undefined'
|
||||
? window.matchMedia('(max-width: 768px)').matches
|
||||
: false
|
||||
)
|
||||
useEffect(() => {
|
||||
const mq = window.matchMedia('(max-width: 768px)')
|
||||
const apply = () => setUseCompactPhaseMoves(!!mq.matches)
|
||||
apply()
|
||||
mq.addEventListener('change', apply)
|
||||
return () => mq.removeEventListener('change', apply)
|
||||
}, [])
|
||||
|
||||
const ensure = (prev) =>
|
||||
prev && prev.length ? prev : [defaultSection()]
|
||||
|
||||
|
|
@ -1299,6 +1314,47 @@ export default function TrainingUnitSectionsEditor({
|
|||
return sIdx === 0
|
||||
}
|
||||
|
||||
const moveSectionToPlanTarget = (sIdx, rawKey) => {
|
||||
if (!rawKey) return
|
||||
patch((prev) => {
|
||||
const opts = planSelectOptionsForSection(prev, sIdx, buildPlanTargetOptions(prev))
|
||||
const hit = opts.find((o) => o.key === rawKey)
|
||||
if (!hit) return prev
|
||||
const tpl = { ...hit.template }
|
||||
let next = prev.map((s, i) => (i === sIdx ? { ...s, planLoc: tpl } : s))
|
||||
if (enableParallelPhaseControls) next = afterSectionReorderParallelGuard(prev, next)
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
const moveSectionToWholeGroupAbove = (sIdx) => {
|
||||
const sec = list[sIdx]
|
||||
const po = sec?.planLoc?.phaseOrderIndex ?? 0
|
||||
if (sec?.planLoc?.phaseKind === 'parallel') {
|
||||
patch((prev) => {
|
||||
let next = reorderSectionBeforeParallelRunAsWholeGroup(prev, sIdx, po)
|
||||
if (enableParallelPhaseControls) next = afterSectionReorderParallelGuard(prev, next)
|
||||
return next
|
||||
})
|
||||
} else {
|
||||
moveSection(sIdx, -1)
|
||||
}
|
||||
}
|
||||
|
||||
const moveSectionToWholeGroupBelow = (sIdx) => {
|
||||
const sec = list[sIdx]
|
||||
const po = sec?.planLoc?.phaseOrderIndex ?? 0
|
||||
if (sec?.planLoc?.phaseKind === 'parallel') {
|
||||
patch((prev) => {
|
||||
let next = reorderSectionAfterParallelRunAsWholeGroup(prev, sIdx, po)
|
||||
if (enableParallelPhaseControls) next = afterSectionReorderParallelGuard(prev, next)
|
||||
return next
|
||||
})
|
||||
} else {
|
||||
moveSection(sIdx, 1)
|
||||
}
|
||||
}
|
||||
|
||||
const sectionMoveDisabledDown = (sIdx) => {
|
||||
const sec = list[sIdx]
|
||||
const L = sec?.planLoc
|
||||
|
|
@ -2004,28 +2060,74 @@ export default function TrainingUnitSectionsEditor({
|
|||
}
|
||||
placeholder="Hinweise zum Abschnitt (Material, Zeit, Zielrichtung …)"
|
||||
/>
|
||||
{enableParallelPhaseControls ? (
|
||||
<div className="form-row" style={{ marginTop: '10px', marginBottom: '2px' }}>
|
||||
{!structureOnly ? (
|
||||
<div
|
||||
className="form-row"
|
||||
style={{ marginTop: '10px', marginBottom: '2px', maxWidth: '220px' }}
|
||||
>
|
||||
<label className="form-label" style={{ fontSize: '0.78rem' }}>
|
||||
Zuordnung
|
||||
Geplante Abschnittsdauer
|
||||
</label>
|
||||
<select
|
||||
className="form-input"
|
||||
value={planLocKey(sec.planLoc)}
|
||||
onChange={(e) => applySectionPlanTarget(sIdx, e.target.value)}
|
||||
<input
|
||||
type="text"
|
||||
className="form-input tu-ex-duration"
|
||||
inputMode="decimal"
|
||||
value={sec.planned_duration_min ?? ''}
|
||||
onChange={(e) =>
|
||||
updateSectionField(sIdx, 'planned_duration_min', e.target.value)
|
||||
}
|
||||
placeholder="z. B. 15 oder 0,25 h"
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
{enableParallelPhaseControls && useCompactPhaseMoves ? (
|
||||
<div className="tu-section-phase-moves" style={{ marginTop: '8px' }}>
|
||||
<span
|
||||
className="form-label"
|
||||
style={{ fontSize: '0.75rem', display: 'block', marginBottom: '6px' }}
|
||||
>
|
||||
<option value="">Standard — eine Ganzgruppe (klassischer Ablauf)</option>
|
||||
{planSelectOptionsForSection(list, sIdx, buildPlanTargetOptions(list)).map((o) => (
|
||||
<option key={o.key} value={o.key}>
|
||||
{o.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
Phase / Gruppe
|
||||
</span>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '4px' }}>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary framework-ctrl framework-ctrl--xs"
|
||||
title="In Ganzgruppe darüber"
|
||||
onClick={() => moveSectionToWholeGroupAbove(sIdx)}
|
||||
>
|
||||
Ganz ↑
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary framework-ctrl framework-ctrl--xs"
|
||||
title="In Ganzgruppe darunter"
|
||||
onClick={() => moveSectionToWholeGroupBelow(sIdx)}
|
||||
>
|
||||
Ganz ↓
|
||||
</button>
|
||||
{planSelectOptionsForSection(list, sIdx, buildPlanTargetOptions(list))
|
||||
.filter((o) => o.key.startsWith('par:'))
|
||||
.map((o) => (
|
||||
<button
|
||||
key={o.key}
|
||||
type="button"
|
||||
className="btn btn-secondary framework-ctrl framework-ctrl--xs"
|
||||
title={o.label}
|
||||
onClick={() => moveSectionToPlanTarget(sIdx, o.key)}
|
||||
>
|
||||
{o.label.length > 14 ? `${o.label.slice(0, 12)}…` : o.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
{!structureOnly && planMin > 0 && (
|
||||
<p style={{ fontSize: '0.78rem', color: 'var(--text2)', marginTop: '0.35rem' }}>
|
||||
Geplant in diesem Abschnitt: ca. {planMin} Min. (Übungen)
|
||||
Geplant in diesem Abschnitt:{' '}
|
||||
<strong>{formatDurationDisplay(planMin)}</strong>
|
||||
{parseDurationInput(sec.planned_duration_min) != null
|
||||
? ' (Abschnittsangabe)'
|
||||
: ' (Summe Übungen)'}
|
||||
</p>
|
||||
)}
|
||||
|
||||
|
|
|
|||
|
|
@ -1157,8 +1157,11 @@ function TrainingPlanningPageRoot() {
|
|||
<div style={{ minWidth: 0, flex: '1 1 200px' }}>
|
||||
<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)}`}
|
||||
{unit.planned_duration_min
|
||||
? ` · ${unit.planned_duration_min >= 60 && unit.planned_duration_min % 60 === 0 ? `${unit.planned_duration_min / 60} h` : `${unit.planned_duration_min} Min`}`
|
||||
: unit.planned_time_start
|
||||
? ` · ${unit.planned_time_start.slice(0, 5)} - ${unit.planned_time_end?.slice(0, 5)}`
|
||||
: ''}
|
||||
</h3>
|
||||
{planScope === 'club' && (unit.group_name || '').trim() ? (
|
||||
<p
|
||||
|
|
|
|||
|
|
@ -4,6 +4,13 @@ import TrainingPlanExerciseVisibilityPanel from '../TrainingPlanExerciseVisibili
|
|||
import TrainingUnitSectionsEditor from '../TrainingUnitSectionsEditor'
|
||||
import { activeClubMemberships } from '../../utils/activeClub'
|
||||
import { frameworkLineageText } from '../../utils/trainingPlanningPageHelpers'
|
||||
import {
|
||||
formatDurationDisplay,
|
||||
minutesToDurationFieldValue,
|
||||
parseDurationInput,
|
||||
sumExercisePlannedMinutes,
|
||||
sumSectionPlannedMinutes,
|
||||
} from '../../utils/trainingDurationUtils'
|
||||
|
||||
/**
|
||||
* Vollseiten-Formular: Trainingseinheit planen / nachbereiten (ohne Modal-Overlay).
|
||||
|
|
@ -40,6 +47,10 @@ export default function TrainingUnitFormShell({
|
|||
const roleLc = String(user?.role || '').toLowerCase()
|
||||
const isSuperadmin = roleLc === 'superadmin'
|
||||
|
||||
const plannedTotalParsed = parseDurationInput(formData.planned_duration_min)
|
||||
const sumFromSections = sumSectionPlannedMinutes(formData.sections)
|
||||
const sumFromExercises = sumExercisePlannedMinutes(formData.sections)
|
||||
|
||||
useEffect(() => {
|
||||
if (planningClubId != null && planningClubId !== '') {
|
||||
setNewTplClubId(String(planningClubId))
|
||||
|
|
@ -155,6 +166,39 @@ export default function TrainingUnitFormShell({
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<div className="form-row" style={{ marginBottom: '1rem' }}>
|
||||
<label className="form-label">Geplante Gesamtdauer</label>
|
||||
<input
|
||||
type="text"
|
||||
className="form-input"
|
||||
inputMode="decimal"
|
||||
value={formData.planned_duration_min}
|
||||
onChange={(e) => updateFormField('planned_duration_min', e.target.value)}
|
||||
placeholder="z. B. 1,5 oder 90 (Minuten)"
|
||||
/>
|
||||
<p className="form-sub" style={{ marginTop: '0.35rem' }}>
|
||||
{plannedTotalParsed != null ? (
|
||||
<>
|
||||
Eingabe: <strong>{formatDurationDisplay(plannedTotalParsed)}</strong>
|
||||
</>
|
||||
) : (
|
||||
'Stunden mit Komma/Punkt (1,5 = 90 Min) oder Minuten ab 8.'
|
||||
)}
|
||||
{sumFromSections > 0 || sumFromExercises > 0 ? (
|
||||
<>
|
||||
{' '}
|
||||
· Summe Abschnitte: <strong>{formatDurationDisplay(sumFromSections)}</strong>
|
||||
{sumFromExercises > 0 && sumFromExercises !== sumFromSections ? (
|
||||
<>
|
||||
{' '}
|
||||
· Übungen: <strong>{formatDurationDisplay(sumFromExercises)}</strong>
|
||||
</>
|
||||
) : null}
|
||||
</>
|
||||
) : null}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="form-row">
|
||||
<label className="form-label">Trainingsfokus</label>
|
||||
<input
|
||||
|
|
|
|||
|
|
@ -26,6 +26,7 @@ import {
|
|||
reorderSectionBeforeParallelRunAsWholeGroup,
|
||||
reorderSectionAsFirstInParallelStream,
|
||||
} from '../utils/trainingUnitSectionsForm'
|
||||
import { parseDurationInput } from '../utils/trainingDurationUtils'
|
||||
|
||||
const DND_FW_SLOT = 'application/x-shinkan-framework-slot'
|
||||
|
||||
|
|
@ -52,7 +53,7 @@ function emptyGoal() {
|
|||
|
||||
|
||||
function emptySlot() {
|
||||
return { title: '', notes: '', sections: [defaultSection('Ablauf')] }
|
||||
return { title: '', notes: '', planned_duration_min: '', sections: [defaultSection('Ablauf')] }
|
||||
}
|
||||
|
||||
async function enrichFrameworkSlotSections(slots) {
|
||||
|
|
@ -79,12 +80,10 @@ function defaultForm() {
|
|||
return {
|
||||
title: '',
|
||||
description: '',
|
||||
focus_area_id: '',
|
||||
style_direction_id: '',
|
||||
focus_area_ids: [],
|
||||
style_direction_ids: [],
|
||||
training_type_ids: [],
|
||||
target_group_ids: [],
|
||||
planned_period_start: '',
|
||||
planned_period_end: '',
|
||||
visibility: 'private',
|
||||
club_id: '',
|
||||
goals: [emptyGoal()],
|
||||
|
|
@ -105,12 +104,10 @@ function frameworkDraftSnapshot(fm) {
|
|||
return JSON.stringify({
|
||||
title: (fm.title || '').trim(),
|
||||
description: (fm.description || '').trim(),
|
||||
focus_area_id: fm.focus_area_id || '',
|
||||
style_direction_id: fm.style_direction_id || '',
|
||||
focus_area_ids: [...(fm.focus_area_ids || [])].map(String).sort(),
|
||||
style_direction_ids: [...(fm.style_direction_ids || [])].map(String).sort(),
|
||||
training_type_ids: [...(fm.training_type_ids || [])].map(String).sort(),
|
||||
target_group_ids: [...(fm.target_group_ids || [])].map(String).sort(),
|
||||
planned_period_start: fm.planned_period_start || '',
|
||||
planned_period_end: fm.planned_period_end || '',
|
||||
visibility: (fm.visibility || '').trim(),
|
||||
club_id: (fm.club_id || '').trim(),
|
||||
goals: goalsNorm,
|
||||
|
|
@ -123,16 +120,22 @@ function serverFrameworkToForm(fw) {
|
|||
return {
|
||||
title: fw.title || '',
|
||||
description: fw.description || '',
|
||||
focus_area_id: fw.focus_area_id != null ? String(fw.focus_area_id) : '',
|
||||
style_direction_id: fw.style_direction_id != null ? String(fw.style_direction_id) : '',
|
||||
focus_area_ids: Array.isArray(fw.focus_area_ids)
|
||||
? fw.focus_area_ids.map((x) => String(x))
|
||||
: fw.focus_area_id != null
|
||||
? [String(fw.focus_area_id)]
|
||||
: [],
|
||||
style_direction_ids: Array.isArray(fw.style_direction_ids)
|
||||
? fw.style_direction_ids.map((x) => String(x))
|
||||
: fw.style_direction_id != null
|
||||
? [String(fw.style_direction_id)]
|
||||
: [],
|
||||
training_type_ids: Array.isArray(fw.training_type_ids)
|
||||
? fw.training_type_ids.map((x) => String(x))
|
||||
: [],
|
||||
target_group_ids: Array.isArray(fw.target_group_ids)
|
||||
? fw.target_group_ids.map((x) => String(x))
|
||||
: [],
|
||||
planned_period_start: fw.planned_period_start || '',
|
||||
planned_period_end: fw.planned_period_end || '',
|
||||
visibility: fw.visibility || 'private',
|
||||
club_id: fw.club_id != null ? String(fw.club_id) : '',
|
||||
goals: goalsIn.map((g) => ({
|
||||
|
|
@ -142,6 +145,10 @@ function serverFrameworkToForm(fw) {
|
|||
slots: (fw.slots || []).map((s) => ({
|
||||
title: s.title || '',
|
||||
notes: s.notes || '',
|
||||
planned_duration_min:
|
||||
s.planned_duration_min != null && s.planned_duration_min !== undefined
|
||||
? String(s.planned_duration_min)
|
||||
: '',
|
||||
sections: normalizeUnitToForm({
|
||||
sections: s.sections,
|
||||
exercises: s.exercises,
|
||||
|
|
@ -170,6 +177,7 @@ function buildApiPayload(form) {
|
|||
sort_order: si,
|
||||
title: (s.title || '').trim() || null,
|
||||
notes: (s.notes || '').trim() || null,
|
||||
planned_duration_min: parseDurationInput(s.planned_duration_min),
|
||||
}
|
||||
if (plan.phases) {
|
||||
return { ...base, phases: plan.phases }
|
||||
|
|
@ -177,14 +185,12 @@ function buildApiPayload(form) {
|
|||
return { ...base, sections: plan.sections }
|
||||
})
|
||||
|
||||
const focusAreaId =
|
||||
form.focus_area_id && !Number.isNaN(parseInt(form.focus_area_id, 10))
|
||||
? parseInt(form.focus_area_id, 10)
|
||||
: null
|
||||
const styleDirectionId =
|
||||
form.style_direction_id && !Number.isNaN(parseInt(form.style_direction_id, 10))
|
||||
? parseInt(form.style_direction_id, 10)
|
||||
: null
|
||||
const focus_area_ids = (form.focus_area_ids || [])
|
||||
.map((x) => parseInt(String(x), 10))
|
||||
.filter((n) => !Number.isNaN(n) && n > 0)
|
||||
const style_direction_ids = (form.style_direction_ids || [])
|
||||
.map((x) => parseInt(String(x), 10))
|
||||
.filter((n) => !Number.isNaN(n) && n > 0)
|
||||
|
||||
const training_type_ids = (form.training_type_ids || [])
|
||||
.map((x) => parseInt(String(x), 10))
|
||||
|
|
@ -201,12 +207,10 @@ function buildApiPayload(form) {
|
|||
return {
|
||||
title: (form.title || '').trim(),
|
||||
description: (form.description || '').trim() || null,
|
||||
focus_area_id: focusAreaId,
|
||||
style_direction_id: styleDirectionId,
|
||||
focus_area_ids,
|
||||
style_direction_ids,
|
||||
training_type_ids,
|
||||
target_group_ids,
|
||||
planned_period_start: form.planned_period_start || null,
|
||||
planned_period_end: form.planned_period_end || null,
|
||||
visibility: form.visibility || 'private',
|
||||
club_id: clubId,
|
||||
goals,
|
||||
|
|
@ -544,14 +548,15 @@ export default function TrainingFrameworkProgramEditPage() {
|
|||
desktopLayout ? undefined : { display: panelActive(key) ? 'block' : 'none' }
|
||||
|
||||
const trainingTypesFiltered = useMemo(() => {
|
||||
if (!form.focus_area_id) return trainingTypesCatalog
|
||||
const faSet = new Set((form.focus_area_ids || []).map(String))
|
||||
if (!faSet.size) return trainingTypesCatalog
|
||||
return trainingTypesCatalog.filter(
|
||||
(t) => !t.focus_area_id || String(t.focus_area_id) === String(form.focus_area_id)
|
||||
(t) => !t.focus_area_id || faSet.has(String(t.focus_area_id))
|
||||
)
|
||||
}, [trainingTypesCatalog, form.focus_area_id])
|
||||
}, [trainingTypesCatalog, form.focus_area_ids])
|
||||
|
||||
useEffect(() => {
|
||||
if (!form.focus_area_id || trainingTypesCatalog.length === 0) return
|
||||
if (!(form.focus_area_ids || []).length || trainingTypesCatalog.length === 0) return
|
||||
const allowed = new Set(trainingTypesFiltered.map((t) => String(t.id)))
|
||||
setForm((prev) => {
|
||||
const cur = prev.training_type_ids || []
|
||||
|
|
@ -559,7 +564,27 @@ export default function TrainingFrameworkProgramEditPage() {
|
|||
if (next.length === cur.length) return prev
|
||||
return { ...prev, training_type_ids: next }
|
||||
})
|
||||
}, [form.focus_area_id, trainingTypesCatalog.length, trainingTypesFiltered])
|
||||
}, [form.focus_area_ids, trainingTypesCatalog.length, trainingTypesFiltered])
|
||||
|
||||
const toggleFocusAreaId = (fid) => {
|
||||
const idStr = String(fid)
|
||||
setForm((prev) => {
|
||||
const s = new Set(prev.focus_area_ids || [])
|
||||
if (s.has(idStr)) s.delete(idStr)
|
||||
else s.add(idStr)
|
||||
return { ...prev, focus_area_ids: [...s].sort((a, b) => Number(a) - Number(b)) }
|
||||
})
|
||||
}
|
||||
|
||||
const toggleStyleDirectionId = (sid) => {
|
||||
const idStr = String(sid)
|
||||
setForm((prev) => {
|
||||
const s = new Set(prev.style_direction_ids || [])
|
||||
if (s.has(idStr)) s.delete(idStr)
|
||||
else s.add(idStr)
|
||||
return { ...prev, style_direction_ids: [...s].sort((a, b) => Number(a) - Number(b)) }
|
||||
})
|
||||
}
|
||||
|
||||
const toggleTrainingTypeId = (tid) => {
|
||||
const idStr = String(tid)
|
||||
|
|
@ -786,6 +811,20 @@ export default function TrainingFrameworkProgramEditPage() {
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<div className="form-row" style={{ marginTop: '0.5rem', maxWidth: '220px' }}>
|
||||
<label className="form-label" style={{ fontSize: '0.82rem' }}>
|
||||
Geplante Session-Dauer
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
className="form-input"
|
||||
inputMode="decimal"
|
||||
value={slot.planned_duration_min ?? ''}
|
||||
onChange={(e) => slotField(si, 'planned_duration_min', e.target.value)}
|
||||
placeholder="z. B. 1,5 h"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<details className="framework-slot-details">
|
||||
<summary className="framework-slot-details__summary">Notizen (Session)</summary>
|
||||
<div className="form-row">
|
||||
|
|
@ -910,35 +949,47 @@ export default function TrainingFrameworkProgramEditPage() {
|
|||
/>
|
||||
</div>
|
||||
<div className="form-row">
|
||||
<label className="form-label">Fokusbereich (optional)</label>
|
||||
<select
|
||||
className="form-input"
|
||||
value={form.focus_area_id}
|
||||
onChange={(e) => updateField('focus_area_id', e.target.value)}
|
||||
>
|
||||
<option value="">— keiner —</option>
|
||||
{focusAreas.map((fa) => (
|
||||
<option key={fa.id} value={String(fa.id)}>
|
||||
{fa.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<p className="form-sub">Hilft beim Filtern der Trainingsarten und bei der späteren Zuordnung in der Planung.</p>
|
||||
<label className="form-label">Fokusbereiche (optional, Mehrfachwahl)</label>
|
||||
<div className="framework-catalog-checkgrid">
|
||||
{focusAreas.length === 0 ? (
|
||||
<p className="form-sub" style={{ marginTop: 0 }}>
|
||||
Keine Fokusbereiche im Katalog.
|
||||
</p>
|
||||
) : (
|
||||
focusAreas.map((fa) => (
|
||||
<label key={fa.id} className="framework-catalog-check">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={(form.focus_area_ids || []).includes(String(fa.id))}
|
||||
onChange={() => toggleFocusAreaId(fa.id)}
|
||||
/>
|
||||
<span>{fa.name}</span>
|
||||
</label>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
<p className="form-sub">Aus dem Katalog; filtert die Trainingsarten unten.</p>
|
||||
</div>
|
||||
<div className="form-row">
|
||||
<label className="form-label">Stilrichtung (optional)</label>
|
||||
<select
|
||||
className="form-input"
|
||||
value={form.style_direction_id}
|
||||
onChange={(e) => updateField('style_direction_id', e.target.value)}
|
||||
>
|
||||
<option value="">— keine —</option>
|
||||
{styleDirections.map((sd) => (
|
||||
<option key={sd.id} value={String(sd.id)}>
|
||||
{sd.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<label className="form-label">Stilrichtungen (optional, Mehrfachwahl)</label>
|
||||
<div className="framework-catalog-checkgrid">
|
||||
{styleDirections.length === 0 ? (
|
||||
<p className="form-sub" style={{ marginTop: 0 }}>
|
||||
Keine Stilrichtungen im Katalog.
|
||||
</p>
|
||||
) : (
|
||||
styleDirections.map((sd) => (
|
||||
<label key={sd.id} className="framework-catalog-check">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={(form.style_direction_ids || []).includes(String(sd.id))}
|
||||
onChange={() => toggleStyleDirectionId(sd.id)}
|
||||
/>
|
||||
<span>{sd.name}</span>
|
||||
</label>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="form-row">
|
||||
<label className="form-label">Trainingsarten (optional, Mehrfachwahl)</label>
|
||||
|
|
@ -983,27 +1034,6 @@ export default function TrainingFrameworkProgramEditPage() {
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<div className="responsive-grid-2" style={{ marginBottom: '16px' }}>
|
||||
<div>
|
||||
<label className="form-label">Zeitraum von</label>
|
||||
<input
|
||||
type="date"
|
||||
className="form-input"
|
||||
value={form.planned_period_start}
|
||||
onChange={(e) => updateField('planned_period_start', e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="form-label">Zeitraum bis</label>
|
||||
<input
|
||||
type="date"
|
||||
className="form-input"
|
||||
value={form.planned_period_end}
|
||||
onChange={(e) => updateField('planned_period_end', e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="responsive-grid-2" style={{ marginBottom: '16px' }}>
|
||||
<div>
|
||||
<label className="form-label">Sichtbarkeit</label>
|
||||
|
|
|
|||
|
|
@ -15,8 +15,18 @@ function FrameworkSummaryMeta({ r }) {
|
|||
typeof r.training_type_names_agg === 'string' ? r.training_type_names_agg.trim() : ''
|
||||
const targetGroups =
|
||||
typeof r.target_group_names_agg === 'string' ? r.target_group_names_agg.trim() : ''
|
||||
const styleDir = typeof r.style_direction_name === 'string' ? r.style_direction_name.trim() : ''
|
||||
const focus = typeof r.focus_area_name === 'string' ? r.focus_area_name.trim() : ''
|
||||
const styleDir =
|
||||
typeof r.style_direction_names_agg === 'string'
|
||||
? r.style_direction_names_agg.trim()
|
||||
: typeof r.style_direction_name === 'string'
|
||||
? r.style_direction_name.trim()
|
||||
: ''
|
||||
const focus =
|
||||
typeof r.focus_area_names_agg === 'string'
|
||||
? r.focus_area_names_agg.trim()
|
||||
: typeof r.focus_area_name === 'string'
|
||||
? r.focus_area_name.trim()
|
||||
: ''
|
||||
|
||||
const rowStyle = {
|
||||
display: 'grid',
|
||||
|
|
@ -33,12 +43,10 @@ function FrameworkSummaryMeta({ r }) {
|
|||
<dt style={{ margin: 0, fontWeight: 600, color: 'var(--text3)' }}>Fokusbereich</dt>
|
||||
<dd style={{ margin: 0 }}>{dashIfEmpty(focus)}</dd>
|
||||
</div>
|
||||
{styleDir ? (
|
||||
<div style={rowStyle}>
|
||||
<dt style={{ margin: 0, fontWeight: 600, color: 'var(--text3)' }}>Stilrichtung</dt>
|
||||
<dd style={{ margin: 0 }}>{styleDir}</dd>
|
||||
</div>
|
||||
) : null}
|
||||
<div style={rowStyle}>
|
||||
<dt style={{ margin: 0, fontWeight: 600, color: 'var(--text3)' }}>Stilrichtungen</dt>
|
||||
<dd style={{ margin: 0 }}>{dashIfEmpty(styleDir)}</dd>
|
||||
</div>
|
||||
<div style={rowStyle}>
|
||||
<dt style={{ margin: 0, fontWeight: 600, color: 'var(--text3)' }}>Trainingsarten</dt>
|
||||
<dd style={{ margin: 0 }}>{trainingTypes.length ? trainingTypes : '—'}</dd>
|
||||
|
|
|
|||
82
frontend/src/utils/trainingDurationUtils.js
Normal file
82
frontend/src/utils/trainingDurationUtils.js
Normal file
|
|
@ -0,0 +1,82 @@
|
|||
/**
|
||||
* Eingabe/Ausgabe geplanter Trainingsdauer (Minuten intern, Anzeige z. B. 1,5 h).
|
||||
*/
|
||||
|
||||
export function parseDurationInput(raw) {
|
||||
if (raw === '' || raw == null) return null
|
||||
const s = String(raw).trim().toLowerCase().replace(/\s+/g, '')
|
||||
if (!s) return null
|
||||
|
||||
const hMatch = s.match(/^(\d+(?:[.,]\d+)?)\s*h(?:\s*(\d{1,2}))?$/)
|
||||
if (hMatch) {
|
||||
const h = parseFloat(hMatch[1].replace(',', '.'))
|
||||
const extraMin = hMatch[2] ? parseInt(hMatch[2], 10) : 0
|
||||
if (!Number.isFinite(h) || h < 0) return null
|
||||
const total = Math.round(h * 60) + (Number.isFinite(extraMin) ? extraMin : 0)
|
||||
return total > 0 ? total : null
|
||||
}
|
||||
|
||||
if (/^\d+(?:[.,]\d+)?$/.test(s)) {
|
||||
const n = parseFloat(s.replace(',', '.'))
|
||||
if (!Number.isFinite(n) || n <= 0) return null
|
||||
if (s.includes(',') || s.includes('.') || n < 8) {
|
||||
return Math.round(n * 60)
|
||||
}
|
||||
return Math.round(n)
|
||||
}
|
||||
|
||||
const minMatch = s.match(/^(\d+)\s*min?$/)
|
||||
if (minMatch) {
|
||||
const m = parseInt(minMatch[1], 10)
|
||||
return Number.isFinite(m) && m > 0 ? m : null
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
export function formatDurationDisplay(minutes, { empty = '—' } = {}) {
|
||||
if (minutes == null || minutes === '') return empty
|
||||
const m = Number(minutes)
|
||||
if (!Number.isFinite(m) || m <= 0) return empty
|
||||
if (m % 60 === 0) return `${m / 60} h`
|
||||
if (m >= 60) {
|
||||
const h = Math.floor(m / 60)
|
||||
const rest = m % 60
|
||||
return `${h} h ${rest} Min`
|
||||
}
|
||||
return `${m} Min`
|
||||
}
|
||||
|
||||
/** Formularfeld: Minuten → Anzeige-String (z. B. 90 → "1,5") */
|
||||
export function minutesToDurationFieldValue(minutes) {
|
||||
if (minutes == null || minutes === '') return ''
|
||||
const m = Number(minutes)
|
||||
if (!Number.isFinite(m) || m <= 0) return ''
|
||||
if (m % 60 === 0) return String(m / 60)
|
||||
if (m >= 60) {
|
||||
const h = m / 60
|
||||
return Number.isInteger(h) ? String(h) : String(Math.round(h * 10) / 10).replace('.', ',')
|
||||
}
|
||||
return String(m)
|
||||
}
|
||||
|
||||
export function sumExercisePlannedMinutes(sections) {
|
||||
let sum = 0
|
||||
for (const sec of sections || []) {
|
||||
for (const it of sec.items || []) {
|
||||
if (it.item_type !== 'exercise') continue
|
||||
const n = parseDurationInput(it.planned_duration_min)
|
||||
if (n != null) sum += n
|
||||
}
|
||||
}
|
||||
return sum
|
||||
}
|
||||
|
||||
export function sumSectionPlannedMinutes(sections) {
|
||||
let sum = 0
|
||||
for (const sec of sections || []) {
|
||||
const n = parseDurationInput(sec.planned_duration_min)
|
||||
if (n != null) sum += n
|
||||
}
|
||||
return sum
|
||||
}
|
||||
|
|
@ -1,5 +1,6 @@
|
|||
import { buildPlanPayloadForSave, defaultSection } from './trainingUnitSectionsForm'
|
||||
import { sessionAssignDefaults } from './trainingPlanningPageHelpers'
|
||||
import { minutesToDurationFieldValue, parseDurationInput } from './trainingDurationUtils'
|
||||
|
||||
/** Leeres Formular für neue Einheit (ohne async Varianten-Anreicherung). */
|
||||
export function createEmptyTrainingUnitFormData({
|
||||
|
|
@ -13,6 +14,7 @@ export function createEmptyTrainingUnitFormData({
|
|||
planned_date: plannedDate || '',
|
||||
planned_time_start: timeStart || '',
|
||||
planned_time_end: timeEnd || '',
|
||||
planned_duration_min: '',
|
||||
planned_focus: '',
|
||||
actual_date: '',
|
||||
actual_time_start: '',
|
||||
|
|
@ -52,6 +54,7 @@ export function trainingUnitToFormFields(fullUnit, sections) {
|
|||
planned_date: fullUnit.planned_date || '',
|
||||
planned_time_start: fullUnit.planned_time_start?.slice?.(0, 5) || fullUnit.planned_time_start || '',
|
||||
planned_time_end: fullUnit.planned_time_end?.slice?.(0, 5) || fullUnit.planned_time_end || '',
|
||||
planned_duration_min: minutesToDurationFieldValue(fullUnit.planned_duration_min),
|
||||
planned_focus: fullUnit.planned_focus || '',
|
||||
actual_date: fullUnit.actual_date || '',
|
||||
actual_time_start: fullUnit.actual_time_start?.slice?.(0, 5) || fullUnit.actual_time_start || '',
|
||||
|
|
@ -84,6 +87,7 @@ export function buildTrainingUnitSavePayload(formData, { editingUnit = null, dra
|
|||
planned_date: formData.planned_date,
|
||||
planned_time_start: formData.planned_time_start || null,
|
||||
planned_time_end: formData.planned_time_end || null,
|
||||
planned_duration_min: parseDurationInput(formData.planned_duration_min),
|
||||
planned_focus: formData.planned_focus || null,
|
||||
actual_date: formData.actual_date || null,
|
||||
actual_time_start: formData.actual_time_start || null,
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import api from './api'
|
|||
import { sortCombinationSlotsForDisplay } from '../constants/combinationArchetypes'
|
||||
|
||||
export function defaultSection(title = 'Hauptteil') {
|
||||
return { title, guidance_notes: '', items: [] }
|
||||
return { title, guidance_notes: '', planned_duration_min: '', items: [] }
|
||||
}
|
||||
|
||||
/** Standard-`planLoc` für eine Ganzgruppen-Phase (Editor-Breakout-UI). */
|
||||
|
|
@ -440,6 +440,10 @@ function normalizePhasesToFormSections(fullUnit) {
|
|||
out.push({
|
||||
title: sec.title,
|
||||
guidance_notes: sec.guidance_notes || '',
|
||||
planned_duration_min:
|
||||
sec.planned_duration_min != null && sec.planned_duration_min !== undefined
|
||||
? String(sec.planned_duration_min)
|
||||
: '',
|
||||
items: formItemsFromApiItems(sec.items),
|
||||
planLoc: { ...streamLoc },
|
||||
})
|
||||
|
|
@ -459,6 +463,10 @@ function normalizePhasesToFormSections(fullUnit) {
|
|||
out.push({
|
||||
title: sec.title,
|
||||
guidance_notes: sec.guidance_notes || '',
|
||||
planned_duration_min:
|
||||
sec.planned_duration_min != null && sec.planned_duration_min !== undefined
|
||||
? String(sec.planned_duration_min)
|
||||
: '',
|
||||
items: formItemsFromApiItems(sec.items),
|
||||
planLoc: { ...loc },
|
||||
})
|
||||
|
|
@ -476,6 +484,10 @@ export function normalizeUnitToForm(fullUnit) {
|
|||
return fullUnit.sections.map((sec) => ({
|
||||
title: sec.title,
|
||||
guidance_notes: sec.guidance_notes || '',
|
||||
planned_duration_min:
|
||||
sec.planned_duration_min != null && sec.planned_duration_min !== undefined
|
||||
? String(sec.planned_duration_min)
|
||||
: '',
|
||||
items: formItemsFromApiItems(sec.items),
|
||||
}))
|
||||
}
|
||||
|
|
@ -665,6 +677,7 @@ export function buildOneSectionPayload(sec, orderIndex) {
|
|||
order_index: orderIndex,
|
||||
title: (sec.title || '').trim() || 'Abschnitt',
|
||||
guidance_notes: sec.guidance_notes?.trim() ? sec.guidance_notes.trim() : null,
|
||||
planned_duration_min: parseMin(sec.planned_duration_min),
|
||||
items: (sec.items || [])
|
||||
.map((it, ii) => {
|
||||
if (it.item_type === 'note') {
|
||||
|
|
@ -1173,6 +1186,7 @@ export function templateSectionsPayloadFromFormSections(sections) {
|
|||
order_index: si,
|
||||
title: (s.title || '').trim() || `Abschnitt ${si + 1}`,
|
||||
guidance_text: s.guidance_notes?.trim() ? s.guidance_notes.trim() : null,
|
||||
planned_duration_min: parseMin(s.planned_duration_min),
|
||||
phase_kind: pk,
|
||||
phase_order_index: poi,
|
||||
parallel_stream_order_index: pso,
|
||||
|
|
@ -1263,6 +1277,10 @@ export function formSectionsFromPlanTemplateRows(templateSections) {
|
|||
return {
|
||||
title: s.title || 'Abschnitt',
|
||||
guidance_notes: s.guidance_text || '',
|
||||
planned_duration_min:
|
||||
s.planned_duration_min != null && s.planned_duration_min !== undefined
|
||||
? String(s.planned_duration_min)
|
||||
: '',
|
||||
items: [],
|
||||
planLoc,
|
||||
}
|
||||
|
|
@ -1342,9 +1360,34 @@ export async function insertTrainingModuleIntoPlanningSections({
|
|||
}
|
||||
|
||||
export function sectionPlannedMinutes(sec) {
|
||||
const sectionMin = parseMin(sec?.planned_duration_min)
|
||||
if (sectionMin != null && sectionMin > 0) return sectionMin
|
||||
return (sec.items || []).reduce((sum, it) => {
|
||||
if (it.item_type !== 'exercise') return sum
|
||||
const m = parseMin(it.planned_duration_min)
|
||||
return sum + (m || 0)
|
||||
}, 0)
|
||||
}
|
||||
|
||||
/** Abschnitt unterhalb einer Parallel-Phase in die Ganzgruppe darunter verschieben. */
|
||||
export function reorderSectionAfterParallelRunAsWholeGroup(prev, fromI, phaseOrderIndex) {
|
||||
const po = Number(phaseOrderIndex) || 0
|
||||
const idxs = indicesOfParallelPhase(prev, po)
|
||||
if (!idxs.length || fromI < 0 || fromI >= (prev?.length ?? 0)) return prev
|
||||
const lg = idxs[idxs.length - 1]
|
||||
const arr = [...prev]
|
||||
const [moved] = arr.splice(fromI, 1)
|
||||
let insertAt = lg + 1
|
||||
if (fromI < insertAt) insertAt -= 1
|
||||
insertAt = Math.max(0, Math.min(insertAt, arr.length))
|
||||
const below = insertAt < arr.length ? arr[insertAt] : undefined
|
||||
let planLocNext
|
||||
if (below?.planLoc?.phaseKind === 'whole_group') {
|
||||
planLocNext = { ...below.planLoc }
|
||||
} else {
|
||||
const mx = maxPhaseOrderIndexFromSections(arr)
|
||||
planLocNext = defaultPlanLocWholeGroup(mx + 1)
|
||||
}
|
||||
arr.splice(insertAt, 0, { ...moved, planLoc: planLocNext })
|
||||
return arr
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user