1// Tournament Teams V2 â Add Team dialog (visually identical to the league 2// add_player_modal.html.erb; only the staging block at the bottom is 3// tournament-specific). This controller drives: 4// ⢠tab switching (Current / Club Members / Create New / Import) 5// ⢠filtering the Current list inline by name + email 6// ⢠debounced search against /teams_v2/players/search 7// ⢠per-row + â adds to staging; row paints green + check when added 8// ⢠Create New form â adds an unpersisted "new player" to staging 9// ⢠Submit â POSTs to create_team endpoint 10 11import { Controller } from "@hotwired/stimulus"; 12 13export default class extends Controller { 14 static targets = [ 15 "tabButton", "tabPane", 16 "divisionLabel", 17 "currentList", "currentFilterInput", "benchOption", 18 "searchInput", "searchResults", "searchEmpty", 19 "newFirstName", "newLastName", "newEmail", "newSendWelcome", 20 "stagingSlots", "stagingCounter", 21 "teamName", "seed", 22 "submitButton", "errorMessage", 23 "swapConfirm", "swapConfirmBody" 24 ]; 25 static values = { searchUrl: String, createUrl: String, reloadUrl: String }; 26 27 connect() { 28 this.divisionId = null; 29 this.maxSlots = 2; 30 this.mlpFormat = false; 31 this.staging = []; 32 this._searchTimer = null; 33 this.renderSlots(); 34 } 35 36 // ââ Public open/close âââââââââââââââââââââââââââââââââââââââââââââââââ 37 38 open(divisionId, divisionName, opts = {}) { 39 this.divisionId = divisionId; 40 this.divisionMode = opts.divisionMode || "team"; // 'team' | 'individual_rotating' 41 // Mixer divisions take ONE player at a time â no team roster. 42 this.maxSlots = this.divisionMode === "individual_rotating" 43 ? 1 44 : Math.max(1, parseInt(opts.playersPerTeam || 2, 10)); 45 this.mlpFormat = !!opts.mlpFormat; 46 if (this.hasDivisionLabelTarget) { 47 this.divisionLabelTarget.textContent = divisionName ? `to ${divisionName}` : "to division"; 48 } 49 // Toggle a class on the dialog root so CSS can hide team-only 50 // sections (Team Players header / Team Name / Seed) for mixer. 51 this.element.classList.toggle( 52 "ttv-add-team-dialog--mixer", 53 this.divisionMode === "individual_rotating" 54 ); 55 this.staging = []; 56 this._addedCount = 0; 57 this.renderSlots(); 58 this._refreshAllAddedState(); 59 this.clearError(); 60 this.element.hidden = false; 61 document.body.style.overflow = "hidden"; 62 } 63 64 close(event) { 65 if (event && event.preventDefault) event.preventDefault(); 66 this.element.hidden = true; 67 document.body.style.overflow = ""; 68 this.staging = []; 69 this.renderSlots(); 70 if (this.hasTeamNameTarget) this.teamNameTarget.value = ""; 71 if (this.hasSeedTarget) this.seedTarget.value = ""; 72 if (this.hasSearchInputTarget) this.searchInputTarget.value = ""; 73 if (this.hasCurrentFilterInputTarget) this.currentFilterInputTarget.value = ""; 74 if (this.hasSearchResultsTarget) this.searchResultsTarget.innerHTML = this._searchEmptyHtml(); 75 this.clearError(); 76 } 77 78 // ââ Rapid add: reset the modal for the next team WITHOUT closing it ââââ 79 _resetForNextTeam() { 80 this.staging = []; 81 this.renderSlots(); 82 if (this.hasTeamNameTarget) this.teamNameTarget.value = ""; 83 if (this.hasSeedTarget) this.seedTarget.value = ""; 84 if (this.hasSearchInputTarget) this.searchInputTarget.value = ""; 85 if (this.hasCurrentFilterInputTarget) this.currentFilterInputTarget.value = ""; 86 if (this.hasSearchResultsTarget) this.searchResultsTarget.innerHTML = this._searchEmptyHtml(); 87 this.clearError(); 88 } 89 90 // Resolve the main Teams V2 controller (owns the division panes + bench) 91 // so the modal can trigger a surgical pane refresh instead of a reload. 92 _mainController() { 93 const el = document.querySelector('[data-controller~="tournament-teams-v2"]'); 94 if (!el) return null; 95 return this.application.getControllerForElementAndIdentifier(el, "tournament-teams-v2"); 96 } 97
98 // Mark just-seated players as "added" in the modal's Current bench list so 99 // they can't be re-staged before the next full refresh. 100 _markSeatedInBench(ids) { 101 (ids || []).forEach((id) => { 102 this.element 103 .querySelectorAll(`.session-player-item[data-user-id="${id}"]`) 104 .forEach((row) => row.classList.add("session-player-item--added")); 105 }); 106 } 107 108 _showAddedFeedback(json, count, main) { 109 if (!main || typeof main._toast !== "function") return; 110 const name = json && json.team_name ? `"${json.team_name}"` : "Team"; 111 try { main._toast(`${name} added (${count} this session).`, "success"); } catch (_e) { /* toast is best-effort */ } 112 } 113 114 // ââ Tab switching âââââââââââââââââââââââââââââââââââââââââââââââââââââ 115 116 switchTab(event) { 117 const target = event.currentTarget.dataset.tab; 118 this.tabButtonTargets.forEach((btn) => { 119 btn.classList.toggle("active", btn.dataset.tab === target); 120 }); 121 this.tabPaneTargets.forEach((pane) => { 122 pane.style.display = pane.dataset.tabPane === target ? "" : "none"; 123 }); 124 } 125 126 // ââ Current bench list (filter + pick) ââââââââââââââââââââââââââââââââ 127 128 filterCurrent(event) { 129 const q = event.currentTarget.value.toLowerCase().trim(); 130 const rows = this.currentListTarget.querySelectorAll(".session-player-item"); 131 rows.forEach((row) => { 132 const name = (row.dataset.nameLower || ""); 133 const email = (row.dataset.emailLower || ""); 134 const match = !q || name.includes(q) || email.includes(q); 135 row.style.display = match ? "" : "none"; 136 }); 137 // Hide division headers whose rows are all filtered out. 138 const headers = this.currentListTarget.querySelectorAll(".addplayer-div-header"); 139 headers.forEach((h) => { 140 let next = h.nextElementSibling; 141 let visible = false; 142 while (next && next.classList.contains("session-player-item")) { 143 if (next.style.display !== "none") { visible = true; break; } 144 next = next.nextElementSibling; 145 } 146 h.style.display = visible ? "" : "none"; 147 }); 148 } 149 150 pickPlayer(event) { 151 const row = event.currentTarget.closest(".session-player-item") || event.currentTarget; 152 if (row.classList.contains("session-player-item--added")) return; 153 if (row.classList.contains("session-player-item--disabled")) return; 154 if (row.dataset.locked === "true") return; 155 const id = parseInt(row.dataset.userId, 10); 156 if (!id) return; 157 if (this.staging.some((p) => p.id === id)) return; 158 this.addToStaging({ 159 id, 160 name: row.dataset.userName || "Player", 161 gender: row.dataset.gender || "", 162 source: "existing" 163 }); 164 } 165 166 // On-team row click â open the premium in-modal swap confirmation 167 // instead of the native window.confirm. The actual remove-from-current 168 // -team server action runs in submit() as part of create_team, so the 169 // swap is atomic with the new team's creation: if create fails, no one 170 // was removed. 171 beginSwapFromTeam(event) { 172 if (event.target && event.target.closest(".ttv-gender-picker")) return; 173 const row = event.currentTarget.closest(".session-player-item") || event.currentTarget; 174 const id = parseInt(row.dataset.userId, 10); 175 if (!id) return; 176 if (this.staging.some((p) => p.id === id)) return; 177 178 const name = row.dataset.userName || "Player"; 179 const teamName = row.dataset.currentTeamName || "their current team"; 180 const divisionNm = row.dataset.currentDivisionName; 181 const currentTeam = parseInt(row.dataset.currentTeamId, 10); 182 if (!currentTeam) return; 183 184 if (!this.hasSwapConfirmTarget || !this.hasSwapConfirmBodyTarget) return; 185 186 // Stash the pending swap on the controller so confirmSwap can act on it. 187 this._pendingSwap = { 188 id, name, teamName, 189 currentTeam, 190 gender: row.dataset.gender || "", 191 rowEl: row 192 }; 193 194 const escape = (s) => this._esc(s); 195 this.swapConfirmBodyTarget.innerHTML = 196 `<strong>${escape(name)}</strong> is currently on Team <strong>${escape(teamName)}</strong>` + 197 `${divisionNm ? ` in ${escape(divisionNm)}` : ""}.<br><br>` + 198 `Remove them from that team and add them to the new team you're building?` + 199 `<em>The swap happens when you tap <strong>Add team</strong> â until then nothing changes on Team ${escape(teamName)}.</em>`; 200 this.swapConfirmTarget.hidden = false;
201 } 202 203 confirmSwap(_event) { 204 const pending = this._pendingSwap; 205 if (!pending) { this._closeSwapConfirm(); return; } 206 207 this.addToStaging({ 208 id: pending.id, 209 name: pending.name, 210 gender: pending.gender, 211 source: "swap_from_team", 212 swap_from_team_id: pending.currentTeam, 213 swap_from_team_name: pending.teamName 214 }); 215 if (pending.rowEl) pending.rowEl.classList.add("session-player-item--added"); 216 this._closeSwapConfirm(); 217 } 218 219 cancelSwap(_event) { this._closeSwapConfirm(); } 220 swapBackdropClick(event) { if (event.target === this.swapConfirmTarget) this._closeSwapConfirm(); } 221 swapStopPropagation(event) { event.stopPropagation(); } 222 223 _closeSwapConfirm() { 224 this._pendingSwap = null; 225 if (this.hasSwapConfirmTarget) this.swapConfirmTarget.hidden = true; 226 } 227 228 // Click handler for the inline M/F mini-picker on rows whose user has 229 // no User.gender set. The choice is the staging entry's gender (not a 230 // backfill of User.gender â that's a separate flow). 231 pickPlayerWithGender(event) { 232 event.stopPropagation(); 233 const btn = event.currentTarget; 234 if (btn.classList.contains("ttv-gender-picker-btn--disabled")) return; 235 const row = btn.closest(".session-player-item"); 236 if (!row) return; 237 if (row.classList.contains("session-player-item--added")) return; 238 const id = parseInt(row.dataset.userId, 10); 239 if (!id) return; 240 if (this.staging.some((p) => p.id === id)) return; 241 this.addToStaging({ 242 id, 243 name: row.dataset.userName || "Player", 244 gender: btn.dataset.gender || "", 245 source: "existing" 246 }); 247 } 248 249 // ââ Search Club Members âââââââââââââââââââââââââââââââââââââââââââââââ 250 251 searchDebounced(event) { 252 const q = event.currentTarget.value.trim(); 253 clearTimeout(this._searchTimer); 254 this._searchTimer = setTimeout(() => this.runSearch(q), 220); 255 } 256 257 async runSearch(q) { 258 if (q.length < 2) { 259 this.searchResultsTarget.innerHTML = this._searchEmptyHtml(); 260 return; 261 } 262 try { 263 const url = `${this.searchUrlValue}?q=${encodeURIComponent(q)}`; 264 const res = await fetch(url, { headers: { Accept: "application/json" } }); 265 if (!res.ok) throw new Error(`search failed: ${res.status}`); 266 const json = await res.json(); 267 const results = json.results || []; 268 if (results.length === 0) { 269 this.searchResultsTarget.innerHTML = `<div class="session-search-hint">No matches.</div>`; 270 return; 271 } 272 this.searchResultsTarget.innerHTML = results.map((u) => this._renderSearchRow(u)).join(""); 273 this._refreshAllAddedState(); 274 } catch (err) { 275 console.warn("[ttv] search failed", err); 276 this.searchResultsTarget.innerHTML = `<div class="session-search-hint">Search failed. Try again.</div>`; 277 } 278 } 279 280 _searchEmptyHtml() { 281 return `<div class="session-search-hint"> 282 <i class="fas fa-search" style="display: block; font-size: 24px; margin-bottom: 8px; opacity: 0.4;"></i> 283 Type a name or email to search club members 284 </div>`; 285 } 286 287 _renderSearchRow(u) { 288 const safeName = this._esc(u.name || u.email || "Player"); 289 const safeEmail = this._esc(u.email || ""); 290 const avatar = `<div class="pb-avatar pb-avatar--sm" style="width:32px;height:32px"><img width="32" height="32" src="${this._esc(u.avatar_url || '/blue_claws.png')}" alt=""></div>`; 291 const g = String(u.gender || "").toLowerCase(); 292 const genderLetter = g.startsWith("m") ? "m" : (g.startsWith("f") ? "f" : ""); 293 const genderBadge = genderLetter 294 ? `<span class="ttv-gender-badge ttv-gender-badge--${genderLetter}">${genderLetter.toUpperCase()}</span>` 295 : ""; 296 297 // Registration indicator: user is already on a team â "On {team}" pill; 298 // user is registered but on the bench â green "Registered" pill; 299 // user is brand-new to the tournament â no pill. 300 const onTeam = u.placed_team_name && String(u.placed_team_name).trim().length > 0; 301 const isRegistered = !!u.registered && !onTeam; 302 let statusBadge = ""; 303 if (onTeam) { 304 statusBadge = `<span class="ttv-status-chip ttv-status-chip--on-team" title="Already on this team in this tournament">On ${this._esc(u.placed_team_name)}</span>`;
305 } else if (isRegistered) { 306 statusBadge = `<span class="ttv-status-chip ttv-status-chip--registered" title="Already registered for this tournament">Registered</span>`; 307 } 308 309 // On-team users can't be picked â show a muted lock chip in the action 310 // slot. Everyone else gets the normal [+] (known gender) or M/F picker 311 // (unknown gender). 312 let rightSide; 313 if (onTeam) { 314 rightSide = `<span class="ttv-locked-chip" title="Already on a team"> 315 <i class="fas fa-lock"></i> 316 </span>`; 317 } else if (genderLetter) { 318 rightSide = `<div class="session-player-item__action"> 319 <span class="session-player-item__add"><i class="fas fa-plus"></i></span> 320 </div>`; 321 } else { 322 rightSide = `<div class="ttv-gender-picker" title="Pick gender for MLP roster"> 323 <button type="button" class="ttv-gender-picker-btn ttv-gender-picker-btn--m" data-gender="m" 324 data-action="click->tournament-teams-v2-add-team#pickPlayerWithGender">M</button> 325 <button type="button" class="ttv-gender-picker-btn ttv-gender-picker-btn--f" data-gender="f" 326 data-action="click->tournament-teams-v2-add-team#pickPlayerWithGender">F</button> 327 </div>`; 328 } 329 330 // On-team rows are non-interactive. Otherwise wire pickPlayer for 331 // known-gender rows (M/F picker handles unknowns). 332 const rowAction = onTeam ? "" : (genderLetter ? `data-action="click->tournament-teams-v2-add-team#pickPlayer"` : ""); 333 const rowClass = onTeam ? "session-player-item session-player-item--locked" : "session-player-item"; 334 335 return ` 336 <div class="${rowClass}" 337 data-user-id="${u.id}" 338 data-user-name="${safeName}" 339 data-gender="${genderLetter}" 340 ${onTeam ? 'data-locked="true"' : ''} 341 ${rowAction}> 342 <div class="session-player-item__info"> 343 ${avatar} 344 <div class="session-player-item__details"> 345 <span class="session-player-item__name">${safeName}${genderBadge}${statusBadge}</span> 346 <span class="session-player-item__meta">${safeEmail}</span> 347 </div> 348 </div> 349 <div class="session-player-item__right"> 350 ${rightSide} 351 </div> 352 </div>`; 353 } 354 355 // ââ Create New ââââââââââââââââââââââââââââââââââââââââââââââââââââââââ 356 357 addNewPlayer(_event) { 358 const first = this.newFirstNameTarget.value.trim(); 359 const last = this.newLastNameTarget.value.trim(); 360 const email = this.newEmailTarget.value.trim(); 361 if (!email || !email.includes("@")) { 362 this.showError("Valid email required."); 363 return; 364 } 365 if (!first && !last) { 366 this.showError("First or last name required."); 367 return; 368 } 369 this.clearError(); 370 const name = [first, last].filter(Boolean).join(" "); 371 const sendWelcome = this.hasNewSendWelcomeTarget ? !!this.newSendWelcomeTarget.checked : true; 372 this.addToStaging({ name, email, first, last, sendWelcome, source: "new" }); 373 this.newFirstNameTarget.value = ""; 374 this.newLastNameTarget.value = ""; 375 this.newEmailTarget.value = ""; 376 } 377 378 // ââ Staging ââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ 379 380 addToStaging(player) { 381 if (this.staging.length >= this.maxSlots) { 382 this.showError(`This division takes ${this.maxSlots} player${this.maxSlots > 1 ? 's' : ''} per team.`); 383 return; 384 } 385 this.staging.push(player); 386 this.renderSlots(); 387 this._refreshAllAddedState(); 388 } 389 390 removeFromStaging(event) { 391 event.stopPropagation(); 392 const idx = parseInt(event.currentTarget.dataset.idx, 10); 393 if (Number.isNaN(idx)) return; 394 this.staging.splice(idx, 1); 395 this.renderSlots(); 396 this._refreshAllAddedState(); 397 } 398 399 renderSlots() { 400 const slots = []; 401 // Captain crown only renders for true team-captain formats â 402 // Team Cup style rosters (>2 non-MLP players). Mixer, doubles
403 // and MLP all set captain_id server-side, but the operator 404 // doesn't need to think about it visually. 405 const showCaptainCrown = 406 this.divisionMode !== "individual_rotating" && 407 this.maxSlots > 2 && 408 !this.mlpFormat; 409 410 for (let i = 0; i < this.maxSlots; i++) { 411 const p = this.staging[i]; 412 const isCaptainSlot = i === 0 && showCaptainCrown; 413 // Trailing space inside the tag so the crown and the label that 414 // follows aren't visually glued together â `Captain · + slot 1` and 415 // the player-name label both render after this. 416 const captainBadge = isCaptainSlot 417 ? `<span class="ttv-captain-tag" title="Slot 1 becomes the team captain"><i class="fas fa-crown"></i></span> ` 418 : ""; 419 420 if (p) { 421 const label = this._esc(p.name || p.email || "Player"); 422 const meta = p.source === "new" ? ` <span style="font-size:10px; opacity:0.7;">(new)</span>` : ""; 423 const chipClass = isCaptainSlot ? "ttv-staging-chip ttv-staging-chip--captain" : "ttv-staging-chip"; 424 slots.push(` 425 <span class="${chipClass}"> 426 ${captainBadge} 427 <span>${label}${meta}</span> 428 <button type="button" class="ttv-staging-chip-remove" 429 data-idx="${i}" 430 data-action="click->tournament-teams-v2-add-team#removeFromStaging" 431 aria-label="Remove"> 432 <i class="fas fa-times" style="font-size: 9px;"></i> 433 </button> 434 </span>`); 435 } else { 436 const emptyClass = isCaptainSlot ? "ttv-staging-slot-empty ttv-staging-slot-empty--captain" : "ttv-staging-slot-empty"; 437 // For mixer (maxSlots=1) and doubles where the crown is hidden, 438 // just say "+ Player" instead of "+ slot 1" â the slot framing 439 // is awkward when there's only one or two anyway. 440 let emptyText; 441 if (this.divisionMode === "individual_rotating") { 442 emptyText = "+ Player"; 443 } else if (isCaptainSlot) { 444 emptyText = "Captain · + slot 1"; 445 } else { 446 emptyText = `+ slot ${i + 1}`; 447 } 448 slots.push(`<span class="${emptyClass}">${captainBadge}${emptyText}</span>`); 449 } 450 } 451 this.stagingSlotsTarget.innerHTML = slots.join(""); 452 if (this.hasStagingCounterTarget) { 453 // For mixer mode (1 slot) the "of 1" framing is awkward â just 454 // say "1 selected" / "0 selected". 455 this.stagingCounterTarget.textContent = (this.maxSlots <= 1) 456 ? `${this.staging.length} selected` 457 : `${this.staging.length} of ${this.maxSlots} selected`; 458 } 459 } 460 461 _refreshAllAddedState() { 462 // For every visible row in either list, mark added vs not + apply 463 // MLP gender gating in the same pass. 464 const lists = []; 465 if (this.hasCurrentListTarget) lists.push(this.currentListTarget); 466 if (this.hasSearchResultsTarget) lists.push(this.searchResultsTarget); 467 468 const gateByGender = this.mlpFormat; 469 const staged = { m: 0, f: 0 }; 470 if (gateByGender) { 471 this.staging.forEach((p) => { 472 const g = String(p.gender || "").toLowerCase(); 473 if (g.startsWith("m")) staged.m += 1; 474 else if (g.startsWith("f")) staged.f += 1; 475 }); 476 } 477 // Default MLP-mixed requirement: 2 men + 2 women. If a future division 478 // surfaces a different requirement we can pass it through opts; for 479 // now this matches the underlying create_organizer_mlp_team service. 480 const requiredMale = 2; 481 const requiredFemale = 2; 482 483 lists.forEach((scope) => { 484 scope.querySelectorAll(".session-player-item").forEach((row) => { 485 const id = parseInt(row.dataset.userId, 10); 486 const added = this.staging.some((p) => p.id === id); 487 row.classList.toggle("session-player-item--added", added); 488 489 const g = String(row.dataset.gender || "").toLowerCase(); 490 491 // Row-level gating (only meaningful for known-gender rows). Unknown 492 // rows are gated at the M/F mini-button level below. 493 if (gateByGender && !added && g) { 494 let disabled = false;
495 if (g === "m") disabled = staged.m >= requiredMale; 496 else if (g === "f") disabled = staged.f >= requiredFemale; 497 row.classList.toggle("session-player-item--disabled", disabled); 498 } else if (!gateByGender) { 499 row.classList.remove("session-player-item--disabled"); 500 } 501 502 // Mini-picker gating for unknown-gender rows. 503 row.querySelectorAll(".ttv-gender-picker-btn").forEach((btn) => { 504 const bg = btn.dataset.gender; 505 let bDisabled = added; // also disable if the row is already added 506 if (gateByGender && !bDisabled) { 507 if (bg === "m") bDisabled = staged.m >= requiredMale; 508 else if (bg === "f") bDisabled = staged.f >= requiredFemale; 509 } 510 btn.classList.toggle("ttv-gender-picker-btn--disabled", bDisabled); 511 }); 512 513 const addEl = row.querySelector(".session-player-item__add"); 514 if (addEl) { 515 addEl.innerHTML = added 516 ? `<i class="fas fa-check"></i>` 517 : `<i class="fas fa-plus"></i>`; 518 } 519 }); 520 }); 521 } 522 523 // ââ Submit ââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ 524 525 async submit(_event) { 526 if (this.staging.length === 0) { 527 this.showError("Add at least one player."); 528 return; 529 } 530 const newPlayers = this.staging.filter((p) => p.source === "new"); 531 if (newPlayers.length > 0) { 532 this.showError("Magic-link onboarding for new players ships in Phase 7b. Use Current or Club Members for now."); 533 return; 534 } 535 const playerIds = this.staging.map((p) => p.id).filter(Boolean); 536 if (playerIds.length < 1) { 537 this.showError("Pick at least one player."); 538 return; 539 } 540 // Partial rosters are allowed â under-filled teams are created with 541 // needs_partner: true and surface in the bench for the remaining slots. 542 // No "must match maxSlots" check here. 543 544 // Build a per-player gender dict so the controller can route MLP rosters 545 // with the correct gender AND backfill User.gender on records that 546 // were previously nil. 547 const playerGenders = {}; 548 this.staging.forEach((p) => { 549 if (p.id && p.gender) playerGenders[String(p.id)] = p.gender; 550 }); 551 552 // Swap-from-team map: { user_id_string => old_team_id }. The backend 553 // removes each user from their old team before the new team is created 554 // (single create_team request, server-side transaction per swap). 555 const swapFromTeam = {}; 556 this.staging.forEach((p) => { 557 if (p.source === "swap_from_team" && p.id && p.swap_from_team_id) { 558 swapFromTeam[String(p.id)] = p.swap_from_team_id; 559 } 560 }); 561 562 const payload = { 563 division_id: this.divisionId, 564 player_ids: playerIds, 565 player_genders: playerGenders, 566 swap_from_team: swapFromTeam, 567 team_name: this.teamNameTarget?.value?.trim() || null, 568 seed: this.seedTarget?.value?.trim() || null 569 }; 570 571 this.submitButtonTarget.disabled = true; 572 this.clearError(); 573 574 try { 575 const res = await fetch(this.createUrlValue, { 576 method: "POST", 577 headers: { 578 "Content-Type": "application/json", 579 Accept: "application/json", 580 "X-CSRF-Token": this._csrfToken() 581 }, 582 body: JSON.stringify(payload) 583 }); 584 const json = await res.json().catch(() => ({})); 585 // Capacity gate (ADMIN_CAPACITY_DECISION_ENABLED_FOR) returns a 200 with 586 // needs_admin_decision â that flow isn't wired into this modal, so fall 587 // back to the original close+reload rather than rapid-add. 588 if (res.ok && json.needs_admin_decision) { 589 this.close(); 590 window.location.assign(this.reloadUrlValue); 591 return; 592 } 593 if (!res.ok) { 594 this.showError(json.detail || json.error || `Request failed (${res.status})`); 595 return; 596 } 597 // ââ Rapid add ââââââââââââââââââââââââââââââââââââââââââââââââââââââ 598 // Keep the modal OPEN, surgically refresh the affected division (no 599 // full-page reload), reset the inputs, and let the operator stage the 600 // next team immediately. If the stale bench is re-picked, the server 601 // refuses it (TournamentTeamForcePairService rejects a player already on 602 // an active team in this division â :already_on_team), so a re-pick shows 603 // an error rather than silently moving a seated player. 604 const seatedIds = [...playerIds]; 605 const main = this._mainController(); 606 if (main) { 607 try { main._refreshDivisionPane && main._refreshDivisionPane(this.divisionId); } catch (_e) { /* pane not visible â server state still correct */ } 608 try { main._refreshBenchSection && main._refreshBenchSection(); } catch (_e) { /* fire-and-forget */ } 609 } 610 this._markSeatedInBench(seatedIds); 611 this._addedCount = (this._addedCount || 0) + 1; 612 this._showAddedFeedback(json, this._addedCount, main); 613 this._resetForNextTeam(); 614 if (this.hasSearchInputTarget) this.searchInputTarget.focu
614s(); 615 } catch (err) { 616 this.showError(err.message || "Network error."); 617 } finally { 618 this.submitButtonTarget.disabled = false; 619 } 620 } 621 622 // ââ Helpers ââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ 623 624 showError(msg) { 625 if (!this.hasErrorMessageTarget) return; 626 this.errorMessageTarget.textContent = msg; 627 this.errorMessageTarget.style.display = "block"; 628 } 629 clearError() { 630 if (!this.hasErrorMessageTarget) return; 631 this.errorMessageTarget.style.display = "none"; 632 this.errorMessageTarget.textContent = ""; 633 } 634 _csrfToken() { 635 const meta = document.querySelector('meta[name="csrf-token"]'); 636 return meta ? meta.getAttribute("content") : ""; 637 } 638 _esc(s) { 639 return String(s || "").replace(/[&<>"']/g, (c) => ( 640 { "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c] 641 )); 642 } 643};
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.