events page
This commit is contained in:
@@ -56,6 +56,7 @@
|
|||||||
<div class="dropdown-menu" role="menu" tabindex="-1">
|
<div class="dropdown-menu" role="menu" tabindex="-1">
|
||||||
<a href="/ledger" role="menuitem">ledger</a>
|
<a href="/ledger" role="menuitem">ledger</a>
|
||||||
<a href="/registrations" role="menuitem">registrations</a>
|
<a href="/registrations" role="menuitem">registrations</a>
|
||||||
|
<a href="/manage-events" role="menuitem">events</a>
|
||||||
<a href="/import-csv" role="menuitem">import</a>
|
<a href="/import-csv" role="menuitem">import</a>
|
||||||
<a href="/settings" role="menuitem">settings</a>
|
<a href="/settings" role="menuitem">settings</a>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
21
src/routes/api/eventTypes/+server.ts
Normal file
21
src/routes/api/eventTypes/+server.ts
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
import { json } from '@sveltejs/kit';
|
||||||
|
import { db } from '$lib/server/db';
|
||||||
|
import { eventTypes } from '$lib/server/db/schema';
|
||||||
|
|
||||||
|
export async function POST({ request, locals }) {
|
||||||
|
if (!locals.user) return new Response('Unauthorized', { status: 401 });
|
||||||
|
|
||||||
|
const body = await request.json();
|
||||||
|
const { name, scoringPreset, resultPreset } = body;
|
||||||
|
|
||||||
|
if (!name || scoringPreset == null || resultPreset == null) {
|
||||||
|
return json({ error: 'Missing required fields' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const [inserted] = await db
|
||||||
|
.insert(eventTypes)
|
||||||
|
.values({ name, scoringPreset, resultPreset })
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
return json(inserted);
|
||||||
|
}
|
||||||
24
src/routes/api/registerEvent/+server.ts
Normal file
24
src/routes/api/registerEvent/+server.ts
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
import { json } from '@sveltejs/kit';
|
||||||
|
import { db } from '$lib/server/db';
|
||||||
|
import { registeredEvents } from '$lib/server/db/schema';
|
||||||
|
import { globalEmitter } from '$lib/server/globalEmitter';
|
||||||
|
|
||||||
|
export async function POST({ request, locals }) {
|
||||||
|
if (!locals.user) return new Response('Unauthorized', { status: 401 });
|
||||||
|
|
||||||
|
const body = await request.json();
|
||||||
|
const { eventType, division } = body;
|
||||||
|
|
||||||
|
if (eventType == null || division == null) {
|
||||||
|
return json({ error: 'Missing required fields' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const [inserted] = await db
|
||||||
|
.insert(registeredEvents)
|
||||||
|
.values({ eventType, division, state: 0 })
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
globalEmitter.emit('eventUpdate');
|
||||||
|
|
||||||
|
return json(inserted);
|
||||||
|
}
|
||||||
74
src/routes/manage-events/+page.server.ts
Normal file
74
src/routes/manage-events/+page.server.ts
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
import { error, redirect } from '@sveltejs/kit';
|
||||||
|
import { eq } from 'drizzle-orm';
|
||||||
|
import { db } from '$lib/server/db';
|
||||||
|
import {
|
||||||
|
scorers,
|
||||||
|
eventTypes,
|
||||||
|
registeredEvents,
|
||||||
|
divisions,
|
||||||
|
scoringPresets,
|
||||||
|
resultPresets,
|
||||||
|
teams
|
||||||
|
} 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 eventTypeList = await db.select().from(eventTypes).orderBy(eventTypes.id);
|
||||||
|
const divisionList = await db.select().from(divisions).orderBy(divisions.id);
|
||||||
|
const resultPresetList = await db.select().from(resultPresets).orderBy(resultPresets.id);
|
||||||
|
|
||||||
|
const scoringPresetRows = await db
|
||||||
|
.select()
|
||||||
|
.from(scoringPresets)
|
||||||
|
.orderBy(scoringPresets.presetID, scoringPresets.placement);
|
||||||
|
|
||||||
|
const scoringPresetMap = new Map<number, { placement: number; points: number }[]>();
|
||||||
|
for (const row of scoringPresetRows) {
|
||||||
|
if (!scoringPresetMap.has(row.presetID)) {
|
||||||
|
scoringPresetMap.set(row.presetID, []);
|
||||||
|
}
|
||||||
|
scoringPresetMap.get(row.presetID)!.push({ placement: row.placement, points: row.points });
|
||||||
|
}
|
||||||
|
|
||||||
|
const scoringPresetsList = Array.from(scoringPresetMap.entries()).map(([id, entries]) => ({
|
||||||
|
presetID: id,
|
||||||
|
entries
|
||||||
|
}));
|
||||||
|
|
||||||
|
const registeredEventRows = await db
|
||||||
|
.select({
|
||||||
|
id: registeredEvents.id,
|
||||||
|
eventId: registeredEvents.id,
|
||||||
|
eventName: eventTypes.name,
|
||||||
|
division: divisions.name,
|
||||||
|
state: registeredEvents.state,
|
||||||
|
timeCompleted: registeredEvents.timeCompleted,
|
||||||
|
winner: registeredEvents.teamWinner,
|
||||||
|
winnerName: teams.name,
|
||||||
|
winnerColor: teams.color,
|
||||||
|
scorePreset: eventTypes.scoringPreset,
|
||||||
|
resultPreset: eventTypes.resultPreset
|
||||||
|
})
|
||||||
|
.from(registeredEvents)
|
||||||
|
.innerJoin(eventTypes, eq(registeredEvents.eventType, eventTypes.id))
|
||||||
|
.innerJoin(divisions, eq(registeredEvents.division, divisions.id))
|
||||||
|
.leftJoin(teams, eq(registeredEvents.teamWinner, teams.id))
|
||||||
|
.orderBy(registeredEvents.id);
|
||||||
|
|
||||||
|
return {
|
||||||
|
eventTypes: eventTypeList,
|
||||||
|
divisions: divisionList,
|
||||||
|
resultPresets: resultPresetList,
|
||||||
|
scoringPresets: scoringPresetsList,
|
||||||
|
registeredEvents: registeredEventRows
|
||||||
|
};
|
||||||
|
};
|
||||||
321
src/routes/manage-events/+page.svelte
Normal file
321
src/routes/manage-events/+page.svelte
Normal file
@@ -0,0 +1,321 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import Table from '$lib/ui/Table.svelte';
|
||||||
|
import type { PageData } from './$types';
|
||||||
|
|
||||||
|
let { data }: { data: PageData } = $props();
|
||||||
|
|
||||||
|
// --- Create Event Type form ---
|
||||||
|
let showCreateForm = $state(false);
|
||||||
|
let newEventName = $state('');
|
||||||
|
let newScoringPreset = $state<number>(0);
|
||||||
|
let newResultPreset = $state<number>(0);
|
||||||
|
|
||||||
|
// --- Register Event form ---
|
||||||
|
let showRegisterForm = $state(false);
|
||||||
|
let registerEventType = $state<number>(0);
|
||||||
|
let registerDivision = $state<number>(0);
|
||||||
|
|
||||||
|
function scoringPresetDescription(entries: { placement: number; points: number }[]): string {
|
||||||
|
return entries.map((e) => `${e.placement}→${e.points}`).join(' ');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitCreateEventType() {
|
||||||
|
if (!newEventName || !newScoringPreset || !newResultPreset) return;
|
||||||
|
const res = await fetch('/api/eventTypes', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
name: newEventName,
|
||||||
|
scoringPreset: newScoringPreset,
|
||||||
|
resultPreset: newResultPreset
|
||||||
|
})
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
alert('Failed to create event type');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const result = await res.json();
|
||||||
|
data.eventTypes.push({
|
||||||
|
id: result.id,
|
||||||
|
name: newEventName,
|
||||||
|
scoringPreset: newScoringPreset,
|
||||||
|
resultPreset: newResultPreset
|
||||||
|
});
|
||||||
|
newEventName = '';
|
||||||
|
newScoringPreset = 0;
|
||||||
|
newResultPreset = 0;
|
||||||
|
showCreateForm = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitRegisterEvent() {
|
||||||
|
if (!registerEventType || !registerDivision) return;
|
||||||
|
const res = await fetch('/api/registerEvent', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
eventType: registerEventType,
|
||||||
|
division: registerDivision
|
||||||
|
})
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
alert('Failed to register event');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const result = await res.json();
|
||||||
|
const et = data.eventTypes.find((e) => e.id === registerEventType);
|
||||||
|
const dv = data.divisions.find((d) => d.id === registerDivision);
|
||||||
|
if (et && dv) {
|
||||||
|
data.registeredEvents.push({
|
||||||
|
id: result.id,
|
||||||
|
eventId: result.id,
|
||||||
|
eventName: et.name,
|
||||||
|
division: dv.name,
|
||||||
|
state: 0,
|
||||||
|
timeCompleted: null,
|
||||||
|
winner: null,
|
||||||
|
winnerName: null,
|
||||||
|
winnerColor: null,
|
||||||
|
scorePreset: et.scoringPreset,
|
||||||
|
resultPreset: et.resultPreset
|
||||||
|
});
|
||||||
|
}
|
||||||
|
registerEventType = 0;
|
||||||
|
registerDivision = 0;
|
||||||
|
showRegisterForm = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
let eventTypesWithPresets = $derived(
|
||||||
|
data.eventTypes.map((et) => {
|
||||||
|
const sp = data.scoringPresets.find((s) => s.presetID === et.scoringPreset);
|
||||||
|
const rp = data.resultPresets.find((r) => r.id === et.resultPreset);
|
||||||
|
return {
|
||||||
|
id: et.id,
|
||||||
|
name: et.name,
|
||||||
|
scoringPresetDescription: sp ? scoringPresetDescription(sp.entries) : 'Unknown',
|
||||||
|
resultPresetName: rp ? rp.presetName : 'Unknown'
|
||||||
|
};
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
function stateLabel(state: number): string {
|
||||||
|
if (state === 0) return 'Pending';
|
||||||
|
if (state === 1) return 'Ongoing';
|
||||||
|
return 'Finished';
|
||||||
|
}
|
||||||
|
|
||||||
|
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">Manage Events</div>
|
||||||
|
|
||||||
|
<!-- Create Event Type -->
|
||||||
|
<div
|
||||||
|
class="section-label"
|
||||||
|
style="padding-bottom: 4px; display: flex; justify-content: space-between; align-items: center;"
|
||||||
|
>
|
||||||
|
<span>Create Event Type</span>
|
||||||
|
<button class="action-btn new-btn" onclick={() => { showCreateForm = !showCreateForm; }}>
|
||||||
|
{showCreateForm ? 'Cancel' : 'New Event Type'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if showCreateForm}
|
||||||
|
<div class="card new-form-card">
|
||||||
|
<div class="new-form-title">New Event Type</div>
|
||||||
|
<div class="flex flex-wrap gap-4 items-end">
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm mb-1" for="new-event-name">Event Name</label>
|
||||||
|
<input
|
||||||
|
id="new-event-name"
|
||||||
|
type="text"
|
||||||
|
bind:value={newEventName}
|
||||||
|
placeholder="e.g. 100m Sprint"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm mb-1" for="new-scoring-preset">Scoring Preset</label>
|
||||||
|
<select id="new-scoring-preset" bind:value={newScoringPreset}>
|
||||||
|
<option value={0}>Select scoring...</option>
|
||||||
|
{#each data.scoringPresets as preset (preset.presetID)}
|
||||||
|
<option value={preset.presetID}>
|
||||||
|
Preset {preset.presetID} ({scoringPresetDescription(preset.entries)})
|
||||||
|
</option>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm mb-1" for="new-result-preset">Result Preset</label>
|
||||||
|
<select id="new-result-preset" bind:value={newResultPreset}>
|
||||||
|
<option value={0}>Select result...</option>
|
||||||
|
{#each data.resultPresets as rp (rp.id)}
|
||||||
|
<option value={rp.id}>{rp.presetName} ({rp.numberOfResults} result(s), {rp.unit})</option>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<button
|
||||||
|
class="action-btn submit-btn"
|
||||||
|
onclick={submitCreateEventType}
|
||||||
|
disabled={!newEventName || !newScoringPreset || !newResultPreset}
|
||||||
|
>
|
||||||
|
Create
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Event Types Table -->
|
||||||
|
<div
|
||||||
|
class="section-label"
|
||||||
|
style="padding-bottom: 4px; display: flex; justify-content: space-between; align-items: center;"
|
||||||
|
>
|
||||||
|
<span>Event Types</span>
|
||||||
|
<span class="text-sm opacity-60">{data.eventTypes.length} entries</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Table data={eventTypesWithPresets} maxHeight="none">
|
||||||
|
{#snippet header()}
|
||||||
|
<th class="px-2">ID</th>
|
||||||
|
<th class="px-2">Name</th>
|
||||||
|
<th class="px-2">Scoring Preset</th>
|
||||||
|
<th class="px-2">Result Preset</th>
|
||||||
|
{/snippet}
|
||||||
|
{#snippet row(et: (typeof eventTypesWithPresets)[number])}
|
||||||
|
<td class="px-2">{et.id}</td>
|
||||||
|
<td class="px-2">{et.name}</td>
|
||||||
|
<td class="px-2">{et.scoringPresetDescription}</td>
|
||||||
|
<td class="px-2">{et.resultPresetName}</td>
|
||||||
|
{/snippet}
|
||||||
|
</Table>
|
||||||
|
|
||||||
|
<!-- Register Event -->
|
||||||
|
<div
|
||||||
|
class="section-label"
|
||||||
|
style="padding-bottom: 4px; display: flex; justify-content: space-between; align-items: center;"
|
||||||
|
>
|
||||||
|
<span>Register Event</span>
|
||||||
|
<button class="action-btn new-btn" onclick={() => { showRegisterForm = !showRegisterForm; }}>
|
||||||
|
{showRegisterForm ? 'Cancel' : 'Register Event'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if showRegisterForm}
|
||||||
|
<div class="card new-form-card">
|
||||||
|
<div class="new-form-title">Register an Event for a Division</div>
|
||||||
|
<div class="flex flex-wrap gap-4 items-end">
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm mb-1" for="register-event-type">Event Type</label>
|
||||||
|
<select id="register-event-type" bind:value={registerEventType}>
|
||||||
|
<option value={0}>Select event type...</option>
|
||||||
|
{#each data.eventTypes as et (et.id)}
|
||||||
|
<option value={et.id}>{et.name}</option>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm mb-1" for="register-division">Division</label>
|
||||||
|
<select id="register-division" bind:value={registerDivision}>
|
||||||
|
<option value={0}>Select division...</option>
|
||||||
|
{#each data.divisions as div (div.id)}
|
||||||
|
<option value={div.id}>{div.name}</option>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<button
|
||||||
|
class="action-btn submit-btn"
|
||||||
|
onclick={submitRegisterEvent}
|
||||||
|
disabled={!registerEventType || !registerDivision}
|
||||||
|
>
|
||||||
|
Register
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Registered Events Table -->
|
||||||
|
<div
|
||||||
|
class="section-label"
|
||||||
|
style="padding-bottom: 4px; display: flex; justify-content: space-between; align-items: center;"
|
||||||
|
>
|
||||||
|
<span>Registered Events</span>
|
||||||
|
<span class="text-sm opacity-60">{data.registeredEvents.length} entries</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Table data={data.registeredEvents} maxHeight="none">
|
||||||
|
{#snippet header()}
|
||||||
|
<th class="px-2">ID</th>
|
||||||
|
<th class="px-2">Event Name</th>
|
||||||
|
<th class="px-2">Division</th>
|
||||||
|
<th class="px-2">State</th>
|
||||||
|
<th class="px-2">Winner</th>
|
||||||
|
{/snippet}
|
||||||
|
{#snippet row(event: (typeof data.registeredEvents)[number])}
|
||||||
|
<td class="px-2">{event.eventId}</td>
|
||||||
|
<td class="px-2">{event.eventName}</td>
|
||||||
|
<td class="px-2">{event.division}</td>
|
||||||
|
<td class="px-2" style="color: {stateColor(event.state)};">{stateLabel(event.state)}</td>
|
||||||
|
<td
|
||||||
|
class="px-2"
|
||||||
|
style={event.winnerName ? `color: var(--ctp-mocha-${event.winnerColor});` : ''}
|
||||||
|
>
|
||||||
|
{event.winnerName ?? '-'}
|
||||||
|
</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.new-btn {
|
||||||
|
border-color: var(--ctp-mocha-green, #a6e3a1);
|
||||||
|
color: var(--ctp-mocha-green, #a6e3a1);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
padding: 0.25rem 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-btn.submit-btn {
|
||||||
|
border-color: var(--ctp-mocha-blue, #89b4fa);
|
||||||
|
color: var(--ctp-mocha-blue, #89b4fa);
|
||||||
|
padding: 0.3rem 0.75rem;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-btn.submit-btn:disabled {
|
||||||
|
opacity: 0.4;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.new-form-card {
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.new-form-title {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-weight: 600;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
Reference in New Issue
Block a user