diff --git a/tests/test_build82_player_panel_reorder_and_settings_tabs.py b/tests/test_build82_player_panel_reorder_and_settings_tabs.py
new file mode 100644
index 0000000..67d6e5a
--- /dev/null
+++ b/tests/test_build82_player_panel_reorder_and_settings_tabs.py
@@ -0,0 +1,51 @@
+from pathlib import Path
+
+ROOT = Path(__file__).resolve().parents[1]
+APP_JS = (ROOT / "ui_builder/static/app.js").read_text(encoding="utf-8")
+STYLES = (ROOT / "ui_builder/static/styles.css").read_text(encoding="utf-8")
+
+
+def test_player_selection_slots_can_be_reordered_inside_panel():
+ for token in (
+ "hockeyPlayerSelectionDrag",
+ "async function hockeySwapPlayerSelectionSlots(panel, fromSlot, toSlot)",
+ 'application/x-ui-builder-hockey-player-selection-slot',
+ 'event.dataTransfer.dropEffect = "move"',
+ "await hockeySwapPlayerSelectionSlots(panel, sourceSlot, targetSlot)",
+ ):
+ assert token in APP_JS
+
+
+def test_swap_updates_both_slots_in_one_mapping_refresh():
+ start = APP_JS.index("async function hockeySwapPlayerSelectionSlots")
+ end = APP_JS.index("async function hockeyClearPlayerSelectionSlot", start)
+ block = APP_JS[start:end]
+ assert "...hockeyPlayerSelectionPatch(panel, targetSlot, sourcePlayer)" in block
+ assert "...hockeyPlayerSelectionPatch(panel, sourceSlot, targetPlayer)" in block
+ assert "await hockeySetMatchValues(patch, { refreshMapping: true });" in block
+
+
+def test_filled_player_slots_expose_drag_handle_and_move_cursor():
+ assert 'draggable="true" title="Перетащите на другой слот' in APP_JS
+ assert "hockey-player-select-drag" in APP_JS
+ assert '.hockey-player-select-slot[draggable="true"] { cursor:grab; }' in STYLES
+ assert ".hockey-player-select-slot.is-dragging" in STYLES
+
+
+def test_bottom_panel_settings_are_split_into_two_tabs():
+ for token in (
+ 'data-prematch-editor-tab="buttons"',
+ 'data-prematch-editor-tab="players"',
+ 'Кнопки',
+ 'Блоки игроков ${draftPlayerPanels.length} ',
+ 'activeEditorSection = section;',
+ ):
+ assert token in APP_JS
+ assert ".prematch-editor-tabs" in STYLES
+ assert ".prematch-editor-tab.active" in STYLES
+
+
+def test_player_panel_editor_only_renders_in_players_tab():
+ assert 'activeEditorSection === "buttons" ? `' in APP_JS
+ assert 'data-prematch-editor-section="players"' in APP_JS
+ assert 'player-selection-editor prematch-editor-section' in APP_JS
diff --git a/ui_builder/static/app.js b/ui_builder/static/app.js
index 73a3301..d534fc0 100644
--- a/ui_builder/static/app.js
+++ b/ui_builder/static/app.js
@@ -122,6 +122,7 @@
hockeyTimerDirty: false,
hockeyTimerRevision: 0,
hockeyDragPlayer: null,
+ hockeyPlayerSelectionDrag: null,
hockeyDragInfraction: null,
hockeyDragPreset: null,
editorAuthTimer: null,
@@ -7648,6 +7649,7 @@ function openHockeyPrematchButtonsEditor() {
let dragButtonId = "";
let dragSelectorId = "";
let activeEditorGroupId = String(state.quickPanelActiveTab || "");
+ let activeEditorSection = "buttons";
if (activeEditorGroupId === "__ungrouped") activeEditorGroupId = "";
if (activeEditorGroupId && !draftGroups.some((group) => group.id === activeEditorGroupId)) activeEditorGroupId = draftGroups[0]?.id || "";
const collapsedEditorGroups = new Set();
@@ -7755,22 +7757,31 @@ function openHockeyPrematchButtonsEditor() {
draftPlayerPanels = normalizePlayerSelectionPanels(draftPlayerPanels);
showSettingsModal("Нижняя панель · кнопки", `
-
-
Создавайте вкладки, кнопки и маленькие параметры рядом с кнопками. Значение параметра попадает в Mapping/SQL до запуска Shortcut Sequence.
-
- + Вкладка
- + Переключатель
- + Кнопка
-
-
-
- ${renderGroup(null, -1)}
- ${draftGroups.map((group, index) => renderGroup(group, index)).join("")}
-
-
-
- ${draftPlayerPanels.length ? draftPlayerPanels.map(renderPlayerPanelRow).join("") : `
Блоков игроков пока нет
`}
-
+
+ Кнопки
+ Блоки игроков ${draftPlayerPanels.length}
+
+ ${activeEditorSection === "buttons" ? `
+
+
+
Создавайте вкладки, кнопки и маленькие параметры рядом с кнопками. Значение параметра попадает в Mapping/SQL до запуска Shortcut Sequence.
+
+ + Вкладка
+ + Переключатель
+ + Кнопка
+
+
+
+ ${renderGroup(null, -1)}
+ ${draftGroups.map((group, index) => renderGroup(group, index)).join("")}
+
+
+ ` : `
+
+
+ ${draftPlayerPanels.length ? draftPlayerPanels.map(renderPlayerPanelRow).join("") : `
Блоков игроков пока нет
`}
+
+ `}
Отмена
Сохранить и опубликовать
@@ -7778,6 +7789,13 @@ function openHockeyPrematchButtonsEditor() {
`, { className: "modal-prematch-full" });
+ document.querySelectorAll("[data-prematch-editor-tab]").forEach((button) => button.addEventListener("click", () => {
+ const section = String(button.dataset.prematchEditorTab || "buttons");
+ if (!["buttons", "players"].includes(section) || section === activeEditorSection) return;
+ activeEditorSection = section;
+ renderEditor();
+ }));
+
document.querySelector("[data-prematch-add-group]")?.addEventListener("click", () => {
if (draftGroups.length >= 24) return toast("Можно создать до 24 вкладок", true);
const newGroup = { id: `group_${draftGroups.length + 1}`, label: `Вкладка ${draftGroups.length + 1}`, sort_order: draftGroups.length * 10, enabled: true };
@@ -8363,7 +8381,7 @@ function hockeyPlayerSelectionTeamId(player) {
if (!player) return "";
const side = player.side === "away" ? "away" : player.side === "home" ? "home" : "";
const raw = player.raw || {};
- const direct = raw.team_external_id || raw.team_id || raw.club_external_id || raw.club_id || raw.team?.external_id || raw.team?.id || "";
+ const direct = player.teamId || player.team_id || raw.team_external_id || raw.team_id || raw.club_external_id || raw.club_id || raw.team?.external_id || raw.team?.id || "";
if (direct) return String(direct);
if (!side) return "";
const team = getByPath(state.data, `hockey.selected_game.${side}`) || {};
@@ -8456,6 +8474,27 @@ async function hockeySetPlayerSelectionSlot(panel, slot, player) {
return true;
}
+async function hockeySwapPlayerSelectionSlots(panel, fromSlot, toSlot) {
+ const sourceSlot = clamp(Number(fromSlot) || 1, 1, panel.slots);
+ const targetSlot = clamp(Number(toSlot) || 1, 1, panel.slots);
+ if (sourceSlot === targetSlot) return false;
+ const source = hockeyPlayerSelectionSlot(panel, sourceSlot);
+ if (!source.id && !source.dbId) return false;
+ const target = hockeyPlayerSelectionSlot(panel, targetSlot);
+ const sourcePlayer = { externalId: source.id, dbId: source.dbId, teamId: source.teamId, side: source.side, number: source.number, name: source.name };
+ const targetPlayer = (target.id || target.dbId)
+ ? { externalId: target.id, dbId: target.dbId, teamId: target.teamId, side: target.side, number: target.number, name: target.name }
+ : null;
+ const patch = {
+ ...hockeyPlayerSelectionPatch(panel, targetSlot, sourcePlayer),
+ ...hockeyPlayerSelectionPatch(panel, sourceSlot, targetPlayer),
+ };
+ await hockeySetMatchValues(patch, { refreshMapping: true });
+ if (panel.sync_selected_player) await hockeySyncLegacySelectedPlayer(sourcePlayer, source.teamId);
+ toast(targetPlayer ? `${panel.label}: игроки ${sourceSlot} и ${targetSlot} поменяны местами` : `${panel.label}: игрок перемещён ${sourceSlot} → ${targetSlot}`);
+ return true;
+}
+
async function hockeyClearPlayerSelectionSlot(panel, slot) {
await hockeySetMatchValues(hockeyPlayerSelectionPatch(panel, slot, null), { refreshMapping: true });
if (panel.sync_selected_player) await hockeySyncLegacySelectedPlayer(null, "");
@@ -8569,10 +8608,10 @@ function renderHockeyPlayerSelectionWindows() {
const sideLabel = slot.side === "home" ? "HOME" : slot.side === "away" ? "AWAY" : "";
const title = [slot.number ? `#${slot.number}` : "", slot.name].filter(Boolean).join(" ") || (filledSlot ? `Игрок ${number}` : "Перетащите игрока");
const ids = filledSlot ? [sideLabel, slot.id ? `ID ${slot.id}` : "", slot.dbId ? `DB ${slot.dbId}` : ""].filter(Boolean).join(" · ") : "из состава слева или справа";
- return `
+ return `
${number}
${escapeHtml(title)} ${escapeHtml(ids)}
- ${filledSlot ? `
× ` : `
DROP `}
+ ${filledSlot ? `
⋮⋮ × ` : `
DROP `}
`;
}).join("")}
@@ -8584,7 +8623,29 @@ function renderHockeyPlayerSelectionWindows() {
renderRuntime();
});
node.querySelectorAll("[data-player-panel-slot]").forEach((slotNode) => {
+ slotNode.addEventListener("dragstart", (event) => {
+ const slot = Number(slotNode.dataset.playerPanelSlot || 1);
+ const current = hockeyPlayerSelectionSlot(panel, slot);
+ if (!current.id && !current.dbId) { event.preventDefault(); return; }
+ state.hockeyPlayerSelectionDrag = { panelId: panel.id, slot };
+ event.dataTransfer.effectAllowed = "move";
+ event.dataTransfer.setData("application/x-ui-builder-hockey-player-selection-slot", JSON.stringify(state.hockeyPlayerSelectionDrag));
+ event.dataTransfer.setData("text/plain", current.name || current.id || `player-${slot}`);
+ slotNode.classList.add("is-dragging");
+ });
+ slotNode.addEventListener("dragend", () => {
+ state.hockeyPlayerSelectionDrag = null;
+ slotNode.classList.remove("is-dragging");
+ node.querySelectorAll(".hockey-player-select-slot.is-over").forEach((item) => item.classList.remove("is-over"));
+ });
slotNode.addEventListener("dragover", (event) => {
+ const internal = state.hockeyPlayerSelectionDrag;
+ if (internal && internal.panelId === panel.id) {
+ event.preventDefault();
+ event.dataTransfer.dropEffect = "move";
+ slotNode.classList.add("is-over");
+ return;
+ }
const player = state.hockeyDragPlayer || readHockeyDragData(event, "player");
if (!player) return;
event.preventDefault();
@@ -8595,9 +8656,17 @@ function renderHockeyPlayerSelectionWindows() {
slotNode.addEventListener("drop", async (event) => {
event.preventDefault();
slotNode.classList.remove("is-over");
+ const targetSlot = Number(slotNode.dataset.playerPanelSlot || 1);
+ const internal = state.hockeyPlayerSelectionDrag;
+ if (internal && internal.panelId === panel.id) {
+ const sourceSlot = Number(internal.slot || 1);
+ state.hockeyPlayerSelectionDrag = null;
+ await hockeySwapPlayerSelectionSlots(panel, sourceSlot, targetSlot);
+ return;
+ }
const player = state.hockeyDragPlayer || readHockeyDragData(event, "player");
if (!player) return;
- await hockeySetPlayerSelectionSlot(panel, Number(slotNode.dataset.playerPanelSlot || 1), player);
+ await hockeySetPlayerSelectionSlot(panel, targetSlot, player);
});
});
node.querySelectorAll("[data-player-panel-clear-slot]").forEach((button) => button.addEventListener("click", async (event) => {
diff --git a/ui_builder/static/styles.css b/ui_builder/static/styles.css
index adb7b5c..d644e94 100644
--- a/ui_builder/static/styles.css
+++ b/ui_builder/static/styles.css
@@ -7363,3 +7363,73 @@ body.hockey-navigation-open .runtime-viewport.has-hockey-pbp { gap: 14px !import
.hockey-runtime-side-stack { flex-basis:290px; width:290px; }
.player-selection-editor-row { grid-template-columns:1fr 1fr; }
}
+
+/* Build82 — quick-panel settings tabs + reorderable player slots. */
+.prematch-editor-tabs {
+ display:flex;
+ align-items:center;
+ gap:6px;
+ padding:4px;
+ border:1px solid #2a4058;
+ border-radius:11px;
+ background:#0b1725;
+}
+.prematch-editor-tab {
+ min-height:34px;
+ display:flex;
+ align-items:center;
+ justify-content:center;
+ gap:7px;
+ padding:0 14px;
+ color:#7890a8;
+ border:1px solid transparent;
+ border-radius:8px;
+ background:transparent;
+ font-size:10px;
+ font-weight:950;
+ cursor:pointer;
+}
+.prematch-editor-tab:hover { color:#c8d7e5; background:#102238; }
+.prematch-editor-tab.active { color:#eaf5ff; border-color:#365a72; background:#14304a; box-shadow:inset 0 -2px 0 #48dfbd; }
+.prematch-editor-tab span {
+ min-width:20px;
+ height:18px;
+ display:grid;
+ place-items:center;
+ padding:0 5px;
+ border-radius:9px;
+ color:#a9c1d5;
+ background:#203b53;
+ font:900 8px "Roboto Mono",Consolas,monospace;
+}
+.prematch-editor-section { min-height:0; }
+.player-selection-editor.prematch-editor-section {
+ min-height:0;
+ margin-top:0;
+ padding-top:0;
+ border-top:0;
+ display:grid;
+ grid-template-rows:auto minmax(0,1fr);
+}
+.player-selection-editor.prematch-editor-section .player-selection-editor-list {
+ min-height:0;
+ overflow:auto;
+ padding-right:4px;
+ overscroll-behavior:contain;
+}
+
+.hockey-player-select-slot { grid-template-columns:27px minmax(0,1fr) auto auto; }
+.hockey-player-select-slot[draggable="true"] { cursor:grab; }
+.hockey-player-select-slot[draggable="true"]:active { cursor:grabbing; }
+.hockey-player-select-slot.is-dragging { opacity:.48; border-color:#48dfbd; }
+.hockey-player-select-drag {
+ width:16px;
+ display:grid;
+ place-items:center;
+ color:#58748b;
+ font-size:10px;
+ font-weight:950;
+ letter-spacing:-2px;
+ user-select:none;
+}
+.hockey-player-select-slot:hover .hockey-player-select-drag { color:#9ab2c6; }