Add target numbers, results and X-10 with sortable admin table

This commit is contained in:
Finn Skov Henriksen 2026-10-02 21:53:40 +02:00
parent 150e0a00b5
commit 619ab9133a
6 changed files with 170 additions and 34 deletions

View File

@ -35,16 +35,17 @@ app.MapPost("/api/visitors", async (VisitorInput input, Database database) =>
command.CommandText = """ command.CommandText = """
insert into visitors (name, street_address, postal_code, city, birthday, email) insert into visitors (name, street_address, postal_code, city, birthday, email)
values ($name, $street, $postal, $city, $birthday, $email) values ($name, $street, $postal, $city, $birthday, $email)
returning id
"""; """;
command.Parameters.AddWithValue("$name", input.Name.Trim()); command.Parameters.AddWithValue("$name", input.Name.Trim());
command.Parameters.AddWithValue("$street", input.StreetAddress.Trim()); command.Parameters.AddWithValue("$street", input.StreetAddress.Trim());
command.Parameters.AddWithValue("$postal", input.PostalCode.Trim()); command.Parameters.AddWithValue("$postal", input.PostalCode.Trim());
command.Parameters.AddWithValue("$city", input.City.Trim()); command.Parameters.AddWithValue("$city", input.City.Trim());
command.Parameters.AddWithValue("$birthday", DateOnly.Parse(input.Birthday).ToString("yyyy-MM-dd")); command.Parameters.AddWithValue("$birthday", DateOnly.Parse(input.Birthday).ToString("yyyy-MM-dd"));
command.Parameters.AddWithValue("$email", input.Email.Trim().ToLowerInvariant()); command.Parameters.AddWithValue("$email", input.Email?.Trim().ToLowerInvariant() ?? "");
await command.ExecuteNonQueryAsync(); var number = Convert.ToInt64(await command.ExecuteScalarAsync());
return Results.Created("/api/visitors", null); return Results.Created("/api/visitors", new { number });
}); });
app.MapGet("/api/visitors", async (Database database) => app.MapGet("/api/visitors", async (Database database) =>
@ -53,7 +54,7 @@ app.MapGet("/api/visitors", async (Database database) =>
await using var connection = await database.OpenConnectionAsync(); await using var connection = await database.OpenConnectionAsync();
await using var command = connection.CreateCommand(); await using var command = connection.CreateCommand();
command.CommandText = """ command.CommandText = """
select id, name, street_address, postal_code, city, birthday, email, created_at select id, name, street_address, postal_code, city, birthday, email, created_at, result, x10
from visitors from visitors
order by created_at desc, id desc order by created_at desc, id desc
"""; """;
@ -69,13 +70,31 @@ app.MapGet("/api/visitors", async (Database database) =>
reader.GetString(4), reader.GetString(4),
DateOnly.Parse(reader.GetString(5)), DateOnly.Parse(reader.GetString(5)),
reader.GetString(6), reader.GetString(6),
DateTime.SpecifyKind(reader.GetDateTime(7), DateTimeKind.Utc) DateTime.SpecifyKind(reader.GetDateTime(7), DateTimeKind.Utc),
reader.GetString(8),
reader.IsDBNull(9) ? null : reader.GetInt32(9)
)); ));
} }
return Results.Ok(visitors); return Results.Ok(visitors);
}).RequireAuthorization(); }).RequireAuthorization();
app.MapPut("/api/visitors/{id:long}/result", async (long id, VisitorResultInput input, Database database) =>
{
var result = input.Result?.Trim() ?? "";
if (input.X10 < 0)
return Results.ValidationProblem(new Dictionary<string, string[]> { ["x10"] = ["X-10 skal være et heltal på mindst 0."] });
if (result.Length > 100)
return Results.ValidationProblem(new Dictionary<string, string[]> { ["result"] = ["Resultatet må højst være 100 tegn."] });
await using var connection = await database.OpenConnectionAsync();
await using var command = connection.CreateCommand();
command.CommandText = "update visitors set result = $result, x10 = $x10 where id = $id";
command.Parameters.AddWithValue("$x10", (object?)input.X10 ?? DBNull.Value);
command.Parameters.AddWithValue("$result", result);
command.Parameters.AddWithValue("$id", id);
return await command.ExecuteNonQueryAsync() == 0 ? Results.NotFound() : Results.Ok(new { result, x10 = input.X10 });
}).RequireAuthorization();
app.MapDelete("/api/visitors/{id:long}", async (long id, Database database) => app.MapDelete("/api/visitors/{id:long}", async (long id, Database database) =>
{ {
await using var connection = await database.OpenConnectionAsync(); await using var connection = await database.OpenConnectionAsync();
@ -122,17 +141,18 @@ static Dictionary<string, string[]> Validate(VisitorInput input)
errors["birthday"] = ["Fødselsdag mangler eller er ugyldig."]; errors["birthday"] = ["Fødselsdag mangler eller er ugyldig."];
} }
if (string.IsNullOrWhiteSpace(input.Email) || !input.Email.Contains('@') || !input.Email.Contains('.')) if (!string.IsNullOrWhiteSpace(input.Email) && (!input.Email.Contains('@') || !input.Email.Contains('.')))
{ {
errors["email"] = ["Email mangler eller er ugyldig."]; errors["email"] = ["Email er ugyldig."];
} }
return errors; return errors;
} }
public sealed record VisitorInput(string Name, string StreetAddress, string PostalCode, string City, string Birthday, string Email); public sealed record VisitorInput(string Name, string StreetAddress, string PostalCode, string City, string Birthday, string? Email);
public sealed record VisitorListItem(long Id, string Name, string StreetAddress, string PostalCode, string City, DateOnly Birthday, string Email, DateTime CreatedAt); public sealed record VisitorResultInput(string? Result, int? X10);
public sealed record VisitorListItem(long Id, string Name, string StreetAddress, string PostalCode, string City, DateOnly Birthday, string Email, DateTime CreatedAt, string Result, int? X10);
public sealed class Database public sealed class Database
{ {
@ -181,6 +201,19 @@ public sealed class Database
"""; """;
await command.ExecuteNonQueryAsync(); await command.ExecuteNonQueryAsync();
await using var schemaCommand = connection.CreateCommand();
schemaCommand.CommandText = "select count(*) from pragma_table_info('visitors') where name = 'result'";
if (Convert.ToInt64(await schemaCommand.ExecuteScalarAsync()) == 0)
{
schemaCommand.CommandText = "alter table visitors add column result text not null default ''";
await schemaCommand.ExecuteNonQueryAsync();
}
schemaCommand.CommandText = "select count(*) from pragma_table_info('visitors') where name = 'x10'";
if (Convert.ToInt64(await schemaCommand.ExecuteScalarAsync()) == 0)
{
schemaCommand.CommandText = "alter table visitors add column x10 integer";
await schemaCommand.ExecuteNonQueryAsync();
}
} }
} }

