diff --git a/app.py b/app.py index e06b8e7..a060a7a 100644 --- a/app.py +++ b/app.py @@ -1711,6 +1711,22 @@ def api_save_squad_editor_data( home_team_id = session_row[13] away_team_id = session_row[17] + missing_captains = [] + if payload.home_starting and not any(player.is_captain for player in payload.home_starting): + missing_captains.append(str(session_row[14] or "Домашняя команда")) + if payload.away_starting and not any(player.is_captain for player in payload.away_starting): + missing_captains.append(str(session_row[18] or "Гостевая команда")) + + if missing_captains: + teams_text = ", ".join(missing_captains) + return JSONResponse( + { + "success": False, + "error": f"Не выбран капитан: {teams_text}. Назначьте капитана из основного состава.", + }, + status_code=400, + ) + save_match_lineup_for_editor( match_id=match_id, home_team_id=home_team_id, diff --git a/repositories/match_lineup_repository.py b/repositories/match_lineup_repository.py index 850ee24..2448b68 100644 --- a/repositories/match_lineup_repository.py +++ b/repositories/match_lineup_repository.py @@ -471,6 +471,52 @@ def save_match_lineup_for_editor( insert_players("away", "starting", away_starting) insert_players("away", "bench", away_bench) + # Синхронизируем капитана с уже сохранённой расстановкой. + # /home-formations и /away-formations читают is_captain из + # match_formations, поэтому ручная смена капитана в редакторе + # состава должна сразу попадать и туда без пересохранения + # вкладки «Расстановки». + def sync_formation_captain(side: str, team_id: int) -> None: + cur.execute( + """ + UPDATE match_formations mf + SET + is_captain = COALESCE(( + SELECT mlp.is_captain + FROM match_lineup_players mlp + WHERE mlp.match_id = mf.match_id + AND mlp.side = %s + AND mlp.role = 'starting' + AND ( + ( + mf.player_id IS NOT NULL + AND mlp.player_id = mf.player_id + ) + OR ( + COALESCE(NULLIF(TRIM(mf.number), ''), '') <> '' + AND COALESCE(mlp.number::text, '') = COALESCE(mf.number, '') + ) + ) + ORDER BY + CASE + WHEN mf.player_id IS NOT NULL + AND mlp.player_id = mf.player_id + THEN 0 + ELSE 1 + END, + mlp.sort_order + LIMIT 1 + ), FALSE), + updated_at = NOW() + WHERE mf.match_id = %s + AND mf.team_id = %s + """, + (side, match_id, team_id), + ) + + sync_formation_captain("home", home_team_id) + sync_formation_captain("away", away_team_id) + insert_coaches("home", home_coaches) insert_coaches("away", away_coaches) diff --git a/static/script.js b/static/script.js index efb752b..8149cd1 100644 --- a/static/script.js +++ b/static/script.js @@ -2580,6 +2580,13 @@ function makeDraggable(el) { document.addEventListener("DOMContentLoaded", () => { setEditMode(false); renderRefereesEditor(); + + if (isGameTabActive()) { + const missingCaptainSides = getMissingCaptainSidesFromMatchData(); + if (missingCaptainSides.length) { + window.setTimeout(() => showCaptainRequiredWarning(missingCaptainSides), 150); + } + } }); @@ -3354,6 +3361,69 @@ function cloneSideState(side) { }; } +let suppressNextCaptainWarning = false; + +function sideNeedsCaptain(state) { + const starting = Array.isArray(state?.starting) ? state.starting : []; + return starting.length > 0 && !starting.some(player => !!player.is_captain); +} + +function getMissingCaptainSidesFromEditor() { + const missing = []; + if (sideNeedsCaptain(squadEditorState.home)) missing.push("home"); + if (sideNeedsCaptain(squadEditorState.away)) missing.push("away"); + return missing; +} + +function getMissingCaptainSidesFromMatchData() { + const missing = []; + const homeState = { starting: window.MATCH_DATA?.homeStarting || [] }; + const awayState = { starting: window.MATCH_DATA?.awayStarting || [] }; + if (sideNeedsCaptain(homeState)) missing.push("home"); + if (sideNeedsCaptain(awayState)) missing.push("away"); + return missing; +} + +function getCaptainWarningTeamNames(sides) { + return sides.map(side => { + const fallback = side === "home" ? "Домашняя команда" : "Гостевая команда"; + return String(window.MATCH_DATA?.teamNames?.[side] || fallback).trim() || fallback; + }); +} + +function showCaptainRequiredWarning(sides) { + const uniqueSides = Array.from(new Set((sides || []).filter(side => side === "home" || side === "away"))); + if (!uniqueSides.length) { + closeCaptainRequiredWarning(); + return; + } + + const modal = document.getElementById("captainRequiredModal"); + const text = document.getElementById("captainRequiredText"); + if (!modal || !text) return; + + const names = getCaptainWarningTeamNames(uniqueSides); + if (names.length === 1) { + text.textContent = `У команды «${names[0]}» не выбран капитан. Обязательно назначьте капитана из основного состава перед сохранением.`; + } else { + text.textContent = `У команд «${names[0]}» и «${names[1]}» не выбраны капитаны. Обязательно назначьте капитана каждой команды из основного состава перед сохранением.`; + } + + modal.classList.add("show"); +} + +function closeCaptainRequiredWarning() { + document.getElementById("captainRequiredModal")?.classList.remove("show"); +} + +function openCaptainEditorFromWarning() { + closeCaptainRequiredWarning(); + if (!editModeEnabled) { + suppressNextCaptainWarning = true; + setEditMode(true); + } +} + async function openSquadEditor() { if (!editModeEnabled) return; @@ -3370,6 +3440,13 @@ async function openSquadEditor() { renderSquadEditorSide("away"); document.getElementById("squadEditorModal")?.classList.add("show"); + + const missingCaptainSides = getMissingCaptainSidesFromEditor(); + if (suppressNextCaptainWarning) { + suppressNextCaptainWarning = false; + } else if (missingCaptainSides.length) { + showCaptainRequiredWarning(missingCaptainSides); + } } @@ -3410,8 +3487,12 @@ function renderSquadEditorSide(side) { const availablePlayers = getAvailableSquadPool(side); const coachOptions = state.coachPool; + const captainWarning = sideNeedsCaptain(state) + ? `
⚠ Капитан не выбран. Назначьте капитана из основного состава.
` + : ""; mount.innerHTML = ` + ${captainWarning}
Добавить игрока
@@ -3560,6 +3641,9 @@ function removePlayerFromEditor(side, playerId, role) { const state = getSideEditorState(side); if (!state) return; + const removedPlayer = state[role]?.find(p => String(p.player_id) === String(playerId)); + const removedCaptain = role === "starting" && !!removedPlayer?.is_captain; + const hasLinkedEvents = matchEvents.some(event => String(event.side) === String(side) && ( @@ -3576,6 +3660,10 @@ function removePlayerFromEditor(side, playerId, role) { state[role] = state[role].filter(p => String(p.player_id) !== String(playerId)); renderSquadEditorSide(side); + + if (removedCaptain && sideNeedsCaptain(state)) { + showCaptainRequiredWarning([side]); + } } function movePlayerBetweenZones(side, playerId, fromRole, toRole) { @@ -3586,19 +3674,18 @@ function movePlayerBetweenZones(side, playerId, fromRole, toRole) { if (idx === -1) return; const [player] = state[fromRole].splice(idx, 1); + const movedCaptainOutOfStarting = fromRole === "starting" && toRole === "bench" && !!player.is_captain; if (toRole === "bench") { player.is_captain = false; } state[toRole].push(player); - - const hasCaptainInStarting = state.starting.some(p => p.is_captain); - if (!hasCaptainInStarting && state.starting.length) { - state.starting[0].is_captain = true; - } - renderSquadEditorSide(side); + + if (movedCaptainOutOfStarting && sideNeedsCaptain(state)) { + showCaptainRequiredWarning([side]); + } } @@ -3620,6 +3707,10 @@ function setCaptainInEditor(side, playerId, role) { }); renderSquadEditorSide(side); + + if (!getMissingCaptainSidesFromEditor().length) { + closeCaptainRequiredWarning(); + } } function updateCoachInEditor(side, index, coachId) { @@ -3757,6 +3848,12 @@ function rebuildFormationFromMatchData(side) { async function applySquadEditorChanges() { if (!squadEditorState.home || !squadEditorState.away) return; + const missingCaptainSides = getMissingCaptainSidesFromEditor(); + if (missingCaptainSides.length) { + showCaptainRequiredWarning(missingCaptainSides); + return; + } + const payload = buildSquadSavePayload(); const result = await saveSquadEditorChangesToServer(payload); diff --git a/static/styles.css b/static/styles.css index 59479ec..aad4823 100644 --- a/static/styles.css +++ b/static/styles.css @@ -1897,6 +1897,63 @@ body.edit-mode-on .player-node.dragging { margin-top: 6px; } + +.squad-editor-captain-warning { + margin-bottom: 12px; + padding: 10px 12px; + border: 1px solid #f1b9b9; + border-radius: 10px; + background: #fff1f1; + color: #9f1d1d; + font-size: 13px; + font-weight: 800; + line-height: 1.35; +} + +#captainRequiredModal { + z-index: 3400; +} + +.captain-required-modal-content { + width: min(560px, 92vw); + margin-top: 16vh; + padding: 24px; + overflow: visible; + border: 1px solid #f0c7c7; +} + +.captain-required-icon { + width: 54px; + height: 54px; + display: flex; + align-items: center; + justify-content: center; + margin: 0 auto 14px; + border-radius: 50%; + background: #fff0f0; + color: #c62828; + border: 2px solid #ef9a9a; + font-size: 30px; + font-weight: 900; +} + +.captain-required-copy { + text-align: center; +} + +.captain-required-text { + margin-top: 10px; + color: #4d4d4d; + font-size: 15px; + font-weight: 650; + line-height: 1.5; +} + +.captain-required-actions { + justify-content: center; + margin-top: 22px; +} + @media (max-width: 1100px) { .squad-editor-grid { grid-template-columns: 1fr; diff --git a/templates/match_workspace.html b/templates/match_workspace.html index 3c62f00..6d4091c 100644 --- a/templates/match_workspace.html +++ b/templates/match_workspace.html @@ -1387,6 +1387,27 @@ data-role="{{ p.position or '' }}"
+
+
+ +
+
!
+
+
Не выбран капитан
+
+
+ + +
+
+