PageSourceSearch

https://www.picklebeastpickleball.com/assets/controllers/tournamen…2b3d2084c23030c504cd238a062ea042623cd431.js

js picklebeastpickleball.com collected 2026-09-24 14:18:36 UTC 163,613 bytes, 3,604 lines download raw bytes

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      { "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" }[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 &amp; 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, "&amp;")
1447        .replace(/</g, "&lt;")
1448        .replace(/>/g, "&gt;")
1449        .replace(/"/g, "&quot;");
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      ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[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 &amp; 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 &amp; 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 &amp; 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 &amp; 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} &middot; 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} &middot; 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      { "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" }[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.