View File

@ -16,11 +16,13 @@
<p class="eyebrow">Vestfalster Skytteforening</p> <p class="eyebrow">Vestfalster Skytteforening</p>
<h1>Besøgende</h1> <h1>Besøgende</h1>
</div> </div>
<span id="count" class="count">Henter...</span> <div class="toolbar-right">
</div> <div class="admin-actions no-print">
<div class="admin-actions no-print"> <button id="export-csv" type="button" disabled>CSV / Excel</button>
<button id="export-csv" type="button" disabled>Download CSV</button> <button id="print-list" type="button" disabled>Udskriv / PDF</button>
<button id="print-list" type="button" disabled>Udskriv / gem som PDF</button> </div>
<span id="count" class="count">Henter...</span>
</div>
</div> </div>
<div id="message" class="notice" hidden></div> <div id="message" class="notice" hidden></div>
<section class="participant-map no-print" aria-labelledby="map-title"> <section class="participant-map no-print" aria-labelledby="map-title">
@ -33,18 +35,21 @@
<table> <table>
<thead> <thead>
<tr> <tr>
<th>Navn</th> <th aria-sort="none"><button type="button" class="sort-button" data-sort="id">Skivenr.<span class="sort-arrow" aria-hidden="true"> ↕</span></button></th>
<th>Vejnavn og husnummer</th> <th aria-sort="none"><button type="button" class="sort-button" data-sort="name">Navn<span class="sort-arrow" aria-hidden="true"> ↕</span></button></th>
<th>Postnummer</th> <th aria-sort="none"><button type="button" class="sort-button" data-sort="streetAddress">Vejnavn og husnummer<span class="sort-arrow" aria-hidden="true"> ↕</span></button></th>
<th>Postby</th> <th aria-sort="none"><button type="button" class="sort-button" data-sort="postalCode">Postnummer<span class="sort-arrow" aria-hidden="true"> ↕</span></button></th>
<th>Fødselsdag</th> <th aria-sort="none"><button type="button" class="sort-button" data-sort="city">Postby<span class="sort-arrow" aria-hidden="true"> ↕</span></button></th>
<th>Email</th> <th aria-sort="none"><button type="button" class="sort-button" data-sort="birthday">Fødselsdag<span class="sort-arrow" aria-hidden="true"> ↕</span></button></th>
<th>Registreret</th> <th aria-sort="none"><button type="button" class="sort-button" data-sort="email">Email<span class="sort-arrow" aria-hidden="true"> ↕</span></button></th>
<th aria-sort="none"><button type="button" class="sort-button" data-sort="createdAt">Registreret<span class="sort-arrow" aria-hidden="true"> ↕</span></button></th>
<th aria-sort="none"><button type="button" class="sort-button" data-sort="result">Resultat<span class="sort-arrow" aria-hidden="true"> ↕</span></button></th>
<th aria-sort="none"><button type="button" class="sort-button" data-sort="x10">X-10<span class="sort-arrow" aria-hidden="true"> ↕</span></button></th>
<th class="no-print">Handling</th> <th class="no-print">Handling</th>
</tr> </tr>
</thead> </thead>
<tbody id="visitors"> <tbody id="visitors">
<tr><td colspan="8" class="empty">Henter besøgende...</td></tr> <tr><td colspan="11" class="empty">Henter besøgende...</td></tr>
</tbody> </tbody>
</table> </table>
</div> </div>

