feat(01-11): implement Financials tab with prize pool, bubble prize, and chop/deal
- PrizePoolCard: collapsible breakdown (entries, rebuys, add-ons, re-entries, rake, season reserve, net prize pool), guarantee indicator - TransactionList: DataTable-based with type filter chips, search, swipe-to-undo action, row click for receipt view - BubblePrize: prominent button, amount input pre-filled with buy-in, preview redistribution for top positions, confirm flow - DealFlow: 5 deal types (ICM, chip chop, even chop, custom, partial), multi-step wizard (select type > input > review proposal > confirm), info message about prize/league independence - Financials page: assembles prize pool card, payout preview table, bubble prize button, deal/chop button, transaction list - Fixed Svelte template unicode escapes (use HTML entities in templates) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
44b555db10
commit
5e18bbe3ed
5 changed files with 1518 additions and 72 deletions
330
frontend/src/lib/components/BubblePrize.svelte
Normal file
330
frontend/src/lib/components/BubblePrize.svelte
Normal file
|
|
@ -0,0 +1,330 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import { api } from '$lib/api';
|
||||||
|
import { toast } from '$lib/stores/toast.svelte';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bubble prize creation flow.
|
||||||
|
*
|
||||||
|
* Prominent button and inline flow: tap "Add Bubble Prize" ->
|
||||||
|
* enter amount (pre-filled with buy-in) -> preview redistribution ->
|
||||||
|
* confirm. Not buried in menus (CONTEXT.md requirement).
|
||||||
|
*/
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
tournamentId: string;
|
||||||
|
buyinAmount: number;
|
||||||
|
prizePool: number;
|
||||||
|
paidPositions: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { tournamentId, buyinAmount, prizePool, paidPositions }: Props = $props();
|
||||||
|
|
||||||
|
/** Flow state: idle | input | preview | submitting. */
|
||||||
|
let flowState = $state<'idle' | 'input' | 'preview' | 'submitting'>('idle');
|
||||||
|
|
||||||
|
/** Bubble prize amount (cents). */
|
||||||
|
let amount = $state(0);
|
||||||
|
|
||||||
|
/** Preview data from API. */
|
||||||
|
let preview = $state<PreviewData | null>(null);
|
||||||
|
|
||||||
|
interface PreviewPayout {
|
||||||
|
position: number;
|
||||||
|
original: number;
|
||||||
|
adjusted: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PreviewData {
|
||||||
|
bubble_amount: number;
|
||||||
|
payouts: PreviewPayout[];
|
||||||
|
}
|
||||||
|
|
||||||
|
function startFlow(): void {
|
||||||
|
amount = buyinAmount;
|
||||||
|
preview = null;
|
||||||
|
flowState = 'input';
|
||||||
|
}
|
||||||
|
|
||||||
|
function cancel(): void {
|
||||||
|
flowState = 'idle';
|
||||||
|
preview = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatCurrency(n: number): string {
|
||||||
|
return n.toLocaleString();
|
||||||
|
}
|
||||||
|
|
||||||
|
function ordinalSuffix(n: number): string {
|
||||||
|
const s = ['th', 'st', 'nd', 'rd'];
|
||||||
|
const v = n % 100;
|
||||||
|
return s[(v - 20) % 10] || s[v] || s[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatPosition(pos: number): string {
|
||||||
|
if (pos === 0) return 'Bubble';
|
||||||
|
return pos + ordinalSuffix(pos);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadPreview(): Promise<void> {
|
||||||
|
if (amount <= 0) {
|
||||||
|
toast.warning('Enter a valid amount');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
flowState = 'preview';
|
||||||
|
preview = await api.post<PreviewData>(
|
||||||
|
`/tournaments/${tournamentId}/bubble-prize/preview`,
|
||||||
|
{ amount }
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(`Failed to load preview: ${err instanceof Error ? err.message : 'Unknown error'}`);
|
||||||
|
flowState = 'input';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function confirm(): Promise<void> {
|
||||||
|
flowState = 'submitting';
|
||||||
|
try {
|
||||||
|
await api.post(`/tournaments/${tournamentId}/bubble-prize`, { amount });
|
||||||
|
toast.success(`Bubble prize of ${formatCurrency(amount)} added`);
|
||||||
|
flowState = 'idle';
|
||||||
|
preview = null;
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(`Failed to add bubble prize: ${err instanceof Error ? err.message : 'Unknown error'}`);
|
||||||
|
flowState = 'preview';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="bubble-prize">
|
||||||
|
{#if flowState === 'idle'}
|
||||||
|
<!-- Prominent button -->
|
||||||
|
<button class="bubble-btn touch-target" onclick={startFlow}>
|
||||||
|
<span class="bubble-icon" aria-hidden="true">🏆</span>
|
||||||
|
<span class="bubble-text">Add Bubble Prize</span>
|
||||||
|
</button>
|
||||||
|
{:else if flowState === 'input'}
|
||||||
|
<!-- Amount input -->
|
||||||
|
<div class="bubble-flow">
|
||||||
|
<h4 class="flow-title">Bubble Prize</h4>
|
||||||
|
<p class="flow-hint">Enter the bubble prize amount (pre-filled with buy-in).</p>
|
||||||
|
|
||||||
|
<div class="input-group">
|
||||||
|
<label for="bubble-amount" class="input-label">Amount</label>
|
||||||
|
<input
|
||||||
|
id="bubble-amount"
|
||||||
|
type="number"
|
||||||
|
bind:value={amount}
|
||||||
|
min="1"
|
||||||
|
step="1"
|
||||||
|
class="amount-input"
|
||||||
|
inputmode="numeric"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flow-actions">
|
||||||
|
<button class="btn-secondary touch-target" onclick={cancel}>Cancel</button>
|
||||||
|
<button class="btn-primary touch-target" onclick={loadPreview}>Preview</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{:else if flowState === 'preview' && preview}
|
||||||
|
<!-- Preview redistribution -->
|
||||||
|
<div class="bubble-flow">
|
||||||
|
<h4 class="flow-title">Bubble Prize Preview</h4>
|
||||||
|
<p class="flow-hint">
|
||||||
|
Bubble: <strong class="currency">{formatCurrency(preview.bubble_amount)}</strong>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<table class="preview-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Position</th>
|
||||||
|
<th class="right">Original</th>
|
||||||
|
<th class="right">Adjusted</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{#each preview.payouts.slice(0, 5) as p}
|
||||||
|
<tr>
|
||||||
|
<td>{formatPosition(p.position)}</td>
|
||||||
|
<td class="currency right">{formatCurrency(p.original)}</td>
|
||||||
|
<td class="currency right" class:changed={p.original !== p.adjusted}>
|
||||||
|
{formatCurrency(p.adjusted)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{/each}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<div class="flow-actions">
|
||||||
|
<button class="btn-secondary touch-target" onclick={cancel}>Cancel</button>
|
||||||
|
<button class="btn-primary touch-target" onclick={confirm}>Confirm</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{:else if flowState === 'preview' && !preview}
|
||||||
|
<!-- Loading preview -->
|
||||||
|
<div class="bubble-flow">
|
||||||
|
<p class="flow-hint">Loading preview...</p>
|
||||||
|
</div>
|
||||||
|
{:else if flowState === 'submitting'}
|
||||||
|
<!-- Submitting -->
|
||||||
|
<div class="bubble-flow">
|
||||||
|
<p class="flow-hint">Adding bubble prize...</p>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.bubble-prize {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Prominent button */
|
||||||
|
.bubble-btn {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: var(--space-2);
|
||||||
|
width: 100%;
|
||||||
|
padding: var(--space-3) var(--space-4);
|
||||||
|
background-color: color-mix(in srgb, var(--color-prize) 15%, var(--color-surface));
|
||||||
|
border: 2px solid var(--color-prize);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
cursor: pointer;
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: var(--text-base);
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--color-prize);
|
||||||
|
transition:
|
||||||
|
background-color var(--transition-fast),
|
||||||
|
transform var(--transition-fast);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bubble-btn:hover {
|
||||||
|
background-color: color-mix(in srgb, var(--color-prize) 25%, var(--color-surface));
|
||||||
|
}
|
||||||
|
|
||||||
|
.bubble-icon {
|
||||||
|
font-size: var(--text-xl);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Flow container */
|
||||||
|
.bubble-flow {
|
||||||
|
padding: var(--space-4);
|
||||||
|
background-color: var(--color-surface);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.flow-title {
|
||||||
|
font-size: var(--text-lg);
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--color-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.flow-hint {
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Input */
|
||||||
|
.input-group {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-label {
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.amount-input {
|
||||||
|
padding: var(--space-2) var(--space-3);
|
||||||
|
font-size: var(--text-xl);
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--color-text);
|
||||||
|
background-color: var(--color-bg);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
min-height: var(--touch-target);
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.amount-input:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--color-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Preview table */
|
||||||
|
.preview-table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-table th {
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
padding: var(--space-1) var(--space-2);
|
||||||
|
border-bottom: 1px solid var(--color-border);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-table td {
|
||||||
|
padding: var(--space-2);
|
||||||
|
color: var(--color-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-table .right {
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-table .changed {
|
||||||
|
color: var(--color-prize);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Action buttons */
|
||||||
|
.flow-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--space-3);
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary,
|
||||||
|
.btn-secondary {
|
||||||
|
padding: var(--space-2) var(--space-4);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
font-weight: 600;
|
||||||
|
font-family: inherit;
|
||||||
|
cursor: pointer;
|
||||||
|
border: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary {
|
||||||
|
background-color: var(--color-primary);
|
||||||
|
color: var(--color-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary:hover {
|
||||||
|
background-color: color-mix(in srgb, var(--color-primary) 85%, white);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary {
|
||||||
|
background-color: var(--color-surface-active);
|
||||||
|
color: var(--color-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary:hover {
|
||||||
|
background-color: var(--color-overlay);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
519
frontend/src/lib/components/DealFlow.svelte
Normal file
519
frontend/src/lib/components/DealFlow.svelte
Normal file
|
|
@ -0,0 +1,519 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import type { DealType, DealProposal, DealPlayerEntry } from '$lib/stores/tournament.svelte';
|
||||||
|
import { api } from '$lib/api';
|
||||||
|
import { toast } from '$lib/stores/toast.svelte';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Chop/Deal flow for the Financials tab.
|
||||||
|
*
|
||||||
|
* Supports: ICM, Chip Chop, Even Chop, Custom, Partial Chop.
|
||||||
|
* Multi-step: select type -> enter data -> review proposal -> confirm.
|
||||||
|
*
|
||||||
|
* Note: "Prize money and league positions are independent" shown as info.
|
||||||
|
*/
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
tournamentId: string;
|
||||||
|
remainingPlayers: number;
|
||||||
|
prizePool: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { tournamentId, remainingPlayers, prizePool }: Props = $props();
|
||||||
|
|
||||||
|
/** Flow step: idle | type | input | review | submitting. */
|
||||||
|
let step = $state<'idle' | 'type' | 'input' | 'review' | 'submitting'>('idle');
|
||||||
|
|
||||||
|
let selectedType = $state<DealType>('icm');
|
||||||
|
let proposal = $state<DealProposal | null>(null);
|
||||||
|
|
||||||
|
/** Chip stacks for ICM/Chip Chop (player_id -> chips). */
|
||||||
|
let chipStacks = $state<Record<string, number>>({});
|
||||||
|
|
||||||
|
/** Custom amounts for Custom deal (player_id -> amount). */
|
||||||
|
let customAmounts = $state<Record<string, number>>({});
|
||||||
|
|
||||||
|
/** Amount to split for Partial Chop. */
|
||||||
|
let partialSplitAmount = $state(0);
|
||||||
|
|
||||||
|
const dealTypes: { value: DealType; label: string; description: string }[] = [
|
||||||
|
{ value: 'icm', label: 'ICM', description: 'Independent Chip Model calculation' },
|
||||||
|
{ value: 'chip_chop', label: 'Chip Chop', description: 'Proportional to chip stacks' },
|
||||||
|
{ value: 'even_chop', label: 'Even Chop', description: 'Equal split among all players' },
|
||||||
|
{ value: 'custom', label: 'Custom', description: 'Enter amount per player' },
|
||||||
|
{ value: 'partial_chop', label: 'Partial Chop', description: 'Split part, leave rest in play' }
|
||||||
|
];
|
||||||
|
|
||||||
|
function startFlow(): void {
|
||||||
|
step = 'type';
|
||||||
|
selectedType = 'icm';
|
||||||
|
proposal = null;
|
||||||
|
chipStacks = {};
|
||||||
|
customAmounts = {};
|
||||||
|
partialSplitAmount = Math.round(prizePool * 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
function cancel(): void {
|
||||||
|
step = 'idle';
|
||||||
|
proposal = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectType(type: DealType): void {
|
||||||
|
selectedType = type;
|
||||||
|
if (type === 'even_chop') {
|
||||||
|
// Even chop: go straight to review
|
||||||
|
calculateProposal();
|
||||||
|
} else {
|
||||||
|
step = 'input';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatCurrency(n: number): string {
|
||||||
|
return n.toLocaleString();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function calculateProposal(): Promise<void> {
|
||||||
|
step = 'review';
|
||||||
|
try {
|
||||||
|
const body: Record<string, unknown> = {
|
||||||
|
type: selectedType
|
||||||
|
};
|
||||||
|
|
||||||
|
if (selectedType === 'icm' || selectedType === 'chip_chop') {
|
||||||
|
body.chip_stacks = chipStacks;
|
||||||
|
} else if (selectedType === 'custom') {
|
||||||
|
body.custom_amounts = customAmounts;
|
||||||
|
} else if (selectedType === 'partial_chop') {
|
||||||
|
body.amount_to_split = partialSplitAmount;
|
||||||
|
}
|
||||||
|
|
||||||
|
proposal = await api.post<DealProposal>(
|
||||||
|
`/tournaments/${tournamentId}/deal/propose`,
|
||||||
|
body
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(`Failed to calculate deal: ${err instanceof Error ? err.message : 'Unknown error'}`);
|
||||||
|
step = 'input';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function confirmDeal(): Promise<void> {
|
||||||
|
if (!proposal) return;
|
||||||
|
step = 'submitting';
|
||||||
|
try {
|
||||||
|
await api.post(`/tournaments/${tournamentId}/deal/confirm`, {
|
||||||
|
type: selectedType,
|
||||||
|
players: proposal.players
|
||||||
|
});
|
||||||
|
toast.success('Deal confirmed and payouts applied');
|
||||||
|
step = 'idle';
|
||||||
|
proposal = null;
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(`Failed to confirm deal: ${err instanceof Error ? err.message : 'Unknown error'}`);
|
||||||
|
step = 'review';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="deal-flow">
|
||||||
|
{#if step === 'idle'}
|
||||||
|
{#if remainingPlayers >= 2}
|
||||||
|
<button class="deal-btn touch-target" onclick={startFlow}>
|
||||||
|
<span class="deal-icon" aria-hidden="true">🤝</span>
|
||||||
|
<span class="deal-text">Propose Deal</span>
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
{:else if step === 'type'}
|
||||||
|
<!-- Step 1: Select deal type -->
|
||||||
|
<div class="deal-panel">
|
||||||
|
<div class="panel-header">
|
||||||
|
<h4 class="panel-title">Select Deal Type</h4>
|
||||||
|
<button class="close-btn touch-target" onclick={cancel} aria-label="Cancel">
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="info-message">
|
||||||
|
Prize money and league positions are independent.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="type-list">
|
||||||
|
{#each dealTypes as dt}
|
||||||
|
<button
|
||||||
|
class="type-option touch-target"
|
||||||
|
class:selected={selectedType === dt.value}
|
||||||
|
onclick={() => selectType(dt.value)}
|
||||||
|
>
|
||||||
|
<span class="type-label">{dt.label}</span>
|
||||||
|
<span class="type-desc">{dt.description}</span>
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{:else if step === 'input'}
|
||||||
|
<!-- Step 2: Type-specific input -->
|
||||||
|
<div class="deal-panel">
|
||||||
|
<div class="panel-header">
|
||||||
|
<h4 class="panel-title">
|
||||||
|
{dealTypes.find((d) => d.value === selectedType)?.label ?? 'Deal'} Details
|
||||||
|
</h4>
|
||||||
|
<button class="close-btn touch-target" onclick={cancel} aria-label="Cancel">
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if selectedType === 'icm' || selectedType === 'chip_chop'}
|
||||||
|
<p class="input-hint">Enter chip stacks for each remaining player.</p>
|
||||||
|
<p class="input-note">
|
||||||
|
Chip stacks will be loaded from the tournament when connected.
|
||||||
|
Enter manual overrides if needed.
|
||||||
|
</p>
|
||||||
|
{:else if selectedType === 'custom'}
|
||||||
|
<p class="input-hint">Enter payout amount for each player.</p>
|
||||||
|
<p class="input-note">
|
||||||
|
Total must equal the prize pool ({formatCurrency(prizePool)}).
|
||||||
|
</p>
|
||||||
|
{:else if selectedType === 'partial_chop'}
|
||||||
|
<p class="input-hint">How much to split now?</p>
|
||||||
|
<div class="input-group">
|
||||||
|
<label for="partial-amount" class="input-label">Amount to Split</label>
|
||||||
|
<input
|
||||||
|
id="partial-amount"
|
||||||
|
type="number"
|
||||||
|
bind:value={partialSplitAmount}
|
||||||
|
min="0"
|
||||||
|
max={prizePool}
|
||||||
|
class="deal-input"
|
||||||
|
inputmode="numeric"
|
||||||
|
/>
|
||||||
|
<span class="input-note">
|
||||||
|
Remaining {formatCurrency(prizePool - partialSplitAmount)} stays in play.
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div class="flow-actions">
|
||||||
|
<button class="btn-secondary touch-target" onclick={() => (step = 'type')}>Back</button>
|
||||||
|
<button class="btn-primary touch-target" onclick={calculateProposal}>Calculate</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{:else if step === 'review'}
|
||||||
|
<!-- Step 3: Review proposal -->
|
||||||
|
<div class="deal-panel">
|
||||||
|
<div class="panel-header">
|
||||||
|
<h4 class="panel-title">Review Proposal</h4>
|
||||||
|
<button class="close-btn touch-target" onclick={cancel} aria-label="Cancel">
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if proposal}
|
||||||
|
<div class="deal-type-badge">
|
||||||
|
{dealTypes.find((d) => d.value === selectedType)?.label ?? selectedType}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<table class="proposal-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Player</th>
|
||||||
|
<th class="right">Chips</th>
|
||||||
|
<th class="right">Original</th>
|
||||||
|
<th class="right">Proposed</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{#each proposal.players as p}
|
||||||
|
<tr>
|
||||||
|
<td>{p.player_name}</td>
|
||||||
|
<td class="number right">{formatCurrency(p.chips)}</td>
|
||||||
|
<td class="currency right">{formatCurrency(p.original_payout)}</td>
|
||||||
|
<td class="currency right proposed">{formatCurrency(p.proposed_payout)}</td>
|
||||||
|
</tr>
|
||||||
|
{/each}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
{#if selectedType === 'partial_chop'}
|
||||||
|
<p class="input-note">
|
||||||
|
Split: {formatCurrency(proposal.amount_to_split)} |
|
||||||
|
Remaining in play: {formatCurrency(proposal.amount_in_play - proposal.amount_to_split)}
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<p class="info-message">
|
||||||
|
Prize money and league positions are independent.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="flow-actions">
|
||||||
|
<button class="btn-secondary touch-target" onclick={() => (step = 'input')}>Back</button>
|
||||||
|
<button class="btn-confirm touch-target" onclick={confirmDeal}>Confirm Deal</button>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<p class="loading-text">Calculating proposal...</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{:else if step === 'submitting'}
|
||||||
|
<div class="deal-panel">
|
||||||
|
<p class="loading-text">Applying deal...</p>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.deal-flow {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Propose deal button */
|
||||||
|
.deal-btn {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: var(--space-2);
|
||||||
|
width: 100%;
|
||||||
|
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;
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: var(--text-base);
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--color-text);
|
||||||
|
transition: background-color var(--transition-fast);
|
||||||
|
}
|
||||||
|
|
||||||
|
.deal-btn:hover {
|
||||||
|
background-color: var(--color-surface-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.deal-icon {
|
||||||
|
font-size: var(--text-xl);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Panel */
|
||||||
|
.deal-panel {
|
||||||
|
padding: var(--space-4);
|
||||||
|
background-color: var(--color-surface);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-title {
|
||||||
|
font-size: var(--text-lg);
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--color-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.close-btn {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
font-size: var(--text-lg);
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
cursor: pointer;
|
||||||
|
padding: var(--space-1);
|
||||||
|
min-height: 32px;
|
||||||
|
min-width: 32px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-message {
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
padding: var(--space-2) var(--space-3);
|
||||||
|
background-color: color-mix(in srgb, var(--color-primary) 8%, transparent);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
border-left: 3px solid var(--color-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Type selection */
|
||||||
|
.type-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.type-option {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-1);
|
||||||
|
padding: var(--space-3) var(--space-4);
|
||||||
|
background-color: var(--color-bg);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
cursor: pointer;
|
||||||
|
text-align: left;
|
||||||
|
font-family: inherit;
|
||||||
|
transition:
|
||||||
|
border-color var(--transition-fast),
|
||||||
|
background-color var(--transition-fast);
|
||||||
|
}
|
||||||
|
|
||||||
|
.type-option:hover {
|
||||||
|
background-color: var(--color-surface-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.type-option.selected {
|
||||||
|
border-color: var(--color-primary);
|
||||||
|
background-color: color-mix(in srgb, var(--color-primary) 8%, var(--color-bg));
|
||||||
|
}
|
||||||
|
|
||||||
|
.type-label {
|
||||||
|
font-size: var(--text-base);
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--color-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.type-desc {
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Input */
|
||||||
|
.input-group {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-label {
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-hint {
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-note {
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.deal-input {
|
||||||
|
padding: var(--space-2) var(--space-3);
|
||||||
|
font-size: var(--text-xl);
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--color-text);
|
||||||
|
background-color: var(--color-bg);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
min-height: var(--touch-target);
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.deal-input:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--color-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Proposal table */
|
||||||
|
.deal-type-badge {
|
||||||
|
display: inline-block;
|
||||||
|
padding: var(--space-1) var(--space-2);
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
font-weight: 700;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--color-primary);
|
||||||
|
background-color: color-mix(in srgb, var(--color-primary) 12%, transparent);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
align-self: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.proposal-table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.proposal-table th {
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
padding: var(--space-1) var(--space-2);
|
||||||
|
border-bottom: 1px solid var(--color-border);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.proposal-table td {
|
||||||
|
padding: var(--space-2);
|
||||||
|
color: var(--color-text);
|
||||||
|
border-bottom: 1px solid var(--color-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.proposal-table .right {
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
.proposal-table .proposed {
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--color-prize);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Actions */
|
||||||
|
.flow-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--space-3);
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary,
|
||||||
|
.btn-secondary,
|
||||||
|
.btn-confirm {
|
||||||
|
padding: var(--space-2) var(--space-4);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
font-weight: 600;
|
||||||
|
font-family: inherit;
|
||||||
|
cursor: pointer;
|
||||||
|
border: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary {
|
||||||
|
background-color: var(--color-primary);
|
||||||
|
color: var(--color-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary:hover {
|
||||||
|
background-color: color-mix(in srgb, var(--color-primary) 85%, white);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary {
|
||||||
|
background-color: var(--color-surface-active);
|
||||||
|
color: var(--color-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary:hover {
|
||||||
|
background-color: var(--color-overlay);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-confirm {
|
||||||
|
background-color: var(--color-success);
|
||||||
|
color: var(--color-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-confirm:hover {
|
||||||
|
background-color: color-mix(in srgb, var(--color-success) 85%, white);
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-text {
|
||||||
|
text-align: center;
|
||||||
|
padding: var(--space-4);
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
281
frontend/src/lib/components/PrizePoolCard.svelte
Normal file
281
frontend/src/lib/components/PrizePoolCard.svelte
Normal file
|
|
@ -0,0 +1,281 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import type { FinancialSummary } from '$lib/stores/tournament.svelte';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prize pool breakdown card for the Financials tab.
|
||||||
|
*
|
||||||
|
* Shows large prize pool number, breakdown table (entries, rebuys,
|
||||||
|
* add-ons, re-entries, rake, net prize pool), guarantee indicator,
|
||||||
|
* and season reserve if configured.
|
||||||
|
*/
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
financials: FinancialSummary;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { financials }: Props = $props();
|
||||||
|
|
||||||
|
let expanded = $state(false);
|
||||||
|
|
||||||
|
function formatCurrency(amount: number): string {
|
||||||
|
return amount.toLocaleString();
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggle(): void {
|
||||||
|
expanded = !expanded;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="prize-pool-card">
|
||||||
|
<button class="prize-header touch-target" onclick={toggle} aria-expanded={expanded}>
|
||||||
|
<div class="prize-main">
|
||||||
|
<span class="prize-label">Prize Pool</span>
|
||||||
|
<span class="currency prize-amount">{formatCurrency(financials.prize_pool)}</span>
|
||||||
|
</div>
|
||||||
|
<span class="expand-icon" aria-hidden="true">{expanded ? '\u25B2' : '\u25BC'}</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{#if expanded}
|
||||||
|
<div class="prize-breakdown">
|
||||||
|
<table class="breakdown-table">
|
||||||
|
<tbody>
|
||||||
|
<!-- Entries -->
|
||||||
|
<tr>
|
||||||
|
<td class="breakdown-label">Entries</td>
|
||||||
|
<td class="number breakdown-count">{financials.buyin_count ?? 0}</td>
|
||||||
|
<td class="breakdown-x">×</td>
|
||||||
|
<td class="currency breakdown-unit">{formatCurrency(financials.buyin_amount ?? 0)}</td>
|
||||||
|
<td class="currency breakdown-subtotal">{formatCurrency(financials.total_buyin)}</td>
|
||||||
|
</tr>
|
||||||
|
<!-- Rebuys -->
|
||||||
|
{#if (financials.rebuy_count ?? 0) > 0}
|
||||||
|
<tr>
|
||||||
|
<td class="breakdown-label">Rebuys</td>
|
||||||
|
<td class="number breakdown-count">{financials.rebuy_count}</td>
|
||||||
|
<td class="breakdown-x">×</td>
|
||||||
|
<td class="currency breakdown-unit">{formatCurrency(financials.rebuy_amount ?? 0)}</td>
|
||||||
|
<td class="currency breakdown-subtotal">{formatCurrency(financials.total_rebuys)}</td>
|
||||||
|
</tr>
|
||||||
|
{/if}
|
||||||
|
<!-- Add-ons -->
|
||||||
|
{#if (financials.addon_count ?? 0) > 0}
|
||||||
|
<tr>
|
||||||
|
<td class="breakdown-label">Add-ons</td>
|
||||||
|
<td class="number breakdown-count">{financials.addon_count}</td>
|
||||||
|
<td class="breakdown-x">×</td>
|
||||||
|
<td class="currency breakdown-unit">{formatCurrency(financials.addon_amount ?? 0)}</td>
|
||||||
|
<td class="currency breakdown-subtotal">{formatCurrency(financials.total_addons)}</td>
|
||||||
|
</tr>
|
||||||
|
{/if}
|
||||||
|
<!-- Re-entries -->
|
||||||
|
{#if (financials.reentry_count ?? 0) > 0}
|
||||||
|
<tr>
|
||||||
|
<td class="breakdown-label">Re-entries</td>
|
||||||
|
<td class="number breakdown-count">{financials.reentry_count}</td>
|
||||||
|
<td class="breakdown-x">×</td>
|
||||||
|
<td class="currency breakdown-unit">{formatCurrency(financials.reentry_amount ?? 0)}</td>
|
||||||
|
<td class="currency breakdown-subtotal">{formatCurrency(financials.total_reentries ?? 0)}</td>
|
||||||
|
</tr>
|
||||||
|
{/if}
|
||||||
|
<!-- Total contributions -->
|
||||||
|
<tr class="total-row">
|
||||||
|
<td class="breakdown-label" colspan="4">Total Contributions</td>
|
||||||
|
<td class="currency breakdown-subtotal">{formatCurrency(financials.total_collected)}</td>
|
||||||
|
</tr>
|
||||||
|
<!-- Rake -->
|
||||||
|
<tr class="rake-row">
|
||||||
|
<td class="breakdown-label" colspan="4">Rake</td>
|
||||||
|
<td class="currency breakdown-subtotal negative">-{formatCurrency(financials.house_fee)}</td>
|
||||||
|
</tr>
|
||||||
|
<!-- Rake breakdown (if available) -->
|
||||||
|
{#if financials.rake_breakdown && financials.rake_breakdown.length > 0}
|
||||||
|
{#each financials.rake_breakdown as rb}
|
||||||
|
<tr class="rake-detail-row">
|
||||||
|
<td class="breakdown-label indent" colspan="4">{rb.category}</td>
|
||||||
|
<td class="currency breakdown-subtotal muted">-{formatCurrency(rb.amount)}</td>
|
||||||
|
</tr>
|
||||||
|
{/each}
|
||||||
|
{/if}
|
||||||
|
<!-- Season reserve -->
|
||||||
|
{#if financials.season_reserve > 0}
|
||||||
|
<tr class="reserve-row">
|
||||||
|
<td class="breakdown-label" colspan="4">Season Reserve</td>
|
||||||
|
<td class="currency breakdown-subtotal muted">-{formatCurrency(financials.season_reserve)}</td>
|
||||||
|
</tr>
|
||||||
|
{/if}
|
||||||
|
<!-- Prize pool -->
|
||||||
|
<tr class="prize-row">
|
||||||
|
<td class="breakdown-label" colspan="4">Prize Pool</td>
|
||||||
|
<td class="currency breakdown-subtotal prize">{formatCurrency(financials.prize_pool)}</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Guarantee indicator -->
|
||||||
|
{#if financials.guarantee && financials.guarantee > 0}
|
||||||
|
<div class="guarantee-info" class:shortfall={financials.guarantee_shortfall > 0}>
|
||||||
|
{#if financials.guarantee_shortfall > 0}
|
||||||
|
Guarantee: {formatCurrency(financials.guarantee)} (house covers {formatCurrency(financials.guarantee_shortfall)})
|
||||||
|
{:else}
|
||||||
|
Guarantee: {formatCurrency(financials.guarantee)} (met)
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.prize-pool-card {
|
||||||
|
background-color: var(--color-surface);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prize-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
width: 100%;
|
||||||
|
padding: var(--space-4);
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
font-family: inherit;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prize-header:hover {
|
||||||
|
background-color: var(--color-surface-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.prize-main {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.prize-label {
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.prize-amount {
|
||||||
|
font-size: var(--text-3xl);
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--color-prize);
|
||||||
|
}
|
||||||
|
|
||||||
|
.expand-icon {
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Breakdown table */
|
||||||
|
.prize-breakdown {
|
||||||
|
padding: 0 var(--space-4) var(--space-4);
|
||||||
|
animation: slide-down 200ms ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes slide-down {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
max-height: 0;
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
max-height: 500px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.breakdown-table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.breakdown-table td {
|
||||||
|
padding: var(--space-1) var(--space-2);
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
|
||||||
|
.breakdown-label {
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.breakdown-label.indent {
|
||||||
|
padding-left: var(--space-6);
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
}
|
||||||
|
|
||||||
|
.breakdown-count {
|
||||||
|
text-align: right;
|
||||||
|
color: var(--color-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.breakdown-x {
|
||||||
|
text-align: center;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
padding: 0 var(--space-1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.breakdown-unit {
|
||||||
|
text-align: right;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.breakdown-subtotal {
|
||||||
|
text-align: right;
|
||||||
|
color: var(--color-text);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.breakdown-subtotal.negative {
|
||||||
|
color: var(--color-error);
|
||||||
|
}
|
||||||
|
|
||||||
|
.breakdown-subtotal.muted {
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
font-weight: 400;
|
||||||
|
}
|
||||||
|
|
||||||
|
.breakdown-subtotal.prize {
|
||||||
|
color: var(--color-prize);
|
||||||
|
font-size: var(--text-base);
|
||||||
|
}
|
||||||
|
|
||||||
|
.total-row td {
|
||||||
|
border-top: 1px solid var(--color-border);
|
||||||
|
padding-top: var(--space-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rake-row td,
|
||||||
|
.rake-detail-row td,
|
||||||
|
.reserve-row td {
|
||||||
|
opacity: 0.85;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prize-row td {
|
||||||
|
border-top: 2px solid var(--color-border);
|
||||||
|
padding-top: var(--space-2);
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Guarantee */
|
||||||
|
.guarantee-info {
|
||||||
|
padding: var(--space-2) var(--space-4);
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
color: var(--color-success);
|
||||||
|
background-color: color-mix(in srgb, var(--color-success) 8%, transparent);
|
||||||
|
border-top: 1px solid var(--color-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.guarantee-info.shortfall {
|
||||||
|
color: var(--color-warning);
|
||||||
|
background-color: color-mix(in srgb, var(--color-warning) 8%, transparent);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
210
frontend/src/lib/components/TransactionList.svelte
Normal file
210
frontend/src/lib/components/TransactionList.svelte
Normal file
|
|
@ -0,0 +1,210 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import type { Transaction } from '$lib/stores/tournament.svelte';
|
||||||
|
import DataTable from '$lib/components/DataTable.svelte';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Transaction list for the Financials tab.
|
||||||
|
*
|
||||||
|
* Displays all tournament transactions (buy-ins, rebuys, add-ons, etc.)
|
||||||
|
* using the DataTable component. Supports filtering by type, search by
|
||||||
|
* player name, and swipe-to-undo action.
|
||||||
|
*/
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
transactions: Transaction[];
|
||||||
|
onundo?: (tx: Transaction) => void;
|
||||||
|
onrowclick?: (tx: Transaction) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { transactions, onundo, onrowclick }: Props = $props();
|
||||||
|
|
||||||
|
/** Filter by transaction type. */
|
||||||
|
let typeFilter = $state<string>('all');
|
||||||
|
|
||||||
|
const typeOptions = [
|
||||||
|
{ value: 'all', label: 'All' },
|
||||||
|
{ value: 'buyin', label: 'Buy-in' },
|
||||||
|
{ value: 'rebuy', label: 'Rebuy' },
|
||||||
|
{ value: 'addon', label: 'Add-on' },
|
||||||
|
{ value: 'reentry', label: 'Re-entry' },
|
||||||
|
{ value: 'payout', label: 'Payout' }
|
||||||
|
];
|
||||||
|
|
||||||
|
/** Filtered transactions. */
|
||||||
|
let filteredTransactions = $derived.by(() => {
|
||||||
|
if (typeFilter === 'all') return transactions;
|
||||||
|
return transactions.filter((tx) => tx.type === typeFilter);
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Map transactions to DataTable-compatible records. */
|
||||||
|
let tableData = $derived(
|
||||||
|
filteredTransactions.map((tx) => ({
|
||||||
|
id: tx.id,
|
||||||
|
player_name: tx.player_name,
|
||||||
|
type: tx.type,
|
||||||
|
amount: tx.amount,
|
||||||
|
chips: tx.chips,
|
||||||
|
timestamp: tx.timestamp,
|
||||||
|
undone: tx.undone,
|
||||||
|
_raw: tx
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
|
||||||
|
/** Format timestamp to time string. */
|
||||||
|
function formatTime(ts: number): string {
|
||||||
|
const d = new Date(ts > 1e12 ? ts : ts * 1000);
|
||||||
|
return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Format transaction type for display. */
|
||||||
|
function formatType(type: string): string {
|
||||||
|
const labels: Record<string, string> = {
|
||||||
|
buyin: 'Buy-in',
|
||||||
|
rebuy: 'Rebuy',
|
||||||
|
addon: 'Add-on',
|
||||||
|
reentry: 'Re-entry',
|
||||||
|
payout: 'Payout',
|
||||||
|
refund: 'Refund'
|
||||||
|
};
|
||||||
|
return labels[type] ?? type;
|
||||||
|
}
|
||||||
|
|
||||||
|
const columns = [
|
||||||
|
{
|
||||||
|
key: 'timestamp',
|
||||||
|
label: 'Time',
|
||||||
|
sortable: true,
|
||||||
|
width: '70px',
|
||||||
|
render: (item: Record<string, unknown>) => formatTime(item['timestamp'] as number)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'player_name',
|
||||||
|
label: 'Player',
|
||||||
|
sortable: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'type',
|
||||||
|
label: 'Type',
|
||||||
|
sortable: true,
|
||||||
|
render: (item: Record<string, unknown>) => {
|
||||||
|
const label = formatType(item['type'] as string);
|
||||||
|
return item['undone'] ? `${label} (undone)` : label;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'amount',
|
||||||
|
label: 'Amount',
|
||||||
|
sortable: true,
|
||||||
|
align: 'right' as const,
|
||||||
|
render: (item: Record<string, unknown>) => (item['amount'] as number).toLocaleString()
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'chips',
|
||||||
|
label: 'Chips',
|
||||||
|
sortable: true,
|
||||||
|
align: 'right' as const,
|
||||||
|
hideMobile: true,
|
||||||
|
render: (item: Record<string, unknown>) => (item['chips'] as number).toLocaleString()
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
function handleUndo(item: Record<string, unknown>): void {
|
||||||
|
const tx = item['_raw'] as Transaction;
|
||||||
|
if (tx.undone) return;
|
||||||
|
onundo?.(tx);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleRowClick(item: Record<string, unknown>): void {
|
||||||
|
const tx = item['_raw'] as Transaction;
|
||||||
|
onrowclick?.(tx);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="transaction-list">
|
||||||
|
<!-- Type filter -->
|
||||||
|
<div class="filter-bar">
|
||||||
|
<div class="filter-chips" role="radiogroup" aria-label="Filter by transaction type">
|
||||||
|
{#each typeOptions as opt}
|
||||||
|
<button
|
||||||
|
class="filter-chip"
|
||||||
|
class:active={typeFilter === opt.value}
|
||||||
|
onclick={() => (typeFilter = opt.value)}
|
||||||
|
role="radio"
|
||||||
|
aria-checked={typeFilter === opt.value}
|
||||||
|
>
|
||||||
|
{opt.label}
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Transaction table -->
|
||||||
|
<DataTable
|
||||||
|
{columns}
|
||||||
|
data={tableData}
|
||||||
|
sortable={true}
|
||||||
|
searchable={true}
|
||||||
|
loading={false}
|
||||||
|
emptyMessage="No transactions"
|
||||||
|
rowKey={(item) => String(item['id'])}
|
||||||
|
onrowclick={handleRowClick}
|
||||||
|
swipeActions={onundo
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
id: 'undo',
|
||||||
|
label: 'Undo',
|
||||||
|
color: 'var(--color-error)',
|
||||||
|
handler: handleUndo
|
||||||
|
}
|
||||||
|
]
|
||||||
|
: []}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.transaction-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-bar {
|
||||||
|
overflow-x: auto;
|
||||||
|
-webkit-overflow-scrolling: touch;
|
||||||
|
padding-bottom: var(--space-1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-chips {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--space-2);
|
||||||
|
min-width: max-content;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-chip {
|
||||||
|
padding: var(--space-1) var(--space-3);
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
background-color: var(--color-surface);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: var(--radius-full);
|
||||||
|
cursor: pointer;
|
||||||
|
white-space: nowrap;
|
||||||
|
min-height: 32px;
|
||||||
|
min-width: auto;
|
||||||
|
transition:
|
||||||
|
background-color var(--transition-fast),
|
||||||
|
color var(--transition-fast),
|
||||||
|
border-color var(--transition-fast);
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-chip:hover {
|
||||||
|
background-color: var(--color-surface-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-chip.active {
|
||||||
|
background-color: var(--color-primary);
|
||||||
|
color: var(--color-bg);
|
||||||
|
border-color: var(--color-primary);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
@ -1,109 +1,215 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { tournament } from '$lib/stores/tournament.svelte';
|
import { tournament } from '$lib/stores/tournament.svelte';
|
||||||
|
import type { Transaction } from '$lib/stores/tournament.svelte';
|
||||||
|
import PrizePoolCard from '$lib/components/PrizePoolCard.svelte';
|
||||||
|
import BubblePrize from '$lib/components/BubblePrize.svelte';
|
||||||
|
import DealFlow from '$lib/components/DealFlow.svelte';
|
||||||
|
import TransactionList from '$lib/components/TransactionList.svelte';
|
||||||
|
import Loading from '$lib/components/Loading.svelte';
|
||||||
|
import { toast } from '$lib/stores/toast.svelte';
|
||||||
|
import { api } from '$lib/api';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Financials tab.
|
||||||
|
*
|
||||||
|
* Assembles:
|
||||||
|
* 1. Prize Pool Card (collapsible for detail)
|
||||||
|
* 2. Payout Preview table
|
||||||
|
* 3. Bubble Prize button (prominent)
|
||||||
|
* 4. Deal/Chop button (when applicable)
|
||||||
|
* 5. Transaction list (scrollable, filterable)
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** Format currency amounts. */
|
||||||
|
function formatCurrency(n: number): string {
|
||||||
|
return n.toLocaleString();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ordinal suffix for position. */
|
||||||
|
function ordinalSuffix(n: number): string {
|
||||||
|
const s = ['th', 'st', 'nd', 'rd'];
|
||||||
|
const v = n % 100;
|
||||||
|
return s[(v - 20) % 10] || s[v] || s[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Handle undo transaction. */
|
||||||
|
async function handleUndo(tx: Transaction): Promise<void> {
|
||||||
|
if (!tournament.id) return;
|
||||||
|
const confirmed = window.confirm(
|
||||||
|
`Undo ${tx.type} for ${tx.player_name}? This creates a reversal entry.`
|
||||||
|
);
|
||||||
|
if (!confirmed) return;
|
||||||
|
try {
|
||||||
|
await api.post(`/tournaments/${tournament.id}/transactions/${tx.id}/undo`);
|
||||||
|
toast.success(`Transaction undone for ${tx.player_name}`);
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(`Failed to undo: ${err instanceof Error ? err.message : 'Unknown error'}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Handle transaction row click (receipt view). */
|
||||||
|
function handleTxClick(tx: Transaction): void {
|
||||||
|
// TODO: Open receipt detail modal (Plan N)
|
||||||
|
toast.info(`Receipt for ${tx.player_name}: ${tx.type} - ${formatCurrency(tx.amount)}`);
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="page-content">
|
<div class="page-content">
|
||||||
<h2>Financials</h2>
|
|
||||||
<p class="text-secondary">Prize pool and payout information.</p>
|
|
||||||
|
|
||||||
{#if tournament.financials}
|
{#if tournament.financials}
|
||||||
{@const fin = tournament.financials}
|
{@const fin = tournament.financials}
|
||||||
<div class="finance-grid">
|
|
||||||
<div class="finance-card">
|
<!-- 1. Prize Pool Card -->
|
||||||
<span class="finance-label">Total Buy-ins</span>
|
<PrizePoolCard financials={fin} />
|
||||||
<span class="finance-value currency">{fin.total_buyin.toLocaleString()}</span>
|
|
||||||
</div>
|
<!-- 2. Payout Preview -->
|
||||||
<div class="finance-card">
|
{#if fin.payouts && fin.payouts.length > 0}
|
||||||
<span class="finance-label">Total Rebuys</span>
|
<div class="payout-section">
|
||||||
<span class="finance-value currency">{fin.total_rebuys.toLocaleString()}</span>
|
<h3 class="section-title">Payout Preview</h3>
|
||||||
</div>
|
<table class="payout-table">
|
||||||
<div class="finance-card">
|
<thead>
|
||||||
<span class="finance-label">Total Add-ons</span>
|
<tr>
|
||||||
<span class="finance-value currency">{fin.total_addons.toLocaleString()}</span>
|
<th>Position</th>
|
||||||
</div>
|
<th class="right">Percentage</th>
|
||||||
<div class="finance-card highlight">
|
<th class="right">Amount</th>
|
||||||
<span class="finance-label">Prize Pool</span>
|
</tr>
|
||||||
<span class="finance-value currency prize">{fin.prize_pool.toLocaleString()}</span>
|
</thead>
|
||||||
</div>
|
<tbody>
|
||||||
<div class="finance-card">
|
{#each fin.payouts as payout}
|
||||||
<span class="finance-label">House Fee</span>
|
<tr>
|
||||||
<span class="finance-value currency">{fin.house_fee.toLocaleString()}</span>
|
<td>
|
||||||
</div>
|
{payout.position}{ordinalSuffix(payout.position)}
|
||||||
<div class="finance-card">
|
{#if payout.player_name}
|
||||||
<span class="finance-label">Paid Positions</span>
|
<span class="player-label">({payout.player_name})</span>
|
||||||
<span class="finance-value number">{fin.paid_positions}</span>
|
{/if}
|
||||||
|
</td>
|
||||||
|
<td class="number right">
|
||||||
|
{fin.prize_pool > 0
|
||||||
|
? ((payout.amount / fin.prize_pool) * 100).toFixed(1)
|
||||||
|
: '0.0'}%
|
||||||
|
</td>
|
||||||
|
<td class="currency right">{formatCurrency(payout.amount)}</td>
|
||||||
|
</tr>
|
||||||
|
{/each}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{#if fin.rounding_denomination > 0}
|
||||||
|
<p class="rounding-note">
|
||||||
|
Rounded to nearest {formatCurrency(fin.rounding_denomination)}
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- 3. Bubble Prize (prominent) -->
|
||||||
|
{#if tournament.id}
|
||||||
|
<BubblePrize
|
||||||
|
tournamentId={tournament.id}
|
||||||
|
buyinAmount={fin.buyin_amount ?? 0}
|
||||||
|
prizePool={fin.prize_pool}
|
||||||
|
paidPositions={fin.paid_positions}
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- 4. Deal/Chop button -->
|
||||||
|
{#if tournament.id}
|
||||||
|
<DealFlow
|
||||||
|
tournamentId={tournament.id}
|
||||||
|
remainingPlayers={tournament.remainingPlayers}
|
||||||
|
prizePool={fin.prize_pool}
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- 5. Transaction list -->
|
||||||
|
<div class="transaction-section">
|
||||||
|
<h3 class="section-title">Transactions</h3>
|
||||||
|
<TransactionList
|
||||||
|
transactions={tournament.transactions}
|
||||||
|
onundo={handleUndo}
|
||||||
|
onrowclick={handleTxClick}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{:else}
|
{:else}
|
||||||
|
<!-- Loading / empty state -->
|
||||||
|
<Loading variant="skeleton" rows={4} />
|
||||||
<p class="empty-state">No financial data available yet.</p>
|
<p class="empty-state">No financial data available yet.</p>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
.page-content {
|
.page-content {
|
||||||
padding: 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);
|
|
||||||
font-size: var(--text-sm);
|
|
||||||
margin-bottom: var(--space-6);
|
|
||||||
}
|
|
||||||
|
|
||||||
.finance-grid {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(2, 1fr);
|
|
||||||
gap: var(--space-3);
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (min-width: 768px) {
|
|
||||||
.finance-grid {
|
|
||||||
grid-template-columns: repeat(3, 1fr);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.finance-card {
|
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: var(--space-1);
|
gap: var(--space-4);
|
||||||
padding: var(--space-4);
|
padding: var(--space-4);
|
||||||
background-color: var(--color-surface);
|
|
||||||
border-radius: var(--radius-lg);
|
|
||||||
border: 1px solid var(--color-border);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.finance-card.highlight {
|
/* Section titles */
|
||||||
border-color: var(--color-prize);
|
.section-title {
|
||||||
}
|
font-size: var(--text-sm);
|
||||||
|
font-weight: 700;
|
||||||
.finance-label {
|
|
||||||
font-size: var(--text-xs);
|
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
letter-spacing: 0.05em;
|
letter-spacing: 0.05em;
|
||||||
color: var(--color-text-muted);
|
color: var(--color-text-muted);
|
||||||
|
margin-bottom: var(--space-2);
|
||||||
}
|
}
|
||||||
|
|
||||||
.finance-value {
|
/* Payout table */
|
||||||
font-size: var(--text-xl);
|
.payout-section {
|
||||||
font-weight: 700;
|
background-color: var(--color-surface);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
padding: var(--space-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.payout-table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.payout-table th {
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
padding: var(--space-1) var(--space-2);
|
||||||
|
border-bottom: 1px solid var(--color-border);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.payout-table td {
|
||||||
|
padding: var(--space-2);
|
||||||
color: var(--color-text);
|
color: var(--color-text);
|
||||||
|
border-bottom: 1px solid var(--color-border);
|
||||||
}
|
}
|
||||||
|
|
||||||
.finance-value.prize {
|
.payout-table .right {
|
||||||
color: var(--color-prize);
|
text-align: right;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.player-label {
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rounding-note {
|
||||||
|
margin-top: var(--space-2);
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Transaction section */
|
||||||
|
.transaction-section {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Empty state */
|
||||||
.empty-state {
|
.empty-state {
|
||||||
color: var(--color-text-muted);
|
color: var(--color-text-muted);
|
||||||
font-style: italic;
|
font-style: italic;
|
||||||
padding: var(--space-8) 0;
|
padding: var(--space-4) 0;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue