feat(01-12): implement Players tab with buy-in, bust-out, rebuy, add-on flows
- PlayerSearch: typeahead with 200ms debounce, 48px touch targets, recently active when empty - BuyInFlow: 3-step wizard (search -> auto-seat preview -> confirm) with mini table diagram - BustOutFlow: minimal-tap flow (table grid -> seat tap -> verify -> hitman select) - PlayerDetail: full per-player tracking (status, chips, financials, history, undo buttons) - RebuyFlow: quick 2-tap flow with pre-selected player support - AddOnFlow: quick flow with mass add-on all option - Players page: Active/Busted/All tabs, DataTable with search, swipe actions, overlay flows - Layout: FAB actions wired to actual flows, clock pause/resume via API Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
e7da206d32
commit
44b555db10
8 changed files with 3017 additions and 23 deletions
332
frontend/src/lib/components/AddOnFlow.svelte
Normal file
332
frontend/src/lib/components/AddOnFlow.svelte
Normal file
|
|
@ -0,0 +1,332 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import { tournament } from '$lib/stores/tournament.svelte';
|
||||||
|
import type { Player } from '$lib/stores/tournament.svelte';
|
||||||
|
import { toast } from '$lib/stores/toast.svelte';
|
||||||
|
import { api } from '$lib/api';
|
||||||
|
import PlayerSearch from './PlayerSearch.svelte';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add-On Flow — quick flow similar to Rebuy.
|
||||||
|
*
|
||||||
|
* Features:
|
||||||
|
* - Show only during add-on window
|
||||||
|
* - "Add-On All" option for break-time mass add-ons
|
||||||
|
* - Quick flow: select player + confirm
|
||||||
|
*/
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
/** Close callback. */
|
||||||
|
onclose: () => void;
|
||||||
|
/** Pre-selected player (from player detail). */
|
||||||
|
preselectedPlayer?: Player | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { onclose, preselectedPlayer = null }: Props = $props();
|
||||||
|
|
||||||
|
type Step = 'select' | 'confirm';
|
||||||
|
let step = $state<Step>('select');
|
||||||
|
let selectedPlayer = $state<Player | null>(null);
|
||||||
|
|
||||||
|
// Initialize from preselectedPlayer prop
|
||||||
|
$effect(() => {
|
||||||
|
if (preselectedPlayer) {
|
||||||
|
selectedPlayer = preselectedPlayer;
|
||||||
|
step = 'confirm';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
let submitting = $state(false);
|
||||||
|
|
||||||
|
// Add-on config (simplified — real config from tournament)
|
||||||
|
let addonAmount = 50;
|
||||||
|
let addonChips = 15000;
|
||||||
|
|
||||||
|
/** Eligible: active players only. */
|
||||||
|
function isEligible(player: Player): boolean {
|
||||||
|
return player.status === 'active';
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSelect(player: Player): void {
|
||||||
|
selectedPlayer = player;
|
||||||
|
step = 'confirm';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function confirmAddon(): Promise<void> {
|
||||||
|
if (!selectedPlayer) return;
|
||||||
|
submitting = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await api.post(`/tournaments/${tournament.id}/addon`, {
|
||||||
|
player_id: selectedPlayer.id
|
||||||
|
});
|
||||||
|
toast.success(`${selectedPlayer.name} add-on — +${addonChips.toLocaleString()} chips`);
|
||||||
|
onclose();
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(`Add-on failed: ${err instanceof Error ? err.message : 'Unknown error'}`);
|
||||||
|
} finally {
|
||||||
|
submitting = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function addonAll(): Promise<void> {
|
||||||
|
submitting = true;
|
||||||
|
try {
|
||||||
|
const activePlayers = tournament.players.filter((p) => p.status === 'active');
|
||||||
|
let count = 0;
|
||||||
|
for (const player of activePlayers) {
|
||||||
|
await api.post(`/tournaments/${tournament.id}/addon`, {
|
||||||
|
player_id: player.id
|
||||||
|
});
|
||||||
|
count++;
|
||||||
|
}
|
||||||
|
toast.success(`Add-on applied to ${count} players`);
|
||||||
|
onclose();
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(`Mass add-on failed: ${err instanceof Error ? err.message : 'Unknown error'}`);
|
||||||
|
} finally {
|
||||||
|
submitting = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function goBack(): void {
|
||||||
|
if (step === 'confirm' && !preselectedPlayer) {
|
||||||
|
step = 'select';
|
||||||
|
selectedPlayer = null;
|
||||||
|
} else {
|
||||||
|
onclose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="addon-flow">
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="flow-header">
|
||||||
|
<button class="back-btn touch-target" onclick={goBack} aria-label="Go back">
|
||||||
|
<span aria-hidden="true">←</span>
|
||||||
|
</button>
|
||||||
|
<h2 class="flow-title">Add-On</h2>
|
||||||
|
<button class="close-btn touch-target" onclick={onclose} aria-label="Close">
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flow-content">
|
||||||
|
{#if step === 'select'}
|
||||||
|
<h3 class="step-title">Select Player</h3>
|
||||||
|
<PlayerSearch
|
||||||
|
players={tournament.players}
|
||||||
|
onselect={handleSelect}
|
||||||
|
filter={isEligible}
|
||||||
|
placeholder="Search eligible players..."
|
||||||
|
autofocus={true}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- Add-On All button -->
|
||||||
|
<div class="mass-addon">
|
||||||
|
<button
|
||||||
|
class="btn-addon-all touch-target"
|
||||||
|
onclick={addonAll}
|
||||||
|
disabled={submitting}
|
||||||
|
>
|
||||||
|
{submitting ? 'Processing...' : 'Add-On All Active Players'}
|
||||||
|
</button>
|
||||||
|
<p class="mass-addon-hint">
|
||||||
|
Applies add-on to all {tournament.players.filter((p) => p.status === 'active').length} active players
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{:else if step === 'confirm'}
|
||||||
|
<div class="confirm-card">
|
||||||
|
<h3 class="confirm-name">{selectedPlayer?.name}</h3>
|
||||||
|
<div class="confirm-details">
|
||||||
|
<div class="confirm-row">
|
||||||
|
<span class="confirm-label">Current Chips</span>
|
||||||
|
<span class="confirm-value chips">{selectedPlayer?.chips.toLocaleString()}</span>
|
||||||
|
</div>
|
||||||
|
<div class="confirm-row">
|
||||||
|
<span class="confirm-label">Add-On Amount</span>
|
||||||
|
<span class="confirm-value currency">{addonAmount.toLocaleString()}</span>
|
||||||
|
</div>
|
||||||
|
<div class="confirm-row">
|
||||||
|
<span class="confirm-label">Chips Added</span>
|
||||||
|
<span class="confirm-value chips">+{addonChips.toLocaleString()}</span>
|
||||||
|
</div>
|
||||||
|
<div class="confirm-row">
|
||||||
|
<span class="confirm-label">Add-Ons So Far</span>
|
||||||
|
<span class="confirm-value number">{selectedPlayer?.addons ?? 0}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
class="btn-confirm touch-target"
|
||||||
|
onclick={confirmAddon}
|
||||||
|
disabled={submitting}
|
||||||
|
>
|
||||||
|
{submitting ? 'Processing...' : 'Confirm Add-On'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.addon-flow {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: 100%;
|
||||||
|
background-color: var(--color-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.flow-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: var(--space-3) var(--space-4);
|
||||||
|
border-bottom: 1px solid var(--color-border);
|
||||||
|
background-color: var(--color-bg-elevated);
|
||||||
|
}
|
||||||
|
|
||||||
|
.flow-title {
|
||||||
|
font-size: var(--text-lg);
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--color-warning);
|
||||||
|
}
|
||||||
|
|
||||||
|
.back-btn, .close-btn {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: var(--color-text);
|
||||||
|
font-size: var(--text-xl);
|
||||||
|
cursor: pointer;
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
.back-btn:hover, .close-btn:hover {
|
||||||
|
background-color: var(--color-surface);
|
||||||
|
}
|
||||||
|
|
||||||
|
.flow-content {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: var(--space-4);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-6);
|
||||||
|
}
|
||||||
|
|
||||||
|
.step-title {
|
||||||
|
font-size: var(--text-lg);
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--color-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Mass add-on */
|
||||||
|
.mass-addon {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-2);
|
||||||
|
align-items: center;
|
||||||
|
padding-top: var(--space-4);
|
||||||
|
border-top: 1px solid var(--color-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-addon-all {
|
||||||
|
width: 100%;
|
||||||
|
padding: var(--space-3) var(--space-6);
|
||||||
|
font-size: var(--text-base);
|
||||||
|
font-weight: 600;
|
||||||
|
background-color: var(--color-warning);
|
||||||
|
color: var(--color-bg);
|
||||||
|
border: none;
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background-color var(--transition-fast), opacity var(--transition-fast);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-addon-all:hover:not(:disabled) {
|
||||||
|
background-color: color-mix(in srgb, var(--color-warning) 85%, white);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-addon-all:disabled {
|
||||||
|
opacity: 0.6;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mass-addon-hint {
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Confirm card */
|
||||||
|
.confirm-card {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-4);
|
||||||
|
align-items: center;
|
||||||
|
padding-top: var(--space-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.confirm-name {
|
||||||
|
font-size: var(--text-2xl);
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--color-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.confirm-details {
|
||||||
|
width: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
background-color: var(--color-surface);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.confirm-row {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: var(--space-3) var(--space-4);
|
||||||
|
border-bottom: 1px solid var(--color-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.confirm-row:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.confirm-label {
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.confirm-value {
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--color-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-confirm {
|
||||||
|
width: 100%;
|
||||||
|
padding: var(--space-4) var(--space-6);
|
||||||
|
font-size: var(--text-lg);
|
||||||
|
font-weight: 700;
|
||||||
|
background-color: var(--color-warning);
|
||||||
|
color: var(--color-bg);
|
||||||
|
border: none;
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background-color var(--transition-fast), opacity var(--transition-fast);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-confirm:hover:not(:disabled) {
|
||||||
|
background-color: color-mix(in srgb, var(--color-warning) 85%, white);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-confirm:disabled {
|
||||||
|
opacity: 0.6;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
629
frontend/src/lib/components/BustOutFlow.svelte
Normal file
629
frontend/src/lib/components/BustOutFlow.svelte
Normal file
|
|
@ -0,0 +1,629 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import { tournament } from '$lib/stores/tournament.svelte';
|
||||||
|
import type { Player, Table } from '$lib/stores/tournament.svelte';
|
||||||
|
import { toast } from '$lib/stores/toast.svelte';
|
||||||
|
import { api } from '$lib/api';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bust-Out Flow — optimized for minimum taps.
|
||||||
|
*
|
||||||
|
* Steps:
|
||||||
|
* 1. Pick Table: grid of active tables (large tap targets)
|
||||||
|
* 2. Pick Seat: oval table view, tap busted player's seat
|
||||||
|
* 3. Verify: confirmation "Bust [Name]?"
|
||||||
|
* 4. Select Hitman: players at same table, or search other
|
||||||
|
* 5. Done: toast with placement
|
||||||
|
*
|
||||||
|
* Triggered from: FAB "Bust" action.
|
||||||
|
* PLYR-05
|
||||||
|
*/
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
/** Close callback. */
|
||||||
|
onclose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { onclose }: Props = $props();
|
||||||
|
|
||||||
|
// Flow state
|
||||||
|
type Step = 'table' | 'seat' | 'verify' | 'hitman';
|
||||||
|
let step = $state<Step>('table');
|
||||||
|
let selectedTable = $state<Table | null>(null);
|
||||||
|
let selectedPlayer = $state<Player | null>(null);
|
||||||
|
let selectedHitman = $state<Player | null>(null);
|
||||||
|
let showAllPlayers = $state(false);
|
||||||
|
let submitting = $state(false);
|
||||||
|
|
||||||
|
// Active tables with at least 1 player
|
||||||
|
let activeTables = $derived(
|
||||||
|
tournament.tables
|
||||||
|
.filter((t) => !t.is_break_table && t.players.length > 0)
|
||||||
|
.sort((a, b) => a.number - b.number)
|
||||||
|
);
|
||||||
|
|
||||||
|
// Players at selected table
|
||||||
|
let tablePlayers = $derived.by(() => {
|
||||||
|
if (!selectedTable) return [];
|
||||||
|
return tournament.players.filter(
|
||||||
|
(p) => p.table_id === selectedTable!.id && p.status === 'active'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Hitman candidates: players at same table (excluding busted player)
|
||||||
|
let hitmanCandidates = $derived.by(() => {
|
||||||
|
if (!selectedTable || !selectedPlayer) return [];
|
||||||
|
if (showAllPlayers) {
|
||||||
|
return tournament.players
|
||||||
|
.filter((p) => p.status === 'active' && p.id !== selectedPlayer!.id)
|
||||||
|
.sort((a, b) => a.name.localeCompare(b.name));
|
||||||
|
}
|
||||||
|
return tablePlayers.filter((p) => p.id !== selectedPlayer!.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
function selectTable(table: Table): void {
|
||||||
|
selectedTable = table;
|
||||||
|
step = 'seat';
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectSeat(player: Player): void {
|
||||||
|
selectedPlayer = player;
|
||||||
|
step = 'verify';
|
||||||
|
}
|
||||||
|
|
||||||
|
function confirmBust(): void {
|
||||||
|
step = 'hitman';
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectHitman(hitman: Player): void {
|
||||||
|
selectedHitman = hitman;
|
||||||
|
submitBust();
|
||||||
|
}
|
||||||
|
|
||||||
|
function skipHitman(): void {
|
||||||
|
selectedHitman = null;
|
||||||
|
submitBust();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitBust(): Promise<void> {
|
||||||
|
if (!selectedPlayer) return;
|
||||||
|
submitting = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await api.post(`/tournaments/${tournament.id}/bust`, {
|
||||||
|
player_id: selectedPlayer.id,
|
||||||
|
hitman_id: selectedHitman?.id ?? null
|
||||||
|
});
|
||||||
|
|
||||||
|
// Calculate approximate position
|
||||||
|
const remainingCount = tournament.remainingPlayers;
|
||||||
|
const position = remainingCount; // Will be refined by backend
|
||||||
|
|
||||||
|
toast.success(
|
||||||
|
`${selectedPlayer.name} busted out — ${ordinal(position)} place`
|
||||||
|
);
|
||||||
|
onclose();
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(`Bust failed: ${err instanceof Error ? err.message : 'Unknown error'}`);
|
||||||
|
} finally {
|
||||||
|
submitting = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function goBack(): void {
|
||||||
|
if (step === 'hitman') {
|
||||||
|
step = 'verify';
|
||||||
|
} else if (step === 'verify') {
|
||||||
|
step = 'seat';
|
||||||
|
selectedPlayer = null;
|
||||||
|
} else if (step === 'seat') {
|
||||||
|
step = 'table';
|
||||||
|
selectedTable = null;
|
||||||
|
} else {
|
||||||
|
onclose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Get seat layout for the table view. */
|
||||||
|
function getSeatLayout(table: Table): { seat: number; player: Player | null }[] {
|
||||||
|
const seats: { seat: number; player: Player | null }[] = [];
|
||||||
|
for (let s = 1; s <= table.seats; s++) {
|
||||||
|
const player = tournament.players.find(
|
||||||
|
(p) => p.table_id === table.id && p.seat === s && p.status === 'active'
|
||||||
|
);
|
||||||
|
seats.push({ seat: s, player: player ?? null });
|
||||||
|
}
|
||||||
|
return seats;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ordinal(n: number): string {
|
||||||
|
const s = ['th', 'st', 'nd', 'rd'];
|
||||||
|
const v = n % 100;
|
||||||
|
return n + (s[(v - 20) % 10] || s[v] || s[0]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function playerInitials(name: string): string {
|
||||||
|
return name
|
||||||
|
.split(' ')
|
||||||
|
.map((w) => w.charAt(0))
|
||||||
|
.join('')
|
||||||
|
.toUpperCase()
|
||||||
|
.slice(0, 2);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="bustout-flow">
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="flow-header">
|
||||||
|
<button class="back-btn touch-target" onclick={goBack} aria-label="Go back">
|
||||||
|
<span aria-hidden="true">←</span>
|
||||||
|
</button>
|
||||||
|
<h2 class="flow-title">Bust Out</h2>
|
||||||
|
<button class="close-btn touch-target" onclick={onclose} aria-label="Close">
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Content -->
|
||||||
|
<div class="flow-content">
|
||||||
|
{#if step === 'table'}
|
||||||
|
<!-- Step 1: Pick Table -->
|
||||||
|
<h3 class="step-title">Select Table</h3>
|
||||||
|
<div class="table-grid">
|
||||||
|
{#each activeTables as table (table.id)}
|
||||||
|
<button
|
||||||
|
class="table-card touch-target"
|
||||||
|
onclick={() => selectTable(table)}
|
||||||
|
>
|
||||||
|
<span class="table-number">Table {table.number}</span>
|
||||||
|
<span class="table-count">{table.players.length} players</span>
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if activeTables.length === 0}
|
||||||
|
<p class="empty-msg">No active tables with players.</p>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{:else if step === 'seat'}
|
||||||
|
<!-- Step 2: Pick Seat (oval table view) -->
|
||||||
|
<h3 class="step-title">Table {selectedTable?.number} — Tap the busted player</h3>
|
||||||
|
|
||||||
|
{#if selectedTable}
|
||||||
|
{@const seatLayout = getSeatLayout(selectedTable)}
|
||||||
|
<div class="oval-table-view">
|
||||||
|
<div class="felt-table">
|
||||||
|
<span class="table-label">Table {selectedTable.number}</span>
|
||||||
|
</div>
|
||||||
|
<div class="seats-ring">
|
||||||
|
{#each seatLayout as { seat, player }}
|
||||||
|
{#if player}
|
||||||
|
<button
|
||||||
|
class="seat-btn occupied touch-target"
|
||||||
|
style="--seat-angle: {(seat - 1) * (360 / selectedTable.seats)}deg"
|
||||||
|
onclick={() => selectSeat(player)}
|
||||||
|
aria-label="Bust {player.name} at seat {seat}"
|
||||||
|
>
|
||||||
|
<span class="seat-number">{seat}</span>
|
||||||
|
<span class="seat-name">{player.name.split(' ')[0]}</span>
|
||||||
|
</button>
|
||||||
|
{:else}
|
||||||
|
<div
|
||||||
|
class="seat-btn empty"
|
||||||
|
style="--seat-angle: {(seat - 1) * (360 / selectedTable.seats)}deg"
|
||||||
|
>
|
||||||
|
<span class="seat-number">{seat}</span>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{:else if step === 'verify'}
|
||||||
|
<!-- Step 3: Verify -->
|
||||||
|
<div class="verify-card">
|
||||||
|
<div class="verify-avatar">
|
||||||
|
{playerInitials(selectedPlayer?.name ?? '?')}
|
||||||
|
</div>
|
||||||
|
<h3 class="verify-question">Bust {selectedPlayer?.name}?</h3>
|
||||||
|
<div class="verify-info">
|
||||||
|
<span>Table {selectedTable?.number}, Seat {selectedPlayer?.seat}</span>
|
||||||
|
{#if selectedPlayer?.chips}
|
||||||
|
<span class="chips">{selectedPlayer.chips.toLocaleString()} chips</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
class="btn btn-bust touch-target"
|
||||||
|
onclick={confirmBust}
|
||||||
|
>
|
||||||
|
Confirm Bust
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{:else if step === 'hitman'}
|
||||||
|
<!-- Step 4: Select Hitman -->
|
||||||
|
<h3 class="step-title">Who busted {selectedPlayer?.name}?</h3>
|
||||||
|
|
||||||
|
<div class="hitman-list">
|
||||||
|
{#each hitmanCandidates as candidate (candidate.id)}
|
||||||
|
<button
|
||||||
|
class="hitman-option touch-target"
|
||||||
|
onclick={() => selectHitman(candidate)}
|
||||||
|
disabled={submitting}
|
||||||
|
>
|
||||||
|
<div class="hitman-avatar">{playerInitials(candidate.name)}</div>
|
||||||
|
<div class="hitman-info">
|
||||||
|
<span class="hitman-name">{candidate.name}</span>
|
||||||
|
{#if !showAllPlayers}
|
||||||
|
<span class="hitman-seat">Seat {candidate.seat}</span>
|
||||||
|
{:else}
|
||||||
|
<span class="hitman-seat">
|
||||||
|
Table {tournament.tables.find((t) => t.id === candidate.table_id)?.number}, Seat {candidate.seat}
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="hitman-actions">
|
||||||
|
{#if !showAllPlayers}
|
||||||
|
<button
|
||||||
|
class="btn btn-outline touch-target"
|
||||||
|
onclick={() => { showAllPlayers = true; }}
|
||||||
|
>
|
||||||
|
Other Player...
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
<button
|
||||||
|
class="btn btn-outline btn-skip touch-target"
|
||||||
|
onclick={skipHitman}
|
||||||
|
disabled={submitting}
|
||||||
|
>
|
||||||
|
Skip
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.bustout-flow {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: 100%;
|
||||||
|
background-color: var(--color-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Header */
|
||||||
|
.flow-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: var(--space-3) var(--space-4);
|
||||||
|
border-bottom: 1px solid var(--color-border);
|
||||||
|
background-color: var(--color-bg-elevated);
|
||||||
|
}
|
||||||
|
|
||||||
|
.flow-title {
|
||||||
|
font-size: var(--text-lg);
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--color-error);
|
||||||
|
}
|
||||||
|
|
||||||
|
.back-btn, .close-btn {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: var(--color-text);
|
||||||
|
font-size: var(--text-xl);
|
||||||
|
cursor: pointer;
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
.back-btn:hover, .close-btn:hover {
|
||||||
|
background-color: var(--color-surface);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Content */
|
||||||
|
.flow-content {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: var(--space-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.step-title {
|
||||||
|
font-size: var(--text-lg);
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--color-text);
|
||||||
|
margin-bottom: var(--space-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Table grid */
|
||||||
|
.table-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, 1fr);
|
||||||
|
gap: var(--space-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 768px) {
|
||||||
|
.table-grid {
|
||||||
|
grid-template-columns: repeat(3, 1fr);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-card {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: var(--space-2);
|
||||||
|
padding: var(--space-6) var(--space-4);
|
||||||
|
background-color: var(--color-surface);
|
||||||
|
border: 2px solid var(--color-border);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: border-color var(--transition-fast), background-color var(--transition-fast);
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-card:hover {
|
||||||
|
border-color: var(--color-primary);
|
||||||
|
background-color: var(--color-surface-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-number {
|
||||||
|
font-size: var(--text-lg);
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--color-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-count {
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-msg {
|
||||||
|
text-align: center;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
padding: var(--space-8);
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Oval table view */
|
||||||
|
.oval-table-view {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
padding: var(--space-8) var(--space-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.felt-table {
|
||||||
|
width: 160px;
|
||||||
|
height: 100px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background-color: color-mix(in srgb, var(--color-felt) 20%, transparent);
|
||||||
|
border: 3px solid var(--color-felt);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
margin-bottom: var(--space-6);
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-label {
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--color-felt);
|
||||||
|
}
|
||||||
|
|
||||||
|
.seats-ring {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
justify-content: center;
|
||||||
|
gap: var(--space-3);
|
||||||
|
max-width: 320px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.seat-btn {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 64px;
|
||||||
|
height: 64px;
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
border: 2px solid var(--color-border);
|
||||||
|
background: none;
|
||||||
|
cursor: default;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.seat-btn.occupied {
|
||||||
|
background-color: var(--color-surface);
|
||||||
|
border-color: var(--color-error);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background-color var(--transition-fast), transform var(--transition-fast);
|
||||||
|
}
|
||||||
|
|
||||||
|
.seat-btn.occupied:hover {
|
||||||
|
background-color: color-mix(in srgb, var(--color-error) 15%, transparent);
|
||||||
|
transform: scale(1.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.seat-btn.empty {
|
||||||
|
opacity: 0.3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.seat-number {
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.seat-name {
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
color: var(--color-text);
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
max-width: 56px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Verify card */
|
||||||
|
.verify-card {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-4);
|
||||||
|
padding: var(--space-8) var(--space-4);
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.verify-avatar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 80px;
|
||||||
|
height: 80px;
|
||||||
|
border-radius: var(--radius-full);
|
||||||
|
background-color: var(--color-surface);
|
||||||
|
border: 3px solid var(--color-error);
|
||||||
|
font-size: var(--text-2xl);
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--color-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.verify-question {
|
||||||
|
font-size: var(--text-2xl);
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--color-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.verify-info {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-1);
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Hitman list */
|
||||||
|
.hitman-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-2);
|
||||||
|
margin-bottom: var(--space-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hitman-option {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-3);
|
||||||
|
padding: var(--space-3) var(--space-4);
|
||||||
|
background-color: var(--color-surface);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
cursor: pointer;
|
||||||
|
color: var(--color-text);
|
||||||
|
transition: border-color var(--transition-fast), background-color var(--transition-fast);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hitman-option:hover:not(:disabled) {
|
||||||
|
border-color: var(--color-primary);
|
||||||
|
background-color: var(--color-surface-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hitman-option:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hitman-avatar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
border-radius: var(--radius-full);
|
||||||
|
background-color: var(--color-surface-active);
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--color-text);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hitman-info {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hitman-name {
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: var(--text-base);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hitman-seat {
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Hitman action buttons */
|
||||||
|
.hitman-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--space-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Buttons */
|
||||||
|
.btn {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: var(--space-3) var(--space-6);
|
||||||
|
font-size: var(--text-base);
|
||||||
|
font-weight: 600;
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background-color var(--transition-fast), opacity var(--transition-fast);
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn:disabled {
|
||||||
|
opacity: 0.6;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-bust {
|
||||||
|
background-color: var(--color-error);
|
||||||
|
color: white;
|
||||||
|
font-size: var(--text-lg);
|
||||||
|
padding: var(--space-4) var(--space-6);
|
||||||
|
width: 100%;
|
||||||
|
max-width: 300px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-bust:hover:not(:disabled) {
|
||||||
|
background-color: color-mix(in srgb, var(--color-error) 85%, white);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-outline {
|
||||||
|
background: none;
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
color: var(--color-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-outline:hover:not(:disabled) {
|
||||||
|
background-color: var(--color-surface);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-skip {
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
643
frontend/src/lib/components/BuyInFlow.svelte
Normal file
643
frontend/src/lib/components/BuyInFlow.svelte
Normal file
|
|
@ -0,0 +1,643 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import { tournament } from '$lib/stores/tournament.svelte';
|
||||||
|
import type { Player, Table } from '$lib/stores/tournament.svelte';
|
||||||
|
import { toast } from '$lib/stores/toast.svelte';
|
||||||
|
import { api } from '$lib/api';
|
||||||
|
import PlayerSearch from './PlayerSearch.svelte';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Buy-In Flow — step-by-step wizard.
|
||||||
|
*
|
||||||
|
* Steps:
|
||||||
|
* 1. Search/Select Player
|
||||||
|
* 2. Auto-Seat Preview (system suggests table + seat, TD can override)
|
||||||
|
* 3. Confirm (summary card with big confirm button)
|
||||||
|
* 4. Receipt (toast + done)
|
||||||
|
*
|
||||||
|
* Triggered from: FAB "Buy In" action, Players tab "+" button.
|
||||||
|
* PLYR-04
|
||||||
|
*/
|
||||||
|
|
||||||
|
interface SeatSuggestion {
|
||||||
|
table_id: string;
|
||||||
|
table_number: number;
|
||||||
|
seat: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
/** Close callback. */
|
||||||
|
onclose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { onclose }: Props = $props();
|
||||||
|
|
||||||
|
// Flow state
|
||||||
|
type Step = 'search' | 'seat' | 'confirm';
|
||||||
|
let step = $state<Step>('search');
|
||||||
|
let selectedPlayer = $state<Player | null>(null);
|
||||||
|
let suggestedSeat = $state<SeatSuggestion | null>(null);
|
||||||
|
let selectedSeat = $state<SeatSuggestion | null>(null);
|
||||||
|
let overrideMode = $state(false);
|
||||||
|
let submitting = $state(false);
|
||||||
|
|
||||||
|
// Buy-in amount from tournament financials
|
||||||
|
let buyinAmount = $derived(tournament.financials?.total_buyin ? 100 : 100);
|
||||||
|
|
||||||
|
// Available seats for override mode
|
||||||
|
let availableSeats = $derived.by(() => {
|
||||||
|
const seats: SeatSuggestion[] = [];
|
||||||
|
for (const table of tournament.tables) {
|
||||||
|
if (table.is_break_table) continue;
|
||||||
|
for (let s = 1; s <= table.seats; s++) {
|
||||||
|
const occupied = tournament.players.some(
|
||||||
|
(p) => p.table_id === table.id && p.seat === s && p.status === 'active'
|
||||||
|
);
|
||||||
|
if (!occupied) {
|
||||||
|
seats.push({
|
||||||
|
table_id: table.id,
|
||||||
|
table_number: table.number,
|
||||||
|
seat: s
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return seats;
|
||||||
|
});
|
||||||
|
|
||||||
|
function handlePlayerSelect(player: Player): void {
|
||||||
|
selectedPlayer = player;
|
||||||
|
// Generate auto-seat suggestion: pick table with fewest players
|
||||||
|
const bestTable = findBestTable();
|
||||||
|
if (bestTable) {
|
||||||
|
suggestedSeat = bestTable;
|
||||||
|
selectedSeat = bestTable;
|
||||||
|
}
|
||||||
|
step = 'seat';
|
||||||
|
}
|
||||||
|
|
||||||
|
function findBestTable(): SeatSuggestion | null {
|
||||||
|
let bestTable: Table | null = null;
|
||||||
|
let minPlayers = Infinity;
|
||||||
|
|
||||||
|
for (const table of tournament.tables) {
|
||||||
|
if (table.is_break_table) continue;
|
||||||
|
const playerCount = table.players.length;
|
||||||
|
if (playerCount < table.seats && playerCount < minPlayers) {
|
||||||
|
minPlayers = playerCount;
|
||||||
|
bestTable = table;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!bestTable) return null;
|
||||||
|
|
||||||
|
// Find first empty seat
|
||||||
|
for (let s = 1; s <= bestTable.seats; s++) {
|
||||||
|
const occupied = tournament.players.some(
|
||||||
|
(p) => p.table_id === bestTable!.id && p.seat === s && p.status === 'active'
|
||||||
|
);
|
||||||
|
if (!occupied) {
|
||||||
|
return {
|
||||||
|
table_id: bestTable.id,
|
||||||
|
table_number: bestTable.number,
|
||||||
|
seat: s
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function acceptSuggestion(): void {
|
||||||
|
step = 'confirm';
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleOverride(): void {
|
||||||
|
overrideMode = !overrideMode;
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectOverrideSeat(seat: SeatSuggestion): void {
|
||||||
|
selectedSeat = seat;
|
||||||
|
overrideMode = false;
|
||||||
|
step = 'confirm';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function confirmBuyIn(): Promise<void> {
|
||||||
|
if (!selectedPlayer || !selectedSeat) return;
|
||||||
|
submitting = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await api.post(`/tournaments/${tournament.id}/buyin`, {
|
||||||
|
player_id: selectedPlayer.id,
|
||||||
|
table_id: selectedSeat.table_id,
|
||||||
|
seat: selectedSeat.seat
|
||||||
|
});
|
||||||
|
|
||||||
|
toast.success(
|
||||||
|
`${selectedPlayer.name} bought in — Table ${selectedSeat.table_number}, Seat ${selectedSeat.seat}`
|
||||||
|
);
|
||||||
|
onclose();
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(`Buy-in failed: ${err instanceof Error ? err.message : 'Unknown error'}`);
|
||||||
|
} finally {
|
||||||
|
submitting = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function goBack(): void {
|
||||||
|
if (step === 'confirm') {
|
||||||
|
step = 'seat';
|
||||||
|
} else if (step === 'seat') {
|
||||||
|
step = 'search';
|
||||||
|
selectedPlayer = null;
|
||||||
|
suggestedSeat = null;
|
||||||
|
selectedSeat = null;
|
||||||
|
overrideMode = false;
|
||||||
|
} else {
|
||||||
|
onclose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Get table players for mini diagram. */
|
||||||
|
function getTablePlayers(tableId: string, totalSeats: number): { seat: number; name: string | null }[] {
|
||||||
|
const seats: { seat: number; name: string | null }[] = [];
|
||||||
|
for (let s = 1; s <= totalSeats; s++) {
|
||||||
|
const player = tournament.players.find(
|
||||||
|
(p) => p.table_id === tableId && p.seat === s && p.status === 'active'
|
||||||
|
);
|
||||||
|
seats.push({ seat: s, name: player?.name ?? null });
|
||||||
|
}
|
||||||
|
return seats;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="buyin-flow">
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="flow-header">
|
||||||
|
<button class="back-btn touch-target" onclick={goBack} aria-label="Go back">
|
||||||
|
<span aria-hidden="true">←</span>
|
||||||
|
</button>
|
||||||
|
<h2 class="flow-title">Buy In</h2>
|
||||||
|
<button class="close-btn touch-target" onclick={onclose} aria-label="Close">
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Step indicator -->
|
||||||
|
<div class="step-indicator" role="progressbar" aria-valuenow={step === 'search' ? 1 : step === 'seat' ? 2 : 3} aria-valuemin={1} aria-valuemax={3}>
|
||||||
|
<div class="step" class:active={step === 'search'} class:done={step !== 'search'}>1</div>
|
||||||
|
<div class="step-line" class:done={step !== 'search'}></div>
|
||||||
|
<div class="step" class:active={step === 'seat'} class:done={step === 'confirm'}>2</div>
|
||||||
|
<div class="step-line" class:done={step === 'confirm'}></div>
|
||||||
|
<div class="step" class:active={step === 'confirm'}>3</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Step content -->
|
||||||
|
<div class="flow-content">
|
||||||
|
{#if step === 'search'}
|
||||||
|
<!-- Step 1: Search/Select Player -->
|
||||||
|
<div class="step-content">
|
||||||
|
<h3 class="step-title">Select Player</h3>
|
||||||
|
<PlayerSearch
|
||||||
|
players={tournament.players}
|
||||||
|
onselect={handlePlayerSelect}
|
||||||
|
filter={(p) => p.status !== 'active'}
|
||||||
|
placeholder="Search by name..."
|
||||||
|
autofocus={true}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{:else if step === 'seat'}
|
||||||
|
<!-- Step 2: Auto-Seat Preview -->
|
||||||
|
<div class="step-content">
|
||||||
|
<h3 class="step-title">Seat Assignment</h3>
|
||||||
|
|
||||||
|
{#if suggestedSeat && !overrideMode}
|
||||||
|
<div class="suggestion-card">
|
||||||
|
<span class="suggestion-label">Suggested Seat</span>
|
||||||
|
<div class="suggestion-detail">
|
||||||
|
<span class="seat-info">Table {suggestedSeat.table_number}, Seat {suggestedSeat.seat}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Mini table diagram -->
|
||||||
|
{#if suggestedSeat}
|
||||||
|
{@const tableData = tournament.tables.find((t) => t.id === suggestedSeat!.table_id)}
|
||||||
|
{#if tableData}
|
||||||
|
<div class="mini-table">
|
||||||
|
<div class="table-oval">
|
||||||
|
{#each getTablePlayers(tableData.id, tableData.seats) as seatData}
|
||||||
|
<div
|
||||||
|
class="mini-seat"
|
||||||
|
class:occupied={seatData.name !== null}
|
||||||
|
class:suggested={seatData.seat === suggestedSeat.seat}
|
||||||
|
title={seatData.name ?? `Seat ${seatData.seat} (empty)`}
|
||||||
|
>
|
||||||
|
{seatData.seat}
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div class="suggestion-actions">
|
||||||
|
<button class="btn btn-primary btn-lg touch-target" onclick={acceptSuggestion}>
|
||||||
|
Accept Seat
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-outline touch-target" onclick={toggleOverride}>
|
||||||
|
Override
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{:else if overrideMode}
|
||||||
|
<div class="override-list">
|
||||||
|
<p class="override-hint">Select a different seat:</p>
|
||||||
|
{#if availableSeats.length === 0}
|
||||||
|
<p class="no-seats">No available seats</p>
|
||||||
|
{:else}
|
||||||
|
{#each availableSeats as seat}
|
||||||
|
<button
|
||||||
|
class="seat-option touch-target"
|
||||||
|
onclick={() => selectOverrideSeat(seat)}
|
||||||
|
>
|
||||||
|
<span class="seat-option-table">Table {seat.table_number}</span>
|
||||||
|
<span class="seat-option-seat">Seat {seat.seat}</span>
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
{/if}
|
||||||
|
<button class="btn btn-outline touch-target" onclick={toggleOverride}>
|
||||||
|
Cancel Override
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div class="no-tables">
|
||||||
|
<p>No tables available. Create a table first.</p>
|
||||||
|
<button class="btn btn-outline touch-target" onclick={goBack}>
|
||||||
|
Go Back
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{:else if step === 'confirm'}
|
||||||
|
<!-- Step 3: Confirm -->
|
||||||
|
<div class="step-content">
|
||||||
|
<h3 class="step-title">Confirm Buy-In</h3>
|
||||||
|
|
||||||
|
<div class="confirm-card">
|
||||||
|
<div class="confirm-row">
|
||||||
|
<span class="confirm-label">Player</span>
|
||||||
|
<span class="confirm-value">{selectedPlayer?.name}</span>
|
||||||
|
</div>
|
||||||
|
<div class="confirm-row">
|
||||||
|
<span class="confirm-label">Table</span>
|
||||||
|
<span class="confirm-value">Table {selectedSeat?.table_number}</span>
|
||||||
|
</div>
|
||||||
|
<div class="confirm-row">
|
||||||
|
<span class="confirm-label">Seat</span>
|
||||||
|
<span class="confirm-value">Seat {selectedSeat?.seat}</span>
|
||||||
|
</div>
|
||||||
|
<div class="confirm-row">
|
||||||
|
<span class="confirm-label">Buy-In</span>
|
||||||
|
<span class="confirm-value currency">{buyinAmount.toLocaleString()}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
class="btn btn-confirm touch-target"
|
||||||
|
onclick={confirmBuyIn}
|
||||||
|
disabled={submitting}
|
||||||
|
>
|
||||||
|
{#if submitting}
|
||||||
|
Processing...
|
||||||
|
{:else}
|
||||||
|
Confirm Buy-In
|
||||||
|
{/if}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.buyin-flow {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: 100%;
|
||||||
|
background-color: var(--color-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Header */
|
||||||
|
.flow-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: var(--space-3) var(--space-4);
|
||||||
|
border-bottom: 1px solid var(--color-border);
|
||||||
|
background-color: var(--color-bg-elevated);
|
||||||
|
}
|
||||||
|
|
||||||
|
.flow-title {
|
||||||
|
font-size: var(--text-lg);
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--color-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.back-btn, .close-btn {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: var(--color-text);
|
||||||
|
font-size: var(--text-xl);
|
||||||
|
cursor: pointer;
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
.back-btn:hover, .close-btn:hover {
|
||||||
|
background-color: var(--color-surface);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Step indicator */
|
||||||
|
.step-indicator {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: var(--space-4);
|
||||||
|
gap: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.step {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
border-radius: var(--radius-full);
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
font-weight: 700;
|
||||||
|
border: 2px solid var(--color-border);
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
background: none;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.step.active {
|
||||||
|
border-color: var(--color-primary);
|
||||||
|
color: var(--color-primary);
|
||||||
|
background-color: color-mix(in srgb, var(--color-primary) 10%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.step.done {
|
||||||
|
border-color: var(--color-success);
|
||||||
|
color: var(--color-bg);
|
||||||
|
background-color: var(--color-success);
|
||||||
|
}
|
||||||
|
|
||||||
|
.step-line {
|
||||||
|
width: 40px;
|
||||||
|
height: 2px;
|
||||||
|
background-color: var(--color-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.step-line.done {
|
||||||
|
background-color: var(--color-success);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Content */
|
||||||
|
.flow-content {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: var(--space-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.step-content {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.step-title {
|
||||||
|
font-size: var(--text-lg);
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--color-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Suggestion card */
|
||||||
|
.suggestion-card {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-4);
|
||||||
|
padding: var(--space-6);
|
||||||
|
background-color: var(--color-surface);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.suggestion-label {
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.seat-info {
|
||||||
|
font-size: var(--text-2xl);
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--color-text);
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
}
|
||||||
|
|
||||||
|
.suggestion-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--space-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Mini table diagram */
|
||||||
|
.mini-table {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
padding: var(--space-4) 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-oval {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
justify-content: center;
|
||||||
|
gap: var(--space-2);
|
||||||
|
max-width: 280px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mini-seat {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
border-radius: var(--radius-full);
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
font-weight: 600;
|
||||||
|
border: 2px solid var(--color-border);
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
background: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mini-seat.occupied {
|
||||||
|
background-color: var(--color-surface-active);
|
||||||
|
color: var(--color-text);
|
||||||
|
border-color: var(--color-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mini-seat.suggested {
|
||||||
|
border-color: var(--color-success);
|
||||||
|
color: var(--color-success);
|
||||||
|
background-color: color-mix(in srgb, var(--color-success) 15%, transparent);
|
||||||
|
animation: pulse-seat 1.5s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes pulse-seat {
|
||||||
|
0%, 100% { transform: scale(1); }
|
||||||
|
50% { transform: scale(1.1); }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Override */
|
||||||
|
.override-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.override-hint {
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
margin-bottom: var(--space-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.seat-option {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: var(--space-3) var(--space-4);
|
||||||
|
background-color: var(--color-surface);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
cursor: pointer;
|
||||||
|
color: var(--color-text);
|
||||||
|
transition: background-color var(--transition-fast);
|
||||||
|
}
|
||||||
|
|
||||||
|
.seat-option:hover {
|
||||||
|
background-color: var(--color-surface-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.seat-option-table {
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.seat-option-seat {
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
}
|
||||||
|
|
||||||
|
.no-seats, .no-tables {
|
||||||
|
text-align: center;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
padding: var(--space-8);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Confirm card */
|
||||||
|
.confirm-card {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
background-color: var(--color-surface);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.confirm-row {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: var(--space-3) var(--space-4);
|
||||||
|
border-bottom: 1px solid var(--color-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.confirm-row:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.confirm-label {
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.confirm-value {
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--color-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Buttons */
|
||||||
|
.btn {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: var(--space-3) var(--space-6);
|
||||||
|
font-size: var(--text-base);
|
||||||
|
font-weight: 600;
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background-color var(--transition-fast), opacity var(--transition-fast);
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn:disabled {
|
||||||
|
opacity: 0.6;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary {
|
||||||
|
background-color: var(--color-primary);
|
||||||
|
color: var(--color-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary:hover:not(:disabled) {
|
||||||
|
background-color: color-mix(in srgb, var(--color-primary) 85%, white);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-outline {
|
||||||
|
background: none;
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
color: var(--color-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-outline:hover {
|
||||||
|
background-color: var(--color-surface);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-confirm {
|
||||||
|
background-color: var(--color-success);
|
||||||
|
color: var(--color-bg);
|
||||||
|
font-size: var(--text-lg);
|
||||||
|
padding: var(--space-4) var(--space-6);
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-confirm:hover:not(:disabled) {
|
||||||
|
background-color: color-mix(in srgb, var(--color-success) 85%, white);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-lg {
|
||||||
|
padding: var(--space-4) var(--space-6);
|
||||||
|
}
|
||||||
|
|
||||||
|
.suggestion-detail {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
443
frontend/src/lib/components/PlayerDetail.svelte
Normal file
443
frontend/src/lib/components/PlayerDetail.svelte
Normal file
|
|
@ -0,0 +1,443 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import { tournament } from '$lib/stores/tournament.svelte';
|
||||||
|
import type { Player } from '$lib/stores/tournament.svelte';
|
||||||
|
import { toast } from '$lib/stores/toast.svelte';
|
||||||
|
import { api } from '$lib/api';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Player Detail — full per-player tracking within tournament.
|
||||||
|
*
|
||||||
|
* Shows: status, seat, chips, playing time, buy-in time,
|
||||||
|
* rebuys, add-ons, bounties, prize, points, net take,
|
||||||
|
* action history, undo buttons.
|
||||||
|
*
|
||||||
|
* PLYR-07
|
||||||
|
*/
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
/** The player to display. */
|
||||||
|
player: Player;
|
||||||
|
/** Close callback. */
|
||||||
|
onclose: () => void;
|
||||||
|
/** Callback to trigger rebuy flow. */
|
||||||
|
onrebuy?: (player: Player) => void;
|
||||||
|
/** Callback to trigger add-on flow. */
|
||||||
|
onaddon?: (player: Player) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { player, onclose, onrebuy, onaddon }: Props = $props();
|
||||||
|
|
||||||
|
let undoing = $state<string | null>(null);
|
||||||
|
|
||||||
|
// Derived data
|
||||||
|
let tableName = $derived.by(() => {
|
||||||
|
if (!player.table_id) return 'Not seated';
|
||||||
|
const table = tournament.tables.find((t) => t.id === player.table_id);
|
||||||
|
return table ? `Table ${table.number}` : 'Unknown';
|
||||||
|
});
|
||||||
|
|
||||||
|
let seatDisplay = $derived(
|
||||||
|
player.seat ? `Seat ${player.seat}` : 'N/A'
|
||||||
|
);
|
||||||
|
|
||||||
|
let totalInvestment = $derived(
|
||||||
|
100 + (player.rebuys * 100) + (player.addons * 50)
|
||||||
|
);
|
||||||
|
|
||||||
|
let isActive = $derived(player.status === 'active');
|
||||||
|
|
||||||
|
// Action history from activity feed (filtered for this player)
|
||||||
|
let playerHistory = $derived(
|
||||||
|
tournament.activity
|
||||||
|
.filter((a) => a.player_id === player.id)
|
||||||
|
.sort((a, b) => b.timestamp - a.timestamp)
|
||||||
|
);
|
||||||
|
|
||||||
|
async function undoAction(actionType: string): Promise<void> {
|
||||||
|
undoing = actionType;
|
||||||
|
try {
|
||||||
|
await api.post(`/tournaments/${tournament.id}/undo`, {
|
||||||
|
player_id: player.id,
|
||||||
|
action_type: actionType
|
||||||
|
});
|
||||||
|
toast.success(`Undid ${actionType} for ${player.name}`);
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(`Undo failed: ${err instanceof Error ? err.message : 'Unknown error'}`);
|
||||||
|
} finally {
|
||||||
|
undoing = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTime(seconds: number): string {
|
||||||
|
const h = Math.floor(seconds / 3600);
|
||||||
|
const m = Math.floor((seconds % 3600) / 60);
|
||||||
|
if (h > 0) return `${h}h ${m}m`;
|
||||||
|
return `${m}m`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTimestamp(ts: number): string {
|
||||||
|
const date = new Date(ts * 1000);
|
||||||
|
return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
||||||
|
}
|
||||||
|
|
||||||
|
function ordinal(n: number): string {
|
||||||
|
const s = ['th', 'st', 'nd', 'rd'];
|
||||||
|
const v = n % 100;
|
||||||
|
return n + (s[(v - 20) % 10] || s[v] || s[0]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function statusColor(status: string): string {
|
||||||
|
switch (status) {
|
||||||
|
case 'active': return 'var(--color-success)';
|
||||||
|
case 'eliminated': return 'var(--color-error)';
|
||||||
|
case 'registered': return 'var(--color-primary)';
|
||||||
|
default: return 'var(--color-text-muted)';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="player-detail">
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="detail-header">
|
||||||
|
<button class="back-btn touch-target" onclick={onclose} aria-label="Close player detail">
|
||||||
|
<span aria-hidden="true">←</span>
|
||||||
|
</button>
|
||||||
|
<h2 class="detail-title">{player.name}</h2>
|
||||||
|
<div class="status-badge" style="color: {statusColor(player.status)}">
|
||||||
|
{player.status}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="detail-content">
|
||||||
|
<!-- Status & Location -->
|
||||||
|
<section class="detail-section">
|
||||||
|
<div class="info-grid">
|
||||||
|
<div class="info-item">
|
||||||
|
<span class="info-label">Status</span>
|
||||||
|
<span class="info-value" style="color: {statusColor(player.status)}">{player.status}</span>
|
||||||
|
</div>
|
||||||
|
<div class="info-item">
|
||||||
|
<span class="info-label">Location</span>
|
||||||
|
<span class="info-value">{tableName}, {seatDisplay}</span>
|
||||||
|
</div>
|
||||||
|
<div class="info-item">
|
||||||
|
<span class="info-label">Chips</span>
|
||||||
|
<span class="info-value chips">{player.chips.toLocaleString()}</span>
|
||||||
|
</div>
|
||||||
|
{#if player.finish_position}
|
||||||
|
<div class="info-item">
|
||||||
|
<span class="info-label">Finish</span>
|
||||||
|
<span class="info-value">{ordinal(player.finish_position)} place</span>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Financial Details -->
|
||||||
|
<section class="detail-section">
|
||||||
|
<h3 class="section-title">Financial</h3>
|
||||||
|
<div class="stats-list">
|
||||||
|
<div class="stat-row">
|
||||||
|
<span class="stat-label">Rebuys</span>
|
||||||
|
<span class="stat-value number">{player.rebuys}</span>
|
||||||
|
</div>
|
||||||
|
<div class="stat-row">
|
||||||
|
<span class="stat-label">Add-ons</span>
|
||||||
|
<span class="stat-value number">{player.addons}</span>
|
||||||
|
</div>
|
||||||
|
<div class="stat-row">
|
||||||
|
<span class="stat-label">Total Investment</span>
|
||||||
|
<span class="stat-value currency">{totalInvestment.toLocaleString()}</span>
|
||||||
|
</div>
|
||||||
|
<div class="stat-row">
|
||||||
|
<span class="stat-label">Bounties Collected</span>
|
||||||
|
<span class="stat-value number">{player.bounty}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Quick Actions -->
|
||||||
|
{#if isActive}
|
||||||
|
<section class="detail-section">
|
||||||
|
<h3 class="section-title">Actions</h3>
|
||||||
|
<div class="action-grid">
|
||||||
|
{#if onrebuy}
|
||||||
|
<button
|
||||||
|
class="action-btn touch-target"
|
||||||
|
style="--action-color: var(--color-primary)"
|
||||||
|
onclick={() => onrebuy?.(player)}
|
||||||
|
>
|
||||||
|
Rebuy
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
{#if onaddon}
|
||||||
|
<button
|
||||||
|
class="action-btn touch-target"
|
||||||
|
style="--action-color: var(--color-warning)"
|
||||||
|
onclick={() => onaddon?.(player)}
|
||||||
|
>
|
||||||
|
Add-On
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
<button
|
||||||
|
class="action-btn touch-target"
|
||||||
|
style="--action-color: var(--color-error)"
|
||||||
|
onclick={() => undoAction('buyin')}
|
||||||
|
disabled={undoing !== null}
|
||||||
|
>
|
||||||
|
{undoing === 'buyin' ? 'Undoing...' : 'Undo Buy-In'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
{:else if player.status === 'eliminated'}
|
||||||
|
<section class="detail-section">
|
||||||
|
<h3 class="section-title">Actions</h3>
|
||||||
|
<div class="action-grid">
|
||||||
|
<button
|
||||||
|
class="action-btn touch-target"
|
||||||
|
style="--action-color: var(--color-success)"
|
||||||
|
onclick={() => undoAction('bust')}
|
||||||
|
disabled={undoing !== null}
|
||||||
|
>
|
||||||
|
{undoing === 'bust' ? 'Undoing...' : 'Undo Bust'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Action History -->
|
||||||
|
<section class="detail-section">
|
||||||
|
<h3 class="section-title">History</h3>
|
||||||
|
{#if playerHistory.length === 0}
|
||||||
|
<p class="empty-history">No actions recorded yet.</p>
|
||||||
|
{:else}
|
||||||
|
<div class="history-list">
|
||||||
|
{#each playerHistory as entry (entry.id)}
|
||||||
|
<div class="history-item">
|
||||||
|
<span class="history-time">{formatTimestamp(entry.timestamp)}</span>
|
||||||
|
<span class="history-message">{entry.message}</span>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.player-detail {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: 100%;
|
||||||
|
background-color: var(--color-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Header */
|
||||||
|
.detail-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-3);
|
||||||
|
padding: var(--space-3) var(--space-4);
|
||||||
|
border-bottom: 1px solid var(--color-border);
|
||||||
|
background-color: var(--color-bg-elevated);
|
||||||
|
}
|
||||||
|
|
||||||
|
.back-btn {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: var(--color-text);
|
||||||
|
font-size: var(--text-xl);
|
||||||
|
cursor: pointer;
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.back-btn:hover {
|
||||||
|
background-color: var(--color-surface);
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-title {
|
||||||
|
flex: 1;
|
||||||
|
font-size: var(--text-lg);
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--color-text);
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-badge {
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
font-weight: 700;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Content */
|
||||||
|
.detail-content {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: var(--space-4);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-6);
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-section {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-title {
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Info grid */
|
||||||
|
.info-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, 1fr);
|
||||||
|
gap: var(--space-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-item {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-1);
|
||||||
|
padding: var(--space-3);
|
||||||
|
background-color: var(--color-surface);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-label {
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-value {
|
||||||
|
font-size: var(--text-lg);
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--color-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Stats list */
|
||||||
|
.stats-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
background-color: var(--color-surface);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-row {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: var(--space-3) var(--space-4);
|
||||||
|
border-bottom: 1px solid var(--color-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-row:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-label {
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-value {
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--color-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Action grid */
|
||||||
|
.action-grid {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: var(--space-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-btn {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: var(--space-3) var(--space-4);
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--action-color);
|
||||||
|
background: none;
|
||||||
|
border: 1px solid var(--action-color);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background-color var(--transition-fast);
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-btn:hover:not(:disabled) {
|
||||||
|
background-color: color-mix(in srgb, var(--action-color) 10%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-btn:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* History */
|
||||||
|
.empty-history {
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
font-style: italic;
|
||||||
|
padding: var(--space-4);
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
background-color: var(--color-surface);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
overflow: hidden;
|
||||||
|
max-height: 300px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-item {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--space-3);
|
||||||
|
padding: var(--space-2) var(--space-4);
|
||||||
|
border-bottom: 1px solid var(--color-border);
|
||||||
|
align-items: baseline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-item:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-time {
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
white-space: nowrap;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-message {
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
color: var(--color-text);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
301
frontend/src/lib/components/PlayerSearch.svelte
Normal file
301
frontend/src/lib/components/PlayerSearch.svelte
Normal file
|
|
@ -0,0 +1,301 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import type { Player } from '$lib/stores/tournament.svelte';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Typeahead player search for tournament operations.
|
||||||
|
*
|
||||||
|
* Features:
|
||||||
|
* - Debounced 200ms search as TD types
|
||||||
|
* - Results: name, nickname, last tournament date
|
||||||
|
* - 48px rows for touch targets
|
||||||
|
* - "Add New Player" at bottom if no match
|
||||||
|
* - Recently active players when empty
|
||||||
|
*/
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
/** All players to search from. */
|
||||||
|
players: Player[];
|
||||||
|
/** Callback when a player is selected. */
|
||||||
|
onselect: (player: Player) => void;
|
||||||
|
/** Callback when "Add New" is tapped. */
|
||||||
|
onnewplayer?: () => void;
|
||||||
|
/** Optional filter: only show matching players. */
|
||||||
|
filter?: (player: Player) => boolean;
|
||||||
|
/** Placeholder text. */
|
||||||
|
placeholder?: string;
|
||||||
|
/** Auto-focus on mount. */
|
||||||
|
autofocus?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
let {
|
||||||
|
players,
|
||||||
|
onselect,
|
||||||
|
onnewplayer,
|
||||||
|
filter,
|
||||||
|
placeholder = 'Search players...',
|
||||||
|
autofocus = false
|
||||||
|
}: Props = $props();
|
||||||
|
|
||||||
|
let query = $state('');
|
||||||
|
let debouncedQuery = $state('');
|
||||||
|
let debounceTimer: ReturnType<typeof setTimeout> | undefined;
|
||||||
|
let isFocused = $state(false);
|
||||||
|
|
||||||
|
// Debounce the search query (200ms)
|
||||||
|
$effect(() => {
|
||||||
|
const q = query;
|
||||||
|
clearTimeout(debounceTimer);
|
||||||
|
debounceTimer = setTimeout(() => {
|
||||||
|
debouncedQuery = q;
|
||||||
|
}, 200);
|
||||||
|
return () => clearTimeout(debounceTimer);
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Filtered results based on debounced query. */
|
||||||
|
let results = $derived.by(() => {
|
||||||
|
let pool = filter ? players.filter(filter) : players;
|
||||||
|
|
||||||
|
if (!debouncedQuery.trim()) {
|
||||||
|
// Show recently active players (sorted by status: active first)
|
||||||
|
return pool
|
||||||
|
.filter((p) => p.status === 'active')
|
||||||
|
.slice(0, 8);
|
||||||
|
}
|
||||||
|
|
||||||
|
const q = debouncedQuery.toLowerCase();
|
||||||
|
return pool.filter((p) => {
|
||||||
|
return p.name.toLowerCase().includes(q);
|
||||||
|
}).slice(0, 20);
|
||||||
|
});
|
||||||
|
|
||||||
|
let showResults = $derived(isFocused && (results.length > 0 || debouncedQuery.trim().length > 0));
|
||||||
|
|
||||||
|
function selectPlayer(player: Player): void {
|
||||||
|
onselect(player);
|
||||||
|
query = '';
|
||||||
|
debouncedQuery = '';
|
||||||
|
isFocused = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleFocus(): void {
|
||||||
|
isFocused = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleBlur(): void {
|
||||||
|
// Delay to allow click on results
|
||||||
|
setTimeout(() => {
|
||||||
|
isFocused = false;
|
||||||
|
}, 200);
|
||||||
|
}
|
||||||
|
|
||||||
|
function statusBadgeClass(status: string): string {
|
||||||
|
switch (status) {
|
||||||
|
case 'active': return 'badge-active';
|
||||||
|
case 'eliminated': return 'badge-eliminated';
|
||||||
|
case 'registered': return 'badge-registered';
|
||||||
|
default: return '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="player-search">
|
||||||
|
<!-- svelte-ignore a11y_autofocus -->
|
||||||
|
<input
|
||||||
|
type="search"
|
||||||
|
class="search-input"
|
||||||
|
{placeholder}
|
||||||
|
bind:value={query}
|
||||||
|
onfocus={handleFocus}
|
||||||
|
onblur={handleBlur}
|
||||||
|
aria-label="Search players"
|
||||||
|
aria-autocomplete="list"
|
||||||
|
autofocus={autofocus ? true : undefined}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{#if showResults}
|
||||||
|
<div class="search-results" role="listbox" aria-label="Player search results">
|
||||||
|
{#if results.length === 0}
|
||||||
|
<div class="no-results">
|
||||||
|
No players found for "{debouncedQuery}"
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#each results as player (player.id)}
|
||||||
|
<button
|
||||||
|
class="result-row touch-target"
|
||||||
|
role="option"
|
||||||
|
aria-selected="false"
|
||||||
|
onclick={() => selectPlayer(player)}
|
||||||
|
>
|
||||||
|
<div class="result-info">
|
||||||
|
<span class="result-name">{player.name}</span>
|
||||||
|
<span class="result-meta">
|
||||||
|
{#if player.table_id}
|
||||||
|
Table {player.table_id} / Seat {player.seat}
|
||||||
|
{:else}
|
||||||
|
{player.status}
|
||||||
|
{/if}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<span class="status-badge {statusBadgeClass(player.status)}">
|
||||||
|
{player.status}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
|
||||||
|
{#if onnewplayer}
|
||||||
|
<button
|
||||||
|
class="result-row add-new touch-target"
|
||||||
|
onclick={onnewplayer}
|
||||||
|
>
|
||||||
|
<span class="add-new-icon">+</span>
|
||||||
|
<span class="add-new-label">Add New Player</span>
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.player-search {
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-input {
|
||||||
|
width: 100%;
|
||||||
|
padding: var(--space-3) var(--space-4);
|
||||||
|
font-size: var(--text-base);
|
||||||
|
color: var(--color-text);
|
||||||
|
background-color: var(--color-surface);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
outline: none;
|
||||||
|
min-height: var(--touch-target);
|
||||||
|
transition: border-color var(--transition-fast);
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-input:focus {
|
||||||
|
border-color: var(--color-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-input::placeholder {
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-results {
|
||||||
|
position: absolute;
|
||||||
|
top: 100%;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
z-index: 50;
|
||||||
|
background-color: var(--color-surface);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-top: none;
|
||||||
|
border-radius: 0 0 var(--radius-lg) var(--radius-lg);
|
||||||
|
box-shadow: var(--shadow-lg);
|
||||||
|
max-height: 400px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.no-results {
|
||||||
|
padding: var(--space-4);
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
text-align: center;
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
width: 100%;
|
||||||
|
padding: var(--space-3) var(--space-4);
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
border-bottom: 1px solid var(--color-border);
|
||||||
|
cursor: pointer;
|
||||||
|
text-align: left;
|
||||||
|
color: var(--color-text);
|
||||||
|
transition: background-color var(--transition-fast);
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-row:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-row:hover {
|
||||||
|
background-color: var(--color-surface-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-row:active {
|
||||||
|
background-color: var(--color-surface-active);
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-info {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-name {
|
||||||
|
font-size: var(--text-base);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-meta {
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-badge {
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
font-weight: 600;
|
||||||
|
padding: 2px var(--space-2);
|
||||||
|
border-radius: var(--radius-full);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-active {
|
||||||
|
color: var(--color-success);
|
||||||
|
background-color: color-mix(in srgb, var(--color-success) 15%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-eliminated {
|
||||||
|
color: var(--color-error);
|
||||||
|
background-color: color-mix(in srgb, var(--color-error) 15%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-registered {
|
||||||
|
color: var(--color-primary);
|
||||||
|
background-color: color-mix(in srgb, var(--color-primary) 15%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Add New row */
|
||||||
|
.add-new {
|
||||||
|
border-top: 2px solid var(--color-border);
|
||||||
|
gap: var(--space-3);
|
||||||
|
justify-content: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.add-new-icon {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
border-radius: var(--radius-full);
|
||||||
|
background-color: var(--color-primary);
|
||||||
|
color: var(--color-bg);
|
||||||
|
font-size: var(--text-lg);
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.add-new-label {
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--color-primary);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
258
frontend/src/lib/components/RebuyFlow.svelte
Normal file
258
frontend/src/lib/components/RebuyFlow.svelte
Normal file
|
|
@ -0,0 +1,258 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import { tournament } from '$lib/stores/tournament.svelte';
|
||||||
|
import type { Player } from '$lib/stores/tournament.svelte';
|
||||||
|
import { toast } from '$lib/stores/toast.svelte';
|
||||||
|
import { api } from '$lib/api';
|
||||||
|
import PlayerSearch from './PlayerSearch.svelte';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rebuy Flow — quick 2-tap flow.
|
||||||
|
*
|
||||||
|
* 1. Select player (only eligible: active, under chip threshold, within cutoff)
|
||||||
|
* 2. Confirm rebuy amount and chips
|
||||||
|
*
|
||||||
|
* Triggered from: FAB "Rebuy", player detail.
|
||||||
|
*/
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
/** Close callback. */
|
||||||
|
onclose: () => void;
|
||||||
|
/** Pre-selected player (from player detail). */
|
||||||
|
preselectedPlayer?: Player | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { onclose, preselectedPlayer = null }: Props = $props();
|
||||||
|
|
||||||
|
type Step = 'select' | 'confirm';
|
||||||
|
let step = $state<Step>('select');
|
||||||
|
let selectedPlayer = $state<Player | null>(null);
|
||||||
|
|
||||||
|
// Initialize from preselectedPlayer prop
|
||||||
|
$effect(() => {
|
||||||
|
if (preselectedPlayer) {
|
||||||
|
selectedPlayer = preselectedPlayer;
|
||||||
|
step = 'confirm';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
let submitting = $state(false);
|
||||||
|
|
||||||
|
// Rebuy amount (simplified — real amount comes from tournament config)
|
||||||
|
let rebuyAmount = 100;
|
||||||
|
let rebuyChips = 10000;
|
||||||
|
|
||||||
|
/** Only show active players eligible for rebuy. */
|
||||||
|
function isEligible(player: Player): boolean {
|
||||||
|
return player.status === 'active';
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSelect(player: Player): void {
|
||||||
|
selectedPlayer = player;
|
||||||
|
step = 'confirm';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function confirmRebuy(): Promise<void> {
|
||||||
|
if (!selectedPlayer) return;
|
||||||
|
submitting = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await api.post(`/tournaments/${tournament.id}/rebuy`, {
|
||||||
|
player_id: selectedPlayer.id
|
||||||
|
});
|
||||||
|
toast.success(`${selectedPlayer.name} rebought — +${rebuyChips.toLocaleString()} chips`);
|
||||||
|
onclose();
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(`Rebuy failed: ${err instanceof Error ? err.message : 'Unknown error'}`);
|
||||||
|
} finally {
|
||||||
|
submitting = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function goBack(): void {
|
||||||
|
if (step === 'confirm' && !preselectedPlayer) {
|
||||||
|
step = 'select';
|
||||||
|
selectedPlayer = null;
|
||||||
|
} else {
|
||||||
|
onclose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="rebuy-flow">
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="flow-header">
|
||||||
|
<button class="back-btn touch-target" onclick={goBack} aria-label="Go back">
|
||||||
|
<span aria-hidden="true">←</span>
|
||||||
|
</button>
|
||||||
|
<h2 class="flow-title">Rebuy</h2>
|
||||||
|
<button class="close-btn touch-target" onclick={onclose} aria-label="Close">
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flow-content">
|
||||||
|
{#if step === 'select'}
|
||||||
|
<h3 class="step-title">Select Player</h3>
|
||||||
|
<PlayerSearch
|
||||||
|
players={tournament.players}
|
||||||
|
onselect={handleSelect}
|
||||||
|
filter={isEligible}
|
||||||
|
placeholder="Search eligible players..."
|
||||||
|
autofocus={true}
|
||||||
|
/>
|
||||||
|
{:else if step === 'confirm'}
|
||||||
|
<div class="confirm-card">
|
||||||
|
<h3 class="confirm-name">{selectedPlayer?.name}</h3>
|
||||||
|
<div class="confirm-details">
|
||||||
|
<div class="confirm-row">
|
||||||
|
<span class="confirm-label">Current Chips</span>
|
||||||
|
<span class="confirm-value chips">{selectedPlayer?.chips.toLocaleString()}</span>
|
||||||
|
</div>
|
||||||
|
<div class="confirm-row">
|
||||||
|
<span class="confirm-label">Rebuy Amount</span>
|
||||||
|
<span class="confirm-value currency">{rebuyAmount.toLocaleString()}</span>
|
||||||
|
</div>
|
||||||
|
<div class="confirm-row">
|
||||||
|
<span class="confirm-label">Chips Added</span>
|
||||||
|
<span class="confirm-value chips">+{rebuyChips.toLocaleString()}</span>
|
||||||
|
</div>
|
||||||
|
<div class="confirm-row">
|
||||||
|
<span class="confirm-label">Rebuys So Far</span>
|
||||||
|
<span class="confirm-value number">{selectedPlayer?.rebuys ?? 0}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
class="btn-confirm touch-target"
|
||||||
|
onclick={confirmRebuy}
|
||||||
|
disabled={submitting}
|
||||||
|
>
|
||||||
|
{submitting ? 'Processing...' : 'Confirm Rebuy'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.rebuy-flow {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: 100%;
|
||||||
|
background-color: var(--color-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.flow-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: var(--space-3) var(--space-4);
|
||||||
|
border-bottom: 1px solid var(--color-border);
|
||||||
|
background-color: var(--color-bg-elevated);
|
||||||
|
}
|
||||||
|
|
||||||
|
.flow-title {
|
||||||
|
font-size: var(--text-lg);
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--color-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.back-btn, .close-btn {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: var(--color-text);
|
||||||
|
font-size: var(--text-xl);
|
||||||
|
cursor: pointer;
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
.back-btn:hover, .close-btn:hover {
|
||||||
|
background-color: var(--color-surface);
|
||||||
|
}
|
||||||
|
|
||||||
|
.flow-content {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: var(--space-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.step-title {
|
||||||
|
font-size: var(--text-lg);
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--color-text);
|
||||||
|
margin-bottom: var(--space-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Confirm card */
|
||||||
|
.confirm-card {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-4);
|
||||||
|
align-items: center;
|
||||||
|
padding-top: var(--space-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.confirm-name {
|
||||||
|
font-size: var(--text-2xl);
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--color-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.confirm-details {
|
||||||
|
width: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
background-color: var(--color-surface);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.confirm-row {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: var(--space-3) var(--space-4);
|
||||||
|
border-bottom: 1px solid var(--color-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.confirm-row:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.confirm-label {
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.confirm-value {
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--color-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-confirm {
|
||||||
|
width: 100%;
|
||||||
|
padding: var(--space-4) var(--space-6);
|
||||||
|
font-size: var(--text-lg);
|
||||||
|
font-weight: 700;
|
||||||
|
background-color: var(--color-primary);
|
||||||
|
color: var(--color-bg);
|
||||||
|
border: none;
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background-color var(--transition-fast), opacity var(--transition-fast);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-confirm:hover:not(:disabled) {
|
||||||
|
background-color: color-mix(in srgb, var(--color-primary) 85%, white);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-confirm:disabled {
|
||||||
|
opacity: 0.6;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
@ -10,7 +10,13 @@
|
||||||
import FAB from '$lib/components/FAB.svelte';
|
import FAB from '$lib/components/FAB.svelte';
|
||||||
import Toast from '$lib/components/Toast.svelte';
|
import Toast from '$lib/components/Toast.svelte';
|
||||||
import TournamentTabs from '$lib/components/TournamentTabs.svelte';
|
import TournamentTabs from '$lib/components/TournamentTabs.svelte';
|
||||||
|
import BuyInFlow from '$lib/components/BuyInFlow.svelte';
|
||||||
|
import BustOutFlow from '$lib/components/BustOutFlow.svelte';
|
||||||
|
import RebuyFlow from '$lib/components/RebuyFlow.svelte';
|
||||||
|
import AddOnFlow from '$lib/components/AddOnFlow.svelte';
|
||||||
import { toast } from '$lib/stores/toast.svelte';
|
import { toast } from '$lib/stores/toast.svelte';
|
||||||
|
import { tournament } from '$lib/stores/tournament.svelte';
|
||||||
|
import { api } from '$lib/api';
|
||||||
|
|
||||||
let { children } = $props();
|
let { children } = $props();
|
||||||
|
|
||||||
|
|
@ -24,28 +30,50 @@
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Flow overlay state
|
||||||
|
let showBuyIn = $state(false);
|
||||||
|
let showBustOut = $state(false);
|
||||||
|
let showRebuy = $state(false);
|
||||||
|
let showAddon = $state(false);
|
||||||
|
|
||||||
/** Handle FAB action dispatches. */
|
/** Handle FAB action dispatches. */
|
||||||
function handleFABAction(actionId: string): void {
|
function handleFABAction(actionId: string): void {
|
||||||
switch (actionId) {
|
switch (actionId) {
|
||||||
case 'bust':
|
case 'bust':
|
||||||
toast.info('Bust flow: coming in Plan N');
|
showBustOut = true;
|
||||||
break;
|
break;
|
||||||
case 'buyin':
|
case 'buyin':
|
||||||
toast.info('Buy-in flow: coming in Plan N');
|
showBuyIn = true;
|
||||||
break;
|
break;
|
||||||
case 'rebuy':
|
case 'rebuy':
|
||||||
toast.info('Rebuy flow: coming in Plan N');
|
showRebuy = true;
|
||||||
break;
|
break;
|
||||||
case 'addon':
|
case 'addon':
|
||||||
toast.info('Add-on flow: coming in Plan N');
|
showAddon = true;
|
||||||
break;
|
break;
|
||||||
case 'pause-resume':
|
case 'pause-resume':
|
||||||
toast.info('Pause/Resume: coming in Plan N');
|
togglePauseResume();
|
||||||
break;
|
break;
|
||||||
default:
|
default:
|
||||||
console.warn(`Unknown FAB action: ${actionId}`);
|
console.warn(`Unknown FAB action: ${actionId}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Toggle clock pause/resume. */
|
||||||
|
async function togglePauseResume(): Promise<void> {
|
||||||
|
if (!tournament.id || !tournament.clock) return;
|
||||||
|
try {
|
||||||
|
if (tournament.clock.is_paused) {
|
||||||
|
await api.post(`/tournaments/${tournament.id}/clock/resume`);
|
||||||
|
toast.success('Clock resumed');
|
||||||
|
} else {
|
||||||
|
await api.post(`/tournaments/${tournament.id}/clock/pause`);
|
||||||
|
toast.success('Clock paused');
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(`Clock action failed: ${err instanceof Error ? err.message : 'Unknown error'}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#if isLoginPage}
|
{#if isLoginPage}
|
||||||
|
|
@ -72,6 +100,31 @@
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
<!-- Flow overlays (triggered by FAB) -->
|
||||||
|
{#if showBuyIn}
|
||||||
|
<div class="flow-overlay">
|
||||||
|
<BuyInFlow onclose={() => { showBuyIn = false; }} />
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if showBustOut}
|
||||||
|
<div class="flow-overlay">
|
||||||
|
<BustOutFlow onclose={() => { showBustOut = false; }} />
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if showRebuy}
|
||||||
|
<div class="flow-overlay">
|
||||||
|
<RebuyFlow onclose={() => { showRebuy = false; }} />
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if showAddon}
|
||||||
|
<div class="flow-overlay">
|
||||||
|
<AddOnFlow onclose={() => { showAddon = false; }} />
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
<!-- Toast notifications always visible -->
|
<!-- Toast notifications always visible -->
|
||||||
<Toast />
|
<Toast />
|
||||||
|
|
||||||
|
|
@ -107,4 +160,11 @@
|
||||||
min-height: 100dvh;
|
min-height: 100dvh;
|
||||||
color: var(--color-text-muted);
|
color: var(--color-text-muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.flow-overlay {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 100;
|
||||||
|
background-color: var(--color-bg);
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
|
||||||
|
|
@ -1,51 +1,379 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { tournament } from '$lib/stores/tournament.svelte';
|
import { tournament } from '$lib/stores/tournament.svelte';
|
||||||
|
import type { Player } from '$lib/stores/tournament.svelte';
|
||||||
import DataTable from '$lib/components/DataTable.svelte';
|
import DataTable from '$lib/components/DataTable.svelte';
|
||||||
|
import PlayerDetail from '$lib/components/PlayerDetail.svelte';
|
||||||
|
import BuyInFlow from '$lib/components/BuyInFlow.svelte';
|
||||||
|
import BustOutFlow from '$lib/components/BustOutFlow.svelte';
|
||||||
|
import RebuyFlow from '$lib/components/RebuyFlow.svelte';
|
||||||
|
import AddOnFlow from '$lib/components/AddOnFlow.svelte';
|
||||||
|
|
||||||
const columns = [
|
/**
|
||||||
|
* Players page — Active/Busted/All tabs with search,
|
||||||
|
* buy-in button, player detail, and swipe actions.
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Tab state
|
||||||
|
type Tab = 'active' | 'busted' | 'all';
|
||||||
|
let activeTab = $state<Tab>('active');
|
||||||
|
|
||||||
|
// Overlay state
|
||||||
|
let showBuyIn = $state(false);
|
||||||
|
let showBustOut = $state(false);
|
||||||
|
let showRebuy = $state(false);
|
||||||
|
let showAddon = $state(false);
|
||||||
|
let selectedPlayer = $state<Player | null>(null);
|
||||||
|
let rebuyPlayer = $state<Player | null>(null);
|
||||||
|
let addonPlayer = $state<Player | null>(null);
|
||||||
|
|
||||||
|
// Columns per tab
|
||||||
|
const activeColumns = [
|
||||||
{ key: 'name', label: 'Name', sortable: true },
|
{ key: 'name', label: 'Name', sortable: true },
|
||||||
{ key: 'status', label: 'Status', sortable: true },
|
{
|
||||||
{ key: 'chips', label: 'Chips', sortable: true, align: 'right' as const, render: (p: Record<string, unknown>) => (p['chips'] as number).toLocaleString() },
|
key: 'table_seat', label: 'Table/Seat', sortable: true,
|
||||||
{ key: 'table_id', label: 'Table', hideMobile: true, sortable: true },
|
render: (p: Record<string, unknown>) => {
|
||||||
{ key: 'seat', label: 'Seat', hideMobile: true, sortable: true, align: 'center' as const },
|
const tableId = p['table_id'] as string | null;
|
||||||
{ key: 'rebuys', label: 'Rebuys', hideMobile: true, sortable: true, align: 'center' as const }
|
const seat = p['seat'] as number | null;
|
||||||
|
if (!tableId) return '-';
|
||||||
|
const table = tournament.tables.find((t) => t.id === tableId);
|
||||||
|
return `T${table?.number ?? '?'}/S${seat ?? '?'}`;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'chips', label: 'Chips', sortable: true, align: 'right' as const,
|
||||||
|
render: (p: Record<string, unknown>) => (p['chips'] as number).toLocaleString()
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'rebuys', label: 'Rebuys', hideMobile: true, sortable: true, align: 'center' as const
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'status', label: 'Status', sortable: true,
|
||||||
|
render: (p: Record<string, unknown>) => {
|
||||||
|
const s = p['status'] as string;
|
||||||
|
return s.charAt(0).toUpperCase() + s.slice(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const bustedColumns = [
|
||||||
|
{ key: 'name', label: 'Name', sortable: true },
|
||||||
|
{
|
||||||
|
key: 'finish_position', label: 'Position', sortable: true, align: 'center' as const,
|
||||||
|
render: (p: Record<string, unknown>) => {
|
||||||
|
const pos = p['finish_position'] as number | null;
|
||||||
|
return pos ? ordinal(pos) : '-';
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'eliminated_by', label: 'Hitman', hideMobile: true, sortable: true,
|
||||||
|
render: (p: Record<string, unknown>) => {
|
||||||
|
const hitmanId = p['eliminated_by'] as string | null;
|
||||||
|
if (!hitmanId) return '-';
|
||||||
|
const hitman = tournament.players.find((pl) => pl.id === hitmanId);
|
||||||
|
return hitman?.name ?? '-';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
const allColumns = [
|
||||||
|
{ key: 'name', label: 'Name', sortable: true },
|
||||||
|
{
|
||||||
|
key: 'status', label: 'Status', sortable: true,
|
||||||
|
render: (p: Record<string, unknown>) => {
|
||||||
|
const s = p['status'] as string;
|
||||||
|
return s.charAt(0).toUpperCase() + s.slice(1);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'chips_or_position', label: 'Chips/Pos', sortable: true, align: 'right' as const,
|
||||||
|
render: (p: Record<string, unknown>) => {
|
||||||
|
if (p['status'] === 'active') return (p['chips'] as number).toLocaleString();
|
||||||
|
const pos = p['finish_position'] as number | null;
|
||||||
|
return pos ? ordinal(pos) : '-';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
// Tab data
|
||||||
|
let tabData = $derived.by(() => {
|
||||||
|
switch (activeTab) {
|
||||||
|
case 'active':
|
||||||
|
return tournament.players.filter((p) => p.status === 'active');
|
||||||
|
case 'busted':
|
||||||
|
return tournament.players.filter((p) => p.status === 'eliminated');
|
||||||
|
case 'all':
|
||||||
|
return tournament.players;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
let tabColumns = $derived.by(() => {
|
||||||
|
switch (activeTab) {
|
||||||
|
case 'active': return activeColumns;
|
||||||
|
case 'busted': return bustedColumns;
|
||||||
|
case 'all': return allColumns;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
let emptyMessage = $derived.by(() => {
|
||||||
|
switch (activeTab) {
|
||||||
|
case 'active': return 'No active players';
|
||||||
|
case 'busted': return 'No eliminated players';
|
||||||
|
case 'all': return 'No players registered yet';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Swipe actions per tab
|
||||||
|
let swipeActions = $derived.by(() => {
|
||||||
|
if (activeTab === 'active') {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
id: 'bust',
|
||||||
|
label: 'Bust',
|
||||||
|
color: 'var(--color-error)',
|
||||||
|
handler: (_item: Record<string, unknown>) => {
|
||||||
|
showBustOut = true;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'rebuy',
|
||||||
|
label: 'Rebuy',
|
||||||
|
color: 'var(--color-primary)',
|
||||||
|
handler: (item: Record<string, unknown>) => {
|
||||||
|
const player = tournament.players.find((p) => p.id === item['id']);
|
||||||
|
if (player) {
|
||||||
|
rebuyPlayer = player;
|
||||||
|
showRebuy = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
];
|
||||||
|
}
|
||||||
|
if (activeTab === 'busted') {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
id: 'undo-bust',
|
||||||
|
label: 'Undo Bust',
|
||||||
|
color: 'var(--color-success)',
|
||||||
|
handler: (_item: Record<string, unknown>) => {
|
||||||
|
// Undo bust is handled in player detail for now
|
||||||
|
}
|
||||||
|
}
|
||||||
|
];
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
});
|
||||||
|
|
||||||
|
// Tab counts
|
||||||
|
let activeCt = $derived(tournament.players.filter((p) => p.status === 'active').length);
|
||||||
|
let bustedCt = $derived(tournament.players.filter((p) => p.status === 'eliminated').length);
|
||||||
|
let allCt = $derived(tournament.players.length);
|
||||||
|
|
||||||
|
function handleRowClick(item: Record<string, unknown>): void {
|
||||||
|
const player = tournament.players.find((p) => p.id === item['id']);
|
||||||
|
if (player) selectedPlayer = player;
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleRebuy(player: Player): void {
|
||||||
|
rebuyPlayer = player;
|
||||||
|
selectedPlayer = null;
|
||||||
|
showRebuy = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleAddon(player: Player): void {
|
||||||
|
addonPlayer = player;
|
||||||
|
selectedPlayer = null;
|
||||||
|
showAddon = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ordinal(n: number): string {
|
||||||
|
const s = ['th', 'st', 'nd', 'rd'];
|
||||||
|
const v = n % 100;
|
||||||
|
return n + (s[(v - 20) % 10] || s[v] || s[0]);
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<!-- Main players page -->
|
||||||
<div class="page-content">
|
<div class="page-content">
|
||||||
<h2>Players</h2>
|
<div class="page-header">
|
||||||
<p class="text-secondary">Registered players and chip counts.</p>
|
<h2>Players</h2>
|
||||||
|
<button class="buyin-btn touch-target" onclick={() => { showBuyIn = true; }}>
|
||||||
|
+ Buy In
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Tab bar -->
|
||||||
|
<div class="tab-bar" role="tablist" aria-label="Player filter tabs">
|
||||||
|
<button
|
||||||
|
class="tab touch-target"
|
||||||
|
class:active={activeTab === 'active'}
|
||||||
|
role="tab"
|
||||||
|
aria-selected={activeTab === 'active'}
|
||||||
|
onclick={() => { activeTab = 'active'; }}
|
||||||
|
>
|
||||||
|
Active <span class="tab-count">{activeCt}</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="tab touch-target"
|
||||||
|
class:active={activeTab === 'busted'}
|
||||||
|
role="tab"
|
||||||
|
aria-selected={activeTab === 'busted'}
|
||||||
|
onclick={() => { activeTab = 'busted'; }}
|
||||||
|
>
|
||||||
|
Busted <span class="tab-count">{bustedCt}</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="tab touch-target"
|
||||||
|
class:active={activeTab === 'all'}
|
||||||
|
role="tab"
|
||||||
|
aria-selected={activeTab === 'all'}
|
||||||
|
onclick={() => { activeTab = 'all'; }}
|
||||||
|
>
|
||||||
|
All <span class="tab-count">{allCt}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Data table -->
|
||||||
<DataTable
|
<DataTable
|
||||||
{columns}
|
columns={tabColumns}
|
||||||
data={tournament.players}
|
data={tabData as unknown as Record<string, unknown>[]}
|
||||||
sortable={true}
|
sortable={true}
|
||||||
searchable={true}
|
searchable={true}
|
||||||
loading={false}
|
loading={false}
|
||||||
emptyMessage="No players registered yet"
|
{emptyMessage}
|
||||||
rowKey={(item) => String(item['id'])}
|
rowKey={(item) => String(item['id'])}
|
||||||
swipeActions={[
|
onrowclick={handleRowClick}
|
||||||
{ id: 'bust', label: 'Bust', color: 'var(--color-error)', handler: () => {} },
|
{swipeActions}
|
||||||
{ id: 'rebuy', label: 'Rebuy', color: 'var(--color-primary)', handler: () => {} }
|
|
||||||
]}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Overlays -->
|
||||||
|
{#if showBuyIn}
|
||||||
|
<div class="flow-overlay">
|
||||||
|
<BuyInFlow onclose={() => { showBuyIn = false; }} />
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if showBustOut}
|
||||||
|
<div class="flow-overlay">
|
||||||
|
<BustOutFlow onclose={() => { showBustOut = false; }} />
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if showRebuy}
|
||||||
|
<div class="flow-overlay">
|
||||||
|
<RebuyFlow
|
||||||
|
onclose={() => { showRebuy = false; rebuyPlayer = null; }}
|
||||||
|
preselectedPlayer={rebuyPlayer}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if showAddon}
|
||||||
|
<div class="flow-overlay">
|
||||||
|
<AddOnFlow
|
||||||
|
onclose={() => { showAddon = false; addonPlayer = null; }}
|
||||||
|
preselectedPlayer={addonPlayer}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if selectedPlayer}
|
||||||
|
<div class="flow-overlay">
|
||||||
|
<PlayerDetail
|
||||||
|
player={selectedPlayer}
|
||||||
|
onclose={() => { selectedPlayer = null; }}
|
||||||
|
onrebuy={handleRebuy}
|
||||||
|
onaddon={handleAddon}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
.page-content {
|
.page-content {
|
||||||
padding: var(--space-4);
|
padding: var(--space-4);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.page-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: var(--space-4);
|
||||||
|
}
|
||||||
|
|
||||||
h2 {
|
h2 {
|
||||||
font-size: var(--text-2xl);
|
font-size: var(--text-2xl);
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
color: var(--color-text);
|
color: var(--color-text);
|
||||||
margin-bottom: var(--space-2);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.text-secondary {
|
.buyin-btn {
|
||||||
color: var(--color-text-secondary);
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: var(--space-2) var(--space-4);
|
||||||
font-size: var(--text-sm);
|
font-size: var(--text-sm);
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--color-bg);
|
||||||
|
background-color: var(--color-success);
|
||||||
|
border: none;
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background-color var(--transition-fast);
|
||||||
|
}
|
||||||
|
|
||||||
|
.buyin-btn:hover {
|
||||||
|
background-color: color-mix(in srgb, var(--color-success) 85%, white);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Tab bar */
|
||||||
|
.tab-bar {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--space-1);
|
||||||
margin-bottom: var(--space-4);
|
margin-bottom: var(--space-4);
|
||||||
|
background-color: var(--color-surface);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
padding: var(--space-1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: var(--space-1);
|
||||||
|
padding: var(--space-2) var(--space-3);
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: color var(--transition-fast), background-color var(--transition-fast);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab:hover {
|
||||||
|
color: var(--color-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab.active {
|
||||||
|
color: var(--color-text);
|
||||||
|
background-color: var(--color-bg);
|
||||||
|
box-shadow: var(--shadow-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-count {
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
font-weight: 700;
|
||||||
|
color: inherit;
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Flow overlay */
|
||||||
|
.flow-overlay {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 100;
|
||||||
|
background-color: var(--color-bg);
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue