1import { Controller } from "@hotwired/stimulus" 2 3export default class extends Controller { 4 static targets = [ 5 "divisionTab", "divisionContent", 6 "addTeamModal", "teamDetailModal", 7 "modalDivisionSelect", "teamNameInput", "playerSearchInput", "playerSearchResults", 8 "selectedPlayers", "createTeamBtn", 9 "teamDetailTitle", "teamDetailBody", "deleteTeamBtn", 10 // Division dropdown targets 11 "divisionWrapper", "divisionSearchInput", "divisionDropdown", "divisionPanel", 12 "selectedDivisionName", "divisionCategory", "divisionFormat", "divisionCapacity", "divisionPlayersPerTeam" 13 ] 14 15 static values = { 16 tournamentId: Number 17 } 18 19 connect() { 20 console.log("Team manager controller connected") 21 this.selectedPlayerIds = [] 22 this.currentTeamId = null 23 this.searchTimeout = null 24 this.currentDivisionId = null 25 26 // Set initial search input value to first division name 27 if (this.hasDivisionSearchInputTarget && this.hasSelectedDivisionNameTarget) { 28 this.divisionSearchInputTarget.value = this.selectedDivisionNameTarget.textContent 29 } 30 31 // Get the first division's ID from the dropdown 32 if (this.hasDivisionDropdownTarget) { 33 const firstOption = this.divisionDropdownTarget.querySelector('.team-mgmt__dropdown-option') 34 if (firstOption) { 35 this.currentDivisionId = firstOption.dataset.divisionId 36 } 37 } 38 } 39 40 // Division Dropdown Methods 41 openDivisionDropdown() { 42 if (this.hasDivisionWrapperTarget) { 43 this.divisionWrapperTarget.classList.add('is-open') 44 // Select all text for easy searching 45 if (this.hasDivisionSearchInputTarget) { 46 this.divisionSearchInputTarget.select() 47 } 48 } 49 } 50 51 closeDivisionDropdownDelayed() { 52 // Delay to allow click events on options to fire first 53 setTimeout(() => { 54 if (this.hasDivisionWrapperTarget) { 55 this.divisionWrapperTarget.classList.remove('is-open') 56 } 57 // Restore selected division name if input is empty 58 if (this.hasDivisionSearchInputTarget && this.hasSelectedDivisionNameTarget) { 59 if (this.divisionSearchInputTarget.value.trim() === '') { 60 this.divisionSearchInputTarget.value = this.selectedDivisionNameTarget.textContent 61 } 62 } 63 }, 150) 64 } 65 66 filterDivisionDropdown() { 67 if (!this.hasDivisionSearchInputTarget || !this.hasDivisionDropdownTarget) return 68 69 const query = this.divisionSearchInputTarget.value.toLowerCase().trim() 70 const options = this.divisionDropdownTarget.querySelectorAll('.team-mgmt__dropdown-option') 71 72 options.forEach(option => { 73 const divisionName = option.dataset.divisionName?.toLowerCase() || '' 74 if (query === '' || divisionName.includes(query)) { 75 option.style.display = '' 76 } else { 77 option.style.display = 'none' 78 } 79 }) 80 } 81 82 selectDivisionFromDropdown(event) { 83 const option = event.currentTarget 84 const divisionId = option.dataset.divisionId 85 const divisionName = option.querySelector('.team-mgmt__dropdown-option-name')?.textContent || '' 86 const category = option.querySelector('.team-mgmt__dropdown-option-meta')?.textContent || '' 87 88 this.currentDivisionId = divisionId 89 90 // Update active state in dropdown 91 this.divisionDropdownTarget.querySelectorAll('.team-mgmt__dropdown-option').forEach(opt => { 92 opt.classList.remove('team-mgmt__dropdown-option--active') 93 }) 94 option.classList.add('team-mgmt__dropdown-option--active') 95 96 // Update search input 97 if (this.hasDivisionSearchInputTarget) { 98 this.divisionSearchInputTarget.value = divisionName 99 } 100 101 // Close dropdown 102 if (this.hasDivisionWrapperTarget) { 103 this.divisionWrapperTarget.classList.remove('is-open') 104 } 105 106 // Update sidebar info panel 107 if (this.hasSelectedDivisionNameTarget) { 108 this.selectedDivisionNameTarget.textContent = divisionName 109 } 110 111 // Parse category from the meta text (e.g., "Doubles · Round Robin") 112 const categoryParts = category.split('·').map(s => s.trim()) 113 if (this.hasDivisionCategoryTarget && categoryParts[0]) { 114 this.divisionCategoryTarget.textContent = categoryParts[0] 115 } 116 if (this.hasDivisionFormatTarget && categoryParts[1]) { 117 this.divisionFormatTarget.textContent = categoryParts[1] 118 } 119 120 // Update capacity from the option's count 121 const countEl = option.querySelector('.team-mgmt__dropdown-option-count') 122 if (this.hasDivisionCapacityTarget && countEl) { 123 this.divisionCapacityTarget.textContent = countEl.textContent.trim() 124 } 125
126 // Switch to showing this division's teams in the main panel 127 this.switchDivisionPanel(divisionId) 128 } 129 130 switchDivisionPanel(divisionId) { 131 // Show/hide division panels 132 if (this.hasDivisionPanelTarget) { 133 this.divisionPanelTargets.forEach(panel => { 134 if (panel.dataset.divisionId === divisionId) { 135 panel.style.display = 'block' 136 } else { 137 panel.style.display = 'none' 138 } 139 }) 140 } 141 142 // Update the modal division select if it exists 143 if (this.hasModalDivisionSelectTarget) { 144 this.modalDivisionSelectTarget.value = divisionId 145 } 146 } 147 148 selectDivision(event) { 149 const divisionId = event.currentTarget.dataset.divisionId 150 151 // Update tab states 152 this.divisionTabTargets.forEach(tab => { 153 if (tab.dataset.divisionId === divisionId) { 154 tab.classList.add('team-division-tab--active') 155 } else { 156 tab.classList.remove('team-division-tab--active') 157 } 158 }) 159 160 // Show/hide division content 161 this.divisionContentTargets.forEach(content => { 162 if (content.dataset.divisionId === divisionId) { 163 content.style.display = 'block' 164 } else { 165 content.style.display = 'none' 166 } 167 }) 168 } 169 170 // Modal Management 171 openAddTeamModal() { 172 this.addTeamModalTarget.style.display = 'flex' 173 document.body.style.overflow = 'hidden' 174 this.resetAddTeamForm() 175 } 176 177 closeAddTeamModal() { 178 this.addTeamModalTarget.style.display = 'none' 179 document.body.style.overflow = 'auto' 180 this.resetAddTeamForm() 181 } 182 183 resetAddTeamForm() { 184 this.selectedPlayerIds = [] 185 if (this.hasTeamNameInputTarget) { 186 this.teamNameInputTarget.value = '' 187 } 188 if (this.hasPlayerSearchInputTarget) { 189 this.playerSearchInputTarget.value = '' 190 } 191 if (this.hasPlayerSearchResultsTarget) { 192 this.playerSearchResultsTarget.style.display = 'none' 193 this.playerSearchResultsTarget.innerHTML = '' 194 } 195 if (this.hasSelectedPlayersTarget) { 196 this.selectedPlayersTarget.innerHTML = '<div class="selected-players__empty">No players selected</div>' 197 } 198 if (this.hasCreateTeamBtnTarget) { 199 this.createTeamBtnTarget.disabled = true 200 } 201 } 202 203 // Player Search 204 searchPlayers(event) { 205 const query = event.target.value.trim() 206 207 if (this.searchTimeout) { 208 clearTimeout(this.searchTimeout) 209 } 210 211 if (query.length < 2) { 212 this.playerSearchResultsTarget.style.display = 'none' 213 return 214 } 215 216 this.searchTimeout = setTimeout(() => { 217 this.performPlayerSearch(query) 218 }, 300) 219 } 220 221 async performPlayerSearch(query) { 222 try { 223 const response = await fetch(`/api/users/search?q=${encodeURIComponent(query)}`, { 224 headers: { 225 'Accept': 'application/json', 226 'X-CSRF-Token': this.getCSRFToken() 227 } 228 }) 229 230 if (!response.ok) throw new Error('Search failed') 231 232 const users = await response.json() 233 this.displaySearchResults(users) 234 } catch (error) { 235 console.error('Player search error:', error) 236 this.playerSearchResultsTarget.innerHTML = ` 237 <div class="player-search-result" style="color: var(--pb-error);"> 238 Error searching players 239 </div> 240 ` 241 this.playerSearchResultsTarget.style.display = 'block' 242 } 243 } 244 245 displaySearchResults(users) { 246 if (users.length === 0) { 247 this.playerSearchResultsTarget.innerHTML = ` 248 <div class="player-search-result" style="color: var(--pb-text-tertiary);"> 249 No players found 250 </div> 251 ` 252 this.playerSearchResultsTarget.style.display = 'block' 253 return 254 } 255 256 const filteredUsers = users.filter(u => !this.selectedPlayerIds.includes(u.id)) 257 258 this.playerSearchResultsTarget.innerHTML = filteredUsers.map(user => ` 259 <div class="player-search-result" 260 data-action="click->team-manager#addPlayer" 261 data-user-id="${user.id}" 262 data-user-name="${user.first_name} ${user.last_name}" 263 data-user-email="${user.email || ''}"> 264 <div class="player-search-result__avatar"> 265 ${(user.first_name?.[0] || '')?.toUpperCase()}${(user.last_name?.[0] || '')?.toUpperCase()} 266 </div> 267 <div class="player-search-result__info"> 268 <div class="player-search-result__name">${user.first_name} ${user.last_name}</div> 269 <div class="player-search-result__email">${user.email || ''}</div> 270 </div> 271 <div class="player-search-result__add"> 272 <svg width="12" height="12" viewBox="0 0 24 24" fill="currentColor"> 273 <path d="M19 13h-6v6h-2v-6H5v-2h6V5h2v6h6v2z"/> 274 </svg> 275 </div> 276 </div> 277 `).join('') 278 279 this.playerSearchResultsTarget.style.display = 'block' 280 } 281 282 addPlayer(event) { 283 const userId = parseInt(event.currentTarget.dataset.userId) 284 const userName = event.currentTarget.dataset.userName 285 286 if (this.selectedPlayerIds.includes(userId)) return 287 if (this.selectedPlayerIds.length >= 4) { 288 alert('Maximum 4 players per team') 289 return 290 } 291 292 this.selectedPlayerIds.push(userId) 293 this.updateSelectedPlayers() 294 this.playerSearchInputTarget.value = '' 295 this.playerSearchResultsTarget.style.display = 'none' 296 } 297 298 removePlayer(event) { 299 const userId = parseInt(event.currentTarget.dataset.userId) 300 this.selectedPlayerIds = this.selectedPlayerIds.filter(id => id !== userId) 301 this.updateSelectedPlayers() 302 } 303 304 updateSelectedPlayers() { 305 if (this.selectedPlayerIds.length === 0) { 306 this.selectedPlayersTarget.innerHTML = '<div class="selected-players__empty">No players selected</div>' 307 this.createTeamBtnTarget.disabled = true 308 return 309 } 310 311 // For now, just display the IDs. In production, you'd want to show player names 312 this.selectedPlayersTarget.innerHTML = this.selectedPlayerIds.map((id, index) => ` 313 <div class="selected-player"> 314 <div class="selected-player__avatar">P${index + 1}</div>
315 <span class="selected-player__name">Player #${id}</span> 316 <button type="button" 317 class="selected-player__remove" 318 data-action="click->team-manager#removePlayer" 319 data-user-id="${id}"> 320 <svg width="12" height="12" viewBox="0 0 24 24" fill="currentColor"> 321 <path d="M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z"/> 322 </svg> 323 </button> 324 </div> 325 `).join('') 326 327 // Enable create button if we have at least 2 players and a team name 328 const teamName = this.teamNameInputTarget.value.trim() 329 this.createTeamBtnTarget.disabled = this.selectedPlayerIds.length < 2 || !teamName 330 } 331 332 async createTeam() { 333 const divisionId = this.modalDivisionSelectTarget.value 334 const teamName = this.teamNameInputTarget.value.trim() 335 336 if (!teamName) { 337 alert('Please enter a team name') 338 return 339 } 340 341 if (this.selectedPlayerIds.length < 2) { 342 alert('Please select at least 2 players') 343 return 344 } 345 346 this.createTeamBtnTarget.disabled = true 347 this.createTeamBtnTarget.textContent = 'Creating...' 348 349 try { 350 const response = await fetch(`/api/tournaments/${this.tournamentIdValue}/quick_add`, { 351 method: 'POST', 352 headers: { 353 'Content-Type': 'application/json', 354 'Accept': 'application/json', 355 'X-CSRF-Token': this.getCSRFToken() 356 }, 357 body: JSON.stringify({ 358 division_id: divisionId, 359 team_name: teamName, 360 player_ids: this.selectedPlayerIds 361 }) 362 }) 363 364 const data = await response.json() 365 366 if (!response.ok) { 367 throw new Error(data.error || 'Failed to create team') 368 } 369 370 this.closeAddTeamModal() 371 // Refresh the page to show the new team 372 window.location.reload() 373 } catch (error) { 374 console.error('Error creating team:', error) 375 alert(error.message || 'Failed to create team') 376 this.createTeamBtnTarget.disabled = false 377 this.createTeamBtnTarget.textContent = 'Create Team' 378 } 379 } 380
381 // Team Detail Modal 382 openTeamDetail(event) { 383 event.stopPropagation() 384 const teamId = event.currentTarget.dataset.teamId 385 if (!teamId) return 386 387 this.currentTeamId = teamId 388 this.loadTeamDetails(teamId) 389 } 390 391 async loadTeamDetails(teamId) { 392 try { 393 const response = await fetch(`/api/tournament_teams/${teamId}`, { 394 headers: { 395 'Accept': 'application/json', 396 'X-CSRF-Token': this.getCSRFToken() 397 } 398 }) 399 400 if (!response.ok) throw new Error('Failed to load team details') 401 402 const team = await response.json() 403 this.displayTeamDetails(team) 404 this.teamDetailModalTarget.style.display = 'flex' 405 document.body.style.overflow = 'hidden' 406 } catch (error) { 407 console.error('Error loading team details:', error) 408 alert('Failed to load team details') 409 } 410 } 411 412 displayTeamDetails(team) { 413 this.teamDetailTitleTarget.textContent = team.team_name 414 415 const players = team.team?.users || [] 416 const statusBadge = this.getStatusBadge(team.team_status) 417 const paymentBadge = this.getPaymentBadge(team.payment_status) 418 419 this.teamDetailBodyTarget.innerHTML = ` 420 <div style="margin-bottom: 1.5rem;"> 421 <div style="display: flex; gap: 0.5rem; flex-wrap: wrap; margin-bottom: 1rem;"> 422 ${statusBadge} 423 ${paymentBadge} 424 ${team.needs_partner ? '<span class="team-card__badge team-card__badge--needs-partner">Needs Partner</span>' : ''} 425 </div> 426 427 <div style="margin-bottom: 1rem;"> 428 <label style="font-size: 0.75rem; color: var(--pb-text-tertiary); text-transform: uppercase; display: block; margin-bottom: 0.5rem;">Status Actions</label> 429 <div style="display: flex; gap: 0.5rem; flex-wrap: wrap;"> 430 <button type="button" 431 class="team-modal-btn team-modal-btn--secondary" 432 style="padding: 0.5rem 0.75rem; font-size: 0.75rem;" 433 data-action="click->team-manager#updateTeamStatus" 434 data-status="tournament_confirmed"> 435 Confirm 436 </button> 437 <button type="button" 438 class="team-modal-btn team-modal-btn--secondary" 439 style="padding: 0.5rem 0.75rem; font-size: 0.75rem;" 440 data-action="click->team-manager#updateTeamStatus" 441 data-status="tournament_waitlisted"> 442 Waitlist 443 </button> 444 </div> 445 </div> 446 </div> 447 448 <div style="margin-bottom: 1.5rem;"> 449 <label style="font-size: 0.75rem; color: var(--pb-text-tertiary); text-transform: uppercase; display: block; margin-bottom: 0.5rem;">Players</label> 450 <div style="display: flex; flex-direction: column; gap: 0.5rem;"> 451 ${players.map(player => ` 452 <div class="team-card__player"> 453 <div class="team-card__player-avatar"> 454 <div class="team-card__player-initials">${(player.first_name?.[0] || '').toUpperCase()}${(player.last_name?.[0] || '').toUpperCase()}</div> 455 </div> 456 <div class="team-card__player-info"> 457 <span class="team-card__player-name">${player.first_name} ${player.last_name}</span> 458 ${player.dupr_doubles_rating ? ` 459 <span class="team-card__player-rating"> 460 <svg width="10" height="10" viewBox="0 0 24 24" fill="currentColor"><path d="M12 17.27L18.18 21l-1.64-7.03L22 9.24l-7.19-.61L12 2 9.19 8.63 2 9.24l5.46 4.73L5.82 21z"/></svg> 461 ${parseFloat(player.dupr_doubles_rating).toFixed(2)} 462 </span> 463 ` : ''} 464 </div> 465 <button type="button" 466 class="selected-player__remove" 467 data-action="click->team-manager#removePlayerFromTeam" 468 data-user-id="${player.id}"> 469 <svg width="12" height="12" viewBox="0 0 24 24" fill="currentColor"> 470 <path d="M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z"/> 471 </svg> 472 </button> 473 </div> 474 `).join('')} 475 </div> 476 </div> 477 478 ${team.tournament_pool ? ` 479 <div style="margin-bottom: 1rem;"> 480 <label style="font-size: 0.75rem; color: var(--pb-text-tertiary); text-transform: uppercase; display: block; margin-bottom: 0.5rem;">Pool Assignment</label>
481 <span style="color: var(--pb-text-primary); font-weight: 600;">${team.tournament_pool.pool_name || 'Pool ' + team.tournament_pool.pool_number}</span> 482 </div> 483 ` : ''} 484 485 <div> 486 <label style="font-size: 0.75rem; color: var(--pb-text-tertiary); text-transform: uppercase; display: block; margin-bottom: 0.5rem;">Registration Info</label> 487 <div style="font-size: 0.875rem; color: var(--pb-text-secondary);"> 488 Registered by: ${team.registered_by?.first_name || 'Unknown'} ${team.registered_by?.last_name || ''} 489 </div> 490 </div> 491 ` 492 } 493 494 getStatusBadge(status) { 495 const statusMap = { 496 'tournament_confirmed': { label: 'Confirmed', class: 'team-card__badge--paid' }, 497 'tournament_pending': { label: 'Pending', style: 'background: rgba(255, 214, 10, 0.15); color: #FFD60A;' }, 498 'tournament_waitlisted': { label: 'Waitlisted', style: 'background: rgba(35, 170, 224, 0.15); color: var(--pb-brand-primary);' }, 499 'tournament_eliminated': { label: 'Eliminated', style: 'background: rgba(255, 69, 58, 0.15); color: var(--pb-error);' } 500 } 501 const info = statusMap[status] || { label: status, style: '' } 502 return `<span class="team-card__badge ${info.class || ''}" style="${info.style || ''}">${info.label}</span>` 503 } 504 505 getPaymentBadge(status) { 506 if (status === 'paid') { 507 return '<span class="team-card__badge team-card__badge--paid">Paid</span>' 508 } else if (status === 'pending') { 509 return '<span class="team-card__badge team-card__badge--unpaid">Unpaid</span>' 510 } 511 return '' 512 } 513 514 closeTeamDetailModal() { 515 this.teamDetailModalTarget.style.display = 'none' 516 document.body.style.overflow = 'auto' 517 this.currentTeamId = null 518 } 519 520 async updateTeamStatus(event) { 521 const newStatus = event.currentTarget.dataset.status 522 if (!this.currentTeamId) return 523 524 try { 525 const response = await fetch(`/api/tournaments/${this.tournamentIdValue}/divisions/1/teams/${this.currentTeamId}/update_status`, { 526 method: 'PATCH', 527 headers: { 528 'Content-Type': 'application/json', 529 'Accept': 'application/json', 530 'X-CSRF-Token': this.getCSRFToken() 531 }, 532 body: JSON.stringify({ team_status: newStatus }) 533 }) 534 535 if (!response.ok) { 536 const data = await response.json() 537 throw new Error(data.error || 'Failed to update status') 538 } 539 540 this.closeTeamDetailModal() 541 window.location.reload() 542 } catch (error) { 543 console.error('Error updating team status:', error) 544 alert(error.message || 'Failed to update team status') 545 } 546 } 547 548 async confirmTeam(event) { 549 event.stopPropagation() 550 const teamId = event.currentTarget.dataset.teamId 551 552 try { 553 const response = await fetch(`/api/tournaments/${this.tournamentIdValue}/divisions/1/teams/${teamId}/update_status`, { 554 method: 'PATCH', 555 headers: { 556 'Content-Type': 'application/json', 557 'Accept': 'application/json', 558 'X-CSRF-Token': this.getCSRFToken() 559 }, 560 body: JSON.stringify({ team_status: 'tournament_confirmed' }) 561 }) 562 563 if (!response.ok) { 564 const data = await response.json() 565 throw new Error(data.error || 'Failed to confirm team') 566 } 567 568 window.location.reload() 569 } catch (error) { 570 console.error('Error confirming team:', error) 571 alert(error.message || 'Failed to confirm team') 572 } 573 } 574 575 async deleteTeam() { 576 if (!this.currentTeamId) return 577 578 if (!confirm('Are you sure you want to remove this team? This action cannot be undone.')) { 579 return 580 } 581 582 try { 583 const response = await fetch(`/api/tournaments/${this.tournamentIdValue}/divisions/1/teams/${this.currentTeamId}`, { 584 method: 'DELETE', 585 headers: { 586 'Accept': 'application/json', 587 'X-CSRF-Token': this.getCSRFToken() 588 } 589 }) 590 591 if (!response.ok) { 592 const data = await response.json() 593 throw new Error(data.error || 'Failed to delete team') 594 } 595 596 this.closeTeamDetailModal() 597 window.location.reload() 598 } catch (error) { 599 console.error('Error deleting team:', error) 600 alert(error.message || 'Failed to delete team') 601 } 602 } 603 604 async removePlayerFromTeam(event) { 605 event.stopPropagation() 606 const userId = event.currentTarget.dataset.userId 607 if (!this.currentTeamId || !userId) return 608 609 if (!confirm('Are you sure you want to remove this player from the team?')) { 610 return 611 } 612 613 try { 614 const response = await fetch(`/api/tournaments/${this.tournamentIdValue}/divisions/1/teams/${this.currentTeamId}/remove_player`, { 615 method: 'DELETE', 616 headers: { 617 'Content-Type': 'application/json', 618 'Accept': 'application/json', 619 'X-CSRF-Token': this.getCSRFToken() 620 }, 621 body: JSON.stringify({ user_id: userId }) 622 }) 623 624 if (!response.ok) { 625 const data = await response.json() 626 throw new Error(data.error || 'Failed to remove player') 627 } 628 629 // Reload team details 630 this.loadTeamDetails(this.currentTeamId) 631 } catch (error) { 632 console.error('Error removing player:', error) 633 alert(error.message || 'Failed to remove player') 634 } 635 } 636 637 openPairPlayerModal(event) { 638 const teamId = event.currentTarget.dataset.teamId 639 const playerName = event.currentTarget.dataset.playerName 640 alert(`Partner pairing for ${playerName} coming soon!\n\nTeam ID: ${teamId}`) 641 } 642 643 getCSRFToken() { 644 const meta = document.querySelector('meta[name="csrf-token"]') 645 return meta ? meta.content : '' 646 } 647};
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.