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 Toast from '$lib/components/Toast.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 { tournament } from '$lib/stores/tournament.svelte';
|
||||
import { api } from '$lib/api';
|
||||
|
||||
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. */
|
||||
function handleFABAction(actionId: string): void {
|
||||
switch (actionId) {
|
||||
case 'bust':
|
||||
toast.info('Bust flow: coming in Plan N');
|
||||
showBustOut = true;
|
||||
break;
|
||||
case 'buyin':
|
||||
toast.info('Buy-in flow: coming in Plan N');
|
||||
showBuyIn = true;
|
||||
break;
|
||||
case 'rebuy':
|
||||
toast.info('Rebuy flow: coming in Plan N');
|
||||
showRebuy = true;
|
||||
break;
|
||||
case 'addon':
|
||||
toast.info('Add-on flow: coming in Plan N');
|
||||
showAddon = true;
|
||||
break;
|
||||
case 'pause-resume':
|
||||
toast.info('Pause/Resume: coming in Plan N');
|
||||
togglePauseResume();
|
||||
break;
|
||||
default:
|
||||
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>
|
||||
|
||||
{#if isLoginPage}
|
||||
|
|
@ -72,6 +100,31 @@
|
|||
</div>
|
||||
{/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 />
|
||||
|
||||
|
|
@ -107,4 +160,11 @@
|
|||
min-height: 100dvh;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.flow-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 100;
|
||||
background-color: var(--color-bg);
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -1,51 +1,379 @@
|
|||
<script lang="ts">
|
||||
import { tournament } from '$lib/stores/tournament.svelte';
|
||||
import type { Player } from '$lib/stores/tournament.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: '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_id', label: 'Table', hideMobile: true, sortable: true },
|
||||
{ key: 'seat', label: 'Seat', hideMobile: true, sortable: true, align: 'center' as const },
|
||||
{ key: 'rebuys', label: 'Rebuys', hideMobile: true, sortable: true, align: 'center' as const }
|
||||
{
|
||||
key: 'table_seat', label: 'Table/Seat', sortable: true,
|
||||
render: (p: Record<string, unknown>) => {
|
||||
const tableId = p['table_id'] as string | null;
|
||||
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>
|
||||
|
||||
<!-- Main players page -->
|
||||
<div class="page-content">
|
||||
<div class="page-header">
|
||||
<h2>Players</h2>
|
||||
<p class="text-secondary">Registered players and chip counts.</p>
|
||||
<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
|
||||
{columns}
|
||||
data={tournament.players}
|
||||
columns={tabColumns}
|
||||
data={tabData as unknown as Record<string, unknown>[]}
|
||||
sortable={true}
|
||||
searchable={true}
|
||||
loading={false}
|
||||
emptyMessage="No players registered yet"
|
||||
{emptyMessage}
|
||||
rowKey={(item) => String(item['id'])}
|
||||
swipeActions={[
|
||||
{ id: 'bust', label: 'Bust', color: 'var(--color-error)', handler: () => {} },
|
||||
{ id: 'rebuy', label: 'Rebuy', color: 'var(--color-primary)', handler: () => {} }
|
||||
]}
|
||||
onrowclick={handleRowClick}
|
||||
{swipeActions}
|
||||
/>
|
||||
</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>
|
||||
.page-content {
|
||||
padding: var(--space-4);
|
||||
}
|
||||
|
||||
.page-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: var(--space-4);
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-size: var(--text-2xl);
|
||||
font-weight: 700;
|
||||
color: var(--color-text);
|
||||
margin-bottom: var(--space-2);
|
||||
}
|
||||
|
||||
.text-secondary {
|
||||
color: var(--color-text-secondary);
|
||||
.buyin-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: var(--space-2) var(--space-4);
|
||||
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);
|
||||
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>
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue