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:
Mikkel Georgsen 2026-03-01 08:23:27 +01:00
parent 44b555db10
commit 5e18bbe3ed
5 changed files with 1518 additions and 72 deletions

View 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">&#x1F3C6;</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>

View 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">&#x1F91D;</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">
&#x2715;
</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">
&#x2715;
</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">
&#x2715;
</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>

View 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">&times;</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">&times;</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">&times;</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">&times;</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>

View 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>

View file

@ -1,109 +1,215 @@
<script lang="ts">
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>
<div class="page-content">
<h2>Financials</h2>
<p class="text-secondary">Prize pool and payout information.</p>
{#if tournament.financials}
{@const fin = tournament.financials}
<div class="finance-grid">
<div class="finance-card">
<span class="finance-label">Total Buy-ins</span>
<span class="finance-value currency">{fin.total_buyin.toLocaleString()}</span>
</div>
<div class="finance-card">
<span class="finance-label">Total Rebuys</span>
<span class="finance-value currency">{fin.total_rebuys.toLocaleString()}</span>
</div>
<div class="finance-card">
<span class="finance-label">Total Add-ons</span>
<span class="finance-value currency">{fin.total_addons.toLocaleString()}</span>
</div>
<div class="finance-card highlight">
<span class="finance-label">Prize Pool</span>
<span class="finance-value currency prize">{fin.prize_pool.toLocaleString()}</span>
</div>
<div class="finance-card">
<span class="finance-label">House Fee</span>
<span class="finance-value currency">{fin.house_fee.toLocaleString()}</span>
</div>
<div class="finance-card">
<span class="finance-label">Paid Positions</span>
<span class="finance-value number">{fin.paid_positions}</span>
<!-- 1. Prize Pool Card -->
<PrizePoolCard financials={fin} />
<!-- 2. Payout Preview -->
{#if fin.payouts && fin.payouts.length > 0}
<div class="payout-section">
<h3 class="section-title">Payout Preview</h3>
<table class="payout-table">
<thead>
<tr>
<th>Position</th>
<th class="right">Percentage</th>
<th class="right">Amount</th>
</tr>
</thead>
<tbody>
{#each fin.payouts as payout}
<tr>
<td>
{payout.position}{ordinalSuffix(payout.position)}
{#if payout.player_name}
<span class="player-label">({payout.player_name})</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>
{/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>
{:else}
<!-- Loading / empty state -->
<Loading variant="skeleton" rows={4} />
<p class="empty-state">No financial data available yet.</p>
{/if}
</div>
<style>
.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;
flex-direction: column;
gap: var(--space-1);
gap: 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 {
border-color: var(--color-prize);
}
.finance-label {
font-size: var(--text-xs);
/* Section titles */
.section-title {
font-size: var(--text-sm);
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--color-text-muted);
margin-bottom: var(--space-2);
}
.finance-value {
font-size: var(--text-xl);
font-weight: 700;
/* Payout table */
.payout-section {
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);
border-bottom: 1px solid var(--color-border);
}
.finance-value.prize {
color: var(--color-prize);
.payout-table .right {
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 {
color: var(--color-text-muted);
font-style: italic;
padding: var(--space-8) 0;
padding: var(--space-4) 0;
text-align: center;
}
</style>