added registrations page
This commit is contained in:
@@ -4,6 +4,32 @@
|
|||||||
import type { LayoutData } from './$types';
|
import type { LayoutData } from './$types';
|
||||||
|
|
||||||
let { children, data }: { children: import('svelte').Snippet; data: LayoutData } = $props();
|
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>
|
</script>
|
||||||
|
|
||||||
<svelte:head><link rel="icon" href={favicon} /></svelte:head>
|
<svelte:head><link rel="icon" href={favicon} /></svelte:head>
|
||||||
@@ -17,21 +43,24 @@
|
|||||||
<div class="w-full"></div>
|
<div class="w-full"></div>
|
||||||
|
|
||||||
{#if data.user?.role === 'admin'}
|
{#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
|
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"
|
border-solid border-red-500 px-2"
|
||||||
href="/ledger">ledger</a
|
onclick={toggleAdmin}
|
||||||
>
|
|
||||||
<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
|
|
||||||
>
|
>
|
||||||
|
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}
|
||||||
{#if data.user}
|
{#if data.user}
|
||||||
<a
|
<a
|
||||||
@@ -54,4 +83,34 @@
|
|||||||
.header {
|
.header {
|
||||||
background-color: var(--ctp-mocha-crust);
|
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>
|
</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