View File

@ -4,6 +4,44 @@ const message = document.querySelector("#message");
const csvButton = document.querySelector("#export-csv"); const csvButton = document.querySelector("#export-csv");
const printButton = document.querySelector("#print-list"); const printButton = document.querySelector("#print-list");
let visitors = []; let visitors = [];
let sortKey = "createdAt";
let sortDirection = -1;
const sortCollator = new Intl.Collator("da", { numeric: true, sensitivity: "base" });
function sortVisitors() {
// Move existing rows so unsaved result inputs are retained.
const rows = new Map([...tableBody.querySelectorAll("tr")].map((row) =>
[row.querySelector(".result-form")?.dataset.id, row]));
const value = (visitor) => {
const row = rows.get(String(visitor.id));
if (sortKey === "result") return row?.querySelector('[name="result"]')?.value ?? visitor.result;
if (sortKey === "x10") return row?.querySelector('[name="x10"]')?.value ?? visitor.x10;
return visitor[sortKey];
};
visitors.sort((a, b) => {
const av = value(a), bv = value(b);
if (av === null || av === undefined || av === "") return bv === null || bv === undefined || bv === "" ? a.id - b.id : 1;
if (bv === null || bv === undefined || bv === "") return -1;
return sortDirection * sortCollator.compare(String(av), String(bv)) || a.id - b.id;
});
for (const visitor of visitors) {
const row = rows.get(String(visitor.id));
if (row) tableBody.append(row);
}
document.querySelectorAll("button[data-sort]").forEach((button) => {
const active = button.dataset.sort === sortKey;
button.closest("th").setAttribute("aria-sort", active ? (sortDirection === 1 ? "ascending" : "descending") : "none");
button.querySelector(".sort-arrow").textContent = active ? (sortDirection === 1 ? " ↑" : " ↓") : " ↕";
});
}
document.querySelectorAll("button[data-sort]").forEach((button) => {
button.addEventListener("click", () => {
sortDirection = sortKey === button.dataset.sort ? -sortDirection : 1;
sortKey = button.dataset.sort;
sortVisitors();
});
});
function formatDateTime(value) { function formatDateTime(value) {
return new Intl.DateTimeFormat("da-DK", { return new Intl.DateTimeFormat("da-DK", {
@ -46,28 +84,71 @@ async function loadVisitors() {
count.textContent = `${visitors.length} registreret`; count.textContent = `${visitors.length} registreret`;
if (visitors.length === 0) { if (visitors.length === 0) {
tableBody.innerHTML = '<tr><td colspan="8" class="empty">Ingen besøgende registreret endnu.</td></tr>'; tableBody.innerHTML = '<tr><td colspan="11" class="empty">Ingen besøgende registreret endnu.</td></tr>';
return; return;
} }
tableBody.innerHTML = visitors.map((visitor) => ` tableBody.innerHTML = visitors.map((visitor) => `
<tr> <tr>
<td><strong>${visitor.id}</strong></td>
<td>${escapeHtml(visitor.name)}</td> <td>${escapeHtml(visitor.name)}</td>
<td>${escapeHtml(visitor.streetAddress)}</td> <td>${escapeHtml(visitor.streetAddress)}</td>
<td>${escapeHtml(visitor.postalCode)}</td> <td>${escapeHtml(visitor.postalCode)}</td>
<td>${escapeHtml(visitor.city)}</td> <td>${escapeHtml(visitor.city)}</td>
<td>${escapeHtml(visitor.birthday)}</td> <td>${escapeHtml(visitor.birthday)}</td>
<td><a href="mailto:${escapeHtml(visitor.email)}">${escapeHtml(visitor.email)}</a></td> <td>${visitor.email ? `<a href="mailto:${escapeHtml(visitor.email)}">${escapeHtml(visitor.email)}</a>` : ""}</td>
<td>${escapeHtml(formatDateTime(visitor.createdAt))}</td> <td>${escapeHtml(formatDateTime(visitor.createdAt))}</td>
<td>
<form id="result-${visitor.id}" class="result-form no-print" data-id="${visitor.id}">
<input name="result" value="${escapeHtml(visitor.result)}" maxlength="100" aria-label="Resultat for ${escapeHtml(visitor.name)}" placeholder="Resultat">
<span class="result-status" role="status"></span>
</form>
<span class="print-result">${escapeHtml(visitor.result)}</span>
</td>
<td>
<input class="x10-input no-print" form="result-${visitor.id}" name="x10" type="number" min="0" step="1" value="${escapeHtml(visitor.x10)}" aria-label="X-10 for ${escapeHtml(visitor.name)}">
<button class="save-result-button no-print" type="submit" form="result-${visitor.id}">Gem</button>
<span class="print-result print-x10">${escapeHtml(visitor.x10)}</span>
</td>
<td class="no-print"><button type="button" class="delete-button" data-id="${visitor.id}" aria-label="Slet ${escapeHtml(visitor.name)}">Slet</button></td> <td class="no-print"><button type="button" class="delete-button" data-id="${visitor.id}" aria-label="Slet ${escapeHtml(visitor.name)}">Slet</button></td>
</tr> </tr>
`).join(""); `).join("");
sortVisitors();
} catch { } catch {
showError("Der er ikke forbindelse til besøgslisten lige nu."); showError("Der er ikke forbindelse til besøgslisten lige nu.");
count.textContent = "Fejl"; count.textContent = "Fejl";
} }
} }
tableBody.addEventListener("submit", async (event) => {
const form = event.target.closest(".result-form");
if (!form) return;
event.preventDefault();
const button = form.closest("tr").querySelector(".save-result-button");
if (button.disabled) return;
const status = form.querySelector(".result-status");
const result = form.elements.result.value.trim();
const x10 = form.elements.x10.value === "" ? null : Number(form.elements.x10.value);
button.disabled = true;
status.textContent = "Gemmer...";
try {
const response = await fetch(`/api/visitors/${form.dataset.id}/result`, {
method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ result, x10 })
});
if (!response.ok) throw new Error("Resultat kunne ikke gemmes.");
const saved = await response.json();
const visitor = visitors.find((item) => String(item.id) === form.dataset.id);
if (visitor) { visitor.result = saved.result; visitor.x10 = saved.x10; }
form.elements.result.value = saved.result;
form.parentElement.querySelector(".print-result").textContent = saved.result;
form.elements.x10.value = saved.x10 ?? "";
form.closest("tr").querySelector(".print-x10").textContent = saved.x10 ?? "";
status.textContent = "Gemt";
} catch {
status.textContent = "Ikke gemt. Prøv igen.";
} finally { button.disabled = false; }
});
tableBody.addEventListener("click", async (event) => { tableBody.addEventListener("click", async (event) => {
const button = event.target.closest("button[data-id]"); const button = event.target.closest("button[data-id]");
if (!button || button.disabled) return; if (!button || button.disabled) return;
@ -97,9 +178,9 @@ function csvCell(value) {
csvButton.addEventListener("click", () => { csvButton.addEventListener("click", () => {
const rows = [ const rows = [
["Navn", "Vejnavn og husnummer", "Postnummer", "Postby", "Fødselsdag", "Email", "Registreret"], ["Skivenr.", "Navn", "Vejnavn og husnummer", "Postnummer", "Postby", "Fødselsdag", "Email", "Registreret", "Resultat", "X-10"],
...visitors.map((visitor) => [visitor.name, visitor.streetAddress, visitor.postalCode, ...visitors.map((visitor) => [visitor.id, visitor.name, visitor.streetAddress, visitor.postalCode,
visitor.city, visitor.birthday, visitor.email, formatDateTime(visitor.createdAt)]) visitor.city, visitor.birthday, visitor.email, formatDateTime(visitor.createdAt), visitor.result, visitor.x10])
]; ];
const csv = "\uFEFF" + rows.map((row) => row.map(csvCell).join(";")).join("\r\n") + "\r\n"; const csv = "\uFEFF" + rows.map((row) => row.map(csvCell).join(";")).join("\r\n") + "\r\n";
const url = URL.createObjectURL(new Blob([csv], { type: "text/csv;charset=utf-8" })); const url = URL.createObjectURL(new Blob([csv], { type: "text/csv;charset=utf-8" }));

View File

@ -35,9 +35,13 @@ form.addEventListener("submit", async (event) => {
return; return;
} }
const registration = await response.json();
form.reset(); form.reset();
citySuggestions.replaceChildren(); citySuggestions.replaceChildren();
showMessage("Tak, du er registreret.", "success"); showMessage(`Tak, du er registreret. Dit skivenummer er ${registration.number}. Skriv nummeret på dine skiver.`, "success");
const numberText = document.createElement("strong");
numberText.textContent = `Dit skivenummer er ${registration.number}`;
message.replaceChildren("Tak, du er registreret. ", numberText, ". Skriv nummeret på dine skiver.");
} catch { } catch {
showMessage("Der er ikke forbindelse til registreringen lige nu.", "error"); showMessage("Der er ikke forbindelse til registreringen lige nu.", "error");
} finally { } finally {

View File

@ -13,10 +13,10 @@
<p class="eyebrow">Vestfalster Skytteforening</p> <p class="eyebrow">Vestfalster Skytteforening</p>
<h1>Skydesportens Dag</h1> <h1>Skydesportens Dag</h1>
<p class="intro">Registrer dig som besøgende. Oplysningerne bruges kun i forbindelse med arrangementet.</p> <p class="intro">Registrer dig som besøgende. Oplysningerne bruges kun i forbindelse med arrangementet.</p>
<div id="message" class="notice" hidden></div> <div id="message" class="notice" role="status" hidden></div>
<form id="registration-form" class="form"> <form id="registration-form" class="form">
<label>Navn <label>Navn
<input name="name" autocomplete="name" required maxlength="160"> <input name="name" autocomplete="name" required maxlength="160" autofocus>
</label> </label>
<label>Vejnavn og husnummer <label>Vejnavn og husnummer
<input name="streetAddress" autocomplete="street-address" required maxlength="240"> <input name="streetAddress" autocomplete="street-address" required maxlength="240">
@ -33,8 +33,8 @@
<label>Fødselsdag <label>Fødselsdag
<input name="birthday" type="date" required> <input name="birthday" type="date" required>
</label> </label>
<label>Email <label>Email (valgfri)
<input name="email" type="email" autocomplete="email" required maxlength="240"> <input name="email" type="email" autocomplete="email" maxlength="240">
</label> </label>
<button type="submit">Registrer besøgende</button> <button type="submit">Registrer besøgende</button>
</form> </form>

View File

@ -210,9 +210,21 @@ a {
.admin-actions { .admin-actions {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
gap: 12px; gap: 6px;
margin-bottom: 20px; margin: 0;
} }
.toolbar-right { display: flex; align-items: center; justify-content: flex-end; gap: 12px; flex-wrap: wrap; }
.admin-actions button { margin: 0; padding: 8px 10px; font-size: 13px; }
th .sort-button { background: transparent; color: inherit; margin: 0; padding: 0; font-size: inherit; text-align: left; }
th .sort-button:hover { color: var(--accent); }
.sort-arrow { white-space: nowrap; }
.result-form { display: flex; flex-wrap: wrap; gap: 6px; min-width: 160px; }
.result-form input { width: 100px; flex: 1; padding: 8px; }
.result-form button { margin: 0; padding: 8px 10px; }
.result-status { width: 100%; font-size: 12px; }
.print-result { display: none; }
.x10-input { width: 80px; padding: 8px; }
.save-result-button { margin: 0 0 0 6px; padding: 8px 10px; }
.participant-map { margin: 0 0 24px; } .participant-map { margin: 0 0 24px; }
.participant-map h2 { margin: 0 0 8px; font-size: 22px; } .participant-map h2 { margin: 0 0 8px; font-size: 22px; }
@ -230,6 +242,7 @@ a {
} }
@media print { @media print {
.print-result { display: inline; }
@page { size: A4 landscape; margin: 12mm; } @page { size: A4 landscape; margin: 12mm; }
body { min-height: 0; background: #fff; color: #000; } body { min-height: 0; background: #fff; color: #000; }
.shell { width: 100%; margin: 0; padding: 0; } .shell { width: 100%; margin: 0; padding: 0; }