added registrations page
This commit is contained in:
@@ -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
|
||||
<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"
|
||||
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
|
||||
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>
|
||||
|
||||
69
src/routes/api/registrations/[id]/+server.ts
Normal file
69
src/routes/api/registrations/[id]/+server.ts
Normal 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 });
|
||||
}
|
||||
104
src/routes/registrations/+page.server.ts
Normal file
104
src/routes/registrations/+page.server.ts
Normal 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
|
||||
};
|
||||
};
|
||||
186
src/routes/registrations/+page.svelte
Normal file
186
src/routes/registrations/+page.svelte
Normal 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>
|
||||
Reference in New Issue
Block a user