added registrations page

This commit is contained in:
2026-07-07 20:43:55 +01:00
parent 5e03a5798f
commit df8f5bc9a9
4 changed files with 433 additions and 15 deletions

View File

@@ -4,6 +4,32 @@
import type { LayoutData } from './$types';
let { children, data }: { children: import('svelte').Snippet; data: LayoutData } = $props();
let adminOpen = $state(false);
let wrapperRef = $state<HTMLElement | null>(null);
function toggleAdmin() {
adminOpen = !adminOpen;
}
function closeAdmin() {
adminOpen = false;
}
$effect(() => {
if (!adminOpen) return;
const handler = (e: MouseEvent) => {
if (wrapperRef && !wrapperRef.contains(e.target as Node)) {
closeAdmin();
}
};
const timer = setTimeout(() => document.addEventListener('click', handler), 0);
return () => {
clearTimeout(timer);
document.removeEventListener('click', handler);
};
});
</script>
<svelte:head><link rel="icon" href={favicon} /></svelte:head>
@@ -17,21 +43,24 @@
<div class="w-full"></div>
{#if data.user?.role === 'admin'}
<a
class="align-text-middle justify-right mx-3 my-1 h-auto content-center rounded-sm border-2
border-solid border-red-500 px-2"
href="/ledger">ledger</a
>
<a
class="align-text-middle justify-right mx-3 my-1 h-auto content-center rounded-sm border-2
border-solid border-red-500 px-2"
href="/import-csv">import</a
>
<a
class="align-text-middle justify-right mx-3 my-1 h-auto content-center rounded-sm border-2
border-solid border-red-500 px-2"
href="/settings">settings</a
>
<div class="dropdown-wrapper">
<button
class="align-text-middle justify-right mx-3 my-1 h-auto content-center rounded-sm border-2
border-solid border-red-500 px-2"
onclick={toggleAdmin}
>
admin {adminOpen ? '▲' : '▼'}
</button>
{#if adminOpen}
<!-- svelte-ignore a11y_no_noninteractive_tabindex -->
<div class="dropdown-menu" role="menu" tabindex="-1">
<a href="/ledger" role="menuitem">ledger</a>
<a href="/registrations" role="menuitem">registrations</a>
<a href="/import-csv" role="menuitem">import</a>
<a href="/settings" role="menuitem">settings</a>
</div>
{/if}
</div>
{/if}
{#if data.user}
<a
@@ -54,4 +83,34 @@
.header {
background-color: var(--ctp-mocha-crust);
}
.dropdown-wrapper {
position: relative;
}
.dropdown-menu {
position: absolute;
top: 100%;
right: 0;
z-index: 50;
display: flex;
flex-direction: column;
min-width: 140px;
padding: 0.25rem 0;
border: 2px solid var(--ctp-mocha-red);
border-radius: 6px;
background-color: var(--ctp-mocha-crust);
}
.dropdown-menu a {
padding: 0.35rem 0.75rem;
font-size: 0.8rem;
text-decoration: none;
color: inherit;
white-space: nowrap;
}
.dropdown-menu a:hover {
background-color: color-mix(in srgb, currentColor 12%, transparent);
}
</style>

View File

@@ -0,0 +1,69 @@
import { error, json } from '@sveltejs/kit';
import { eq } from 'drizzle-orm';
import { db } from '$lib/server/db';
import { registeredPlayers, registeredEvents, scorers } from '$lib/server/db/schema';
import { globalEmitter } from '$lib/server/globalEmitter';
export async function PATCH({ params, request, locals }: any) {
if (!locals.user) throw error(401, 'Unauthorized');
const [row] = await db.select({ role: scorers.role }).from(scorers).where(eq(scorers.id, locals.user.id));
if (row?.role !== 'admin') throw error(403, 'Forbidden');
const id = parseInt(params.id);
if (isNaN(id)) throw error(400, 'Invalid registration ID');
const body = await request.json();
if (typeof body.playerId !== 'number') throw error(400, 'playerId is required');
const reg = await db
.select({
registeredEventID: registeredPlayers.registeredEventID
})
.from(registeredPlayers)
.where(eq(registeredPlayers.id, id))
.get();
if (!reg) throw error(404, 'Registration not found');
await db
.update(registeredPlayers)
.set({ playerID: body.playerId })
.where(eq(registeredPlayers.id, id));
globalEmitter.emit('eventUpdate');
return json({ success: true });
}
export async function DELETE({ params, locals }: any) {
if (!locals.user) throw error(401, 'Unauthorized');
const [row] = await db.select({ role: scorers.role }).from(scorers).where(eq(scorers.id, locals.user.id));
if (row?.role !== 'admin') throw error(403, 'Forbidden');
const id = parseInt(params.id);
if (isNaN(id)) throw error(400, 'Invalid registration ID');
const reg = await db
.select({
registeredEventID: registeredPlayers.registeredEventID
})
.from(registeredPlayers)
.where(eq(registeredPlayers.id, id))
.get();
if (!reg) throw error(404, 'Registration not found');
const event = await db
.select({ state: registeredEvents.state })
.from(registeredEvents)
.where(eq(registeredEvents.id, reg.registeredEventID))
.get();
if (!event || event.state !== 0) {
throw error(403, 'Can only delete registrations from events that have not started');
}
await db.delete(registeredPlayers).where(eq(registeredPlayers.id, id));
globalEmitter.emit('eventUpdate');
return json({ success: true });
}

View File

@@ -0,0 +1,104 @@
import { error, redirect } from '@sveltejs/kit';
import { eq, sql } from 'drizzle-orm';
import { db } from '$lib/server/db';
import {
scorers,
registeredPlayers,
registeredEvents,
brackets,
eventTypes,
players,
teams,
divisions,
scoringPresets
} from '$lib/server/db/schema';
import type { PageServerLoad } from './$types';
export const load: PageServerLoad = async ({ locals }) => {
if (!locals.user) throw redirect(303, '/login');
const [row] = await db
.select({ role: scorers.role })
.from(scorers)
.where(eq(scorers.id, locals.user.id));
if (row?.role !== 'admin') throw error(403, 'Forbidden');
const events = await db
.select({
id: registeredEvents.id,
name: eventTypes.name,
division: divisions.name,
state: registeredEvents.state
})
.from(registeredEvents)
.innerJoin(eventTypes, eq(registeredEvents.eventType, eventTypes.id))
.innerJoin(divisions, eq(registeredEvents.division, divisions.id))
.orderBy(registeredEvents.id);
const registrations = await db
.select({
registeredPlayerId: registeredPlayers.id,
playerId: players.id,
firstName: players.firstName,
lastName: players.lastName,
placement: registeredPlayers.placement,
bracket: brackets.name,
eventId: registeredEvents.id,
eventName: eventTypes.name,
teamId: teams.id,
teamName: teams.name,
teamColor: teams.color,
state: registeredEvents.state,
division: divisions.name,
points: sql<number>`COALESCE((
SELECT ${scoringPresets.points}
FROM ${scoringPresets}
WHERE ${scoringPresets.presetID} = ${eventTypes.scoringPreset}
AND ${scoringPresets.placement} = ${registeredPlayers.placement}
), 0)`
.mapWith(Number)
.as('points')
})
.from(registeredPlayers)
.innerJoin(registeredEvents, eq(registeredPlayers.registeredEventID, registeredEvents.id))
.innerJoin(brackets, eq(registeredPlayers.bracket, brackets.id))
.innerJoin(eventTypes, eq(registeredEvents.eventType, eventTypes.id))
.innerJoin(players, eq(registeredPlayers.playerID, players.id))
.innerJoin(teams, eq(players.team, teams.id))
.innerJoin(divisions, eq(registeredEvents.division, divisions.id))
.orderBy(registeredPlayers.id);
const allPlayers = await db
.select({
id: players.id,
firstName: players.firstName,
lastName: players.lastName,
teamId: players.team
})
.from(players)
.orderBy(players.firstName);
return {
events: events.map((e) => ({ id: e.id, name: e.name, division: e.division, state: e.state })),
registrations: registrations.map((r) => ({
id: r.registeredPlayerId,
registeredPlayerId: r.registeredPlayerId,
playerId: r.playerId,
firstName: r.firstName,
lastName: r.lastName,
placement: r.placement,
bracket: r.bracket,
eventId: r.eventId,
eventName: r.eventName,
teamId: r.teamId,
teamName: r.teamName,
teamColor: r.teamColor,
state: r.state,
division: r.division,
points: r.points,
stateLabel: r.state === 0 ? 'Not started' : r.state === 1 ? 'Ongoing' : 'Finished'
})),
allPlayers
};
};

View File

@@ -0,0 +1,186 @@
<script lang="ts">
import Table from '$lib/ui/Table.svelte';
import type { PageData } from './$types';
let { data }: { data: PageData } = $props();
type Registration = (typeof data.registrations)[number];
type PlayerInfo = (typeof data.allPlayers)[number];
let selectedEventId = $state<number | ''>('');
let searchQuery = $state('');
let editingId = $state<number | null>(null);
let editPlayerId = $state<number>(0);
let filteredRegistrations = $derived(
data.registrations.filter((r) => {
if (selectedEventId !== '' && r.eventId !== selectedEventId) return false;
if (searchQuery) {
const q = searchQuery.toLowerCase();
const fullName = `${r.firstName} ${r.lastName}`.toLowerCase();
if (!fullName.includes(q)) return false;
}
return true;
})
);
function playersByTeam(teamId: number): PlayerInfo[] {
return data.allPlayers.filter((p) => p.teamId === teamId);
}
function startEdit(reg: Registration) {
editingId = reg.registeredPlayerId;
editPlayerId = reg.playerId;
}
function cancelEdit() {
editingId = null;
}
async function saveEdit(reg: Registration) {
if (editPlayerId === reg.playerId) {
editingId = null;
return;
}
await fetch(`/api/registrations/${reg.registeredPlayerId}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ playerId: editPlayerId })
});
reg.playerId = editPlayerId;
const newPlayer = data.allPlayers.find((p) => p.id === editPlayerId);
if (newPlayer) {
reg.firstName = newPlayer.firstName;
reg.lastName = newPlayer.lastName;
}
editingId = null;
}
async function deleteReg(reg: Registration) {
if (!confirm(`Delete registration for ${reg.firstName} ${reg.lastName} from ${reg.eventName}?`)) return;
await fetch(`/api/registrations/${reg.registeredPlayerId}`, { method: 'DELETE' });
const idx = data.registrations.indexOf(reg);
if (idx !== -1) data.registrations.splice(idx, 1);
}
function stateColor(state: number): string {
if (state === 0) return 'var(--ctp-mocha-yellow)';
if (state === 1) return 'var(--ctp-mocha-green)';
return 'var(--ctp-mocha-blue)';
}
</script>
<div class="page-container">
<div class="section-label">Event Registration Management</div>
<div class="card" style="margin-bottom: 1rem;">
<div class="flex flex-wrap gap-4 items-end">
<div>
<label class="block text-sm mb-1" for="filter-event">Filter by Event</label>
<select id="filter-event" bind:value={selectedEventId}>
<option value="">All events</option>
{#each data.events as event (event.id)}
<option value={event.id}>{event.name} {event.division}</option>
{/each}
</select>
</div>
<div>
<label class="block text-sm mb-1" for="search-player">Search Player</label>
<input
id="search-player"
type="text"
placeholder="Type player name..."
bind:value={searchQuery}
style="min-width: 200px;"
/>
</div>
</div>
</div>
<div
class="section-label"
style="padding-bottom: 4px; display: flex; justify-content: space-between; align-items: center;"
>
<span>Registrations</span>
<span class="text-sm opacity-60">{filteredRegistrations.length} entries</span>
</div>
<Table data={filteredRegistrations} maxHeight="none">
{#snippet header()}
<th class="px-2">RP ID</th>
<th class="px-2">Event</th>
<th class="px-2">Team</th>
<th class="px-2">Division</th>
<th class="px-2">Bracket</th>
<th class="px-2">Player Name</th>
<th class="px-2">Event State</th>
<th class="px-2">Placement</th>
<th class="px-2">Points</th>
<th class="px-2">Actions</th>
{/snippet}
{#snippet row(reg: Registration)}
<td class="px-2">{reg.registeredPlayerId}</td>
<td class="px-2">{reg.eventName}</td>
<td class="px-2" style="color: var(--ctp-mocha-{reg.teamColor});">{reg.teamName}</td>
<td class="px-2">{reg.division}</td>
<td class="px-2">{reg.bracket}</td>
<td class="px-2">
{#if editingId === reg.registeredPlayerId}
<select bind:value={editPlayerId}>
{#each playersByTeam(reg.teamId) as p (p.id)}
<option value={p.id}>{p.firstName} {p.lastName}</option>
{/each}
</select>
{:else}
{reg.firstName} {reg.lastName}
{/if}
</td>
<td class="px-2" style="color: {stateColor(reg.state)};">{reg.stateLabel}</td>
<td class="px-2">{reg.placement > 0 ? reg.placement : '-'}</td>
<td class="px-2">{reg.points > 0 ? reg.points : '-'}</td>
<td class="px-2">
{#if editingId === reg.registeredPlayerId}
<button class="action-btn" onclick={() => saveEdit(reg)}>Save</button>
<button class="action-btn" onclick={cancelEdit}>Cancel</button>
{:else}
<button class="action-btn" onclick={() => startEdit(reg)}>Edit</button>
{#if reg.state === 0}
<button class="action-btn delete" onclick={() => deleteReg(reg)}>Delete</button>
{/if}
{/if}
</td>
{/snippet}
</Table>
</div>
<style>
.action-btn {
padding: 0.15rem 0.5rem;
font-size: 0.75rem;
border: 1px solid color-mix(in srgb, currentColor 18%, transparent);
border-radius: 4px;
background: color-mix(in srgb, currentColor 6%, transparent);
color: inherit;
cursor: pointer;
margin: 0 2px;
}
.action-btn:hover {
filter: brightness(1.15);
}
.action-btn.delete {
border-color: var(--ctp-mocha-red, #f38ba8);
color: var(--ctp-mocha-red, #f38ba8);
}
td select {
padding: 0.15rem 0.25rem;
font-size: 0.75rem;
border: 1px solid color-mix(in srgb, currentColor 18%, transparent);
border-radius: 4px;
background: color-mix(in srgb, currentColor 6%, transparent);
color: inherit;
max-width: 140px;
}
</style>