PageSourceSearch

https://www.picklebeastpickleball.com/assets/controllers/team_mana…b8c9010d9ee1b5e62978cff3fa02f7253b2d9674.js

js picklebeastpickleball.com collected 2026-09-24 14:18:23 UTC 22,786 bytes, 647 lines download raw bytes

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.