1// Tournament Teams V2 â root Stimulus controller. 2// 3// Phase 8 wires HTML5 drag-and-drop for the four canonical gestures: 4// 1. Bench entry â team's player area â¶ add_from_bench 5// 2. Player chip â sidebar Bench â¶ remove_to_bench 6// 3. Player chip â existing team's player chip â¶ swap_partner 7// 4. Team row â sidebar division â¶ move_team 8// 9// Touch support is deferred (HTML5 D&D is mouse-only on most touch 10// surfaces; mobile admin is a separate phase). Phase 9 brings surgical 11// DOM repaint; for Phase 8 a successful drop reloads the page (matches 12// the Phase 7 modal pattern). 13 14import { Controller } from "@hotwired/stimulus"; 15 16const DRAG_MIME = "application/x-ttv-payload"; 17 18export default class extends Controller { 19 static targets = [ 20 "sidebar", "main", "bench", "kpiChips", "divisionLink", 21 "benchEntry", "teamRow", "divisionPane", 22 "bulkToolbar", "bulkSelectionCount", "bulkSelectAll", 23 "bulkDialog", "bulkDialogTitle", "bulkDialogBody", 24 "bulkMoveFields", "bulkMoveSelect", 25 "bulkPaidFields", "bulkPaidAmount", 26 "bulkRemoveFields", 27 "bulkDialogError", "bulkDialogConfirm", "bulkDialogConfirmLabel", 28 "removeFromTournamentModal", "rftConfirmBtn", "rftConfirmLabel" 29 ]; 30 static values = { tournamentId: Number }; 31 32 connect() { 33 this._dragPayload = null; 34 this._selection = new Set(); 35 // Scott 2026-05-13 â cross-surface sync. Whenever any team-roster-seed 36 // controller PATCHes (drawer, captains_page expanded roster, captain 37 // detail), we reorder the matching .ttv-team-row's chip cell in 38 // place so the table doesn't go stale until refresh. 39 this._onRosterSeedSaved = this.onRosterSeedSaved.bind(this); 40 document.addEventListener("team-roster-seed:saved", this._onRosterSeedSaved); 41 42 // 2026-06-08 â Seed-by-DUPR (reseed dialog) previously navigated away with 43 // window.location.assign, dropping the operator's division selection + 44 // scroll. It now dispatches this event; we surgically refresh just the 45 // affected division pane in place. 46 this._onDivisionReseeded = (e) => { 47 const id = e && e.detail && e.detail.divisionId; 48 if (id) this._refreshDivisionPane(id); 49 }; 50 document.addEventListener("tournament-teams-v2:reseeded", this._onDivisionReseeded); 51 52 // 2026-06-08 â Roster mutations from the team detail drawer (make captain, 53 // remove player to bench) previously did window.location.reload(), which 54 // violates §3.8 and drops the operator's division/scroll state. They now 55 // dispatch this event; we surgically refresh the affected division pane 56 // AND the bench (remove-to-bench adds a bench entry) in place. 57 this._onRosterChanged = (e) => { 58 const id = e && e.detail && e.detail.divisionId; 59 const tasks = []; 60 if (id) tasks.push(this._refreshDivisionPane(id)); 61 if (typeof this._refreshBenchSection === "function") tasks.push(this._refreshBenchSection()); 62 return Promise.all(tasks).catch(() => {}); 63 }; 64 document.addEventListener("tournament-teams-v2:roster-changed", this._onRosterChanged); 65 66 // 2026-05-14 â Cross-page jump: the Divisions page links here with 67 // `?open=add_team` so the operator can go straight from "look at my 68 // divisions" â "add a player/team" without a second click. Defer to 69 // the next frame so the modal Stimulus controllers have time to 70 // connect (DOM is ready but child controllers init async). 71 try { 72 const params = new URLSearchParams(window.location.search); 73 if (params.get("open") === "add_team") { 74 requestAnimationFrame(() => { 75 this.openAddTeam(new Event("auto-open")); 76 }); 77 } 78 } catch (e) { 79 // No-op â auto-open is a convenience; failures here shouldn't 80 // break the page load. 81 } 82 } 83 84 disconnect() { 85 if (this._onRosterSeedSaved) { 86 document.removeEventListener("team-roster-seed:saved", this._onRosterSeedSaved); 87 } 88 if (this._onDivisionReseeded) { 89 document.removeEventListener("tournament-teams-v2:reseeded", this._onDivisionReseeded); 90 } 91 if (this._onRosterChanged) { 92 document.removeEventListener("tournament-teams-v2:roster-changed", this._onRosterChanged); 93 } 94 if (this._evpickerOutsideHandler) { 95 document.removeEventListener("click", this._evpickerOutsideHandler); 96 } 97 } 98 99 onRosterSeedSaved(event) { 100 const { teamId, userIds, source }
100 = event.detail || {}; 101 if (!teamId || !Array.isArray(userIds)) return; 102 103 // 1) V2 chip row in this table â reorder unless the drag originated here. 104 const teamRow = this.element.querySelector(`.ttv-team-row[data-team-id="${teamId}"]`); 105 if (teamRow) { 106 const container = teamRow.querySelector(".ttv-player-avatars"); 107 if (container && container !== source) { 108 const chips = Array.from(container.querySelectorAll(".ttv-player-chip")); 109 const byId = new Map(chips.map((c) => [parseInt(c.dataset.userId, 10), c])); 110 userIds.forEach((uid) => { 111 const chip = byId.get(uid); 112 if (chip) container.appendChild(chip); 113 }); 114 } 115 } 116 117 // 2) Drawer roster â if it's open and showing this team, sync rows 118 // in place. The drawer roster <ul> is identified by data-controller 119 // = "team-roster-seed" but rendered outside our controller scope, so 120 // query the document. 121 const drawerUl = document.querySelector( 122 'aside.ttv-drawer:not([hidden]) ul.ttv-drawer-roster[data-controller~="team-roster-seed"]' 123 ); 124 if (drawerUl && drawerUl !== source) { 125 const drawerUrl = drawerUl.dataset.teamRosterSeedUpdateUrlValue || ""; 126 const drawerTeamMatch = drawerUrl.match(/\/teams_v2\/teams\/(\d+)\/seed_order/); 127 const drawerTeamId = drawerTeamMatch ? parseInt(drawerTeamMatch[1], 10) : null; 128 if (drawerTeamId === teamId) { 129 const rows = Array.from(drawerUl.querySelectorAll("[data-team-roster-seed-target~='row']")); 130 const byId = new Map(rows.map((r) => [parseInt(r.dataset.userId, 10), r])); 131 userIds.forEach((uid) => { 132 const row = byId.get(uid); 133 if (row) drawerUl.appendChild(row); 134 }); 135 } 136 } 137 } 138 139 // ââ Drag start handlers â encode payload onto dataTransfer âââââââââââââ 140 141 benchDragStart(event) { 142 const el = event.currentTarget; 143 // Drag eats a synthetic click after drop on some browsers â flag so 144 // the click handler skips the toggle on this entry. 145 this._suppressNextBenchClick = true; 146 const draggedId = parseInt(el.dataset.userId, 10); 147 148 // 2026-05-14 â Multi-select drag. If the user has a multi-selection 149 // active AND they dragged a player who's IN the selection, the drag 150 // carries ALL selected user_ids as a bulk payload. Dragging an 151 // unselected player just drags that one (and clears the multi- 152 // selection state since the operator clearly switched intent). 153 const selSize = this._benchSelection ? this._benchSelection.size : 0; 154 if (selSize > 1 && this._benchSelection.has(String(draggedId))) { 155 const userIds = Array.from(this._benchSelection).map((id) => parseInt(id, 10)); 156 const payload = { 157 kind: "bench_players_bulk", 158 user_ids: userIds, 159 source: "bench" 160 }; 161 this._setDragPayload(event, payload); 162 // Visually mark every selected row as the active drag group. 163 userIds.forEach((uid) => { 164 const ent = document.querySelector(`.ttv-bench-entry[data-user-id="${uid}"]`); 165 if (ent) ent.classList.add("ttv-dragging"); 166 }); 167 return; 168 } 169 170 // Single-player drag. 171 const payload = { 172 kind: "bench_player", 173 user_id: draggedId, 174 user_name: el.dataset.userName || "", 175 source: el.dataset.source, 176 team_id: el.dataset.teamId ? parseInt(el.dataset.teamId, 10) : null 177 }; 178 this._setDragPayload(event, payload); 179 el.classList.add("ttv-dragging"); 180 } 181 182 playerDragStart(event) { 183 const el = event.currentTarget; 184 const payload = { 185 kind: "team_player", 186 user_id: parseInt(el.dataset.userId, 10), 187 team_id: parseInt(el.dataset.teamId, 10) 188 }; 189 this._setDragPayload(event, payload); 190 el.classList.add("ttv-dragging"); 191 event.stopPropagation(); // prevent the parent <tr> from also starting a team drag 192 } 193 194 // Scott 2026-05-13 â intra-team horizontal drag-and-drop reorders 195 // players within a single team. Dropping a player chip on a sibling 196 // chip moves it before/after based on cursor X, then PATCHes the 197 // team's roster_seed_order via the new update_seed_order endpoint. 198 // Cross-team drag of a chip is a no-op for now (the existing team-row 199 // drop handles whole-team reordering). 200 playerDragOver(event) { 201 const payload = this._dragPayload; 202 if (!payload || payload.kind !== "team_player") return; 203 const target = event.currentTarget; 204 if (parseInt(target.dataset.teamId, 10) !== payload.team_id) return;
205 if (parseInt(target.dataset.userId, 10) === payload.user_id) return; 206 event.preventDefault(); 207 event.stopPropagation(); 208 const rect = target.getBoundingClientRect(); 209 const before = (event.clientX - rect.left) < (rect.width / 2); 210 target.classList.add("ttv-player-drop-target"); 211 target.classList.toggle("ttv-player-drop-target--before", before); 212 target.classList.toggle("ttv-player-drop-target--after", !before); 213 } 214 215 playerDragLeave(event) { 216 const target = event.currentTarget; 217 // Don't clear if leaving onto a child element of the same chip. 218 if (event.relatedTarget && target.contains(event.relatedTarget)) return; 219 target.classList.remove("ttv-player-drop-target", "ttv-player-drop-target--before", "ttv-player-drop-target--after"); 220 } 221 222 async playerDrop(event) { 223 const payload = this._dragPayload; 224 if (!payload || payload.kind !== "team_player") return; 225 const target = event.currentTarget; 226 if (parseInt(target.dataset.teamId, 10) !== payload.team_id) return; 227 if (parseInt(target.dataset.userId, 10) === payload.user_id) return; 228 event.preventDefault(); 229 event.stopPropagation(); 230 target.classList.remove("ttv-player-drop-target", "ttv-player-drop-target--before", "ttv-player-drop-target--after"); 231 232 const teamId = payload.team_id; 233 const sourceChip = this.element.querySelector(`.ttv-player-chip[data-team-id="${teamId}"][data-user-id="${payload.user_id}"]`); 234 if (!sourceChip) return; 235 const container = target.closest(".ttv-player-avatars"); 236 if (!container) return; 237 238 const rect = target.getBoundingClientRect(); 239 const insertBefore = (event.clientX - rect.left) < (rect.width / 2); 240 if (insertBefore) { 241 container.insertBefore(sourceChip, target); 242 } else { 243 container.insertBefore(sourceChip, target.nextSibling); 244 } 245 246 // Collect new user_ids in DOM order and PATCH. 247 const ids = Array.from(container.querySelectorAll(".ttv-player-chip")) 248 .map((chip) => parseInt(chip.dataset.userId, 10)) 249 .filter((n) => Number.isInteger(n) && n > 0); 250 251 // Per-team endpoint URL is rendered onto the chip server-side 252 // (`data-seed-order-url`). Use the source chip's URL â it belongs to 253 // the same team as the drop target by construction. 254 const url = sourceChip.dataset.seedOrderUrl; 255 if (!url) return; 256 try { 257 const csrf = document.querySelector('meta[name="csrf-token"]')?.content; 258 const res = await fetch(url, { 259 method: "PATCH", 260 credentials: "same-origin", 261 headers: { 262 "Content-Type": "application/json", 263 Accept: "application/json", 264 "X-CSRF-Token": csrf, 265 }, 266 body: JSON.stringify({ user_ids: ids }), 267 }); 268 if (!res.ok) { 269 const data = await res.json().catch(() => ({})); 270 if (window.showToast) window.showToast(data.error || "Failed to save player order", "error"); 271 } else { 272 if (window.showToast) window.showToast("Player order saved", "success"); 273 // Scott 2026-05-13 â broadcast so any open drawer / captains-page 274 // expanded roster / captain detail for this team re-syncs to the 275 // new order without a refresh. `source` = the chip container the 276 // drag came from, so the V2 controller's own listener skips it. 277 document.dispatchEvent(new CustomEvent("team-roster-seed:saved", { 278 detail: { teamId, userIds: ids, source: container }, 279 bubbles: true, 280 })); 281 } 282 } catch (e) { 283 if (window.showToast) window.showToast("Network error saving player order", "error"); 284 } 285 } 286 287 teamDragStart(event) { 288 const row = event.currentTarget; 289 const payload = { 290 kind: "team", 291 team_id: parseInt(row.dataset.teamId, 10), 292 lock_version: parseInt(row.dataset.lockVersion || "0", 10), 293 source_division_id: this._activeDivisionId() 294 }; 295 this._setDragPayload(event, payload); 296 row.classList.add("ttv-dragging"); 297 } 298 299 // ââ Generic end handlers ââââââââââââââââââââââââââââââââââââââââââââââ 300 301 dragEnd(event) { 302 event.currentTarget.classList.remove("ttv-dragging"); 303 // For a multi-bench bulk drag, EVERY selected entry got the 304 // ttv-dragging class. Sweep them all clean.
305 document.querySelectorAll(".ttv-bench-entry.ttv-dragging") 306 .forEach((el) => el.classList.remove("ttv-dragging")); 307 this._clearDropHints(); 308 } 309 310 dragLeave(event) { 311 event.currentTarget.classList.remove("ttv-drop-active", "ttv-slot--drop-active", "ttv-slot--swap-target"); 312 } 313 314 // ââ Team-row drop target (drop a bench player or another team's player 315 // onto a row to add/swap). âââââââââââââââââââââââââââââââââââââââ 316 317 teamRowDragOver(event) { 318 const payload = this._dragPayload; 319 if (!payload) return; 320 // Only team-row drag reorders seeds. Player drag onto team rows is a 321 // no-op (use the Add Team modal or the bench's own actions instead). 322 if (payload.kind !== "team") return; 323 if (payload.team_id === parseInt(event.currentTarget.dataset.teamId, 10)) return; 324 event.preventDefault(); 325 // Show top-half vs bottom-half indicator based on cursor Y. 326 const rect = event.currentTarget.getBoundingClientRect(); 327 const above = (event.clientY - rect.top) < (rect.height / 2); 328 event.currentTarget.classList.add("ttv-drop-active"); 329 event.currentTarget.classList.toggle("ttv-drop-active--above", above); 330 event.currentTarget.classList.toggle("ttv-drop-active--below", !above); 331 } 332 333 async teamRowDrop(event) { 334 event.preventDefault(); 335 const targetRow = event.currentTarget; 336 targetRow.classList.remove("ttv-drop-active", "ttv-drop-active--above", "ttv-drop-active--below"); 337 const payload = this._dragPayload; 338 if (!payload || payload.kind !== "team") return; 339 const targetTeamId = parseInt(targetRow.dataset.teamId, 10); 340 if (!targetTeamId || targetTeamId === payload.team_id) return; 341 342 const sourceRow = this.element.querySelector(`.ttv-team-row[data-team-id="${payload.team_id}"]`); 343 if (!sourceRow) return; 344 const tbody = targetRow.parentNode; 345 if (!tbody) return; 346 347 // Determine drop position relative to the target row. 348 const rect = targetRow.getBoundingClientRect(); 349 const dropAbove = (event.clientY - rect.top) < (rect.height / 2); 350 351 // Optimistic DOM reorder. 352 if (dropAbove) { 353 tbody.insertBefore(sourceRow, targetRow); 354 } else { 355 tbody.insertBefore(sourceRow, targetRow.nextSibling); 356 } 357 358 // Renumber seeds 1..N for all visible team rows in this tbody, then 359 // PATCH every row whose seed actually changed. 360 const rows = Array.from(tbody.querySelectorAll(".ttv-team-row")); 361 const updates = []; 362 rows.forEach((row, idx) => { 363 const newSeed = idx + 1; 364 const seedEl = row.querySelector(".ttv-seed"); 365 const currentSeed = parseInt(seedEl?.dataset?.currentValue || "0", 10) || null; 366 if (currentSeed !== newSeed) { 367 if (seedEl) { 368 seedEl.textContent = String(newSeed); 369 seedEl.dataset.currentValue = String(newSeed); 370 } 371 updates.push({ 372 team_id: parseInt(row.dataset.teamId, 10), 373 lock_version: parseInt(row.dataset.lockVersion || "0", 10), 374 seed_number: newSeed 375 }); 376 } 377 }); 378 379 // Fire PATCH per affected row in parallel â small N (rarely > 30 teams 380 // per division). If any single update fails the row's optimistic seed 381 // stays put and a toast surfaces; bulk-revert would be heavier than the 382 // benefit on a manual reorder. 383 if (updates.length === 0) return; 384 const results = await Promise.all(updates.map((u) => this._postBackground( 385 this._teamUrl(u.team_id, ""), 386 { seed_number: u.seed_number, lock_version: u.lock_version }, 387 "PATCH" 388 ))); 389 results.forEach((json, i) => { 390 if (!json) return; 391 const row = this.element.querySelector(`.ttv-team-row[data-team-id="${updates[i].team_id}"]`); 392 if (row && json.lock_version) row.dataset.lockVersion = json.lock_version; 393 }); 394 } 395 396 // Build a snapshot of the dragged bench entry so we can revert on failure. 397 _captureBenchSnapshot(userId) { 398 const benchEntry = this.element.querySelector(`.ttv-bench-entry[data-user-id="${userId}"]`); 399 if (!benchEntry) return null; 400 const benchLi = benchEntry.closest("li") || benchEntry; 401 return { 402 userId, 403 benchLi, 404 benchParent: benchLi.parentNode, 405 benchNextSibling: benchLi.nextSibling, 406 avatarHtml: benchEntry.querySelector(".pb-avatar")?.outerHTML || "", 407 name: benchEntry.querySelector(".ttv-bench-entry-name")?.textContent?.trim() || "Player" 408 }; 409 } 410 411 // Removes the bench <li> and injects a player chip into the target team
412 // row's `.ttv-player-avatars` cell. Returns the injected chip so the 413 // caller can revert it on failure. 414 _applyBenchToTeamOptimistic(snap, targetRow) { 415 const targetCell = targetRow.querySelector(".ttv-player-avatars"); 416 if (!targetCell) return null; 417 418 const chip = document.createElement("span"); 419 chip.className = "ttv-player-chip ttv-player-chip--optimistic"; 420 chip.draggable = true; 421 chip.dataset.userId = String(snap.userId); 422 chip.dataset.teamId = targetRow.dataset.teamId; 423 chip.dataset.userName = snap.name; 424 chip.title = snap.name; 425 // Split "First Last" so the chip matches the server-rendered structure 426 // (two-line name beneath the avatar). Anything past the first whitespace 427 // group is the last name. 428 const parts = snap.name.split(/\s+/); 429 const first = parts.shift() || snap.name; 430 const last = parts.join(" "); 431 const esc = (s) => String(s || "").replace(/[&<>"]/g, (c) => ( 432 { "&": "&", "<": "<", ">": ">", '"': """ }[c] 433 )); 434 chip.innerHTML = 435 `${snap.avatarHtml}` +
436 `<span class="ttv-player-chip-name">` + 437 `<span class="ttv-player-chip-first">${esc(first)}</span>` + 438 (last ? `<span class="ttv-player-chip-last">${esc(last)}</span>` : "") + 439 `</span>`; 440 // Insert before the "+ partner" pill if present, else at end. 441 const partnerPill = targetCell.querySelector(".ttv-needs-partner-pill"); 442 if (partnerPill) { 443 targetCell.insertBefore(chip, partnerPill); 444 } else { 445 targetCell.appendChild(chip); 446 } 447 448 snap.benchLi.remove(); 449 this._adjustBenchCount(-1); 450 return chip; 451 } 452 453 _revertBenchToTeam(snap, optimisticChip) { 454 optimisticChip?.remove(); 455 snap.benchParent.insertBefore(snap.benchLi, snap.benchNextSibling); 456 this._adjustBenchCount(+1); 457 this._toast("Couldn't move player â restored to bench.", "error"); 458 } 459 460 _adjustBenchCount(delta) { 461 const badge = this.element.querySelector(".ttv-bench-count"); 462 if (!badge) return; 463 const current = parseInt(badge.textContent || "0", 10) || 0; 464 const next = Math.max(0, current + delta); 465 badge.textContent = String(next); 466 } 467 468 // Background HTTP call â no page reload, returns JSON on success or null 469 // on failure (with a toast). Used for snappy drag-and-drop updates so the 470 // user sees the change immediately and we only reconcile on the response. 471 // method defaults to POST; pass "PATCH" for inline updates. 472 // Translate server error CODES into human toasts (the raw "team_full" toast was 473 // confusing â Scott 2026-08-23). Returns null for unknown codes so the caller 474 // falls back to the raw string. 475 _humanizeError(code, body) { 476 const map = { 477 team_full: "That team is already full â the board may be out of date, so it's been refreshed. Pick a team with an open slot.", 478 already_on_team: "That player is already on this team.", 479 already_on_a_team: "That player is already on a team in this division.", 480 // 2026-08-28 â The server now returns conflict_team / conflict_division so the 481 // operator is told WHERE, not just "somewhere". Without it this read as nonsense 482 // when dragging inside a single division: the blocking roster is off-screen. 483 cross_division_uniqueness: "That player is already on a team in another division.", 484 cross_division_move_unsupported: "You can't move a player across divisions.", 485 stale_lock_version: "The board changed since it loaded â it's been refreshed. Try again.", 486 user_not_found: "That player couldn't be found.", 487 team_not_found: "That team no longer exists â the board has been refreshed.", 488 user_not_on_source_team: "That player isn't on the team you dragged from." 489 }; 490 // Name the blocking roster when the server told us where it is. "Already on a team 491 // in another division" is unactionable while you are looking at one division; the 492 // operator needs to know which roster to clear. 493 if (code === "cross_division_uniqueness" && body && body.conflict_division) { 494 const team = body.conflict_team ? `${body.conflict_team} in ` : ""; 495 return `That player is already rostered on ${team}${body.conflict_division}. Remove them there first.`; 496 } 497 return code ? (map[code] || null) : null; 498 } 499 500 async _postBackground(url, payload, method = "POST") { 501 if (!url) return null; 502 try { 503 const res = await fetch(url, { 504 method, 505 headers: { 506 "Content-Type": "application/json", 507 Accept: "application/json", 508 "X-CSRF-Token": this._csrfToken() 509 }, 510 body: JSON.stringify(payload) 511 }); 512 const json = await res.json().catch(() => ({})); 513 // â ï¸ THE PROMPT LIVES IN THE SHARED POSTER, not in each caller. Wiring only `_compConfirmTeam` left 514 // `add_player_manual` and `add_to_bench` â both newly gated in the same change â refusing with a toast 515 // and no field to type into. Putting it here means every present and future door on this controller 516 // inherits it, which is the same argument that put the server gate in the service rather than the 517 // controller. Retries once with the PIN folded into the SAME payload. 518 if (!res.ok && window.pbNeedsManagerPin && window.pbNeedsManagerPin(json)) { 519 const pin = await window.pbCollectManagerPin(json); 520 if (!pin) return null;
521 return this._postBackground(url, Object.assign({}, payload, { override_pin: pin }), method); 522 } 523 if (!res.ok) { 524 this._toast(this._humanizeError(json.error, json) || json.error || `Request failed (${res.status})`, "error"); 525 return null; 526 } 527 return json; 528 } catch (err) { 529 this._toast(err.message || "Network error.", "error"); 530 return null; 531 } 532 } 533 534 // ââ Bench drop target (drop a player onto the bench to free them) âââââ 535 536 benchDragOver(event) { 537 const payload = this._dragPayload; 538 if (!payload) return; 539 if (payload.kind === "team_player") { 540 event.preventDefault(); 541 event.currentTarget.classList.add("ttv-drop-active"); 542 } 543 } 544 545 async benchDrop(event) { 546 event.preventDefault(); 547 event.currentTarget.classList.remove("ttv-drop-active"); 548 const payload = this._dragPayload; 549 if (!payload || payload.kind !== "team_player") return; 550 551 await this._post(this._teamUrl(payload.team_id, "remove_player_to_bench"), { 552 user_id: payload.user_id 553 }); 554 } 555 556 // ââ Division-link drop target (drop a team onto a sidebar division 557 // to move it) ââââââââââââââââââââââââââââââââââââââââââââââââââ 558 559 divisionDragOver(event) { 560 const payload = this._dragPayload; 561 if (!payload) return; 562 const pane = event.currentTarget; 563 const isMixer = pane.dataset.divisionMode === "individual_rotating"; 564 // Legacy: dragging a team row onto another division re-homes it. 565 if (payload.kind === "team") { 566 event.preventDefault(); 567 pane.classList.add("ttv-drop-active"); 568 return; 569 } 570 // New (mixer-only): drag a bench player, multi-bench bulk, or a 571 // filled slot from another division onto a mixer pane body to 572 // register them here. Team-style panes ignore body drops â their 573 // slots and +New Team blank are the explicit targets. 574 if (isMixer && ["bench_player", "bench_players_bulk", "filled_slot"].includes(payload.kind)) { 575 event.preventDefault(); 576 pane.classList.add("ttv-drop-active"); 577 } 578 } 579 580 async divisionDrop(event) { 581 event.preventDefault(); 582 const pane = event.currentTarget; 583 pane.classList.remove("ttv-drop-active"); 584 const payload = this._dragPayload; 585 if (!payload) return; 586 const targetDivisionId = parseInt(pane.dataset.divisionId, 10); 587 if (!targetDivisionId) return; 588 589 // Legacy team-row reorder. 590 if (payload.kind === "team") { 591 if (payload.source_division_id === targetDivisionId) return; 592 await this._postBackground(this._teamUrl(payload.team_id, "move"), { 593 tournament_division_id: targetDivisionId, 594 lock_version: payload.lock_version 595 }); 596 // Surgical refresh both affected divisions if we can find them. 597 await this._refreshDivisionPane(targetDivisionId); 598 if (payload.source_division_id) { 599 await this._refreshDivisionPane(payload.source_division_id); 600 } 601 return; 602 } 603 604 // Bench player / filled-slot dropped onto a MIXER pane body = 605 // register that player as a solo entry in this division. 606 const isMixer = pane.dataset.divisionMode === "individual_rotating"; 607 if (!isMixer) return; 608 609 if (payload.kind === "bench_player" || payload.kind === "bench_players_bulk") { 610 const userIds = payload.kind === "bench_players_bulk" 611 ? payload.user_ids 612 : [payload.user_id]; 613 614 // ââ Optimistic insert ââââââââââââââââââââââââââââââââââââââââââââ 615 // Render phantom chips immediately so the drop feels instant â 616 // before the POST has even started. The phantoms are keyed by 617 // user_id so we can swap them with real HTML on response (or 618 // remove them on failure). 619 const targetDivisionName = pane.dataset.divisionName || ""; 620 const phantomIds = this._insertPhantomChips(pane, userIds); 621 // Bench-side optimistic: add the pill in place now. On failure 622 // we'll roll it back. 623 this._addBenchPillForUsers(userIds, targetDivisionId, targetDivisionName); 624 if (this._benchSelection && this._benchSelection.size > 0) { 625 this.clearBenchSelection(); 626 } 627 628 const res = await this._postBulkAdd(targetDivisionId, userIds); 629 if (!res) { 630 // Hard failure â roll back the phantom + pill. 631 this._rollbackPhantomChips(phantomIds); 632 this._rollbackBenchPills(userIds, targetDivisionId); 633 return; 634 } 635 636 // Swap phantoms for real HTML from the response. The server 637 // already rendered them via render_to_string, so no extra G
637ET. 638 const addedIds = (res && Array.isArray(res.added_user_ids)) ? res.added_user_ids : []; 639 const cardsHtml = (res && Array.isArray(res.created_team_html)) ? res.created_team_html : []; 640 this._swapPhantomsWithRealHtml(pane, phantomIds, addedIds, cardsHtml, res.mode); 641 642 // Rollback bench pills for users the server skipped. 643 const failed = (res && Array.isArray(res.failed)) ? res.failed : []; 644 if (failed.length > 0) { 645 const skippedIds = failed.map((f) => parseInt(f.user_id, 10)); 646 this._rollbackBenchPills(skippedIds, targetDivisionId); 647 const msg = `${addedIds.length} added, ${failed.length} skipped:\n` + 648 failed.map((f) => `⢠user ${f.user_id}: ${f.error}`).join("\n"); 649 this._toast(msg, "warning"); 650 } else if (addedIds.length > 0) { 651 this._toast( 652 `Added ${addedIds.length} player${addedIds.length === 1 ? "" : "s"}.`, 653 "success" 654 ); 655 } 656 this._bumpSidebarCount(targetDivisionId, addedIds.length); 657 // Reorder the bench (unregistered â top, registered â bottom) 658 // via a background refresh. Fire-and-forget so the operator gets 659 // their toast + visual feedback immediately while the sort 660 // catches up in the background. 661 this._refreshBenchSection(); 662 return; 663 } 664 665 if (payload.kind === "filled_slot") { 666 // Re-home: pull off old team, register in mixer. 667 try { 668 await this._postRemoveToBench(payload.team_id, payload.user_id); 669 await this._postBulkAdd(targetDivisionId, [payload.user_id]); 670 if (payload.division_id) await this._refreshDivisionPane(payload.division_id); 671 await this._refreshDivisionPane(targetDivisionId); 672 // Silent on success â the board repaint IS the feedback; only failures toast (Scott 2026-08-18). 673 } catch (e) { 674 console.error("[ttv] mixer drop from filled-slot failed", e); 675 this._toast(`Could not move: ${e.message || e}`, "error"); 676 } 677 } 678 } 679 680 // ââ Bench multi-select + click-to-add ââââââââââââââââââââââââââââââââ 681 // 682 // Clicking a bench entry toggles a `.ttv-bench-entry--selected` class 683 // on it. The selection set is mirrored on the controller via 684 // `_benchSelection` (Set of user_id strings). When â¥1 entry is 685 // selected, a floating action bar appears at the bottom of the 686 // viewport summarising the count + a Clear button. Clicking a 687 // division pane while any entries are selected pops a confirmation 688 // dialog and POSTs to the bulk-add endpoint. 689 690 toggleBenchSelection(event) { 691 // Don't trigger on drag â Stimulus fires click after a drag ends 692 // (browsers treat drop as a kind of click). The drag handler clears 693 // _suppressNextBenchClick so we can ignore one synthetic click. 694 if (this._suppressNextBenchClick) { 695 this._suppressNextBenchClick = false; 696 return; 697 } 698 const el = event.currentTarget; 699 const uid = el.dataset.userId; 700 if (!uid) return; 701 if (!this._benchSelection) this._benchSelection = new Set(); 702 703 if (this._benchSelection.has(uid)) { 704 this._benchSelection.delete(uid); 705 el.classList.remove("ttv-bench-entry--selected"); 706 } else { 707 this._benchSelection.add(uid); 708 el.classList.add("ttv-bench-entry--selected"); 709 } 710 this._renderBenchSelectionBar(); 711 } 712 713 // Clicking an existing M/F chip â pops a small floating picker 714 // anchored to the chip so the admin can flip the value. ESC or 715 // click-outside dismisses. Choosing the same value is a no-op. 716 openGenderPopover(event) { 717 event.stopPropagation(); 718 const chip = event.currentTarget; 719 const userId = parseInt(chip.dataset.userId, 10); 720 const current = chip.dataset.currentGender; // "male" | "female" 721 if (!userId || !current) return; 722 // Dismiss any existing popover first. 723 this._closeGenderPopover(); 724 725 // Build the popover. 726 const pop = document.createElement("div"); 727 pop.className = "ttv-gender-popover"; 728 pop.innerHTML = ` 729 <div class="ttv-gender-popover__head">Change gender</div> 730 <div class="ttv-gender-popover__btns"> 731 <button type="button" 732 class="ttv-gender-popover__btn ttv-gender-popover__btn--m ${current === "male" ? "ttv-gender-popover__btn--current" : ""}" 733 data-gender="male">M · Male</button> 734 <button type="button" 735 class="ttv-gender-popover__btn ttv-gender-popover__btn--f ${current === "female" ? "ttv-gender-popover__btn--current" : ""}" 736 data-gender="female">F · Female</button> 737 </div> 738 `;
739 document.body.appendChild(pop); 740 741 // Position below the chip; flip above if it would clip the viewport. 742 const r = chip.getBoundingClientRect(); 743 const popH = pop.offsetHeight; 744 const popW = pop.offsetWidth; 745 let top = window.scrollY + r.bottom + 6; 746 let left = window.scrollX + r.left + (r.width / 2) - (popW / 2); 747 if (left < 8) left = 8; 748 if (left + popW > window.innerWidth - 8) left = window.innerWidth - popW - 8; 749 if (r.bottom + popH + 12 > window.innerHeight) { 750 // Flip above. 751 top = window.scrollY + r.top - popH - 6; 752 } 753 pop.style.top = `${top}px`; 754 pop.style.left = `${left}px`; 755 756 // Wire button clicks. 757 pop.querySelectorAll(".ttv-gender-popover__btn").forEach((b) => { 758 b.addEventListener("click", async (e) => { 759 e.stopPropagation(); 760 const next = b.dataset.gender; 761 if (next === current) { 762 this._closeGenderPopover(); 763 return; 764 } 765 await this._changeUserGender(userId, next); 766 this._closeGenderPopover(); 767 }); 768 }); 769 770 // Click-outside + ESC handlers (one-shot via the close helper). 771 this._genderPopoverEl = pop; 772 this._genderPopoverOff = (e) => { 773 if (!pop.contains(e.target) && e.target !== chip) this._closeGenderPopover(); 774 }; 775 this._genderPopoverEsc = (e) => { if (e.key === "Escape") this._closeGenderPopover(); }; 776 document.addEventListener("mousedown", this._genderPopoverOff); 777 document.addEventListener("keydown", this._genderPopoverEsc); 778 } 779 780 _closeGenderPopover() { 781 if (this._genderPopoverEl) { 782 this._genderPopoverEl.remove(); 783 this._genderPopoverEl = null; 784 } 785 if (this._genderPopoverOff) { 786 document.removeEventListener("mousedown", this._genderPopoverOff); 787 this._genderPopoverOff = null; 788 } 789 if (this._genderPopoverEsc) { 790 document.removeEventListener("keydown", this._genderPopoverEsc); 791 this._genderPopoverEsc = null; 792 } 793 } 794 795 // PATCH with force=true so the server allows overwriting an existing 796 // value. Updates every visible chip (bench, mixer, slots) for this 797 // user in place so the page reflects the change without a reload. 798 async _changeUserGender(userId, newGender) { 799 const res = await this._postBackground( 800 `/club_admin/${this._clubSlug()}/tournaments/${this._tournamentSlug()}/teams_v2/users/${userId}/gender`, 801 { gender: newGender, force: true }, 802 "PATCH" 803 ); 804 if (!res) return; 805 this._applyGenderToAllChips(userId, newGender); 806 this._toast(`Gender updated to ${newGender === "male" ? "Male" : "Female"}.`, "success"); 807 } 808 809 // ââ Pending badge â breakdown + comp override âââââââââââââââââââââââââ 810 // Clicking a Pending status pill fetches WHY the team is pending and shows 811 // a small popover listing the blockers (unpaid seats / gender balance) with 812 // an "Override & mark comped" action. The breakdown is fetched on demand so 813 // the team list pays no per-row cost. 814 async openPendingBreakdown(event) { 815 event.stopPropagation(); 816 if (event.type === "keydown") event.preventDefault(); // Space must not scroll the page 817 const pill = event.currentTarget; 818 const row = pill.closest(".ttv-team-row"); 819 const teamId = pill.dataset.teamId || (row && row.dataset.teamId); 820 if (!teamId) return; 821 const base = this._teamUpdateUrl(teamId); 822 if (!base) return; 823 824 this._closePendingPopover(); 825 826 let data; 827 try { 828 const res = await fetch(`${base}/pending_breakdown`, { 829 headers: { Accept: "application/json" } 830 }); 831 data = await res.json().catch(() => ({})); 832 if (!res.ok) { this._toast(data.error || "Couldn't load details.", "error"); return; } 833 } catch (err) { 834 this._toast(err.message || "Network error.", "error"); 835 return; 836 } 837 838 const reasons = Array.isArray(data.reasons) ? data.reasons : []; 839 const hasBlockers = reasons.length > 0; 840 const reasonList = hasBlockers 841 ? `<ul class="ttv-pending-pop__list">${reasons.map((r) => `<li>${this._esc(r)}</li>`).join("")}</ul>` 842 : `<p class="ttv-pending-pop__ok">No blockers. This team is ready to confirm.</p>`; 843 844 // The CTA matches the actual state: a clean confirm when there are no 845 // blockers, an explicit comp override when payment is the blocker. 846 const ctaLabel = hasBlockers ? "Override & mark comped" : "Confirm team"; 847 const ctaNote = hasBlockers 848 ? "Confirms the team at no charge (every seat marked comped)." 849 : "Confirms this team. No charge."; 850 851 const pop = document.createElement("div"); 852 pop.className = "ttv-pending-pop"; 853 pop.innerHTML = ` 854 <div class="ttv-pending-pop__head">Why this team is pending</div> 855 ${reasonList} 856 ${data.overridable ? ` 857 <button type="button" class="ttv-pending-pop__override" data-team-id="${teamId}"> 858 ${ctaLabel} 859 </button> 860 <p class="ttv-pending-pop__note">${ctaNote}</p> 861 ` : ""} 862 `;
863 document.body.appendChild(pop); 864 865 // Position under the pill; flip above if it would clip the viewport. 866 const rect = pill.getBoundingClientRect(); 867 const popH = pop.offsetHeight; 868 const popW = pop.offsetWidth; 869 let top = window.scrollY + rect.bottom + 6; 870 let left = window.scrollX + rect.left; 871 if (left + popW > window.innerWidth - 8) left = window.innerWidth - popW - 8; 872 if (left < 8) left = 8; 873 if (rect.bottom + popH + 12 > window.innerHeight) top = window.scrollY + rect.top - popH - 6; 874 pop.style.top = `${top}px`; 875 pop.style.left = `${left}px`; 876 877 const overrideBtn = pop.querySelector(".ttv-pending-pop__override"); 878 if (overrideBtn) { 879 overrideBtn.addEventListener("click", async (e) => { 880 e.stopPropagation(); 881 overrideBtn.disabled = true; 882 overrideBtn.textContent = "Confirmingâ¦"; 883 await this._compConfirmTeam(teamId, row); 884 this._closePendingPopover(); 885 }); 886 } 887 888 this._pendingPopEl = pop; 889 this._pendingPopOff = (e) => { if (!pop.contains(e.target) && e.target !== pill) this._closePendingPopover(); }; 890 this._pendingPopEsc = (e) => { if (e.key === "Escape") this._closePendingPopover(); }; 891 document.addEventListener("mousedown", this._pendingPopOff); 892 document.addEventListener("keydown", this._pendingPopEsc); 893 } 894 895 _closePendingPopover() { 896 if (this._pendingPopEl) { this._pendingPopEl.remove(); this._pendingPopEl = null; } 897 if (this._pendingPopOff) { document.removeEventListener("mousedown", this._pendingPopOff); this._pendingPopOff = null; } 898 if (this._pendingPopEsc) { document.removeEventListener("keydown", this._pendingPopEsc); this._pendingPopEsc = null; } 899 } 900 901 // â ï¸ Accepts an optional manager PIN so a refused comp can be retried in place. Confirming a team without 902 // payment is a comp, and at a club that requires manager approval the server refuses it; a Stimulus 903 // controller that only toasts the error leaves the staffer with nowhere to type. 904 async _compConfirmTeam(teamId, row, overridePin) { 905 const base = this._teamUpdateUrl(teamId); 906 if (!base) return; 907 const lockVersion = row ? parseInt(row.dataset.lockVersion || "0", 10) : 0; 908 try { 909 const res = await fetch(`${base}/comp_confirm`, { 910 method: "POST", 911 headers: { 912 "Content-Type": "application/json", 913 Accept: "application/json", 914 "X-CSRF-Token": this._csrfToken() 915 }, 916 body: JSON.stringify(
917 overridePin ? { lock_version: lockVersion, override_pin: overridePin } : { lock_version: lockVersion } 918 ) 919 }); 920 const json = await res.json().catch(() => ({})); 921 if (!res.ok && window.pbNeedsManagerPin && window.pbNeedsManagerPin(json)) { 922 const pin = await window.pbCollectManagerPin(json); 923 if (pin) return this._compConfirmTeam(teamId, row, pin); 924 return; 925 } 926 if (!res.ok) { this._toast(json.message || json.error || "Couldn't confirm the team.", "error"); return; } 927 if (row) this._reconcileRow(row, json); 928 // Payment pill also flips to comped/paid â repaint it in place. 929 if (row && json.payment_status) { 930 const payPill = row.querySelector(".ttv-payment-pill"); 931 if (payPill) { 932 payPill.className = `ttv-payment-pill ttv-payment-pill--${json.payment_status}`; 933 payPill.textContent = json.payment_status.replace(/_/g, " ").replace(/\b\w/g, (c) => c.toUpperCase()); 934 } 935 } 936 // Honest toast: the seats are comped, but the team only confirms when the 937 // roster is structurally valid (full + gender-balanced for MLP). 938 if (json.confirmed) { 939 this._toast("Team confirmed (marked comped).", "success"); 940 } else { 941 const left = Array.isArray(json.resolved_reasons) ? json.resolved_reasons.join("; ") : ""; 942 this._toast(left ? `Seats comped. Still pending: ${left}` : "Seats comped. Team still pending.", "warning"); 943 } 944 } catch (err) { 945 this._toast(err.message || "Network error.", "error"); 946 } 947 } 948 949 // Update every chip for this user (bench entries + mixer chips + 950 // team-card slot chips) in place. Also updates the bench entry's 951 // data-gender so the bench filter sees the new value. 952 _applyGenderToAllChips(userId, newGender) { 953 const klass = newGender === "male" ? "ttv-gender-chip--m" : "ttv-gender-chip--f"; 954 const label = newGender === "male" ? "M" : "F"; 955 const title = `${newGender === "male" ? "Male" : "Female"} â click to change`; 956 document.querySelectorAll( 957 `.ttv-gender-chip[data-user-id="${userId}"]` 958 ).forEach((chip) => { 959 chip.classList.remove("ttv-gender-chip--m", "ttv-gender-chip--f"); 960 chip.classList.add(klass); 961 chip.textContent = label; 962 chip.title = title; 963 chip.dataset.currentGender = newGender; 964 }); 965 document.querySelectorAll( 966 `.ttv-bench-entry[data-user-id="${userId}"]` 967 ).forEach((entry) => { entry.dataset.gender = newGender; }); 968 this._applyBenchFilters(); 969 } 970 971 // Inline gender assignment â clicking M or F on a bench entry whose 972 // user has no gender. PATCHes the user's gender, then updates the 973 // entry's data-gender attribute so the bench filter respects the 974 // new value, and removes the picker so it doesn't show again. 975 async setUserGender(event) { 976 event.stopPropagation(); 977 const btn = event.currentTarget; 978 const userId = parseInt(btn.dataset.userId, 10); 979 const gender = btn.dataset.gender; 980 if (!userId || !["male", "female"].includes(gender)) return; 981 982 const res = await this._postBackground( 983 `/club_admin/${this._clubSlug()}/tournaments/${this._tournamentSlug()}/teams_v2/users/${userId}/gender`, 984 { gender: gender }, 985 "PATCH" 986 ); 987 if (!res) return; 988 989 // Find every bench entry for this user (there's only one after 990 // dedup) and: 991 // - set data-gender so the filter pills work 992 // - remove the inline picker 993 // - inject the new (M)/(F) chip next to the name 994 const chipClass = gender === "male" ? "ttv-gender-chip--m" : "ttv-gender-chip--f"; 995 const chipLabel = gender === "male" ? "M" : "F"; 996 const chipTitle = gender === "male" ? "Male" : "Female"; 997 document.querySelectorAll(`.ttv-bench-entry[data-user-id="${userId}"]`) 998 .forEach((entry) => { 999 entry.dataset.gender = gender; 1000 entry.querySelector(".ttv-bench-entry-gender")?.remove(); 1001 // Skip if a chip is already there (shouldn't be â gender was 1002 // blank â but defensive). 1003 const nameRow = entry.querySelector(".ttv-bench-entry-name-row"); 1004 if (nameRow && !nameRow.querySelector(".ttv-gender-chip")) { 1005 const chip = document.createElement("button"); 1006 chip.type = "button"; 1007 chip.className = `ttv-gender-chip ${chipClass}`; 1008 chip.title = `${chipTitle} â click to change`; 1009 chip.textContent = chipLabel; 1010 // Match the same data-attrs + data-action that server-rendered 1011 // chips have so it's clickable / Stimulus-bound immediately. 1012 chip.dataset.action = "click->tournament-teams-v2#openGenderPopover"; 1013 chip.dataset.userId = userId; 1014 chip.dataset.currentGender = gender; 1015 const nameSpan = nameRow.querySelector(".ttv-bench-entry-name"); 1016 if (nameSpan && nameSpan.nextSibling) { 1017 nameRow.insertBefore(chip, nameSpan.nextSibling); 1018 } else { 1019 nameRow.appendChild(chip); 1020 } 1021 } 1022 }); 1023 // Re-apply filters in case the gender pill is currently restricting 1024 // the bench â the entry should now match (or stay hidden if the 1025 // other gender is selected). 1026 this._applyBenchFilters(); 1027 this._toast(`Set gender to ${gender === "male" ? "Male" : "Female"}.`, "success"); 1028 } 1029 1030 // Unregister a bench player from a specific division â clicking the 1031 // â pill next to a division name on a bench entry. Fires before any 1032 // click on the entry itself bubbles, so it doesn't toggle selection. 1033 async unregisterFromDivision(event) { 1034 event.stopPropagation(); 1035 const btn = event.currentTarget; 1036 const divisionId = parseInt(btn.dataset.divisionId, 10); 1037 const userId = parseInt(btn.dataset.userId, 10); 1038 if (!divisionId || !userId) return; 1039
1040 const res = await this._postBackground( 1041 `/club_admin/${this._clubSlug()}/tournaments/${this._tournamentSlug()}/teams_v2/unregister_from_division`, 1042 { division_id: divisionId, user_id: userId } 1043 ); 1044 if (!res) return; 1045 // Refresh both the division pane (now without this player) and 1046 // the bench section (so the player's pill set updates to reflect 1047 // their remaining placements â other divisions keep their pills, 1048 // the green check disappears if no placements remain). 1049 await Promise.all([ 1050 this._refreshDivisionPane(divisionId), 1051 this._refreshBenchSection() 1052 ]); 1053 this._toast("Removed from division.", "success"); 1054 } 1055 1056 // Re-fetch + swap the sidebar's bench section. Preserves the user's 1057 // current bench search / gender filter by re-applying them after 1058 // the markup lands. 1059 async _refreshBenchSection() { 1060 const aside = document.querySelector(".ttv-sidebar"); 1061 if (!aside) return; 1062 const url = `/club_admin/${this._clubSlug()}/tournaments/${this._tournamentSlug()}/teams_v2/bench_section`; 1063 try { 1064 const res = await fetch(url, { 1065 headers: { "Accept": "text/html", "X-CSRF-Token": this._csrfToken() } 1066 }); 1067 if (!res.ok) return; 1068 const html = await res.text(); 1069 // The action renders the WHOLE sidebar partial (divisions list + 1070 // bench), so replace the inner contents of `.ttv-sidebar` to keep 1071 // the divisions checkbox state in sync as well. 1072 aside.innerHTML = html; 1073 // Re-apply any active bench filters so the operator's state is 1074 // preserved across the refresh. 1075 this._applyBenchFilters(); 1076 } catch (e) { 1077 console.warn("[ttv] bench refresh failed", e); 1078 } 1079 } 1080 1081 // ââ Global player search (parity with Participants) âââââââââââââââââââ 1082 // 2026-06-09 â Spotlights a player anywhere on the board: dims team cards 1083 // that contain no match, highlights the matching player slot(s), and 1084 // highlights matching bench entries. Bench hiding stays owned by the bench 1085 // filter (filterBench) so the two don't fight; this one only highlights on 1086 // the bench. Empty query clears all dim/highlight state. 1087 filterPlayers(event) { 1088 this._playerSearchQuery = (event.target.value || "").toLowerCase().trim(); 1089 this._applyPlayerFilter(); 1090 } 1091 1092 _applyPlayerFilter() { 1093 const q = this._playerSearchQuery || ""; 1094 1095 document.querySelectorAll(".ttv-team-card").forEach((card) => { 1096 let cardMatch = false; 1097 card.querySelectorAll(".ttv-slot--filled").forEach((slot) => { 1098 const name = (slot.dataset.userName || "").toLowerCase(); 1099 const email = (slot.dataset.userEmail || "").toLowerCase(); 1100 // 2026-06-19 â match name OR email so an organizer can paste an email 1101 // address to spotlight a player on the board. 1102 const hit = q !== "" && (name.includes(q) || email.includes(q)); 1103 slot.classList.toggle("ttv-slot--search-hit", hit); 1104 if (hit) cardMatch = true; 1105 }); 1106 card.classList.toggle("ttv-team-card--search-dim", q !== "" && !cardMatch); 1107 }); 1108 1109 document.querySelectorAll(".ttv-bench-entry").forEach((entry) => { 1110 const name = entry.dataset.nameLower || ""; 1111 const email = entry.dataset.emailLower || ""; 1112 entry.classList.toggle("ttv-bench-entry--search-hit", q !== "" && (name.includes(q) || email.includes(q))); 1113 }); 1114 } 1115 1116 // ââ Bench search + gender filter ââââââââââââââââââââââââââââââââââââââ 1117 filterBench(event) { 1118 this._benchSearchQuery = (event.target.value || "").toLowerCase().trim(); 1119 this._applyBenchFilters(); 1120 } 1121 1122 setBenchGenderFilter(event) { 1123 const btn = event.currentTarget; 1124 const gender = btn.dataset.gender || "all"; 1125 // Toggle the visible active state. 1126 document.querySelectorAll(".ttv-bench-filter").forEach((b) => { 1127 b.classList.toggle("ttv-bench-filter--active", b === btn); 1128 }); 1129 this._benchGenderFilter = gender; 1130 this._applyBenchFilters(); 1131 } 1132 1133 _applyBenchFilters() { 1134 const q = this._benchSearchQuery || ""; 1135 const gender = this._benchGenderFilter || "all"; 1136 let visibleCount = 0; 1137 1138 document.querySelectorAll(".ttv-bench-entry").forEach((entry) => { 1139 const name = entry.dataset.nameLower || ""; 1140 const g = entry.dataset.gender || ""; 1141 const matchesQuery = q === "" || name.includes(q); 1142 const matchesGender = gender === "all" || g === gender; 1143 const li = entry.parentElement; // <li> wrapper 1144 const target = (li && li.tagName === "LI") ? li : entry; 1145 if (matchesQuery && matchesGender) { 1146 target.hidden = false;
1147 visibleCount++; 1148 } else { 1149 target.hidden = true; 1150 } 1151 }); 1152 1153 const noMatch = document.querySelector(".ttv-bench-no-match"); 1154 if (noMatch) noMatch.hidden = (visibleCount > 0); 1155 } 1156 1157 clearBenchSelection(_event) { 1158 if (!this._benchSelection) return; 1159 this._benchSelection.clear(); 1160 document.querySelectorAll(".ttv-bench-entry--selected") 1161 .forEach((el) => el.classList.remove("ttv-bench-entry--selected")); 1162 this._renderBenchSelectionBar(); 1163 } 1164 1165 _renderBenchSelectionBar() { 1166 const count = this._benchSelection ? this._benchSelection.size : 0; 1167 let bar = document.getElementById("ttv-bench-selection-bar"); 1168 if (count === 0) { 1169 if (bar) bar.remove(); 1170 return; 1171 } 1172 if (!bar) { 1173 bar = document.createElement("div"); 1174 bar.id = "ttv-bench-selection-bar"; 1175 bar.className = "ttv-bench-selection-bar"; 1176 bar.innerHTML = ` 1177 <span class="ttv-bench-selection-bar__count"> 1178 <strong data-ttv-selection-count>0</strong> selected 1179 </span> 1180 <span class="ttv-bench-selection-bar__hint"> 1181 Click a division to add them, or drag the group. 1182 </span> 1183 <button type="button" class="ttv-bench-selection-bar__clear" 1184 data-action="click->tournament-teams-v2#clearBenchSelection"> 1185 Clear 1186 </button> 1187 `; 1188 document.body.appendChild(bar); 1189 } 1190 const countEl = bar.querySelector("[data-ttv-selection-count]"); 1191 if (countEl) countEl.textContent = count; 1192 } 1193 1194 // Click handler on each .ttv-division-pane. For MIXER (individual_rotating) 1195 // divisions only â clicking anywhere on the pane background bulk-adds 1196 // selected bench players as individual registrations. Team-style 1197 // divisions route clicks through their slot/new-team-blank targets 1198 // (newTeamBlankClick) instead. 1199 async divisionPaneClick(event) { 1200 if (!this._benchSelection || this._benchSelection.size === 0) return; 1201 const pane = event.currentTarget; 1202 if (pane.dataset.divisionMode !== "individual_rotating") return; 1203 const interactive = event.target.closest("button, a, input, .ttv-slot, .ttv-team-card, .ttv-new-team-blank"); 1204 if (interactive) return; 1205 await this._bulkAddSelectedToDivision(pane); 1206 } 1207 1208 // Click handler on the +New Team blank â same flow as 1209 // divisionPaneClick but team-side: bulk-add selected bench players 1210 // as a starter team (one solo team per player, fills the first new 1211 // team and beyond). 1212 async newTeamBlankClick(event) { 1213 event.stopPropagation(); 1214 if (!this._benchSelection || this._benchSelection.size === 0) return; 1215 const blank = event.currentTarget; 1216 const pane = blank.closest(".ttv-division-pane"); 1217 if (!pane) return; 1218 await this._bulkAddSelectedToDivision(pane); 1219 } 1220 1221 async _bulkAddSelectedToDivision(pane) { 1222 const divisionId = parseInt(pane.dataset.divisionId, 10); 1223 const divisionName = pane.dataset.divisionName || "this division"; 1224 if (!divisionId) return; 1225 const userIds = Array.from(this._benchSelection); 1226 if (userIds.length === 0) return; 1227 1228 // Optimistic UX hint â dim the selected entries so the operator 1229 // gets immediate feedback while the POST flies. 1230 userIds.forEach((uid) => { 1231 const el = document.querySelector(`.ttv-bench-entry[data-user-id="${uid}"]`); 1232 if (el) el.classList.add("ttv-bench-entry--pending"); 1233 }); 1234 1235 // _postBackground returns parsed JSON on success, null on failure 1236 // (and surfaces its own toast). _post would reload the page and 1237 // return undefined â not what we want for the surgical-update path. 1238 const res = await this._postBackground( 1239 `/club_admin/${this._clubSlug()}/tournaments/${this._tournamentSlug()}/teams_v2/bulk_add_to_division`, 1240 { division_id: divisionId, user_ids: userIds } 1241 ); 1242 1243 if (!res) { 1244 // Hard failure â _postBackground already toasted the message. 1245 // Clear the pending dim so the operator can retry. 1246 userIds.forEach((uid) => { 1247 const el = document.querySelector(`.ttv-bench-entry[data-user-id="${uid}"]`); 1248 if (el) el.classList.remove("ttv-bench-entry--pending"); 1249 }); 1250 return; 1251 } 1252 1253 if (res.created > 0 && Array.isArray(res.created_team_html)) {
1254 this._surgicalInsertTeams(pane, res.created_team_html, res.mode); 1255 } 1256 // Bench rows STAY visible â a solo-team registration keeps the 1257 // player on the bench (their team needs a partner). Instead of 1258 // removing the entry, add the new division pill in place so the 1259 // operator can see they're now placed in this division too. 1260 if (Array.isArray(res.added_user_ids) && res.division_id && res.division_name) { 1261 this._addBenchPillForUsers(res.added_user_ids, res.division_id, res.division_name); 1262 } 1263 // Strip the pending dim from rows that succeeded. 1264 (res.added_user_ids || []).forEach((uid) => { 1265 const el = document.querySelector(`.ttv-bench-entry[data-user-id="${uid}"]`); 1266 if (el) el.classList.remove("ttv-bench-entry--pending"); 1267 }); 1268 this.clearBenchSelection(); 1269 // Re-sort the bench in the background so newly-registered players 1270 // sink to the registered section. Fire-and-forget â the user- 1271 // visible add already happened above. 1272 this._refreshBenchSection(); 1273 1274 if (Array.isArray(res.failed) && res.failed.length > 0) { 1275 const msg = `${res.created} added, ${res.failed.length} skipped:\n` + 1276 res.failed.map((f) => `⢠user ${f.user_id}: ${f.error}`).join("\n"); 1277 this._toast(msg, "warning"); 1278 } else if (res.created > 0) { 1279 this._toast( 1280 `Added ${res.created} player${res.created === 1 ? "" : "s"} to "${divisionName}".`, 1281 "success" 1282 ); 1283 } 1284 } 1285 1286 // ââ Optimistic phantom insert / swap / rollback âââââââââââââââââââââ 1287 // Renders a placeholder chip/card at the top of the target pane 1288 // for each user_id BEFORE the POST resolves. Phantoms carry the 1289 // user's avatar + first-name (pulled from the bench-entry the user 1290 // dragged from). When the server response comes back, we swap each 1291 // phantom for the real markup keyed by user_id, no extra GET. 1292 _insertPhantomChips(pane, userIds) { 1293 const isMixer = pane.dataset.divisionMode === "individual_rotating"; 1294 const container = isMixer 1295 ? pane.querySelector(".ttv-mixer-list") 1296 : pane.querySelector(".ttv-teams-grid"); 1297 if (!container) return []; 1298 1299 const phantomIds = []; 1300 // Reverse-iterate so the first user_id ends up at the top. 1301 [...userIds].reverse().forEach((uid) => { 1302 const benchEl = document.querySelector(`.ttv-bench-entry[data-user-id="${uid}"]`); 1303 const nameSpan = benchEl?.querySelector(".ttv-bench-entry-name"); 1304 const fullName = nameSpan?.dataset.fullName || nameSpan?.textContent.trim() || "Player"; 1305 const shortName = nameSpan?.dataset.shortName || fullName; 1306 const avatarHtml = benchEl?.querySelector(".pb-avatar")?.outerHTML || '<div class="pb-avatar pb-avatar--xs"></div>'; 1307 const gender = benchEl?.dataset.gender || ""; 1308 const genderChip = gender === "male" 1309 ? '<span class="ttv-gender-chip ttv-gender-chip--m">M</span>' 1310 : gender === "female" 1311 ? '<span class="ttv-gender-chip ttv-gender-chip--f">F</span>' 1312 : ""; 1313 let html; 1314 if (isMixer) { 1315 html = `
1316 <div class="ttv-player-chip ttv-phantom" data-phantom-user-id="${uid}"> 1317 ${avatarHtml} 1318 <span class="ttv-player-chip__name">${this._esc(fullName)}</span> 1319 ${genderChip} 1320 <i class="fas fa-spinner fa-spin" style="opacity:0.5;"></i> 1321 </div>`; 1322 } else { 1323 html = ` 1324 <article class="ttv-team-card ttv-team-card--incomplete ttv-phantom" data-phantom-user-id="${uid}"> 1325 <header class="ttv-team-card__head"> 1326 <span class="ttv-team-card__name">${this._esc(shortName)}</span> 1327 <i class="fas fa-spinner fa-spin" style="opacity:0.5; font-size:11px;"></i> 1328 </header> 1329 <div class="ttv-team-card__slots"> 1330 <div class="ttv-slot ttv-slot--filled" style="pointer-events:none;"> 1331 ${avatarHtml} 1332 <div class="ttv-slot__body"><span class="ttv-slot__name">${this._esc(fullName)}</span></div> 1333 </div> 1334 </div> 1335 </article>`; 1336 } 1337 container.insertAdjacentHTML("afterbegin", html); 1338 phantomIds.push(uid); 1339 }); 1340 return phantomIds; 1341 } 1342 1343 _swapPhantomsWithRealHtml(pane, phantomIds, addedIds, cardsHtml, _mode) { 1344 // For each addedId, find the matching phantom and replace it with 1345 // the real HTML at the same index. If the server skipped a user 1346 // (failed[]), the phantom for that user_id remains an orphan â 1347 // remove it explicitly. 1348 const okSet = new Set(addedIds.map(String)); 1349 // Map server-rendered HTML to user_ids by parsing the data-user-id 1350 // / data-team-id attribute. Mixer chips have data-user-id; team 1351 // cards have data-team-id but a team's captain user_id matches the 1352 // added_user_ids order, so we walk them in parallel. 1353 addedIds.forEach((uid, idx) => { 1354 const phantom = pane.querySelector(`.ttv-phantom[data-phantom-user-id="${uid}"]`); 1355 const realHtml = cardsHtml[idx]; 1356 if (!phantom || !realHtml) return; 1357 phantom.insertAdjacentHTML("beforebegin", realHtml); 1358 phantom.remove(); 1359 }); 1360 // Any remaining phantoms = failed adds â remove. 1361 phantomIds.forEach((uid) => { 1362 if (!okSet.has(String(uid))) { 1363 pane.querySelector(`.ttv-phantom[data-phantom-user-id="${uid}"]`)?.remove(); 1364 } 1365 }); 1366 } 1367 1368 _rollbackPhantomChips(phantomIds) { 1369 phantomIds.forEach((uid) => { 1370 document.querySelector(`.ttv-phantom[data-phantom-user-id="${uid}"]`)?.remove(); 1371 }); 1372 } 1373 1374 _rollbackBenchPills(userIds, divisionId) { 1375 userIds.forEach((uid) => { 1376 const entry = document.querySelector(`.ttv-bench-entry[data-user-id="${uid}"]`); 1377 if (!entry) return; 1378 const pill = entry.querySelector( 1379 `.ttv-bench-pill__remove[data-division-id="${divisionId}"]` 1380 )?.closest(".ttv-bench-pill"); 1381 pill?.remove(); 1382 // If that was the last pill, strip the in-division accent. 1383 const pillsRow = entry.querySelector(".ttv-bench-entry-pills"); 1384 if (pillsRow && pillsRow.children.length === 0) { 1385 entry.classList.remove("ttv-bench-entry--in-division"); 1386 pillsRow.remove(); 1387 } 1388 }); 1389 } 1390 1391 // Splice the rendered team-card / player-chip HTML into the right 1392 // container. Newly-added items go to the TOP of the division so the 1393 // operator can see what they just dropped/clicked-added without 1394 // scrolling. Matches the operator's mental model of "I just added 1395 // this â show me it." 1396 _surgicalInsertTeams(pane, htmlArr, mode) { 1397 if (mode === "individual_rotating") { 1398 const list = pane.querySelector(".ttv-mixer-list"); 1399 if (!list) return; 1400 // Drop the empty-state placeholder if it's there. 1401 const empty = list.querySelector(".ttv-mixer-empty"); 1402 if (empty) empty.remove(); 1403 // Insert in reverse so iteration order is preserved at the top 1404 // of the list (first of htmlArr ends up at the very top). 1405 [...htmlArr].reverse().forEach((html) => list.insertAdjacentHTML("afterbegin", html)); 1406 } else { 1407 const grid = pane.querySelector(".ttv-teams-grid"); 1408 if (!grid) return;
1409 [...htmlArr].reverse().forEach((html) => grid.insertAdjacentHTML("afterbegin", html)); 1410 } 1411 // Bump the division count chip in the sidebar so the X/Y stays 1412 // in sync without a refresh. 1413 this._bumpSidebarCount(pane.dataset.divisionId, htmlArr.length); 1414 } 1415 1416 // Add a green division pill to each bench entry for the given users. 1417 // Idempotent â if the pill for this division already exists on an 1418 // entry, skip. Also adds the green check icon if it's missing. 1419 _addBenchPillForUsers(userIds, divisionId, divisionName) { 1420 userIds.forEach((uid) => { 1421 const entry = document.querySelector( 1422 `.ttv-bench-entry[data-user-id="${uid}"]` 1423 ); 1424 if (!entry) return; 1425 entry.classList.add("ttv-bench-entry--in-division"); 1426 const body = entry.querySelector(".ttv-bench-entry-body"); 1427 if (!body) return; 1428 1429 // Find (or create) the pills container. 1430 let pillsRow = body.querySelector(".ttv-bench-entry-pills"); 1431 if (!pillsRow) { 1432 pillsRow = document.createElement("div"); 1433 pillsRow.className = "ttv-bench-entry-pills"; 1434 body.appendChild(pillsRow); 1435 } 1436 1437 // Skip if a pill for this division already exists on the entry. 1438 const existing = pillsRow.querySelector( 1439 `.ttv-bench-pill__remove[data-division-id="${divisionId}"]` 1440 ); 1441 if (existing) return; 1442 1443 // Build the new pill. innerHTML-style escapes division name to 1444 // keep "<script>" payloads safe. 1445 const safeName = String(divisionName) 1446 .replace(/&/g, "&") 1447 .replace(/</g, "<") 1448 .replace(/>/g, ">") 1449 .replace(/"/g, """); 1450 pillsRow.insertAdjacentHTML( 1451 "beforeend", 1452 `<span class="ttv-bench-pill"> 1453 <span class="ttv-bench-pill__name" title="${safeName}">${safeName}</span> 1454 <button type="button" class="ttv-bench-pill__remove" 1455 data-action="click->tournament-teams-v2#unregisterFromDivision" 1456 data-division-id="${divisionId}" 1457 data-user-id="${uid}" 1458 title="Remove from ${safeName}"> 1459 <i class="fas fa-times"></i> 1460 </button> 1461 </span>` 1462 ); 1463 }); 1464 } 1465 1466 _removeBenchEntries(userIds) { 1467 userIds.forEach((uid) => { 1468 const el = document.querySelector(`.ttv-bench-entry[data-user-id="${uid}"]`); 1469 if (el) el.parentElement?.remove(); // <li> wrapper 1470 }); 1471 // Update the Bench count chip. 1472 const benchCount = document.querySelectorAll(".ttv-bench-entry").length; 1473 const chip = document.querySelector(".ttv-bench-count"); 1474 if (chip) chip.textContent = benchCount; 1475 if (benchCount === 0) { 1476 const list = document.querySelector(".ttv-bench-list"); 1477 if (list && !list.querySelector(".ttv-bench-empty")) { 1478 list.innerHTML = '<p class="ttv-bench-empty">No unassigned players.</p>'; 1479 } 1480 } 1481 } 1482 1483 _bumpSidebarCount(divisionId, delta) { 1484 const row = document.querySelector( 1485 `.ttv-division-row[data-division-id="${divisionId}"] .ttv-division-count` 1486 ); 1487 if (!row) return; 1488 const text = row.textContent.trim(); 1489 const m = text.match(/^(\d+)(\/\d+)?/); 1490 if (!m) return; 1491 const next = parseInt(m[1], 10) + delta; 1492 row.textContent = `${next}${m[2] || ""}`; 1493 } 1494 1495 // Reset all teams in a division back to the bench (Scott 2026-08-18). Disbands every team; 1496 // players return to the bench (unassigned), registrations + payments kept. Silent on success 1497 // (the repaint IS the feedback); _postBackground toasts only on failure. 1498 async resetAllTeams(event) { 1499 const btn = event.currentTarget; 1500 const divisionId = btn.dataset.divisionId; 1501 const divisionName = btn.dataset.divisionName || "this division"; 1502 if (!window.confirm(`Reset all teams in ${divisionName}? Every team is disbanded and all players go back to the bench (unassigned). Registrations and payments are kept.`)) return; 1503 btn.disabled = true; 1504 const url = `/club_admin/${this._clubSlug()}/tournaments/${this._tournamentSlug()}/teams_v2/reset_all_teams`;
1505 const res = await this._postBackground(url, { division_id: divisionId }); 1506 btn.disabled = false; 1507 if (!res) return; // failure already toasted by _postBackground 1508 await Promise.all([ 1509 this._refreshDivisionPane(divisionId), 1510 (typeof this._refreshBenchSection === "function") ? this._refreshBenchSection() : Promise.resolve(), 1511 ]); 1512 } 1513 1514 _clubSlug() { 1515 // The data-base-url on the divisions list contains the full segment; 1516 // pull the club id (or slug) out of it. 1517 const list = document.querySelector("[data-ttv-divisions-list]"); 1518 if (!list) return ""; 1519 const m = (list.dataset.baseUrl || "").match(/\/club_admin\/([^/]+)\//); 1520 return m ? m[1] : ""; 1521 } 1522 _tournamentSlug() { 1523 const list = document.querySelector("[data-ttv-divisions-list]"); 1524 if (!list) return ""; 1525 const m = (list.dataset.baseUrl || "").match(/\/tournaments\/([^/]+)\//); 1526 return m ? m[1] : ""; 1527 } 1528 1529 // âââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ 1530 // Card-view drag/drop + click-to-swap (T-DND.2 / T-DND.3 / T-DND.4) 1531 // âââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ 1532 // 1533 // DOM contract: 1534 // .ttv-team-card data-team-id, data-division-id, data-team-lock-version 1535 // .ttv-slot--filled data-team-id, data-user-id, data-division-id 1536 // .ttv-slot--empty data-team-id, data-division-id 1537 // .ttv-new-team-blank data-division-id 1538 // 1539 // Drag sources: bench entry / filled slot. 1540 // Drop targets: empty slot (fill) / filled slot (swap) / +New Team blank 1541 // (creates a new solo team containing the dropped player). 1542 1543 // ââ Drag-start: drag a player out of a filled slot ââââââââââââââââââââ 1544 filledSlotDragStart(event) { 1545 // CRITICAL: stop the dragstart from bubbling to the parent team card's teamDragStart, which 1546 // would OVERWRITE this slot payload with a whole-team move (Scott 2026-08-18: dragging a player 1547 // fired `move_team` = no-op, so "the player did not move"). Mirrors benchDragStart's guard. 1548 event.stopPropagation(); 1549 const el = event.currentTarget; 1550 // Drag eats a synthetic click; don't toggle swap selection on drop. 1551 this._suppressNextSlotClick = true; 1552 const payload = { 1553 kind: "filled_slot", 1554 user_id: parseInt(el.dataset.userId, 10), 1555 user_name: el.dataset.userName || "", 1556 team_id: parseInt(el.dataset.teamId, 10), 1557 division_id: parseInt(el.dataset.divisionId, 10), 1558 team_lock_version: parseInt(el.dataset.teamLockVersion, 10) 1559 }; 1560 this._setDragPayload(event, payload); 1561 el.classList.add("ttv-dragging"); 1562 } 1563 1564 // ââ Drag-over: highlight slot/blank if the payload is droppable âââââââ 1565 slotDragOver(event) { 1566 const payload = this._dragPayload; 1567 if (!payload) return; 1568 if (payload.kind !== "bench_player" && payload.kind !== "filled_slot") return; 1569 event.preventDefault(); 1570 const slot = event.currentTarget; 1571 // Distinct affordance: dropping onto a FILLED slot will swap/replace the 1572 // occupant, so highlight it as a swap target (amber). Dropping onto an 1573 // EMPTY slot just fills it (green). 2026-06-17. 1574 if (slot.dataset.slotState === "filled" && payload.user_id !== parseInt(slot.dataset.userId || "0", 10)) { 1575 slot.classList.add("ttv-slot--swap-target"); 1576 } else { 1577 slot.classList.add("ttv-slot--drop-active"); 1578 } 1579 } 1580 1581 newTeamBlankDragOver(event) { 1582 const payload = this._dragPayload; 1583 if (!payload) return; 1584 if (!["bench_player", "bench_players_bulk", "filled_slot"].includes(payload.kind)) return; 1585 event.preventDefault(); 1586 event.currentTarget.classList.add("ttv-new-team-blank--drop-active"); 1587 } 1588
1589 // ââ Drop on a slot ââââââââââââââââââââââââââââââââââââââââââââââââââââ 1590 async slotDrop(event) { 1591 event.preventDefault(); 1592 event.stopPropagation(); 1593 const slot = event.currentTarget; 1594 slot.classList.remove("ttv-slot--drop-active", "ttv-slot--swap-target"); 1595 const payload = this._dragPayload; 1596 if (!payload) return; 1597 1598 const targetTeamId = parseInt(slot.dataset.teamId, 10); 1599 const targetDivisionId = parseInt(slot.dataset.divisionId, 10); 1600 const targetState = slot.dataset.slotState; // "empty" | "filled" 1601 const targetUserId = parseInt(slot.dataset.userId || "0", 10); 1602 1603 if (!targetTeamId || !targetDivisionId) return; 1604 1605 // Same player onto their own slot = no-op. 1606 if (payload.user_id === targetUserId) return; 1607 1608 if (targetState === "empty") { 1609 // Fill an empty slot. Source can be bench or another filled slot. 1610 await this._fillEmptySlot(targetTeamId, targetDivisionId, payload); 1611 } else { 1612 // Filled slot â swap. Constraint: same division only. 1613 if (payload.kind === "filled_slot" && payload.division_id !== targetDivisionId) { 1614 this._toast("Cross-division swap not supported. Use the team drawer to move a team.", "warning"); 1615 return; 1616 } 1617 await this._swapWithFilledSlot(slot, payload, targetTeamId, targetUserId); 1618 } 1619 } 1620 1621 // ââ Drop on the +New Team blank â create a fresh team with this player â 1622 async newTeamBlankDrop(event) { 1623 event.preventDefault(); 1624 const blank = event.currentTarget; 1625 blank.classList.remove("ttv-new-team-blank--drop-active"); 1626 const payload = this._dragPayload; 1627 if (!payload) return; 1628 const divisionId = parseInt(blank.dataset.divisionId, 10); 1629 if (!divisionId) return; 1630 1631 // Source from another filled slot? Pull them off first (reuses bulk-add 1632 // semantics â the existing cross_team_swap handles slotâslot swaps; 1633 // bench-only is the simple newteam path). 1634 if (payload.kind === "filled_slot") { 1635 // Remove from current team, then create a new solo team in this 1636 // division. Two POSTs in a row; the server is idempotent so the 1637 // worst case is a no-op on retry. 1638 try { 1639 await this._postRemoveToBench(payload.team_id, payload.user_id); 1640 await this._postBulkAdd(divisionId, [payload.user_id]); 1641 // Repaint (Scott 2026-08-18): the split-out mutated the source team 1642 // (player left) AND created a new team â without this the board went 1643 // gray and "nothing moved" until a manual refresh (§3.8). Mirrors the 1644 // bench_player branch below. 1645 await Promise.all([ 1646 this._refreshDivisionPane(divisionId), 1647 this._refreshBenchSection() 1648 ]); 1649 } catch (e) { 1650 console.error("[ttv] newteam-from-filled-slot failed", e); 1651 this._toast(`Could not move player: ${e.message || e}`, "error"); 1652 } 1653 return; 1654 } 1655 1656 // bench_player or bench_players_bulk â bulk-add (one solo team 1657 // per user_id). Multi-drag onto +New Team creates a fresh team 1658 // for each selected player. 1659 if (payload.kind === "bench_player" || payload.kind === "bench_players_bulk") { 1660 const userIds = payload.kind === "bench_players_bulk" 1661 ? payload.user_ids 1662 : [payload.user_id]; 1663 try { 1664 const res = await this._postBulkAdd(divisionId, userIds); 1665 // Refresh the affected pane (new teams appear) + the bench 1666 // section (pills update on each player). 1667 await Promise.all([ 1668 this._refreshDivisionPane(divisionId), 1669 this._refreshBenchSection() 1670 ]); 1671 if (this._benchSelection && this._benchSelection.size > 0) { 1672 this.clearBenchSelection(); 1673 } 1674 const created = (res && res.created) || userIds.length; 1675 // Silent on success (Scott 2026-08-18) â only failures toast. 1676 } catch (e) { 1677 console.error("[ttv] newteam-from-bench failed", e); 1678 this._toast(`Could not add player: ${e.message || e}`, "error"); 1679 } 1680 } 1681 } 1682 1683 // ââ Click on a slot â click-two-to-swap state machine âââââââââââââââââ 1684 slotClick(event) { 1685 if (this._suppressNextSlotClick) { 1686 this._suppressNextSlotClick = false;
1687 return; 1688 } 1689 // Ignore clicks on the inner â / drag handle. 1690 if (event.target.closest(".ttv-slot__remove")) return; 1691 1692 const slot = event.currentTarget; 1693 const slotState = slot.dataset.slotState; 1694 const teamId = parseInt(slot.dataset.teamId, 10); 1695 const divisionId = parseInt(slot.dataset.divisionId, 10); 1696 1697 // ââ A) Bench-multiselect â empty-slot click = add 1 player there âââ 1698 if (this._benchSelection && this._benchSelection.size > 0 && slotState === "empty") { 1699 event.stopPropagation(); 1700 const firstUid = parseInt(this._benchSelection.values().next().value, 10); 1701 this._fillEmptySlot(teamId, divisionId, { 1702 kind: "bench_player", user_id: firstUid 1703 }); 1704 // Mark that user as no longer selected. 1705 this._benchSelection.delete(String(firstUid)); 1706 const benchEl = document.querySelector(`.ttv-bench-entry[data-user-id="${firstUid}"]`); 1707 if (benchEl) benchEl.classList.remove("ttv-bench-entry--selected"); 1708 this._renderBenchSelectionBar(); 1709 return; 1710 } 1711 1712 // ââ A2) Empty slot, no bench selection â the OPTION modal: choose 1713 // "Send an invite" (search â secure register-and-pay link, the 1714 // 2026-06-17 Feature-4 flow) or "Add manually" (place a searched 1715 // player now with an organizer-chosen payment treatment, 2026-08-10). 1716 if (slotState === "empty") { 1717 event.stopPropagation(); 1718 this.openSlotOptionModal(slot); 1719 return; 1720 } 1721 1722 // ââ B) Click-two-to-swap âââââââââââââââââââââââââââââââââââââââââââ 1723 if (slotState !== "filled") return; 1724 event.stopPropagation(); 1725 const userId = parseInt(slot.dataset.userId, 10); 1726 1727 if (!this._swapSource) { 1728 this._swapSource = { slot, teamId, divisionId, userId }; 1729 slot.classList.add("ttv-slot--swap-source"); 1730 this._toast("Click another filled slot in this division to swap.", "info"); 1731 return; 1732 } 1733 1734 // Clicked the same slot again â clear. 1735 if (this._swapSource.slot === slot) { 1736 this._clearSwapSource(); 1737 return; 1738 } 1739 // Different division â clear and start over with the new one. 1740 if (this._swapSource.divisionId !== divisionId) { 1741 this._clearSwapSource(); 1742 this._toast("Cross-division swap not supported. Select two slots in the same division.", "warning"); 1743 return; 1744 } 1745 // 2026-06-09 â Same team â both players already on this team, so a swap 1746 // is a no-op. Don't POST cross_team_swap with team_a == team_b. 1747 if (this._swapSource.teamId === teamId) { 1748 this._clearSwapSource(); 1749 this._toast("They're already on this team.", "info"); 1750 return; 1751 } 1752 // Real swap â both same division, two filled slots. 1753 const src = this._swapSource; 1754 const srcName = src.slot?.dataset.userName || "this player"; 1755 const tgtName = slot.dataset.userName || "this player"; 1756 this._clearSwapSource(); 1757 this._performSwap(src.teamId, src.userId, teamId, userId, srcName, tgtName); 1758 } 1759 1760 _clearSwapSource() { 1761 if (!this._swapSource) return; 1762 if (this._swapSource.slot) { 1763 this._swapSource.slot.classList.remove("ttv-slot--swap-source"); 1764 } 1765 this._swapSource = null; 1766 } 1767 1768 // ââ Server interactions âââââââââââââââââââââââââââââââââââââââââââââââ 1769 async _fillEmptySlot(teamId, divisionId, payload) { 1770 // Source = bench: existing add_from_bench endpoint. 1771 // Source = filled slot: cross_team_swap is not appropriate (target is 1772 // empty, not filled). Use a 2-step: remove-from-source, add-to-target. 1773 // Refresh the true server state for every affected pane + the bench. Called 1774 // on BOTH success and failure so the DOM never lies (on failure it reverts any 1775 // partial change so the board matches the server). 1776 const reconcile = async () => { 1777 const tasks = [this._refreshDivisionPane(divisionId), this._refreshBenchSection()]; 1778 if (payload.kind === "filled_slot" && payload.division_id && payload.division_id !== divisionId) { 1779 tasks.push(this._refreshDivisionPane(payload.division_id)); 1780 } 1781 await Promise.all(tasks); 1782 }; 1783 try { 1784 if (payload.kind === "filled_slot") {
1785 // _postBackground returns null + toasts the error on failure (e.g. 1786 // stale_lock_version); it does NOT throw. Honor the null so we don't 1787 // falsely report success + repaint stale state (2026-06-16 report: 1788 // "said player moved but the player did not move"). 1789 const removed = await this._postRemoveToBench(payload.team_id, payload.user_id); 1790 if (!removed) { await reconcile(); return; } 1791 } 1792 const added = await this._postAddFromBench(teamId, payload.user_id); 1793 if (!added) { await reconcile(); return; } 1794 await reconcile(); 1795 // Silent on success (Scott 2026-08-18) â only failures toast. 1796 } catch (e) { 1797 console.error("[ttv] fill slot failed", e); 1798 await reconcile(); 1799 this._toast(`Could not move player: ${e.message || e}`, "error"); 1800 } 1801 } 1802 1803 async _swapWithFilledSlot(targetSlot, payload, targetTeamId, targetUserId) { 1804 try { 1805 // 2026-06-09 â Same-team guard. Dropping a player onto a teammate is a 1806 // no-op â they're already on this team. NEVER POST cross_team_swap with 1807 // team_a == team_b: the server rejects it ('same_team'), and attempting 1808 // it produced confusing double toasts ("same team" then "swapped") and 1809 // risked stale optimistic state that made a player appear to vanish. 1810 if (payload.kind === "filled_slot" && payload.team_id === targetTeamId) { 1811 this._toast("They're already on this team.", "info"); 1812 return; 1813 } 1814 // Confirm before mutating. filledâfilled = swap; benchâfilled = the 1815 // dropped player replaces the occupant, who goes to the bench. 2026-06-17. 1816 const srcName = payload.user_name || "this player"; 1817 const tgtName = targetSlot.dataset.userName || "this player"; 1818 const isSwap = payload.kind === "filled_slot"; 1819 const confirmed = await this._confirmSwap( 1820 isSwap 1821 ? `Swap ${srcName} and ${tgtName}? They trade teams.` 1822 : `Put ${srcName} in this slot and send ${tgtName} to the bench?`, 1823 isSwap ? "Swap" : "Replace" 1824 ); 1825 if (!confirmed) return; // nothing changed â no reconcile needed 1826 // payload.team_id (source) â targetTeamId (target). Same division 1827 // checked above. 1828 const reconcile = () => Promise.all([ 1829 this._refreshDivisionPane(targetSlot.dataset.divisionId), 1830 this._refreshBenchSection() 1831 ]); 1832 let ok; 1833 if (payload.kind === "bench_player") { 1834 // bench â filled = the dropped player becomes the new occupant, 1835 // the previous occupant goes to bench. 1836 const removed = await this._postRemoveToBench(targetTeamId, targetUserId); 1837 if (!removed) { await reconcile(); return; } 1838 ok = await this._postAddFromBench(targetTeamId, payload.user_id); 1839 } else { 1840 // filled_slot â filled_slot in same division = swap. 1841 ok = await this._postCrossTeamSwap( 1842 payload.team_id, payload.user_id, 1843 targetTeamId, targetUserId 1844 ); 1845 } 1846 // _postBackground returns null + toasts on failure (no throw) â honor it so 1847 // we don't claim "Swapped" while the board reverts unchanged. 1848 await reconcile(); 1849 if (!ok) return; 1850 // Silent on success (Scott 2026-08-18) â only failures toast. 1851 } catch (e) { 1852 console.error("[ttv] swap failed", e); 1853 this._toast(`Could not swap: ${e.message || e}`, "error"); 1854 } 1855 } 1856 1857 async _performSwap(teamA, userA, teamB, userB, srcName = null, tgtName = null) { 1858 try { 1859 // Confirm "swap X for Y" before mutating (2026-06-17). 1860 if (srcName || tgtName) { 1861 const confirmed = await this._confirmSwap( 1862 `Swap ${srcName || "this player"} and ${tgtName || "this player"}? They trade teams.` 1863 ); 1864 if (!confirmed) return; 1865 } 1866 const ok = await this._postCrossTeamSwap(teamA, userA, teamB, userB); 1867 // Both teams in the same division â one refresh covers it. 1868 const pane = document.querySelector(`.ttv-team-card[data-team-id="${teamA}"]`)?.closest(".ttv-division-pane"); 1869 const tasks = [this._refreshBenchSection()]; 1870 if (pane) tasks.push(this._refreshDivisionPane(pane.dataset.divisionId)); 1871 await Promise.all(tasks); 1872 // Honor a null result (failure already toasted by _postBackgroun
1872d) â don't 1873 // claim success while the board reverts unchanged. 1874 if (!ok) return; 1875 // Silent on success (Scott 2026-08-18); only failures toast. 1876 } catch (e) { 1877 console.error("[ttv] click-swap failed", e); 1878 this._toast(`Could not swap: ${e.message || e}`, "error"); 1879 } 1880 } 1881 1882 // Re-renders a division pane from the server. Cheaper than a full 1883 // reload â used after slot mutations to avoid lock_version drift on 1884 // subsequent edits. 1885 async _refreshDivisionPane(divisionId) { 1886 const pane = document.querySelector(`.ttv-division-pane[data-division-id="${divisionId}"]`); 1887 if (!pane) { 1888 // Fall back to a full reload if the pane isn't visible (filtered out). 1889 window.location.reload(); 1890 return; 1891 } 1892 const url = `/club_admin/${this._clubSlug()}/tournaments/${this._tournamentSlug()}/teams_v2/division/${divisionId}/pane`; 1893 const res = await fetch(url, { 1894 headers: { "Accept": "text/html", "X-CSRF-Token": this._csrfToken() } 1895 }); 1896 if (!res.ok) { 1897 window.location.reload(); 1898 return; 1899 } 1900 const html = await res.text(); 1901 // Server returns the pane's inner HTML â splice it in. 1902 const tmp = document.createElement("div"); 1903 tmp.innerHTML = html; 1904 const newPane = tmp.querySelector(".ttv-division-pane"); 1905 if (newPane) { 1906 pane.outerHTML = newPane.outerHTML; 1907 } else { 1908 pane.innerHTML = html; 1909 } 1910 } 1911 1912 // ââ Thin POST wrappers â use _postBackground so the page doesn't 1913 // reload after each mutation. The caller then runs 1914 // _refreshDivisionPane() for a surgical re-render of just the 1915 // affected division card grid. 1916 _postAddFromBench(teamId, userId) { 1917 return this._postBackground( 1918 `/club_admin/${this._clubSlug()}/tournaments/${this._tournamentSlug()}/teams_v2/teams/${teamId}/add_player_from_bench`, 1919 { user_id: userId } 1920 ); 1921 } 1922 _postRemoveToBench(teamId, userId) { 1923 return this._postBackground( 1924 `/club_admin/${this._clubSlug()}/tournaments/${this._tournamentSlug()}/teams_v2/teams/${teamId}/remove_player_to_bench`, 1925 { user_id: userId } 1926 ); 1927 } 1928 _postCrossTeamSwap(teamAId, userAId, teamBId, userBId) { 1929 return this._postBackground( 1930 `/club_admin/${this._clubSlug()}/tournaments/${this._tournamentSlug()}/teams_v2/cross_team_swap`, 1931 { team_a_id: teamAId, user_a_id: userAId, team_b_id: teamBId, user_b_id: userBId } 1932 ); 1933 } 1934 _postBulkAdd(divisionId, userIds) { 1935 return this._postBackground( 1936 `/club_admin/${this._clubSlug()}/tournaments/${this._tournamentSlug()}/teams_v2/bulk_add_to_division`, 1937 { division_id: divisionId, user_ids: userIds } 1938 ); 1939 } 1940 1941 // ââ Drawer + remove-to-bench glue âââââââââââââââââââââââââââââââââââââ 1942 openTeamDrawer(event) { 1943 event.stopPropagation(); 1944 const card = event.currentTarget.closest(".ttv-team-card"); 1945 if (!card) return; 1946 // 2026-06-08 â Pass the REAL card so the drawer can scrape its player 1947 // slots (roster), name, seed, and payment. The old faux div only carried 1948 // teamId + lockVersion, which is why the drawer showed "Roster empty" and 1949 // "â" everywhere. The card uses data-team-lock-version; the drawer reads 1950 // data-lock-version, so mirror it onto the card. 1951 if (card.dataset.teamLockVersion && !card.dataset.lockVersion) { 1952 card.dataset.lockVersion = card.dataset.teamLockVersion; 1953 } 1954 this._openDrawerForRow(card); 1955 } 1956 1957 async removePlayerToBench(event) { 1958 event.stopPropagation(); 1959 const btn = event.currentTarget; 1960 const userId = parseInt(btn.dataset.userId, 10); 1961 const teamId = parseInt(btn.dataset.teamId, 10); 1962 if (!userId || !teamId) return; 1963 try { 1964 await this._postRemoveToBench(teamId, userId); 1965 const divisionId = btn.closest(".ttv-division-pane")?.dataset.divisionId; 1966 // Refresh the affected division pane AND the bench section in 1967 // parallel â the player just landed back on the bench with one 1968 // fewer division pill. 1969 const tasks = [this._refreshBenchSection()]; 1970 if (divisionId) tasks.push(this._refreshDivisionPane(divisionId)); 1971 await Promise.all(tasks); 1972 // Silent on success (Scott 2026-08-18); only failures toast. 1973 } catch (e) { 1974 console.error("[ttv] remove-to-bench failed", e); 1975 this._toast(`Could not remove: ${e.message || e}`, "error"); 1976 } 1977 } 1978 1979 // 2026-06-17 â Send a WHOLE team to the bench. Every player goes to 1980 // Unassigned (still registered, just unrostered) and the team shell is 1981 // removed, freeing its division slot (waitlist auto-promotes server-side). 1982 // Reversible roster op (players aren't unregistered), so a window.confirm 1983 // is sufficient; the success branch repaints the pane + bench (§3.8). 1984 async benchTeam(event) { 1985 event.stopPropagation(); 1986 const btn = event.currentTarget; 1987 const teamId = parseInt(btn.dataset.teamId, 10); 1988 if (!teamId) return; 1989 const teamName = btn.dataset.teamName || "this team"; 1990 const count = parseInt(btn.dataset.playerCount || "0", 10); 1991 const ok = window.confirm( 1992 `Send ${teamName} to the bench?\n\n` + 1993 `${count} player${count === 1 ? "" : "s"} will move to Unassigned (still registered) ` + 1994 `and the team will be removed from the division.` 1995 ); 1996 if (!ok) return; 1997 const divisionId = btn.closest(".ttv-division-pane")?.dataset.divisionId;
1998 const res = await this._postBackground( 1999 `/club_admin/${this._clubSlug()}/tournaments/${this._tournamentSlug()}/teams_v2/teams/${teamId}/bench_team`, 2000 {} 2001 ); 2002 // _postBackground toasts + returns null on failure â honor it (don't 2003 // claim success while the board is unchanged). 2004 const tasks = [this._refreshBenchSection()]; 2005 if (divisionId) tasks.push(this._refreshDivisionPane(divisionId)); 2006 await Promise.all(tasks); 2007 if (!res) return; 2008 this._toast(`${teamName} sent to bench.`, "success"); 2009 } 2010 2011 // 2026-05-27 â Hard-remove a player from the tournament entirely 2012 // (different from removePlayerToBench, which keeps them registered 2013 // but unrostered). Calls the existing remove_participant endpoint 2014 // backed by TournamentParticipantRemovalService â it handles the 2015 // refund logic + audit log on the server side. UX: two-step confirm 2016 // so the admin can choose refund-or-no. 2017 async removePlayerFromTournament(event) { 2018 event.stopPropagation(); 2019 const btn = event.currentTarget; 2020 const userId = parseInt(btn.dataset.userId, 10); 2021 const divisionId = parseInt(btn.dataset.divisionId, 10); 2022 const userName = btn.dataset.userName || "this player"; 2023 const url = btn.dataset.removeUrl; 2024 if (!userId || !divisionId || !url) { 2025 this._toast("Missing remove URL â refresh the page.", "error"); 2026 return; 2027 } 2028 2029 // First prompt: confirm the destructive action and offer a refund 2030 // path. Refund=true tells the service to attempt a Stripe refund 2031 // when the registration is paid; refund=false leaves payment alone 2032 // (admin will handle externally / via Financials tab). 2033 let refund; 2034 const primary = window.confirm( 2035 `Remove ${userName} from this tournament?\n\n` + 2036 `OK = remove + refund any payment (if paid)\n` + 2037 `Cancel = abort` 2038 ); 2039 if (!primary) return; 2040 2041 // Second confirm: give the admin a no-refund path. (We can't fit a 2042 // 3-option dialog into window.confirm; this is the cleanest split 2043 // without rendering a custom modal.) 2044 const noRefund = window.confirm( 2045 `Refund their payment too?\n\n` + 2046 `OK = remove + refund\n` + 2047 `Cancel = remove WITHOUT refund (you'll handle it manually)` 2048 ); 2049 refund = noRefund; 2050 2051 try { 2052 const csrf = document.querySelector('meta[name="csrf-token"]')?.content; 2053 const res = await fetch(url, { 2054 method: "POST", 2055 credentials: "same-origin", 2056 headers: { 2057 "Content-Type": "application/json", 2058 Accept: "application/json", 2059 "X-CSRF-Token": csrf, 2060 }, 2061 body: JSON.stringify({ user_id: userId, division_id: divisionId, refund: refund }), 2062 }); 2063 const data = await res.json().catch(() => ({})); 2064 if (!res.ok || data.success === false) { 2065 throw new Error(data.error || `HTTP ${res.status}`); 2066 } 2067 // Refresh both the affected division pane (player gone) and the 2068 // bench section (in case the removal cascaded back to the bench). 2069 const tasks = [this._refreshBenchSection(), this._refreshDivisionPane(divisionId)]; 2070 await Promise.all(tasks); 2071 // â ï¸ DOLLARS. `club_tournaments_controller#remove_participant` renders 2072 // `refund_amount: result[:refund_amount]`, which the service returns in DOLLARS â so dividing 2073 // by 100 here toasted a real $45 refund as "$0.45". Same units confusion as the custom removal 2074 // email (rb-custom-removal-email-understates-the-refund-100x), third site. 2075 const refundedNote = data.refund_amount && data.refund_amount > 0 2076 ? ` Refunded $${Number(data.refund_amount).toFixed(2)}.` 2077 : ""; 2078 // RB-1769: money on the backing EVENT cannot be refunded from here, so say where it is. 2079 if (data.warning) { 2080 this._toast(`${userName} removed from tournament.${refundedNote} ${data.warning}`, "warning"); 2081 } else { 2082 this._toast(`${userName} removed from tournament.${refundedNote}`, "success"); 2083 } 2084 } catch (e) { 2085 console.error("[ttv] remove-from-tournament failed", e); 2086 this._toast(`Could not remove: ${e.message || e}`, "error"); 2087 } 2088 } 2089 2090 _toast(msg, kind = "info") { 2091 if (typeof window.showToast === "function") window.showToast(msg, kind); 2092 else console.log(`[ttv:${kind}]`, msg); 2093 } 2094 2095 _esc(s) { 2096 return String(s == null ? "" : s).replace(/[&<>"']/g, (c) => 2097 ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c])); 2098 } 2099 2100 // ââ "Add a player to this open slot" invite modal (Feature 4, 2026-06-17) â 2101 // Search any player â send them a SECURE register-and-pay invite for this
2102 // division (email + push-if-registered, via the hardened DivisionInvite 2103 // path). Consent-safe: we don't force-place an arbitrary player; they accept 2104 // + pay, then the admin drops them into the slot. Self-contained 2105 // ttv-inviteslot-* markup (NOT "modal") to dodge the layout hide rule (§8). 2106 openInviteSlotModal(slot) { 2107 const teamId = parseInt(slot.dataset.teamId, 10); 2108 if (!teamId) return; 2109 const card = slot.closest(".ttv-team-card"); 2110 const teamName = card?.querySelector(".ttv-team-card__name")?.textContent?.trim() || "this team"; 2111 2112 document.querySelectorAll(".ttv-inviteslot-backdrop").forEach((n) => n.remove()); 2113 const backdrop = document.createElement("div"); 2114 backdrop.className = "ttv-inviteslot-backdrop"; 2115 backdrop.innerHTML = 2116 '<div class="ttv-inviteslot" role="dialog" aria-modal="true">' + 2117 ' <div class="ttv-inviteslot__head">' + 2118 ' <div class="ttv-inviteslot__title">Add a player to <span class="ttv-inviteslot__team"></span></div>' + 2119 ' <button type="button" class="ttv-inviteslot__close" aria-label="Close"><i class="fas fa-times"></i></button>' + 2120 ' </div>' + 2121 ' <input type="search" class="ttv-inviteslot__search" placeholder="Search players by name or emailâ¦" autocomplete="off">' + 2122 ' <div class="ttv-inviteslot__results"><div class="ttv-inviteslot__empty">Type at least 2 charactersâ¦</div></div>' + 2123 ' <p class="ttv-inviteslot__note"><i class="fas fa-paper-plane"></i> They get a secure link (push + email) to register & pay for this division. Once they accept, drag them into the open slot.</p>' + 2124 '</div>'; 2125 backdrop.querySelector(".ttv-inviteslot__team").textContent = teamName; 2126 document.body.appendChild(backdrop); 2127 2128 const input = backdrop.querySelector(".ttv-inviteslot__search"); 2129 const results = backdrop.querySelector(".ttv-inviteslot__results"); 2130 const onKey = (e) => { if (e.key === "Escape") close(); }; 2131 const close = () => { document.removeEventListener("keydown", onKey); backdrop.remove(); }; 2132 backdrop.querySelector(".ttv-inviteslot__close").addEventListener("click", close); 2133 backdrop.addEventListener("click", (e) => { if (e.target === backdrop) close(); }); 2134 document.addEventListener("keydown", onKey); 2135 2136 let timer = null; 2137 input.addEventListener("input", () => { 2138 clearTimeout(timer); 2139 const q = input.value.trim(); 2140 if (q.length < 2) { 2141 results.innerHTML = '<div class="ttv-inviteslot__empty">Type at least 2 charactersâ¦</div>'; 2142 return; 2143 } 2144 timer = setTimeout(() => this._runInviteSearch(q, results, teamId, close), 250); 2145 }); 2146 input.focus(); 2147 } 2148 2149 async _runInviteSearch(q, results, teamId, close, onPick = null) { 2150 results.innerHTML = '<div class="ttv-inviteslot__empty">Searchingâ¦</div>'; 2151 try { 2152 const url = `/club_admin/${this._clubSlug()}/tournaments/${this._tournamentSlug()}/teams_v2/players/search?q=${encodeURIComponent(q)}`; 2153 const res = await fetch(url, { 2154 headers: { "Accept": "application/json", "X-CSRF-Token": this._csrfToken() } 2155 }); 2156 const data = await res.json().catch(() => ({})); 2157 const list = (data && data.results) || []; 2158 if (!list.length) { 2159 results.innerHTML = '<div class="ttv-inviteslot__empty">No players found.</div>'; 2160 return; 2161 } 2162 results.innerHTML = ""; 2163 list.forEach((p) => { 2164 const row = document.createElement("button"); 2165 row.type = "button"; 2166 row.className = "ttv-inviteslot__row"; 2167 const placed = p.placed_team_name 2168 ? `<span class="ttv-inviteslot__chip">On ${this._esc(p.placed_team_name)}</span>` 2169 : (p.registered ? '<span class="ttv-inviteslot__chip ttv-inviteslot__chip--reg">Registered</span>' : ""); 2170 const avatar = p.avatar_url 2171 ? `<img class="ttv-inviteslot__avatar" src="${this._esc(p.avatar_url)}" alt="">` 2172 : '<span class="ttv-inviteslot__avatar ttv-inviteslot__avatar--fallback"></span>'; 2173 row.innerHTML = `${avatar}<span class="ttv-inviteslot__name">${this._esc(p.name || "Player")}</span>${placed}`; 2174 row.addEventListener("click", () => (onPick ? onPick(p) : this._sendSlotInvite(teamId, p, close))); 2175 results.appendChild(row); 2176 }); 2177 } catch (e) { 2178 console.error("[ttv] invite search failed", e); 2179 results.innerHTML = '<div class="ttv-inviteslot__empty">Search failed â try again.</div>'; 2180 } 2181 } 2182 2183 async _sendSlotInvite(teamId, player, close) {
2184 const res = await this._postBackground( 2185 `/club_admin/${this._clubSlug()}/tournaments/${this._tournamentSlug()}/teams_v2/teams/${teamId}/invite_to_slot`, 2186 { user_id: player.id } 2187 ); 2188 if (!res) return; // _postBackground already toasted the error 2189 close(); 2190 this._toast(`Invite sent to ${player.name || "player"}.`, "success"); 2191 } 2192 2193 // ââ Empty-slot OPTION modal (2026-08-10) ââââââââââââââââââââââââââââââââ 2194 // Two paths: "Send an invite" (the consent-safe register-and-pay link, the 2195 // existing openInviteSlotModal flow) vs "Add manually" (place a searched 2196 // player now with an organizer-chosen payment treatment â openManualAddModal). 2197 // ttv-slotopt-* markup (NOT "modal") dodges the club_admin layout hide rule (§8). 2198 openSlotOptionModal(slot) { 2199 const card = slot.closest(".ttv-team-card"); 2200 const teamName = card?.querySelector(".ttv-team-card__name")?.textContent?.trim() || "this team"; 2201 document.querySelectorAll(".ttv-slotopt-backdrop").forEach((n) => n.remove()); 2202 const backdrop = document.createElement("div"); 2203 backdrop.className = "ttv-slotopt-backdrop"; 2204 backdrop.innerHTML = 2205 '<div class="ttv-slotopt" role="dialog" aria-modal="true">' + 2206 ' <div class="ttv-slotopt__head">' + 2207 ' <div class="ttv-slotopt__title">Add a player to <span class="ttv-slotopt__team"></span></div>' + 2208 ' <button type="button" class="ttv-slotopt__close" aria-label="Close"><i class="fas fa-times"></i></button>' + 2209 ' </div>' + 2210 ' <button type="button" class="ttv-slotopt__choice" data-choice="invite">' + 2211 ' <span class="ttv-slotopt__ic"><i class="fas fa-paper-plane"></i></span>' + 2212 ' <span class="ttv-slotopt__body"><span class="ttv-slotopt__ct">Send an invite</span>' + 2213 ' <span class="ttv-slotopt__cs">They get a secure link to register & pay, then you place them.</span></span>' + 2214 ' </button>' + 2215 ' <button type="button" class="ttv-slotopt__choice" data-choice="manual">' + 2216 ' <span class="ttv-slotopt__ic"><i class="fas fa-user-plus"></i></span>' + 2217 ' <span class="ttv-slotopt__body"><span class="ttv-slotopt__ct">Add manually</span>' + 2218 ' <span class="ttv-slotopt__cs">Put a player on the team now, you choose how payment is handled.</span></span>' + 2219 ' </button>' + 2220 '</div>'; 2221 backdrop.querySelector(".ttv-slotopt__team").textContent = teamName; 2222 document.body.appendChild(backdrop); 2223 const onKey = (e) => { if (e.key === "Escape") close(); }; 2224 const close = () => { document.removeEventListener("keydown", onKey); backdrop.remove(); }; 2225 backdrop.querySelector(".ttv-slotopt__close").addEventListener("click", close); 2226 backdrop.addEventListener("click", (e) => { if (e.target === backdrop) close(); }); 2227 document.addEventListener("keydown", onKey); 2228 backdrop.querySelector('[data-choice="invite"]').addEventListener("click", () => { close(); this.openInviteSlotModal(slot); }); 2229 backdrop.querySelector('[data-choice="manual"]').addEventListener("click", () => { close(); this.openManualAddModal(slot); }); 2230 } 2231 2232 // ââ Manual-add modal â search any player, then pick a payment treatment ââ 2233 // Reuses the invite modal's search shell + _runInviteSearch (via the onPick 2234 // override) so the search UX is identical; the row click opens the payment 2235 // step instead of sending an invite. 2236 openManualAddModal(slot) { 2237 const teamId = parseInt(slot.dataset.teamId, 10); 2238 const divisionId = parseInt(slot.dataset.divisionId, 10); 2239 if (!teamId) return; 2240 const card = slot.closest(".ttv-team-card"); 2241 const teamName = card?.querySelector(".ttv-team-card__name")?.textContent?.trim() || "this team"; 2242 2243 document.querySelectorAll(".ttv-inviteslot-backdrop").forEach((n) => n.remove()); 2244 const backdrop = document.createElement("div"); 2245 backdrop.className = "ttv-inviteslot-backdrop"; 2246 backdrop.innerHTML = 2247 '<div class="ttv-inviteslot" role="dialog" aria-modal="true">' + 2248 ' <div class="ttv-inviteslot__head">' + 2249 ' <div class="ttv-inviteslot__title">Add a player to <span class="ttv-inviteslot__team"></span></div>' + 2250 ' <button type="button" class="ttv-inviteslot__close" aria-label="Close"><i class="fas fa-times"></i></button>' + 2251 ' </div>' + 2252 ' <input type="search" class="ttv-inviteslot__search" placeholder="Search players by name or emailâ¦" autocomplete="off">' + 2253 ' <div class="ttv-inviteslot__results"><div class="ttv-inviteslot__empty">Type at least 2 charactersâ¦</div></div>' + 2254 ' <p class="ttv-inviteslot__note"><i class="fas fa-user-plus"></i> Placed on the team right away. You pick comp, mark paid, or send a payment link, no card is charged here.</p>' + 2255 '</div>'; 2256 backdrop.querySelector(".ttv-inviteslot__team").textContent = teamName; 2257 document.body.appendChild(backdrop); 2258 2259 const input = backdrop.querySelector(".ttv-inviteslot__search"); 2260 const results = backdrop.querySelector(".ttv-inviteslot__results"); 2261 const onKey = (e) => { if (e.key === "Escape") close(); }; 2262 const close = () => { document.removeEventListener("keydown", onKey); backdrop.remove(); }; 2263 backdrop.querySelector(".ttv-inviteslot__close").addEventListener("click", close); 2264 backdrop.addEventListener("click", (e) => { if (e.target === backdrop) close(); }); 2265 document.addEventListener("keydown", onKey); 2266 2267 let timer = null; 2268 input.addEventListener("input", () => { 2269 clearTimeout(timer); 2270 const q = input.value.trim(); 2271 if (q.length < 2) { 2272 results.innerHTML = '<div class="ttv-inviteslot__empty">Type at least 2 charactersâ¦</div>'; 2273 return; 2274 } 2275 timer = setTimeout(() => this._runInviteSearch(q, results, teamId, close, 2276 (player) => this._chooseManualPayment(backdrop, slot, teamId, divisionId, player, close, teamName)), 250); 2277 }); 2278 input.focus(); 2279 } 2280 2281 // Swap the manual-add modal body to the payment-treatment choice for a picked 2282 // player. No card is ever charged here: comp = waived, mark_paid = offline 2283 // money already collected, payment_link = placed pending + a pay link is sent. 2284 _chooseManualPayment(backdrop, slot, teamId, divisionId, player, close, teamName) { 2285 const panel = backdrop.querySelector(".ttv-inviteslot"); 2286 if (!panel) return; 2287 const name = this._esc(player.name || "this player"); 2288 // Keep the target team visible on the payment step too (the first screen names 2289 // it; without this the step just said "Add <name>" with no team â Scott 2026-08-23). 2290 const toTeam = teamName ? ' to ' + this._esc(teamName) : ''; 2291 panel.innerHTML = 2292 '<div class="ttv-inviteslot__head">' + 2293 ' <div class="ttv-inviteslot__title">Add ' + name + toTeam + '</div>' + 2294 ' <button type="button" class="ttv-inviteslot__close" aria-label="Close"><i class="fas fa-times"></i></button>' + 2295 '</div>' + 2296 '<p class="ttv-manualpay__q">How should payment be handled?</p>' + 2297 '<div class="ttv-manualpay">' + 2298 ' <button type="button" class="ttv-manualpay__opt" data-pay="comp">' + 2299 ' <span class="ttv-manualpay__ot"><i class="fas fa-gift"></i> Comp / no charge</span>' + 2300 ' <span class="ttv-manualpay__os">Waived, they owe nothing.</span></button>' + 2301 ' <button type="button" class="ttv-manualpay__opt" data-pay="mark_paid">' + 2302 ' <span class="ttv-manualpay__ot"><i class="fas fa-check-circle"></i> Already paid (offline)</span>' + 2303 ' <span class="ttv-manualpay__os">Recorded as paid, you collected cash/check/Venmo.</span></button>' + 2304 ' <button type="button" class="ttv-manualpay__opt" data-pay="payment_link">' + 2305 ' <span class="ttv-manualpay__ot"><i class="fas fa-link"></i> Add & send payment link</span>' + 2306 ' <span class="ttv-manualpay__os">Placed now, they get a link to pay their entry fee.</span></button>' + 2307 '</div>' + 2308 '<button type="button" class="ttv-manualpay__back"><i class="fas fa-arrow-left"></i> Back to search</button>'; 2309 panel.querySelector(".ttv-inviteslot__close").addEventListener("click", close); 2310 panel.querySelector(".ttv-manualpay__back").addEventListener("click", () => { close(); this.openManualAddModal(slot); }); 2311 panel.querySelectorAll(".ttv-manualpay__opt").forEach((btn) => { 2312 btn.addEventListener("click", () => { 2313 panel.querySelectorAll(".ttv-manualpay__opt").forEach((b) => { b.disabled = true; }); 2314 this._submitManualAdd(teamId, divisionId, player, btn.dataset.pay, close); 2315 }); 2316 }); 2317 } 2318 2319 async _submitManualAdd(teamId, divisionId, player, payOption, close) {
2320 const res = await this._postBackground( 2321 `/club_admin/${this._clubSlug()}/tournaments/${this._tournamentSlug()}/teams_v2/teams/${teamId}/add_player_manual`, 2322 { user_id: player.id, payment_option: payOption } 2323 ); 2324 if (!res) { 2325 // Failure (e.g. team_full because the card was stale) â repaint the division so 2326 // the true roster shows and the phantom open slot disappears (Scott 2026-08-23). 2327 if (divisionId) this._refreshDivisionPane(divisionId); 2328 return; // _postBackground already toasted the (now humanized) error 2329 } 2330 close(); 2331 const label = payOption === "comp" ? "added (comp)" 2332 : payOption === "mark_paid" ? "added and marked paid" 2333 : "added, payment link sent"; 2334 this._toast(`${player.name || "Player"} ${label}.`, "success"); 2335 if (divisionId) this._refreshDivisionPane(divisionId); 2336 } 2337 2338 // ââ Add a searched player straight to the BENCH (unassigned) ââââââââââââ 2339 // Sibling of the manual-add-to-slot flow: search any player, pick a payment 2340 // treatment, and add them to the active division's bench (a solo/needs-partner 2341 // entry) so the operator can place them on a team later. Reuses the same 2342 // search shell + payment step; POSTs add_to_bench instead of add_player_manual. 2343 openAddToBench(event) { 2344 const btn = event?.currentTarget; 2345 // Fall back to the sole division so a single-division tournament never forces "open a division first". 2346 const divisionId = btn?.dataset?.divisionId || this._activeDivisionId() || this._soleDivisionId(); 2347 if (!divisionId) { this._toast("Open a division first, then add to its bench.", "warning"); return; } 2348 2349 document.querySelectorAll(".ttv-inviteslot-backdrop").forEach((n) => n.remove()); 2350 const backdrop = document.createElement("div"); 2351 backdrop.className = "ttv-inviteslot-backdrop"; 2352 backdrop.innerHTML = 2353 '<div class="ttv-inviteslot" role="dialog" aria-modal="true">' + 2354 ' <div class="ttv-inviteslot__head">' + 2355 ' <div class="ttv-inviteslot__title">Add a player to the bench</div>' + 2356 ' <button type="button" class="ttv-inviteslot__close" aria-label="Close"><i class="fas fa-times"></i></button>' + 2357 ' </div>' + 2358 ' <input type="search" class="ttv-inviteslot__search" placeholder="Search players by name or emailâ¦" autocomplete="off">' + 2359 ' <div class="ttv-inviteslot__results"><div class="ttv-inviteslot__empty">Type at least 2 charactersâ¦</div></div>' + 2360 ' <p class="ttv-inviteslot__note"><i class="fas fa-user-plus"></i> Added to the bench (unassigned). Drag them onto a team, or use a team\'s open slot, to place them.</p>' + 2361 '</div>'; 2362 document.body.appendChild(backdrop); 2363 2364 const input = backdrop.querySelector(".ttv-inviteslot__search"); 2365 const results = backdrop.querySelector(".ttv-inviteslot__results"); 2366 const onKey = (e) => { if (e.key === "Escape") close(); }; 2367 const close = () => { document.removeEventListener("keydown", onKey); backdrop.remove(); }; 2368 backdrop.querySelector(".ttv-inviteslot__close").addEventListener("click", close); 2369 backdrop.addEventListener("click", (e) => { if (e.target === backdrop) close(); }); 2370 document.addEventListener("keydown", onKey); 2371 2372 let timer = null; 2373 input.addEventListener("input", () => { 2374 clearTimeout(timer); 2375 const q = input.value.trim(); 2376 if (q.length < 2) { 2377 results.innerHTML = '<div class="ttv-inviteslot__empty">Type at least 2 charactersâ¦</div>'; 2378 return; 2379 } 2380 timer = setTimeout(() => this._runInviteSearch(q, results, null, close, 2381 (player) => this._chooseBenchPayment(backdrop, divisionId, player, close)), 250); 2382 }); 2383 input.focus(); 2384 } 2385 2386 _chooseBenchPayment(backdrop, divisionId, player, close) { 2387 const panel = backdrop.querySelector(".ttv-inviteslot"); 2388 if (!panel) return; 2389 const name = this._esc(player.name || "this player"); 2390 panel.innerHTML = 2391 '<div class="ttv-inviteslot__head">' + 2392 ' <div class="ttv-inviteslot__title">Add ' + name + ' to bench</div>' + 2393 ' <button type="button" class="ttv-inviteslot__close" aria-label="Close"><i class="fas fa-times"></i></button>' + 2394 '</div>' + 2395 '<p class="ttv-manualpay__q">How should payment be handled?</p>' + 2396 '<div class="ttv-manualpay">' + 2397 ' <button type="button" class="ttv-manualpay__opt" data-pay="comp">' + 2398 ' <span class="ttv-manualpay__ot"><i class="fas fa-gift"></i> Comp / no charge</span>' + 2399 ' <span class="ttv-manualpay__os">Waived, they owe nothing.</span></button>' + 2400 ' <button type="button" class="ttv-manualpay__opt" data-pay="mark_paid">' + 2401 ' <span class="ttv-manualpay__ot"><i class="fas fa-check-circle"></i> Already paid (offline)</span>' + 2402 ' <span class="ttv-manualpay__os">Recorded as paid, you collected it.</span></button>' + 2403 ' <button type="button" class="ttv-manualpay__opt" data-pay="payment_link">' + 2404 ' <span class="ttv-manualpay__ot"><i class="fas fa-link"></i> Add & send payment link</span>' + 2405 ' <span class="ttv-manualpay__os">On the bench now, they get a link to pay.</span></button>' + 2406 '</div>' + 2407 '<button type="button" class="ttv-manualpay__back"><i class="fas fa-arrow-left"></i> Back to search</button>'; 2408 panel.querySelector(".ttv-inviteslot__close").addEventListener("click", close); 2409 panel.querySelector(".ttv-manualpay__back").addEventListener("click", () => { 2410 close();
2410 this.openAddToBench({ currentTarget: { dataset: { divisionId } } }); 2411 }); 2412 panel.querySelectorAll(".ttv-manualpay__opt").forEach((btn) => { 2413 btn.addEventListener("click", () => { 2414 panel.querySelectorAll(".ttv-manualpay__opt").forEach((b) => { b.disabled = true; }); 2415 this._submitAddToBench(divisionId, player, btn.dataset.pay, close); 2416 }); 2417 }); 2418 } 2419 2420 async _submitAddToBench(divisionId, player, payOption, close) { 2421 const res = await this._postBackground( 2422 `/club_admin/${this._clubSlug()}/tournaments/${this._tournamentSlug()}/teams_v2/add_to_bench`, 2423 { division_id: divisionId, user_id: player.id, payment_option: payOption } 2424 ); 2425 if (!res) return; // _postBackground toasted the error; keep the modal open 2426 close(); 2427 const where = res.waitlisted ? "added to the waitlist" : "added to the bench"; 2428 this._toast(`${player.name || "Player"} ${where}.`, "success"); 2429 try { this._refreshBenchSection && this._refreshBenchSection(); } catch (_e) { /* fire-and-forget */ } 2430 if (divisionId) { try { this._refreshDivisionPane(divisionId); } catch (_e) { /* pane not visible */ } } 2431 } 2432 2433 // 2026-06-17 â Styled swap/replace confirm popup. Returns a Promise<boolean> 2434 // (true = proceed). Class prefix ttv-swapconfirm-* (NOT "modal") to dodge the 2435 // club_admin layout's blanket modal-hide rule (§8). Backdrop / ESC / Cancel â 2436 // false. Used by the drag-onto-filled-slot and click-two-to-swap paths so a 2437 // swap never fires without the operator confirming "swap X for Y". 2438 _confirmSwap(message, confirmLabel = "Swap") { 2439 return new Promise((resolve) => { 2440 document.querySelectorAll(".ttv-swapconfirm-backdrop").forEach((n) => n.remove()); 2441 const backdrop = document.createElement("div"); 2442 backdrop.className = "ttv-swapconfirm-backdrop"; 2443 backdrop.innerHTML = 2444 '<div class="ttv-swapconfirm" role="dialog" aria-modal="true">' + 2445 ' <div class="ttv-swapconfirm__icon"><i class="fas fa-right-left"></i></div>' + 2446 ' <div class="ttv-swapconfirm__msg"></div>' + 2447 ' <div class="ttv-swapconfirm__actions">' + 2448 ' <button type="button" class="ttv-swapconfirm__cancel">Cancel</button>' + 2449 ' <button type="button" class="ttv-swapconfirm__confirm"></button>' + 2450 ' </div>' + 2451 '</div>'; 2452 backdrop.querySelector(".ttv-swapconfirm__msg").textContent = message; 2453 backdrop.querySelector(".ttv-swapconfirm__confirm").textContent = confirmLabel; 2454 document.body.appendChild(backdrop); 2455 2456 let done = false; 2457 const finish = (val) => { 2458 if (done) return; 2459 done = true; 2460 document.removeEventListener("keydown", onKey); 2461 backdrop.remove(); 2462 resolve(val); 2463 }; 2464 const onKey = (e) => { if (e.key === "Escape") finish(false); }; 2465 document.addEventListener("keydown", onKey); 2466 backdrop.addEventListener("click", (e) => { if (e.target === backdrop) finish(false); }); 2467 backdrop.querySelector(".ttv-swapconfirm__cancel").addEventListener("click", () => finish(false)); 2468 backdrop.querySelector(".ttv-swapconfirm__confirm").addEventListener("click", () => finish(true)); 2469 backdrop.querySelector(".ttv-swapconfirm__confirm").focus(); 2470 }); 2471 } 2472 2473 // 2026-05-27 â Bench-sidebar trash icon â "Remove from tournament" 2474 // premium confirm modal. Distinct from the per-division â pill: this 2475 // fully cancels every registration the user holds. 2476 openRemoveFromTournamentModal(event) { 2477 event?.preventDefault?.(); 2478 event?.stopPropagation?.(); 2479 const btn = event.currentTarget; 2480 const userId = btn.dataset.userId; 2481 const userName = btn.dataset.userName || "this player"; 2482 if (!userId) return; 2483 2484 const modal = this.hasRemoveFromTournamentModalTarget ? this.removeFromTournamentModalTarget : null; 2485 if (!modal) return; 2486 modal.dataset.userId = userId; 2487 const nameEl = modal.querySelector("#ttvRftUserName"); 2488 if (nameEl) nameEl.textContent = userName; 2489 const errEl = modal.querySelector("#ttvRftError"); 2490 if (errEl) { errEl.hidden = true; errEl.textContent = ""; } 2491 const refundCheckbox = modal.querySelector("#ttvRftRefundCheckbox"); 2492 if (refundCheckbox) refundCheckbox.checked = true; 2493 if (this.hasRftConfirmLabelTarget) this.rftConfirmLabelTarget.textContent = "Remove & Refund"; 2494 2495 modal.hidden = false;
2496 modal.style.display = "flex"; 2497 2498 // 2026-05-28 â Reset refund-mode UI to default + fetch initial preview 2499 // so the admin sees the actual dollar amount immediately. 2500 const fullRadio = modal.querySelector('input[name="ttvRftRefundMode"][value="full"]'); 2501 if (fullRadio) fullRadio.checked = true; 2502 const pctInput = modal.querySelector("#ttvRftRefundPercent"); 2503 if (pctInput) { pctInput.value = "50"; pctInput.disabled = true; } 2504 const fieldset = document.getElementById("ttvRftRefundModes"); 2505 if (fieldset) fieldset.style.display = ""; 2506 const preview = document.getElementById("ttvRftPreview"); 2507 if (preview) preview.style.display = ""; 2508 this._lastRftPreviewAmount = null; 2509 this._refreshRftPreview(); 2510 } 2511 2512 closeRemoveFromTournamentModal(event) { 2513 event?.preventDefault?.(); 2514 const modal = this.hasRemoveFromTournamentModalTarget ? this.removeFromTournamentModalTarget : null; 2515 if (!modal) return; 2516 modal.hidden = true; 2517 modal.style.display = "none"; 2518 } 2519 2520 toggleRftRefund(event) { 2521 const refund = !!event.target.checked; 2522 // Show/hide the refund-mode radio group + preview based on the checkbox. 2523 const fieldset = document.getElementById("ttvRftRefundModes"); 2524 if (fieldset) fieldset.style.display = refund ? "" : "none"; 2525 const preview = document.getElementById("ttvRftPreview"); 2526 if (preview) preview.style.display = refund ? "" : "none"; 2527 this.updateRftConfirmLabel(); 2528 if (refund) this._refreshRftPreview(); 2529 } 2530 2531 // 2026-05-28 â Refund mode picker. Toggles the percent input enabled 2532 // state when "Custom %" is chosen, refreshes the dollar-amount preview, 2533 // and refreshes the button label. 2534 syncRftRefundMode(event) { 2535 const mode = event?.target?.value || this._currentRftRefundMode(); 2536 const pctInput = document.getElementById("ttvRftRefundPercent"); 2537 if (pctInput) pctInput.disabled = (mode !== "percentage"); 2538 this.updateRftConfirmLabel(); 2539 this._refreshRftPreview(); 2540 } 2541 2542 // Recompute the confirm button label based on current refund state + 2543 // the latest preview amount. Falls back to a static label if the preview 2544 // hasn't loaded yet. 2545 updateRftConfirmLabel() { 2546 if (!this.hasRftConfirmLabelTarget) return; 2547 const checkbox = document.getElementById("ttvRftRefundCheckbox"); 2548 const refundOn = !!checkbox?.checked; 2549 if (!refundOn) { 2550 this.rftConfirmLabelTarget.textContent = "Remove Only"; 2551 return; 2552 } 2553 // If the preview loaded, show the dollar amount in the button. 2554 if (this._lastRftPreviewAmount != null) { 2555 this.rftConfirmLabelTarget.textContent = `Remove & Refund $${this._lastRftPreviewAmount.toFixed(2)}`; 2556 } else { 2557 this.rftConfirmLabelTarget.textContent = "Remove & Refund"; 2558 } 2559 } 2560 2561 // Fetch the refund preview from the server and paint the banner. 2562 // Debounced so percent-typing doesn't fire a flurry of requests. 2563 _refreshRftPreview() { 2564 const modal = this.hasRemoveFromTournamentModalTarget ? this.removeFromTournamentModalTarget : null; 2565 if (!modal) return; 2566 const userId = modal.dataset.userId; 2567 const previewUrl = modal.dataset.previewUrl; 2568 if (!userId || !previewUrl) return; 2569 const refundOn = !!document.getElementById("ttvRftRefundCheckbox")?.checked; 2570 if (!refundOn) return; 2571 2572 const mode = this._currentRftRefundMode(); 2573 const percent = mode === "percentage" 2574 ? parseFloat(document.getElementById("ttvRftRefundPercent")?.value || "0") 2575 : null; 2576 2577 if (this._rftPreviewTimer) clearTimeout(this._rftPreviewTimer); 2578 this._rftPreviewTimer = setTimeout(async () => { 2579 try { 2580 const url = new URL(previewUrl, window.location.origin); 2581 url.searchParams.set("user_id", userId); 2582 url.searchParams.set("mode", mode); 2583 if (percent !== null) url.searchParams.set("percent", String(percent)); 2584 const res = await fetch(url.toString(), { credentials: "same-origin", headers: { Accept: "application/json" } }); 2585 const data = await res.json().catch(() => ({})); 2586 if (!data.success) return; 2587 2588 const amount = (data.total_refund_cents || 0) / 100.0; 2589 this._lastRftPreviewAmount = amount; 2590
2591 // Aggregate the per-registration breakdown into modal-level totals. 2592 const regs = data.per_registration || []; 2593 const totalPaid = regs.reduce((s, r) => s + (r.amount_paid_cents || 0), 0) / 100.0; 2594 const totalPlatform = regs.reduce((s, r) => s + (r.platform_fee_cents || 0), 0) / 100.0; 2595 const totalRefundable = (data.total_refundable_cents || 0) / 100.0; 2596 2597 const paidEl = document.getElementById("ttvRftPaid"); 2598 if (paidEl) paidEl.textContent = `$${totalPaid.toFixed(2)}`; 2599 2600 const platformCell = document.getElementById("ttvRftPlatformCell"); 2601 const platformEl = document.getElementById("ttvRftPlatform"); 2602 if (platformCell && platformEl) { 2603 if (totalPlatform > 0) { 2604 platformEl.textContent = `$${totalPlatform.toFixed(2)}`; 2605 platformCell.style.display = ""; 2606 } else { 2607 platformCell.style.display = "none"; 2608 } 2609 } 2610 2611 const refundableEl = document.getElementById("ttvRftRefundable"); 2612 if (refundableEl) refundableEl.textContent = `$${totalRefundable.toFixed(2)}`; 2613 2614 const amtEl = document.getElementById("ttvRftPreviewAmount"); 2615 if (amtEl) amtEl.textContent = `$${amount.toFixed(2)}`; 2616 2617 const noteEl = document.getElementById("ttvRftPreviewNote"); 2618 if (noteEl) noteEl.textContent = data.note || ""; 2619 2620 const detailEl = document.getElementById("ttvRftPreviewDetail"); 2621 if (detailEl) { 2622 const regs = data.per_registration || []; 2623 if (regs.length === 0) { 2624 detailEl.innerHTML = `<em>No paid registrations to refund.</em>`; 2625 } else if (regs.length === 1) { 2626 const r = regs[0]; 2627 const paid = (r.amount_paid_cents / 100).toFixed(2); 2628 const refund = (r.refund_cents / 100).toFixed(2); 2629 detailEl.innerHTML = `<strong>${this._escape(r.division_name || "Division")}:</strong> ` + 2630 `paid $${paid} · refund $${refund}`; 2631 } else { 2632 const rows = regs.map(r => { 2633 const paid = (r.amount_paid_cents / 100).toFixed(2); 2634 const refund = (r.refund_cents / 100).toFixed(2); 2635 return `<div><strong>${this._escape(r.division_name || "Division")}:</strong> ` + 2636 `paid $${paid} · refund $${refund}</div>`; 2637 }).join(""); 2638 detailEl.innerHTML = rows; 2639 } 2640 } 2641 2642 this.updateRftConfirmLabel(); 2643 } catch (e) { 2644 console.error("[ttv] refund preview failed", e); 2645 } 2646 }, 200); 2647 } 2648 2649 _escape(s) { 2650 const d = document.createElement("div"); 2651 d.textContent = String(s || ""); 2652 return d.innerHTML; 2653 } 2654 2655 _currentRftRefundMode() { 2656 const checked = document.querySelector('input[name="ttvRftRefundMode"]:checked'); 2657 return checked ? checked.value : "full"; 2658 } 2659 2660 async confirmRemoveFromTournament(event) { 2661 event?.preventDefault?.(); 2662 const modal = this.hasRemoveFromTournamentModalTarget ? this.removeFromTournamentModalTarget : null; 2663 if (!modal) return; 2664 const userId = modal.dataset.userId; 2665 if (!userId) return; 2666 const endpoint = modal.dataset.endpointUrl; 2667 const refund = !!modal.querySelector("#ttvRftRefundCheckbox")?.checked; 2668 const refundMode = refund ? this._currentRftRefundMode() : null; 2669 const refundPercent = refund && refundMode === "percentage" 2670 ? parseFloat(document.getElementById("ttvRftRefundPercent")?.value || "0") 2671 : null; 2672 const errEl = modal.querySelector("#ttvRftError"); 2673 const btn = this.hasRftConfirmBtnTarget ? this.rftConfirmBtnTarget : null; 2674 2675 if (btn) { btn.disabled = true; btn.style.opacity = "0.6"; } 2676 try { 2677 const csrf = document.querySelector('meta[name="csrf-token"]')?.content || ""; 2678 const body = { user_id: userId, refund: refund }; 2679 if (refund && refundMode) body.refund_mode = refundMode; 2680 if (refund && refundPercent !== null) body.refund_percent = refundPercent; 2681 const res = await fetch(endpoint, { 2682 method: "POST", 2683 credentials: "same-origin", 2684 headers: { "Content-Type": "application/json", "Accept": "application/json", "X-CSRF-Token": csrf }, 2685 body: JSON.stringify(body) 2686 }); 2687 const data = await res.json().catch(() => ({})); 2688 if (!res.ok || !data.success) { 2689 const msg = data.error || (data.errors && data.errors[0]?.error) || "Failed to remove player."; 2690 if (errEl) { errEl.textContent = msg; errEl.hidden = false; } 2691 return; 2692 } 2693 2694 this.closeRemoveFromTournamentModal(); 2695 const refunded = data.refunded_cents ? ` Refunded $${(data.refunded_cents / 100).toFixed(2)}.` : ""; 2696 const count = (data.removed_divisions || []).length; 2697 // RB-1769 round 3: when the player paid on the event side there is no refund to make HERE, and 2698 // round 2 set a warning that no caller read, so this toast said nothing and the organizer 2699 // walked away believing there was no money. Show it, and show it as a warning, not a success. 2700 const warning = data.warning; 2701 if (warning) { 2702 this._toast(`Player removed from ${count || "the"} ${count === 1 ? "division" : "divisions"}. ${warning}`, "warning"); 2703 } else { 2704 this._toast(`Player removed from ${count || "the"} ${count === 1 ? "division" : "divisions"}.${refunded}`, "success"); 2705 } 2706 2707 // Refresh bench + every affected division pane. 2708 if (typeof this._refreshBenchSection === "function") this._refreshBenchSection();
2709 (data.removed_divisions || []).forEach(d => { 2710 if (typeof this._refreshDivisionPane === "function") this._refreshDivisionPane(d.id); 2711 }); 2712 } catch (e) { 2713 console.error("[ttv] remove-from-tournament failed", e); 2714 if (errEl) { errEl.textContent = `Network error: ${e.message || e}`; errEl.hidden = false; } 2715 } finally { 2716 if (btn) { btn.disabled = false; btn.style.opacity = "1"; } 2717 } 2718 } 2719 2720 // Divisions search â client-side filter on the sidebar list. Hides 2721 // any row whose data-name-lower doesn't contain the query. The "All 2722 // divisions" pill stays pinned. 2723 filterDivisions(event) { 2724 const q = (event.target.value || "").toLowerCase().trim(); 2725 let visibleCount = 0; 2726 document.querySelectorAll(".ttv-division-row").forEach((row) => { 2727 const name = row.dataset.nameLower || ""; 2728 const match = q === "" || name.includes(q); 2729 row.hidden = !match; 2730 if (match) visibleCount++; 2731 }); 2732 const noMatch = document.querySelector(".ttv-divisions-no-match"); 2733 if (noMatch) noMatch.hidden = (visibleCount > 0); 2734 } 2735 2736 // Sidebar checkbox toggle â recomputes URL based on the current 2737 // checked set. Zero checked â all-view (no param). All checked â also 2738 // all-view (cleaner URL). Otherwise â ?division_ids=1,2,3. 2739 toggleDivisionFilter(_event) { 2740 const list = document.querySelector("[data-ttv-divisions-list]"); 2741 if (!list) return; 2742 const baseUrl = list.dataset.baseUrl; 2743 const boxes = Array.from(list.querySelectorAll(".ttv-division-checkbox")); 2744 const checked = boxes.filter((b) => b.checked).map((b) => b.dataset.divisionId); 2745 const total = boxes.length; 2746 let url; 2747 if (checked.length === 0 || checked.length === total) { 2748 url = baseUrl; // all-view 2749 } else { 2750 url = `${baseUrl}?division_ids=${checked.join(",")}`; 2751 } 2752 if (typeof Turbo !== "undefined") { 2753 Turbo.visit(url); 2754 } else { 2755 window.location = url; 2756 } 2757 } 2758 2759 // ââ Stub action handlers (real implementations land in their phases) âââ 2760 2761 // Sync the connected event's not-yet-seated confirmed registrations onto the 2762 // bench (idempotent server-side). Bulk one-shot action, so reload to reflect 2763 // the new bench + the updated "Connected to event" banner counts. 2764 async syncFromEvent(event) { 2765 const btn = event.currentTarget; 2766 const url = btn?.dataset?.syncUrl; 2767 if (!url) return; 2768 btn.disabled = true; 2769 const res = await this._postBackground(url, {}); 2770 if (!res) { btn.disabled = false; return; } // _postBackground surfaced its own toast 2771 const n = parseInt(res.seated, 10) || 0; 2772 const remaining = parseInt(res.remaining, 10) || 0; 2773 let msg; 2774 if (n === 0 && remaining === 0) { 2775 msg = "Everyone from the event is already on the roster."; 2776 } else if (remaining > 0) { 2777 msg = `Seated ${n}, ${remaining} remaining. Click Sync from event again to add the rest.`; 2778 } else { 2779 msg = `Seated ${n} player${n === 1 ? "" : "s"} from "${res.event_title}".`; 2780 } 2781 this._toast(msg, remaining > 0 ? "info" : "success"); 2782 window.location.assign(window.location.href); 2783 } 2784 2785 // Connect this standalone tournament to an unlinked event (picker in the 2786 // banner). Reload so the connected banner + Sync button appear. 2787 // ââ Searchable event picker (connect-to-event banner) ââââââââââââââââ 2788 _evpicker() { return this.element.querySelector("[data-ttv-evpicker]"); } 2789 2790 _evpickerSetExpanded(root, open) { 2791 const menu = root.querySelector("[data-ttv-evpicker-menu]"); 2792 const input = root.querySelector("[data-ttv-evpicker-search]"); 2793 if (menu) menu.hidden = !open; 2794 if (input) input.setAttribute("aria-expanded", open ? "true" : "false"); 2795 } 2796 2797 _evpickerVisible(root) { 2798 return Array.from(root.querySelectorAll(".ttv-evpicker__opt")).filter((o) => o.style.display !== "none"); 2799 } 2800 2801 // Keep the VISIBLE selection and the hidden id in lockstep so Connect can 2802 // never fire the wrong event (the "type to filter, then Connect without 2803 // clicking a row" hazard). Passing null clears the id â Connect refuses. 2804 _evpickerCommit(root, opt) { 2805 const hidden = root.querySelector("[data-ttv-connect-select]"); 2806 if (hidden) hidden.value = opt ? (opt.dataset.evId || "") : ""; 2807 root.querySelectorAll(".ttv-evpicker__opt").forEach((o) => o.classList.toggle("is-selected", o === opt)); 2808 } 2809 2810 evpickerOpen(event) { 2811 const root = this._evpicker(); 2812 if (!root) return; 2813 this._evpickerSetExpanded(root, true); 2814 // Select-all so typing replaces the shown selection rather than appending. 2815 if (event?.currentTarget?.select) event.currentTarget.select(); 2816 this._evpickerBindOutside(); 2817 } 2818 2819 evpickerFilter() { 2820 const root = this._evpicker(); 2821 if (!root) return; 2822 this._evpickerSetExpanded(root, true); 2823 const q = (root.querySelector("[data-ttv-evpicker-search]")?.value || "").trim().toLowerCase(); 2824 const regOnly = !!root.querySelector("[data-ttv-evpicker-regonly]")?.checked; 2825 let visible = 0; 2826 root.querySelectorAll(".ttv-evpicker__opt").forEach((opt) => { 2827 const hay = opt.dataset.evSearch || ""; 2828 const count = parseInt(opt.dataset.evCount || "0", 10); 2829 const match = (!q || hay.includes(q)) && (!regOnly || count > 0); 2830 opt.style.display = match ? "" : "none"; 2831 if (match) visible += 1; 2832 }); 2833 // Exactly one match auto-selects; otherwise the selection is valid only if 2834 // its row is still visible (else clear it so Connect can't fire a stale id). 2835 const vis = this._evpickerVisible(root); 2836 if (vis.length === 1) { 2837 this._evpickerCommit(root, vis[0]); 2838 } else { 2839 const sel = root.querySelector(".ttv-evpicker__opt.is-selected"); 2840 if (!sel || sel.style.display === "none") this._evpickerCommit(root, null); 2841 } 2842 const empty = root.querySelector("[data-ttv-evpicker-empty]"); 2843 if (empty) { 2844 empty.hidden = visible > 0; 2845 const anyReg = Array.from(root.querySelectorAll(".ttv-evpicker__opt")) 2846 .some((o) => parseInt(o.dataset.evCount || "0", 10) > 0); 2847 empty.textContent = (visible === 0 && regOnly && !anyReg && !q) 2848 ? "No events have registrations yet. Uncheck the filter to see all events." 2849 : "No events match."; 2850 } 2851 } 2852 2853 evpickerSelect(event) { 2854 const opt = event.currentTarget; 2855 const root = this._evpicker(); 2856 if (!root || !opt) return; 2857 const search = root.querySelector("[data-ttv-evpicker-search]"); 2858 this._evpickerCommit(root, opt); 2859 if (search) search.value = opt.dataset.evTitle || ""; 2860 this._evpickerSetExpanded(root, false); 2861 } 2862 2863 evpickerKeydown(event) { 2864 const root = this._evpicker(); 2865 if (!root) return; 2866 if (event.key === "Escape") { this._evpickerSetExpanded(root, false); return; } 2867 if (event.key === "Enter") { 2868 event.preventDefault(); 2869 const pick = root.querySelector(".ttv-evpicker__opt.is-selected") || this._evpickerVisible(root)[0]; 2870 if (pick && pick.style.display !== "none") { 2871 const search = root.querySelector("[data-ttv-evpicker-search]"); 2872 this._evpickerCommit(root, pick); 2873 if (search) search.value = pick.dataset.evTitle || ""; 2874 this._evpickerSetExpanded(root, false); 2875 } 2876 } 2877 } 2878 2879 _evpickerBindOutside() { 2880 if (this._evpickerOutsideHandler) return; 2881 this._evpickerOutsideHandler = (ev) => { 2882 const root = this._evpicker(); 2883 if (!root || root.contains(ev.target)) return; 2884 this._evpickerSetExpanded(root, false); 2885 }; 2886 document.addEventListener("click", this._evpickerOutsideHandler); 2887 } 2888 2889 async connectEvent(event) { 2890 const btn = event.currentTarget; 2891 const url = btn?.dataset?.connectUrl; 2892 const select = this.element.querySelector("[data-ttv-connect-select]"); 2893 const eventId = select?.value; 2894 if (!url) return; 2895 if (!eventId) { this._toast("Pick an event from the list first.", "warning"); return; } 2896 btn.disabled = true;
2897 const res = await this._postBackground(url, { event_id: eventId }); 2898 if (!res) { btn.disabled = false; return; } // _postBackground surfaced its own toast 2899 this._toast(`Connected to "${res.event_title}". Click Sync from event to add its registrations.`, "success"); 2900 window.location.assign(window.location.href); 2901 } 2902 2903 openAddTeam(event) { 2904 const dialog = document.getElementById("ttv-add-team-dialog"); 2905 if (!dialog) { 2906 console.warn("[ttv] Add Team dialog element missing."); 2907 return; 2908 } 2909 const ctl = this.application.getControllerForElementAndIdentifier( 2910 dialog, "tournament-teams-v2-add-team" 2911 ); 2912 if (!ctl) { 2913 console.warn("[ttv] Add Team controller not connected yet."); 2914 return; 2915 } 2916 const button = event?.currentTarget; 2917 const activeLink = this.element.querySelector(".ttv-division-link--active"); 2918 let divisionId = 2919 button?.dataset?.divisionId || 2920 activeLink?.dataset?.divisionId; 2921 2922 // 2026-05-27 â When the top-right Add Player is tapped on the "All 2923 // divisions" view (no active sidebar division), there's no division 2924 // context for the modal to open into. Instead of opening blindly 2925 // and POSTing with a null division_id, show a picker anchored under 2926 // the button. Mirrors the pattern from participants_page.html.erb. 2927 if (!divisionId && button && event?.type !== "auto-open") { 2928 this._showAddPlayerDivisionPicker(button, ctl); 2929 return; 2930 } 2931 2932 // Pull the matching sidebar link (it carries player-count metadata). 2933 const linkForDivision = 2934 this.element.querySelector(`.ttv-division-link[data-division-id="${divisionId}"]`) || 2935 activeLink; 2936 const divisionName = 2937 linkForDivision?.dataset?.divisionName || 2938 linkForDivision?.querySelector(".ttv-division-name")?.textContent?.trim(); 2939 const playersPerTeam = parseInt( 2940 button?.dataset?.playersPerTeam || 2941 linkForDivision?.dataset?.playersPerTeam || "2", 10 2942 ); 2943 const mlpFormat = (button?.dataset?.mlpFormat || linkForDivision?.dataset?.mlpFormat) === "true"; 2944 // 2026-05-14 â Mode awareness. Division pane button carries this 2945 // directly; for fallbacks, look up the pane in the DOM. 2946 const pane = document.querySelector(`.ttv-division-pane[data-division-id="${divisionId}"]`); 2947 const divisionMode = 2948 button?.dataset?.divisionMode || 2949 pane?.dataset?.divisionMode || 2950 linkForDivision?.dataset?.divisionMode || 2951 "team"; // safe default â preserves existing behavior 2952 ctl.open(divisionId, divisionName, { playersPerTeam, mlpFormat, divisionMode }); 2953 } 2954 2955 // 2026-05-27 â Anchored division-picker dropdown for the top-right 2956 // Add Player button on the "All divisions" view. Built lazily; only 2957 // exists in the DOM while open. Reads division metadata off the 2958 // sidebar links (the source of truth for player-count, mode, etc). 2959 _showAddPlayerDivisionPicker(anchorBtn, addTeamCtl) { 2960 // Remove any existing picker before creating a fresh one. 2961 document.getElementById("ttv-add-player-div-picker")?.remove(); 2962 2963 const links = Array.from(this.element.querySelectorAll(".ttv-division-link[data-division-id]")); 2964 if (links.length === 0) return; 2965 2966 const picker = document.createElement("div"); 2967 picker.id = "ttv-add-player-div-picker"; 2968 picker.setAttribute("role", "menu"); 2969 picker.style.cssText = "position: fixed; z-index: 100050; min-width: 280px; max-width: 360px; max-height: 70vh; overflow: hidden; display: flex; flex-direction: column; background: var(--admin-surface); border: 1px solid var(--admin-border); border-radius: 10px; box-shadow: var(--admin-shadow-lg); padding: 6px;"; 2970 2971 const header = document.createElement("div"); 2972 header.style.cssText = "font-size: 10px; font-weight: 700; letter-spacing: 0.08em; text-transform: uppercase; color: var(--admin-text-muted); padding: 8px 10px 6px;"; 2973 header.textContent = "Add to which division?"; 2974 picker.appendChild(header); 2975 2976 const list = document.createElement("div"); 2977 list.style.cssText = "overflow-y: auto; flex: 1 1 auto;"; 2978 links.forEach((link) => { 2979 const item = document.createElement("button"); 2980 item.type = "button"; 2981 item.style.cssText = "display: block; width: 100%; text-align: left; padding: 9px 10px; border: 0; background: transparent; border-radius: 6px; cursor: pointer; font: inherit; color: var(--admin-text); font-size: 13.5px;"; 2982 const name = link.dataset.divisionName || 2983 link.querySelector(".ttv-division-name")?.textContent?.trim() || 2984 "Division"; 2985 item.textContent = name; 2986 item.addEventListener("mouseenter", () => { item.style.background = "var(--admin-surface-hover, rgba(0,0,0,0.04))"; }); 2987 item.addEventListener("mouseleave", () => { item.style.background = "transparent"; }); 2988 item.addEventListener("click", () => { 2989 const divisionId = link.dataset.divisionId; 2990 const divisionName = name; 2991 const playersPerTeam = parseInt(link.dataset.playersPerTeam || "2", 10); 2992 const mlpFormat = link.dataset.mlpFormat === "true"; 2993 const pane = document.querySelector(`.ttv-division-pane[data-division-id="${divisionId}"]`); 2994 const divisionMode = link.dataset.divisionMode || 2995 pane?.dataset?.divisionMode || 2996 "team"; 2997 picker.remove();
2998 addTeamCtl.open(divisionId, divisionName, { playersPerTeam, mlpFormat, divisionMode }); 2999 }); 3000 list.appendChild(item); 3001 }); 3002 picker.appendChild(list); 3003 document.body.appendChild(picker); 3004 3005 // Anchor under the button, clamp to viewport. 3006 const rect = anchorBtn.getBoundingClientRect(); 3007 const top = rect.bottom + 6; 3008 const menuWidth = picker.offsetWidth || 280; 3009 let left = rect.right - menuWidth; 3010 if (left < 8) left = 8; 3011 if (left + menuWidth > window.innerWidth - 8) left = window.innerWidth - menuWidth - 8; 3012 picker.style.top = `${top}px`; 3013 picker.style.left = `${left}px`; 3014 3015 // Dismiss handlers â click outside, Escape, scroll. 3016 const dismiss = (e) => { 3017 if (e && picker.contains(e.target)) return; 3018 if (e && anchorBtn.contains(e.target)) return; 3019 picker.remove(); 3020 document.removeEventListener("click", dismiss, true); 3021 document.removeEventListener("keydown", onKey); 3022 }; 3023 const onKey = (e) => { if (e.key === "Escape") dismiss(); }; 3024 // Defer to skip the click that opened us. 3025 setTimeout(() => { 3026 document.addEventListener("click", dismiss, true); 3027 document.addEventListener("keydown", onKey); 3028 }, 0); 3029 } 3030 3031 // openImport removed â the top-bar Import button is now a server-rendered 3032 // link to the canonical /club_admin/:slug/event_imports/new flow, matching 3033 // the league pattern. The Phase 12 custom wizard partial still ships in 3034 // the page but no UI entry point opens it. Safe to delete in a cleanup 3035 // pass once the event_imports flow is confirmed sufficient. 3036 3037 // ââ Bulk selection (Phase 14) âââââââââââââââââââââââââââââââââââââââââ 3038 3039 // Prevent the row-level drag handler from firing when the user clicks 3040 // the bulk checkbox; otherwise dragging starts and the click toggle 3041 // gets swallowed. 3042 stopRowEvent(event) { event.stopPropagation(); } 3043 3044 toggleSelection(event) { 3045 const cb = event.currentTarget; 3046 const id = parseInt(cb.dataset.teamId, 10); 3047 if (!id) return; 3048 if (cb.checked) this._selection.add(id); 3049 else this._selection.delete(id); 3050 this._renderSelection(); 3051 } 3052 3053 toggleSelectAll(event) { 3054 const checked = event.currentTarget.checked; 3055 this.element.querySelectorAll(".ttv-team-row .ttv-bulk-checkbox").forEach((cb) => { 3056 cb.checked = checked; 3057 const id = parseInt(cb.dataset.teamId, 10); 3058 if (!id) return; 3059 if (checked) this._selection.add(id); 3060 else this._selection.delete(id); 3061 }); 3062 this._renderSelection(); 3063 } 3064 3065 _renderSelection() { 3066 const count = this._selection.size; 3067 if (this.hasBulkToolbarTarget) this.bulkToolbarTarget.hidden = count === 0; 3068 if (this.hasBulkSelectionCountTarget) this.bulkSelectionCountTarget.textContent = count; 3069 } 3070 3071 bulkClearSelection(_event) { 3072 this._selection.clear(); 3073 this.element.querySelectorAll(".ttv-bulk-checkbox").forEach((cb) => { cb.checked = false; }); 3074 this._renderSelection(); 3075 } 3076 3077 // Premium bulk-action modal â replaces window.prompt / window.confirm. 3078 // Same .ttv-dialog look as the swap + broadcast modals. The current mode 3079 // is stashed on the controller (`_bulkMode`) so `submitBulkDialog` knows 3080 // how to assemble the payload. 3081 bulkOpenMove(_event) { 3082 const ids = Array.from(this._selection); 3083 if (ids.length === 0) return; 3084 if (!this.hasBulkDialogTarget) return; 3085 3086 // Build the destination-division select from the sidebar's divisions. 3087 // Each .ttv-division-link carries data-division-id + the rendered name. 3088 const currentDivisionId = this._activeDivisionId(); 3089 const options = []; 3090 this.element.querySelectorAll(".ttv-division-link").forEach((link) => { 3091 const id = parseInt(link.dataset.divisionId, 10); 3092 if (!id || id === currentDivisionId) return; 3093 const name = link.querySelector(".ttv-division-name")?.textContent?.trim() 3094 || link.textContent.trim(); 3095 options.push({ id, name }); 3096 }); 3097 3098 if (this.hasBulkMoveSelectTarget) { 3099 this.bulkMoveSelectTarget.innerHTML = options.length === 0 3100 ? `<option value="">No other divisions in this tournament</option>` 3101 : options.map((o) => `<option value="${o.id}">${this._esc(o.name)}</option>`).join(""); 3102 this.bulkMoveSelectTarget.disabled = options.length === 0; 3103 } 3104 3105 this._openBulkDialog({ 3106 mode: "move", 3107 ids, 3108 title: `Move ${ids.length} team${ids.length === 1 ? "" : "s"}`, 3109 body: `Pick the destination division. Each team will move with its current roster and seed.`, 3110 confirmLabel: options.length === 0 ? "No targets" : "Move teams", 3111 confirmDisabled: options.length === 0 3112 }); 3113 } 3114 3115 bulkOpenMarkPaid(_event) { 3116 const ids = Array.from(this._selection); 3117 if (ids.length === 0) return; 3118 if (!this.hasBulkDialogTarget) return; 3119 3120 if (this.hasBulkPaidAmountTarget) this.bulkPaidAmountTarget.value = "0"; 3121 3122 this._openBulkDialog({ 3123 mode: "mark_paid", 3124 ids, 3125 title: `Mark ${ids.length} team${ids.length === 1 ? "" : "s"} paid`, 3126 body: `Records the amount as paid for each selected team. Use 0 to mark a comp / waived entry.`, 3127 confirmLabel: "Mark paid"
3128 }); 3129 3130 if (this.hasBulkPaidAmountTarget) { 3131 setTimeout(() => { this.bulkPaidAmountTarget.focus(); this.bulkPaidAmountTarget.select(); }, 30); 3132 } 3133 } 3134 3135 bulkOpenRemove(_event) { 3136 const ids = Array.from(this._selection); 3137 if (ids.length === 0) return; 3138 if (!this.hasBulkDialogTarget) return; 3139 3140 this._openBulkDialog({ 3141 mode: "remove", 3142 ids, 3143 title: `Remove ${ids.length} team${ids.length === 1 ? "" : "s"}?`, 3144 body: `This cancels their registrations. Audit history is preserved.`, 3145 confirmLabel: `Remove ${ids.length} team${ids.length === 1 ? "" : "s"}`, 3146 destructive: true 3147 }); 3148 } 3149 3150 // ââ Bulk-dialog helpers ââââââââââââââââââââââââââââââââââââââââââââââ 3151 3152 _openBulkDialog({ mode, ids, title, body, confirmLabel, confirmDisabled, destructive }) { 3153 this._bulkMode = mode; 3154 this._bulkIds = ids; 3155 if (this.hasBulkDialogTitleTarget) this.bulkDialogTitleTarget.textContent = title; 3156 if (this.hasBulkDialogBodyTarget) this.bulkDialogBodyTarget.textContent = body; 3157 if (this.hasBulkDialogConfirmLabelTarget) this.bulkDialogConfirmLabelTarget.textContent = confirmLabel; 3158 3159 // Toggle the per-mode field sections. 3160 if (this.hasBulkMoveFieldsTarget) this.bulkMoveFieldsTarget.hidden = mode !== "move"; 3161 if (this.hasBulkPaidFieldsTarget) this.bulkPaidFieldsTarget.hidden = mode !== "mark_paid"; 3162 if (this.hasBulkRemoveFieldsTarget) this.bulkRemoveFieldsTarget.hidden = mode !== "remove"; 3163 3164 // Destructive styling on the confirm button. 3165 if (this.hasBulkDialogConfirmTarget) { 3166 this.bulkDialogConfirmTarget.classList.toggle("nx-btn-danger", !!destructive); 3167 this.bulkDialogConfirmTarget.classList.toggle("nx-btn-primary", !destructive); 3168 this.bulkDialogConfirmTarget.disabled = !!confirmDisabled; 3169 } 3170 3171 this._clearBulkError(); 3172 this.bulkDialogTarget.hidden = false; 3173 document.body.style.overflow = "hidden"; 3174 } 3175 3176 closeBulkDialog(event) { 3177 if (event && event.preventDefault) event.preventDefault(); 3178 if (!this.hasBulkDialogTarget) return; 3179 this.bulkDialogTarget.hidden = true; 3180 document.body.style.overflow = ""; 3181 this._bulkMode = null; 3182 this._bulkIds = null; 3183 } 3184 3185 bulkBackdropClick(event) { if (event.target === this.bulkDialogTarget) this.closeBulkDialog(); } 3186 bulkStopProp(event) { event.stopPropagation(); } 3187 3188 submitBulkDialog(_event) { 3189 if (!this._bulkMode || !this._bulkIds) return; 3190 const ids = this._bulkIds; 3191 3192 if (this._bulkMode === "move") { 3193 const targetDivisionId = parseInt(this.bulkMoveSelectTarget?.value, 10); 3194 if (!targetDivisionId) { 3195 this._setBulkError("Pick a destination division."); 3196 return; 3197 } 3198 this.closeBulkDialog(); 3199 this._bulkPost({ operation: "move", team_ids: ids, params: { target_division_id: targetDivisionId } }); 3200 } else if (this._bulkMode === "mark_paid") { 3201 const raw = (this.bulkPaidAmountTarget?.value ?? "").trim(); 3202 const dollars = parseFloat(raw); 3203 if (!Number.isFinite(dollars) || dollars < 0) { 3204 this._setBulkError("Amount must be a non-negative number."); 3205 return; 3206 } 3207 this.closeBulkDialog(); 3208 this._bulkPost({ operation: "mark_paid", team_ids: ids, params: { amount_cents: Math.round(dollars * 100) } }); 3209 } else if (this._bulkMode === "remove") { 3210 this.closeBulkDialog(); 3211 this._bulkPost({ operation: "remove", team_ids: ids }); 3212 } 3213 } 3214 3215 _setBulkError(msg) { 3216 if (!this.hasBulkDialogErrorTarget) return; 3217 this.bulkDialogErrorTarget.textContent = msg; 3218 this.bulkDialogErrorTarget.hidden = false; 3219 } 3220 _clearBulkError() { 3221 if (!this.hasBulkDialogErrorTarget) return; 3222 this.bulkDialogErrorTarget.hidden = true; 3223 this.bulkDialogErrorTarget.textContent = ""; 3224 } 3225 _esc(s) { 3226 return String(s || "").replace(/[&<>"]/g, (c) => ( 3227 { "&": "&", "<": "<", ">": ">", '"': """ }[c] 3228 )); 3229 } 3230 3231 async _bulkPost(payload) { 3232 const pageUrl = window.location.pathname.split("?")[0].replace(/\/$/, ""); 3233 const base = pageUrl.match(/^(.*\/teams_v2)(?:\/.*)?$/); 3234 if (!base) return; 3235 const url = `${base[1]}/bulk`; 3236 try { 3237 const res = await fetch(url, { 3238 method: "POST", 3239 headers: { 3240 "Content-Type": "application/json", 3241 Accept: "application/json", 3242 "X-CSRF-Token": this._csrfToken() 3243 }, 3244 body: JSON.stringify(payload) 3245 }); 3246 const json = await res.json().catch(() => ({})); 3247 if (!res.ok) { 3248 this._toast(json.error || `Bulk failed (${res.status})`, "error"); 3249 return; 3250 } 3251 window.location.reload(); 3252 } catch (err) { 3253 this._toast(err.message || "Network error.", "error"); 3254 } 3255 } 3256 3257 openClone(_event) { 3258 const dialog = document.getElementById("ttv-clone-dialog"); 3259 if (!dialog) return; 3260 const ctl = this.application.getControllerForElementAndIdentifier( 3261 dialog, "tournament-teams-v2-clone" 3262 ); 3263 if (ctl) ctl.open(); 3264 } 3265 openDivisionBroadcast(event) { 3266 const button = event?.currentTarget; 3267 const divisionId = button?.dataset?.divisionId || this._activeDivisionId(); 3268 if (!divisionId) return; 3269 const dialog = document.getElementById("ttv-broadcast-dialog"); 3270 if (!dialog) return; 3271 const ctl = this.application.getControllerForElementAndIdentifier( 3272 dialog, "tournament-teams-v2-broadcast" 3273 ); 3274 if (!ctl) return; 3275 const divisionName = button?.dataset?.divisionName || 3276 this.element.querySelector(`.ttv-division-link[data-division-id="${divisionId}"]`)?.dataset?.divisionName; 3277 ctl.open("division", divisionId, divisionName); 3278 } 3279 3280 // T-RDS.3 â Generate rounds. Reads the button's data-attrs, fires 3281 // the `tgr:open` custom event on the shared modal. The modal does 3282 // its own confirmation and POST. 3283 openGenerateRounds(event) { 3284 event.stopPropagation(); 3285 const btn = event.currentTarget; 3286 const dialog = document.getElementById("tgr-dialog"); 3287 if (!dialog) { 3288 console.warn("[ttv] Generate Rounds modal missing."); 3289 return; 3290 } 3291 const divId = parseInt(btn.dataset.divisionId, 10); 3292 const capRaw = btn.dataset.capacity; 3293 const maxRoundsRaw = btn.dataset.maxRounds; 3294 const defRoundsRaw = btn.dataset.defaultRounds; 3295 const detail = { 3296 bulk: false, 3297 divisions: [{ 3298 id: divId, 3299 name: btn.dataset.divisionName || "", 3300 type: btn.dataset.divisionType || "team", 3301 bracket_type_label: btn.dataset.bracketTypeLabel || "",
3302 filled: parseInt(btn.dataset.filled || "0", 10), 3303 capacity: capRaw === "" ? null : parseInt(capRaw, 10), 3304 full: btn.dataset.full === "true", 3305 max_rounds: (maxRoundsRaw && maxRoundsRaw !== "") ? parseInt(maxRoundsRaw, 10) : null, 3306 default_rounds: (defRoundsRaw && defRoundsRaw !== "") ? parseInt(defRoundsRaw, 10) : null, 3307 // 2026-06-12 â Per-pool round metadata so uneven pools (e.g. 8 + 7) get 3308 // one editable row per pool instead of a single division-wide count that 3309 // over-rotates the bigger pool. Mirrors brackets_page's modal payload. 3310 pools: (function () { try { return JSON.parse(btn.dataset.pools || "[]"); } catch (e) { return []; } })(), 3311 pool_sizes_differ: btn.dataset.poolSizesDiffer === "true", 3312 pool_matches_exist: btn.dataset.poolMatchesExist === "true", 3313 pool_completed_count: parseInt(btn.dataset.poolCompletedCount || "0", 10), 3314 pool_total_count: parseInt(btn.dataset.poolTotalCount || "0", 10) 3315 }] 3316 }; 3317 dialog.dispatchEvent(new CustomEvent("tgr:open", { detail })); 3318 } 3319 3320 openReseed(event) { 3321 const divisionId = event?.currentTarget?.dataset?.divisionId || this._activeDivisionId(); 3322 if (!divisionId) return; 3323 const dialog = document.getElementById("ttv-reseed-dialog"); 3324 if (!dialog) return; 3325 const ctl = this.application.getControllerForElementAndIdentifier( 3326 dialog, "tournament-teams-v2-reseed" 3327 ); 3328 if (!ctl) return; 3329 const linkForDivision = 3330 this.element.querySelector(`.ttv-division-link[data-division-id="${divisionId}"]`); 3331 const divisionName = linkForDivision?.dataset?.divisionName || 3332 linkForDivision?.querySelector(".ttv-division-name")?.textContent; 3333 ctl.open(divisionId, divisionName); 3334 } 3335 3336 // Phase 10: kebab opens the detail drawer (interim while the real 3337 // kebab menu lands in a later phase). 3338 openKebab(event) { 3339 const row = event.currentTarget.closest(".ttv-team-row"); 3340 if (!row) return; 3341 this._openDrawerForRow(row); 3342 event.stopPropagation(); 3343 } 3344 3345 // Whole-row click â opens the drawer, EXCEPT when the click landed on 3346 // an interactive descendant (inline-edit cells, status pill toggle, 3347 // checkbox, kebab, player chip, link, button, input). 3348 openRowDrawer(event) { 3349 const interactiveSelector = [ 3350 "input", "button", "select", "a", "textarea", 3351 ".ttv-inline-edit", ".ttv-status-pill--toggle", 3352 ".ttv-player-chip", ".ttv-bulk-checkbox", ".ttv-kebab" 3353 ].join(","); 3354 if (event.target.closest(interactiveSelector)) return; 3355 const row = event.currentTarget; 3356 if (!row) return; 3357 this._openDrawerForRow(row); 3358 } 3359 3360 _openDrawerForRow(row) { 3361 const drawerEl = document.getElementById("ttv-team-detail-drawer"); 3362 if (!drawerEl) return; 3363 const ctl = this.application.getControllerForElementAndIdentifier( 3364 drawerEl, "tournament-teams-v2-drawer" 3365 ); 3366 if (!ctl) { 3367 console.warn("[ttv] Drawer controller not connected yet."); 3368 return; 3369 } 3370 ctl.open(row); 3371 } 3372 3373 // ââ Inline edit handlers (Phase 9) ââââââââââââââââââââââââââââââââââââ 3374 3375 beginInlineEdit(event) { 3376 const cell = event.currentTarget; 3377 if (cell.dataset.editing === "true") return; 3378 cell.dataset.editing = "true"; 3379 3380 const field = cell.dataset.field; 3381 const current = cell.dataset.currentValue || ""; 3382 3383 const input = document.createElement("input"); 3384 input.type = field === "seed_number" ? "number" : "text"; 3385 if (field === "seed_number") input.min = "1"; 3386 input.value = current; 3387 input.className = "ttv-inline-edit-input"; 3388 input.dataset.field = field; 3389 cell.textContent = ""; 3390 cell.appendChild(input); 3391 input.focus(); 3392 input.select(); 3393 3394 const commit = (save) => { 3395 cell.dataset.editing = "false"; 3396 const newValue = input.value.trim(); 3397 // Restore original content first; reconciler below replaces it with 3398 // server truth on success. 3399 cell.textContent = current || (field === "seed_number" ? "â" : ""); 3400 if (!save) return; 3401 if (newValue === current) return; 3402 this._submitInlineUpdate(cell, field, newValue); 3403 }; 3404 3405 input.addEventListener("blur", () => commit(true), { once: true }); 3406 input.addEventListener("keydown", (e) => { 3407 if (e.key === "Enter") { e.preventDefault(); input.blur(); } 3408 if (e.key === "Escape") { e.preventDefault(); commit(false); input.removeEventListener("blur", () => commit(true)); } 3409 }); 3410 } 3411 3412 toggleStatusPill(event) { 3413 const pill = event.currentTarget; 3414 const current = pill.dataset.currentStatus; 3415 const target = current === "tournament_confirmed" ? "tournament_pending" : "tournament_confirmed"; 3416 this._submitInlineUpdate(pill, "team_status", target); 3417 } 3418 3419 async _submitInlineUpdate(element, field, newValue) {
3420 const row = element.closest(".ttv-team-row"); 3421 if (!row) return; 3422 const teamId = row.dataset.teamId; 3423 const lockVersion = parseInt(row.dataset.lockVersion || "0", 10); 3424 const url = this._teamUpdateUrl(teamId); 3425 if (!url) return; 3426 3427 // Optimistic update â paint the new value immediately. 3428 this._applyFieldOptimistically(row, field, newValue); 3429 3430 try { 3431 const res = await fetch(url, { 3432 method: "PATCH", 3433 headers: { 3434 "Content-Type": "application/json", 3435 Accept: "application/json", 3436 "X-CSRF-Token": this._csrfToken() 3437 }, 3438 body: JSON.stringify({ [field]: newValue, lock_version: lockVersion }) 3439 }); 3440 const json = await res.json().catch(() => ({})); 3441 if (!res.ok) { 3442 // Rollback â refetch the page to be safe (the row may have ripple 3443 // effects, especially for status changes that shift counter caches). 3444 this._toast(json.error || "Save failed"); 3445 window.location.reload(); 3446 return; 3447 } 3448 // Reconcile with server truth. 3449 this._reconcileRow(row, json); 3450 } catch (err) { 3451 this._toast(err.message || "Network error."); 3452 window.location.reload(); 3453 } 3454 } 3455 3456 _applyFieldOptimistically(row, field, newValue) { 3457 if (field === "seed_number") { 3458 const cell = row.querySelector(".ttv-seed"); 3459 if (cell) { 3460 cell.textContent = newValue || "â"; 3461 cell.dataset.currentValue = newValue; 3462 } 3463 } else if (field === "team_name") { 3464 const cell = row.querySelector(".ttv-team-name"); 3465 if (cell) { 3466 cell.textContent = newValue; 3467 cell.dataset.currentValue = newValue; 3468 } 3469 } else if (field === "team_status") { 3470 const pill = row.querySelector(".ttv-status-pill"); 3471 if (pill) { 3472 pill.dataset.currentStatus = newValue; 3473 const label = newValue.replace(/^tournament_/, ""); 3474 pill.textContent = label.charAt(0).toUpperCase() + label.slice(1); 3475 // Swap modifier class. 3476 pill.className = pill.className 3477 .replace(/ttv-status-pill--[a-z]+/g, "") 3478 .trim() + ` ttv-status-pill--${label} ttv-status-pill--toggle`; 3479 } 3480 } 3481 } 3482 3483 _reconcileRow(row, payload) { 3484 if (payload.lock_version != null) row.dataset.lockVersion = payload.lock_version; 3485 if (payload.seed_number != null) { 3486 const cell = row.querySelector(".ttv-seed"); 3487 if (cell) { 3488 cell.textContent = payload.seed_number; 3489 cell.dataset.currentValue = payload.seed_number; 3490 } 3491 } 3492 if (payload.team_name != null) { 3493 const cell = row.querySelector(".ttv-team-name"); 3494 if (cell) { 3495 cell.textContent = payload.team_name; 3496 cell.dataset.currentValue = payload.team_name; 3497 } 3498 } 3499 if (payload.team_status) { 3500 this._applyFieldOptimistically(row, "team_status", payload.team_status); 3501 row.dataset.teamStatus = payload.team_status; 3502 } 3503 // Seed change may need re-sort within the division pane â easy 3504 // client-side reorder. Defer if no seed changed. 3505 if (payload.seed_number != null) this._resortDivisionTable(row); 3506 } 3507 3508 _resortDivisionTable(row) { 3509 const tbody = row.parentElement; 3510 if (!tbody) return; 3511 const rows = Array.from(tbody.querySelectorAll(".ttv-team-row")); 3512 rows.sort((a, b) => { 3513 const aSeed = parseInt(a.querySelector(".ttv-seed")?.dataset?.currentValue || "999999", 10); 3514 const bSeed = parseInt(b.querySelector(".ttv-seed")?.dataset?.currentValue || "999999", 10); 3515 return aSeed - bSeed; 3516 }); 3517 rows.forEach((r) => tbody.appendChild(r)); 3518 } 3519 3520 _teamUpdateUrl(teamId) { 3521 const pageUrl = window.location.pathname.split("?")[0].replace(/\/$/, ""); 3522 const baseMatch = pageUrl.match(/^(.*\/teams_v2)(?:\/.*)?$/); 3523 if (!baseMatch) return null; 3524 return `${baseMatch[1]}/teams/${teamId}`; 3525 } 3526 3527 // ââ Internal ââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ 3528 3529 _setDragPayload(event, payload) { 3530 this._dragPayload = payload; 3531 try { 3532 event.dataTransfer.effectAllowed = "move"; 3533 event.dataTransfer.setData(DRAG_MIME, JSON.stringify(payload)); 3534 // Fall-back type so the browser will allow the drag at all. 3535 event.dataTransfer.setData("text/plain", payload.kind); 3536 } catch (_e) { /* dataTransfer not always writable in tests */ } 3537 } 3538 3539 _activeDivisionId() { 3540 const active = this.element.querySelector(".ttv-division-link--active"); 3541 return active ? parseInt(active.dataset.divisionId, 10) : null; 3542 } 3543 3544 // When only ONE division exists, "All divisions" is effectively that division â so an action 3545 // that needs a division (e.g. add-to-bench) shouldn't force the operator to select it first 3546 // (Scott 2026-08-18). Returns the sole division's id, or null when there are 0 or 2+. 3547 _soleDivisionId() { 3548 const ids = [...new Set( 3549 Array.from(this.element.querySelectorAll(".ttv-division-link[data-division-id]")) 3550 .map((el) => parseInt(el.dataset.divisionId, 10)) 3551 .filter((n) => Number.isInteger(n)) 3552 )]; 3553 return ids.length === 1 ? ids[0] : null; 3554 } 3555 3556 _clearDropHints() { 3557 this.element.querySelectorAll(".ttv-drop-active").forEach((el) => el.classList.remove("ttv-drop-active")); 3558 this._dragPayload = null; 3559 } 3560 3561 _teamUrl(teamId, suffix) { 3562 // Routes are nested under tournaments/:id/teams_v2/teams/:team_id/<suffix>. 3563 // The page knows its own URL; we rewrite from there to keep helpers off 3564 // the JS bundle. 3565 const pageUrl = window.location.pathname.split("?")[0].replace(/\/$/, ""); 3566 // pageUrl ends with /teams_v2 (or /teams_v2/division/:id/teams for the 3567 // bench fetch â but on the index page it's /teams_v2). Trim defensively. 3568 const baseMatch = pageUrl.match(/^(.*\/teams_v2)(?:\/.*)?$/); 3569 if (!baseMatch) return null; 3570 return `${baseMatch[1]}/teams/${teamId}/${suffix}`; 3571 } 3572 3573 async _post(url, payload) { 3574 if (!url) return; 3575 try { 3576 const res = await fetch(url, { 3577 method: "POST", 3578 headers: { 3579 "Content-Type": "application/json", 3580 Accept: "application/json", 3581 "X-CSRF-Token": this._csrfToken() 3582 }, 3583 body: JSON.stringify(payload) 3584 }); 3585 const json = await res.json().catch(() => ({})); 3586 if (!res.ok) { 3587 const message = json.error || `Request failed (${res.status})`; 3588 this._toast(message); 3589 return; 3590 } 3591 // Phase 9 brings surgical DOM repaint; Phase 8 reloads the page so 3592 // KPI counts and division team-counts stay accurate without us 3593 // having to surgically reconcile every affected element. 3594 window.location.reload(); 3595 } catch (err) { 3596 this._toast(err.message || "Network error."); 3597 } 3598 } 3599 3600 _csrfToken() { 3601 const meta = document.querySelector('meta[name="csrf-token"]'); 3602 return meta ? meta.getAttribute("content") : ""; 3603 } 3604};
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.