"swapping distro middle save"
This commit is contained in:
parent
89c6a5a340
commit
4e3be28cf3
17 changed files with 1723 additions and 290 deletions
|
|
@ -9,6 +9,12 @@ use rocket::serde::json::Json;
|
||||||
use rocket::Route;
|
use rocket::Route;
|
||||||
use sqlx::QueryBuilder;
|
use sqlx::QueryBuilder;
|
||||||
|
|
||||||
|
enum SearchCondition {
|
||||||
|
Checked(bool),
|
||||||
|
Inside(bool),
|
||||||
|
Query(String),
|
||||||
|
}
|
||||||
|
|
||||||
pub fn routes() -> Vec<Route> {
|
pub fn routes() -> Vec<Route> {
|
||||||
rocket::routes![
|
rocket::routes![
|
||||||
search_persons,
|
search_persons,
|
||||||
|
|
@ -139,17 +145,13 @@ pub async fn list_checked_in(
|
||||||
let mut conditions = Vec::new();
|
let mut conditions = Vec::new();
|
||||||
|
|
||||||
if let Some(checked_in) = checked {
|
if let Some(checked_in) = checked {
|
||||||
if checked_in {
|
conditions.push(SearchCondition::Checked(checked_in));
|
||||||
conditions.push("checked_in = true".to_string());
|
|
||||||
} else {
|
|
||||||
conditions.push("checked_in = false".to_string());
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if let Some(status) = status {
|
if let Some(status) = status {
|
||||||
match status {
|
match status {
|
||||||
"inside" => conditions.push("inside = true".to_string()),
|
"inside" => conditions.push(SearchCondition::Inside(true)),
|
||||||
"outside" => conditions.push("inside = false".to_string()),
|
"outside" => conditions.push(SearchCondition::Inside(false)),
|
||||||
_ => {}
|
_ => {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -162,18 +164,51 @@ pub async fn list_checked_in(
|
||||||
Some(trimmed.to_string())
|
Some(trimmed.to_string())
|
||||||
}
|
}
|
||||||
}) {
|
}) {
|
||||||
conditions.push(format!(
|
conditions.push(SearchCondition::Query(query));
|
||||||
"(CONCAT(first_name, ' ', last_name) ILIKE '%{query}%' OR parent_name ILIKE '%{query}%' OR parent_phone_number ILIKE '%{query}%')"
|
|
||||||
));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if !conditions.is_empty() {
|
let mut first = true;
|
||||||
query_builder.push(" WHERE ");
|
for condition in &conditions {
|
||||||
for (index, condition) in conditions.iter().enumerate() {
|
if first {
|
||||||
if index > 0 {
|
query_builder.push(" WHERE ");
|
||||||
query_builder.push(" AND ");
|
first = false;
|
||||||
|
} else {
|
||||||
|
query_builder.push(" AND ");
|
||||||
|
}
|
||||||
|
|
||||||
|
match condition {
|
||||||
|
SearchCondition::Checked(flag) => {
|
||||||
|
query_builder.push("checked_in = ");
|
||||||
|
query_builder.push_bind(*flag);
|
||||||
|
}
|
||||||
|
SearchCondition::Inside(flag) => {
|
||||||
|
query_builder.push("inside = ");
|
||||||
|
query_builder.push_bind(*flag);
|
||||||
|
}
|
||||||
|
SearchCondition::Query(term) => {
|
||||||
|
let like = format!("%{}%", term);
|
||||||
|
let starts_with = format!("{}%", term);
|
||||||
|
let digits: String = term.chars().filter(|ch| ch.is_ascii_digit()).collect();
|
||||||
|
|
||||||
|
query_builder.push("(");
|
||||||
|
query_builder.push("CAST(id AS TEXT) ILIKE ");
|
||||||
|
query_builder.push_bind(starts_with.clone());
|
||||||
|
query_builder.push(" OR CONCAT(first_name, ' ', last_name) ILIKE ");
|
||||||
|
query_builder.push_bind(like.clone());
|
||||||
|
query_builder.push(" OR parent_name ILIKE ");
|
||||||
|
query_builder.push_bind(like.clone());
|
||||||
|
query_builder.push(" OR parent_phone_number ILIKE ");
|
||||||
|
query_builder.push_bind(like.clone());
|
||||||
|
|
||||||
|
if !digits.is_empty() {
|
||||||
|
let digits_like = format!("%{}%", digits);
|
||||||
|
query_builder
|
||||||
|
.push(" OR REGEXP_REPLACE(parent_phone_number, '[^0-9]', '', 'g') ILIKE ");
|
||||||
|
query_builder.push_bind(digits_like);
|
||||||
|
}
|
||||||
|
|
||||||
|
query_builder.push(")");
|
||||||
}
|
}
|
||||||
query_builder.push(condition.as_str());
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -198,7 +233,8 @@ pub async fn checkin_person(
|
||||||
let person = sqlx::query_as::<_, Person>(
|
let person = sqlx::query_as::<_, Person>(
|
||||||
r#"
|
r#"
|
||||||
UPDATE persons
|
UPDATE persons
|
||||||
SET checked_in = true
|
SET checked_in = true,
|
||||||
|
inside = true
|
||||||
WHERE id = $1
|
WHERE id = $1
|
||||||
RETURNING
|
RETURNING
|
||||||
id,
|
id,
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ use crate::models::{
|
||||||
TournamentSignupFieldRecord, TournamentSignupSubmission, UpdateTournamentRequest,
|
TournamentSignupFieldRecord, TournamentSignupSubmission, UpdateTournamentRequest,
|
||||||
};
|
};
|
||||||
use crate::AppState;
|
use crate::AppState;
|
||||||
|
use rocket::http::Status;
|
||||||
use rocket::serde::json::Json;
|
use rocket::serde::json::Json;
|
||||||
use rocket::Route;
|
use rocket::Route;
|
||||||
use serde_json::{Map, Value};
|
use serde_json::{Map, Value};
|
||||||
|
|
@ -26,6 +27,8 @@ pub fn routes() -> Vec<Route> {
|
||||||
delete_tournament,
|
delete_tournament,
|
||||||
list_registrations_by_slug,
|
list_registrations_by_slug,
|
||||||
get_registration_detail_by_slug,
|
get_registration_detail_by_slug,
|
||||||
|
update_registration_by_slug,
|
||||||
|
delete_registration_by_slug,
|
||||||
create_registration_by_slug
|
create_registration_by_slug
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
@ -104,6 +107,29 @@ fn build_registration_url(slug: &str) -> String {
|
||||||
format!("/tournament/{slug}")
|
format!("/tournament/{slug}")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn signup_fields_equal(left: &[TournamentSignupField], right: &[TournamentSignupField]) -> bool {
|
||||||
|
if left.len() != right.len() {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
left.iter().zip(right.iter()).all(|(a, b)| {
|
||||||
|
a.id == b.id
|
||||||
|
&& a.label == b.label
|
||||||
|
&& a.field_type == b.field_type
|
||||||
|
&& a.required == b.required
|
||||||
|
&& a.placeholder == b.placeholder
|
||||||
|
&& a.unique == b.unique
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn signup_fields_changed(
|
||||||
|
existing: &TournamentSignupConfig,
|
||||||
|
updated: &TournamentSignupConfig,
|
||||||
|
) -> bool {
|
||||||
|
!signup_fields_equal(&existing.entry_fields, &updated.entry_fields)
|
||||||
|
|| !signup_fields_equal(&existing.participant_fields, &updated.participant_fields)
|
||||||
|
}
|
||||||
|
|
||||||
async fn load_sections(
|
async fn load_sections(
|
||||||
pool: &sqlx::PgPool,
|
pool: &sqlx::PgPool,
|
||||||
tournament_id: i32,
|
tournament_id: i32,
|
||||||
|
|
@ -403,6 +429,191 @@ async fn insert_participant_values(
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn load_registration_detail(
|
||||||
|
pool: &sqlx::PgPool,
|
||||||
|
info: TournamentInfo,
|
||||||
|
registration: TournamentRegistrationRow,
|
||||||
|
) -> Result<TournamentRegistrationDetailResponse, ApiError> {
|
||||||
|
let entry_value_rows = sqlx::query_as::<_, TournamentRegistrationValueRow>(
|
||||||
|
r#"
|
||||||
|
SELECT registration_id, signup_field_id, value
|
||||||
|
FROM tournament_registration_values
|
||||||
|
WHERE registration_id = $1
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(registration.id)
|
||||||
|
.fetch_all(pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let participant_rows = sqlx::query_as::<_, TournamentParticipantRow>(
|
||||||
|
r#"
|
||||||
|
SELECT id, registration_id, position
|
||||||
|
FROM tournament_participants
|
||||||
|
WHERE registration_id = $1
|
||||||
|
ORDER BY position ASC, id ASC
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(registration.id)
|
||||||
|
.fetch_all(pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let participant_value_rows = if participant_rows.is_empty() {
|
||||||
|
Vec::new()
|
||||||
|
} else {
|
||||||
|
let participant_ids: Vec<i32> = participant_rows.iter().map(|row| row.id).collect();
|
||||||
|
sqlx::query_as::<_, TournamentParticipantValueRow>(
|
||||||
|
r#"
|
||||||
|
SELECT participant_id, signup_field_id, value
|
||||||
|
FROM tournament_participant_values
|
||||||
|
WHERE participant_id = ANY($1)
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(&participant_ids)
|
||||||
|
.fetch_all(pool)
|
||||||
|
.await?
|
||||||
|
};
|
||||||
|
|
||||||
|
let field_records = load_signup_field_records(pool, info.id).await?;
|
||||||
|
let mut field_by_id: HashMap<i32, &TournamentSignupFieldRecord> = HashMap::new();
|
||||||
|
for record in &field_records {
|
||||||
|
field_by_id.insert(record.id, record);
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut entry_map = Map::new();
|
||||||
|
for row in entry_value_rows {
|
||||||
|
if let Some(field) = field_by_id.get(&row.signup_field_id) {
|
||||||
|
if field.scope == "entry" {
|
||||||
|
entry_map.insert(field.field_key.clone(), Value::String(row.value));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut participant_value_map: HashMap<i32, Map<String, Value>> = HashMap::new();
|
||||||
|
for row in participant_value_rows {
|
||||||
|
if let Some(field) = field_by_id.get(&row.signup_field_id) {
|
||||||
|
if field.scope == "participant" {
|
||||||
|
let map = participant_value_map
|
||||||
|
.entry(row.participant_id)
|
||||||
|
.or_insert_with(Map::new);
|
||||||
|
map.insert(field.field_key.clone(), Value::String(row.value));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut participant_array = Vec::new();
|
||||||
|
for participant in participant_rows {
|
||||||
|
let values = participant_value_map
|
||||||
|
.remove(&participant.id)
|
||||||
|
.map(Value::Object)
|
||||||
|
.unwrap_or_else(|| Value::Object(Map::new()));
|
||||||
|
participant_array.push(values);
|
||||||
|
}
|
||||||
|
|
||||||
|
let registration_item = TournamentRegistrationItem {
|
||||||
|
id: registration.id,
|
||||||
|
created_at: registration.created_at,
|
||||||
|
entry: Value::Object(entry_map),
|
||||||
|
participants: Value::Array(participant_array),
|
||||||
|
};
|
||||||
|
|
||||||
|
let tournament = load_tournament_data(pool, info).await?;
|
||||||
|
|
||||||
|
Ok(TournamentRegistrationDetailResponse {
|
||||||
|
tournament,
|
||||||
|
registration: registration_item,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
#[rocket::delete("/slug/<slug>/registrations/<registration_id>")]
|
||||||
|
pub async fn delete_registration_by_slug(
|
||||||
|
_user: AuthUser,
|
||||||
|
state: &rocket::State<AppState>,
|
||||||
|
slug: &str,
|
||||||
|
registration_id: i32,
|
||||||
|
) -> Result<Status, ApiError> {
|
||||||
|
let info = sqlx::query_as::<_, TournamentInfo>(
|
||||||
|
r#"
|
||||||
|
SELECT
|
||||||
|
id,
|
||||||
|
title,
|
||||||
|
game,
|
||||||
|
slug,
|
||||||
|
tagline,
|
||||||
|
start_at,
|
||||||
|
location,
|
||||||
|
description,
|
||||||
|
contact,
|
||||||
|
signup_mode,
|
||||||
|
team_size_min,
|
||||||
|
team_size_max,
|
||||||
|
created_at,
|
||||||
|
updated_at
|
||||||
|
FROM tournament_info
|
||||||
|
WHERE slug = $1
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(slug)
|
||||||
|
.fetch_optional(&state.db)
|
||||||
|
.await?
|
||||||
|
.ok_or_else(|| ApiError::not_found("Turneringen hittades inte."))?;
|
||||||
|
|
||||||
|
let registration = sqlx::query_as::<_, TournamentRegistrationRow>(
|
||||||
|
r#"
|
||||||
|
SELECT id, tournament_id, entry_label, created_at
|
||||||
|
FROM tournament_registrations
|
||||||
|
WHERE id = $1 AND tournament_id = $2
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(registration_id)
|
||||||
|
.bind(info.id)
|
||||||
|
.fetch_optional(&state.db)
|
||||||
|
.await?
|
||||||
|
.ok_or_else(|| ApiError::not_found("Anmälan hittades inte."))?;
|
||||||
|
|
||||||
|
let mut tx = state.db.begin().await?;
|
||||||
|
|
||||||
|
sqlx::query(
|
||||||
|
"DELETE FROM tournament_participant_values WHERE participant_id IN (
|
||||||
|
SELECT id FROM tournament_participants WHERE registration_id = $1
|
||||||
|
)",
|
||||||
|
)
|
||||||
|
.bind(registration.id)
|
||||||
|
.execute(&mut *tx)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
sqlx::query("DELETE FROM tournament_participants WHERE registration_id = $1")
|
||||||
|
.bind(registration.id)
|
||||||
|
.execute(&mut *tx)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
sqlx::query("DELETE FROM tournament_registration_values WHERE registration_id = $1")
|
||||||
|
.bind(registration.id)
|
||||||
|
.execute(&mut *tx)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
sqlx::query("DELETE FROM tournament_registrations WHERE id = $1")
|
||||||
|
.bind(registration.id)
|
||||||
|
.execute(&mut *tx)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
tx.commit().await?;
|
||||||
|
|
||||||
|
match load_tournament_data(&state.db, info).await {
|
||||||
|
Ok(tournament) => {
|
||||||
|
let _ = state
|
||||||
|
.event_sender
|
||||||
|
.send(AppEvent::TournamentUpserted { tournament });
|
||||||
|
}
|
||||||
|
Err(err) => {
|
||||||
|
eprintln!(
|
||||||
|
"Failed to load tournament data after deleting registration {registration_id}: {err:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(Status::NoContent)
|
||||||
|
}
|
||||||
|
|
||||||
#[rocket::get("/")]
|
#[rocket::get("/")]
|
||||||
pub async fn list_tournaments(
|
pub async fn list_tournaments(
|
||||||
state: &rocket::State<AppState>,
|
state: &rocket::State<AppState>,
|
||||||
|
|
@ -619,6 +830,35 @@ pub async fn update_tournament(
|
||||||
) -> Result<Json<TournamentItemResponse>, ApiError> {
|
) -> Result<Json<TournamentItemResponse>, ApiError> {
|
||||||
let request = payload.into_inner();
|
let request = payload.into_inner();
|
||||||
|
|
||||||
|
let existing_info = sqlx::query_as::<_, TournamentInfo>(
|
||||||
|
r#"
|
||||||
|
SELECT
|
||||||
|
id,
|
||||||
|
title,
|
||||||
|
game,
|
||||||
|
slug,
|
||||||
|
tagline,
|
||||||
|
start_at,
|
||||||
|
location,
|
||||||
|
description,
|
||||||
|
contact,
|
||||||
|
signup_mode,
|
||||||
|
team_size_min,
|
||||||
|
team_size_max,
|
||||||
|
created_at,
|
||||||
|
updated_at
|
||||||
|
FROM tournament_info
|
||||||
|
WHERE id = $1
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(id)
|
||||||
|
.fetch_optional(&state.db)
|
||||||
|
.await?
|
||||||
|
.ok_or_else(|| ApiError::not_found("Turneringen hittades inte."))?;
|
||||||
|
|
||||||
|
let existing_field_records = load_signup_field_records(&state.db, existing_info.id).await?;
|
||||||
|
let existing_config = build_signup_config(&existing_info, &existing_field_records);
|
||||||
|
|
||||||
if request.title.trim().is_empty() {
|
if request.title.trim().is_empty() {
|
||||||
return Err(ApiError::bad_request("Turneringen måste ha en titel."));
|
return Err(ApiError::bad_request("Turneringen måste ha en titel."));
|
||||||
}
|
}
|
||||||
|
|
@ -639,6 +879,8 @@ pub async fn update_tournament(
|
||||||
let description = normalize_optional(request.description);
|
let description = normalize_optional(request.description);
|
||||||
let contact = normalize_optional(request.contact);
|
let contact = normalize_optional(request.contact);
|
||||||
|
|
||||||
|
let fields_changed = signup_fields_changed(&existing_config, &signup_config);
|
||||||
|
|
||||||
let mut tx = state.db.begin().await?;
|
let mut tx = state.db.begin().await?;
|
||||||
|
|
||||||
let info = sqlx::query_as::<_, TournamentInfo>(
|
let info = sqlx::query_as::<_, TournamentInfo>(
|
||||||
|
|
@ -700,7 +942,9 @@ pub async fn update_tournament(
|
||||||
.ok_or_else(|| ApiError::not_found("Turneringen hittades inte."))?;
|
.ok_or_else(|| ApiError::not_found("Turneringen hittades inte."))?;
|
||||||
|
|
||||||
insert_sections(&mut tx, id, §ions).await?;
|
insert_sections(&mut tx, id, §ions).await?;
|
||||||
insert_signup_fields(&mut tx, id, &signup_config).await?;
|
if fields_changed {
|
||||||
|
insert_signup_fields(&mut tx, id, &signup_config).await?;
|
||||||
|
}
|
||||||
|
|
||||||
tx.commit().await?;
|
tx.commit().await?;
|
||||||
|
|
||||||
|
|
@ -1053,6 +1297,11 @@ pub async fn create_registration_by_slug(
|
||||||
|
|
||||||
tx.commit().await?;
|
tx.commit().await?;
|
||||||
|
|
||||||
|
let tournament_data = load_tournament_data(&state.db, info).await?;
|
||||||
|
let _ = state.event_sender.send(AppEvent::TournamentUpserted {
|
||||||
|
tournament: tournament_data,
|
||||||
|
});
|
||||||
|
|
||||||
Ok(Json(TournamentRegistrationResponse {
|
Ok(Json(TournamentRegistrationResponse {
|
||||||
registration_id: registration.id,
|
registration_id: registration.id,
|
||||||
}))
|
}))
|
||||||
|
|
@ -1265,92 +1514,298 @@ pub async fn get_registration_detail_by_slug(
|
||||||
.await?
|
.await?
|
||||||
.ok_or_else(|| ApiError::not_found("Anmälan hittades inte."))?;
|
.ok_or_else(|| ApiError::not_found("Anmälan hittades inte."))?;
|
||||||
|
|
||||||
let entry_value_rows = sqlx::query_as::<_, TournamentRegistrationValueRow>(
|
let detail = load_registration_detail(&state.db, info, registration).await?;
|
||||||
|
|
||||||
|
Ok(Json(detail))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[rocket::put("/slug/<slug>/registrations/<registration_id>", data = "<payload>")]
|
||||||
|
pub async fn update_registration_by_slug(
|
||||||
|
_user: AuthUser,
|
||||||
|
state: &rocket::State<AppState>,
|
||||||
|
slug: &str,
|
||||||
|
registration_id: i32,
|
||||||
|
payload: Json<TournamentSignupSubmission>,
|
||||||
|
) -> Result<Json<TournamentRegistrationDetailResponse>, ApiError> {
|
||||||
|
let info = sqlx::query_as::<_, TournamentInfo>(
|
||||||
r#"
|
r#"
|
||||||
SELECT registration_id, signup_field_id, value
|
SELECT
|
||||||
FROM tournament_registration_values
|
id,
|
||||||
WHERE registration_id = $1
|
title,
|
||||||
|
game,
|
||||||
|
slug,
|
||||||
|
tagline,
|
||||||
|
start_at,
|
||||||
|
location,
|
||||||
|
description,
|
||||||
|
contact,
|
||||||
|
signup_mode,
|
||||||
|
team_size_min,
|
||||||
|
team_size_max,
|
||||||
|
created_at,
|
||||||
|
updated_at
|
||||||
|
FROM tournament_info
|
||||||
|
WHERE slug = $1
|
||||||
"#,
|
"#,
|
||||||
)
|
)
|
||||||
.bind(registration.id)
|
.bind(slug)
|
||||||
.fetch_all(&state.db)
|
.fetch_optional(&state.db)
|
||||||
.await?;
|
.await?
|
||||||
|
.ok_or_else(|| ApiError::not_found("Turneringen hittades inte."))?;
|
||||||
|
|
||||||
let participant_rows = sqlx::query_as::<_, TournamentParticipantRow>(
|
let mut registration = sqlx::query_as::<_, TournamentRegistrationRow>(
|
||||||
r#"
|
r#"
|
||||||
SELECT id, registration_id, position
|
SELECT id, tournament_id, entry_label, created_at
|
||||||
FROM tournament_participants
|
FROM tournament_registrations
|
||||||
WHERE registration_id = $1
|
WHERE id = $1 AND tournament_id = $2
|
||||||
ORDER BY position ASC, id ASC
|
|
||||||
"#,
|
"#,
|
||||||
)
|
)
|
||||||
.bind(registration.id)
|
.bind(registration_id)
|
||||||
.fetch_all(&state.db)
|
.bind(info.id)
|
||||||
.await?;
|
.fetch_optional(&state.db)
|
||||||
|
.await?
|
||||||
let participant_value_rows = if participant_rows.is_empty() {
|
.ok_or_else(|| ApiError::not_found("Anmälan hittades inte."))?;
|
||||||
Vec::new()
|
|
||||||
} else {
|
|
||||||
let participant_ids: Vec<i32> = participant_rows.iter().map(|row| row.id).collect();
|
|
||||||
sqlx::query_as::<_, TournamentParticipantValueRow>(
|
|
||||||
r#"
|
|
||||||
SELECT participant_id, signup_field_id, value
|
|
||||||
FROM tournament_participant_values
|
|
||||||
WHERE participant_id = ANY($1)
|
|
||||||
"#,
|
|
||||||
)
|
|
||||||
.bind(&participant_ids)
|
|
||||||
.fetch_all(&state.db)
|
|
||||||
.await?
|
|
||||||
};
|
|
||||||
|
|
||||||
let field_records = load_signup_field_records(&state.db, info.id).await?;
|
let field_records = load_signup_field_records(&state.db, info.id).await?;
|
||||||
let mut field_by_id: HashMap<i32, &TournamentSignupFieldRecord> = HashMap::new();
|
let config = build_signup_config(&info, &field_records);
|
||||||
for record in &field_records {
|
let submission = payload.into_inner();
|
||||||
field_by_id.insert(record.id, record);
|
|
||||||
|
validate_submission(&config, &submission)?;
|
||||||
|
|
||||||
|
let mut entry_values: HashMap<String, String> = HashMap::new();
|
||||||
|
for field in &config.entry_fields {
|
||||||
|
let value = submission
|
||||||
|
.entry
|
||||||
|
.get(&field.id)
|
||||||
|
.map(|v| v.trim().to_string())
|
||||||
|
.unwrap_or_default();
|
||||||
|
entry_values.insert(field.id.clone(), value);
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut entry_map = Map::new();
|
let mut participant_values: Vec<HashMap<String, String>> = Vec::new();
|
||||||
for row in entry_value_rows {
|
for participant in submission.participants {
|
||||||
if let Some(field) = field_by_id.get(&row.signup_field_id) {
|
let mut map = HashMap::new();
|
||||||
if field.scope == "entry" {
|
for field in &config.participant_fields {
|
||||||
entry_map.insert(field.field_key.clone(), Value::String(row.value));
|
let value = participant
|
||||||
|
.get(&field.id)
|
||||||
|
.map(|v| v.trim().to_string())
|
||||||
|
.unwrap_or_default();
|
||||||
|
map.insert(field.id.clone(), value);
|
||||||
|
}
|
||||||
|
participant_values.push(map);
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut field_map: HashMap<String, TournamentSignupFieldRecord> = HashMap::new();
|
||||||
|
for record in field_records {
|
||||||
|
field_map.insert(record.field_key.clone(), record);
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut tx = state.db.begin().await?;
|
||||||
|
|
||||||
|
let entry_label = config
|
||||||
|
.entry_fields
|
||||||
|
.first()
|
||||||
|
.and_then(|field| entry_values.get(&field.id))
|
||||||
|
.and_then(|value| trimmed(Some(value)));
|
||||||
|
|
||||||
|
let entry_label_requires_unique = config
|
||||||
|
.entry_fields
|
||||||
|
.first()
|
||||||
|
.map(|field| field.unique)
|
||||||
|
.unwrap_or(false);
|
||||||
|
|
||||||
|
if entry_label_requires_unique {
|
||||||
|
if let Some(label) = entry_label.clone() {
|
||||||
|
let is_duplicate = sqlx::query_scalar::<_, bool>(
|
||||||
|
r#"
|
||||||
|
SELECT EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM tournament_registrations
|
||||||
|
WHERE tournament_id = $1
|
||||||
|
AND entry_label = $2
|
||||||
|
AND id <> $3
|
||||||
|
)
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(info.id)
|
||||||
|
.bind(&label)
|
||||||
|
.bind(registration.id)
|
||||||
|
.fetch_one(&state.db)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
if is_duplicate {
|
||||||
|
return Err(ApiError::bad_request(
|
||||||
|
"Den här spelaren eller laget är redan anmäld till turneringen.",
|
||||||
|
));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut participant_value_map: HashMap<i32, Map<String, Value>> = HashMap::new();
|
for field in &config.entry_fields {
|
||||||
for row in participant_value_rows {
|
if !field.unique {
|
||||||
if let Some(field) = field_by_id.get(&row.signup_field_id) {
|
continue;
|
||||||
if field.scope == "participant" {
|
}
|
||||||
let map = participant_value_map
|
|
||||||
.entry(row.participant_id)
|
if let Some(value) = entry_values.get(&field.id) {
|
||||||
.or_insert_with(Map::new);
|
if value.is_empty() {
|
||||||
map.insert(field.field_key.clone(), Value::String(row.value));
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(record) = field_map.get(&field.id) {
|
||||||
|
let exists = sqlx::query_scalar::<_, bool>(
|
||||||
|
r#"
|
||||||
|
SELECT EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM tournament_registration_values v
|
||||||
|
INNER JOIN tournament_registrations r ON r.id = v.registration_id
|
||||||
|
WHERE r.tournament_id = $1
|
||||||
|
AND v.signup_field_id = $2
|
||||||
|
AND v.value = $3
|
||||||
|
AND r.id <> $4
|
||||||
|
)
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(info.id)
|
||||||
|
.bind(record.id)
|
||||||
|
.bind(value)
|
||||||
|
.bind(registration.id)
|
||||||
|
.fetch_one(&state.db)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
if exists {
|
||||||
|
return Err(ApiError::bad_request(format!(
|
||||||
|
"Värdet för '{label}' används redan i en annan anmälan.",
|
||||||
|
label = field.label
|
||||||
|
)));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut participant_array = Vec::new();
|
for participant_values_map in &participant_values {
|
||||||
for participant in participant_rows {
|
for field in &config.participant_fields {
|
||||||
let values = participant_value_map
|
if !field.unique {
|
||||||
.remove(&participant.id)
|
continue;
|
||||||
.map(Value::Object)
|
}
|
||||||
.unwrap_or_else(|| Value::Object(Map::new()));
|
|
||||||
participant_array.push(values);
|
if let Some(value) = participant_values_map.get(&field.id) {
|
||||||
|
if value.is_empty() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(record) = field_map.get(&field.id) {
|
||||||
|
let exists = sqlx::query_scalar::<_, bool>(
|
||||||
|
r#"
|
||||||
|
SELECT EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM tournament_participant_values pv
|
||||||
|
INNER JOIN tournament_participants tp ON tp.id = pv.participant_id
|
||||||
|
INNER JOIN tournament_registrations tr ON tr.id = tp.registration_id
|
||||||
|
WHERE tr.tournament_id = $1
|
||||||
|
AND pv.signup_field_id = $2
|
||||||
|
AND pv.value = $3
|
||||||
|
AND tr.id <> $4
|
||||||
|
)
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(info.id)
|
||||||
|
.bind(record.id)
|
||||||
|
.bind(value)
|
||||||
|
.bind(registration.id)
|
||||||
|
.fetch_one(&state.db)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
if exists {
|
||||||
|
return Err(ApiError::bad_request(format!(
|
||||||
|
"Värdet för '{label}' används redan i en annan anmälan.",
|
||||||
|
label = field.label
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let registration_item = TournamentRegistrationItem {
|
sqlx::query("DELETE FROM tournament_registration_values WHERE registration_id = $1")
|
||||||
id: registration.id,
|
.bind(registration.id)
|
||||||
created_at: registration.created_at,
|
.execute(&mut *tx)
|
||||||
entry: Value::Object(entry_map),
|
.await?;
|
||||||
participants: Value::Array(participant_array),
|
|
||||||
};
|
|
||||||
|
|
||||||
let tournament = load_tournament_data(&state.db, info.clone()).await?;
|
sqlx::query(
|
||||||
|
"DELETE FROM tournament_participant_values WHERE participant_id IN (
|
||||||
|
SELECT id FROM tournament_participants WHERE registration_id = $1
|
||||||
|
)",
|
||||||
|
)
|
||||||
|
.bind(registration.id)
|
||||||
|
.execute(&mut *tx)
|
||||||
|
.await?;
|
||||||
|
|
||||||
Ok(Json(TournamentRegistrationDetailResponse {
|
sqlx::query("DELETE FROM tournament_participants WHERE registration_id = $1")
|
||||||
tournament,
|
.bind(registration.id)
|
||||||
registration: registration_item,
|
.execute(&mut *tx)
|
||||||
}))
|
.await?;
|
||||||
|
|
||||||
|
sqlx::query(
|
||||||
|
r#"
|
||||||
|
UPDATE tournament_registrations
|
||||||
|
SET entry_label = $1
|
||||||
|
WHERE id = $2
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(entry_label)
|
||||||
|
.bind(registration.id)
|
||||||
|
.execute(&mut *tx)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
for field in &config.entry_fields {
|
||||||
|
if let Some(record) = field_map.get(&field.id) {
|
||||||
|
if let Some(value) = entry_values.get(&field.id) {
|
||||||
|
insert_registration_values(&mut tx, registration.id, record, value).await?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (position, values) in participant_values.iter().enumerate() {
|
||||||
|
let participant = sqlx::query_as::<_, TournamentParticipantRow>(
|
||||||
|
r#"
|
||||||
|
INSERT INTO tournament_participants (registration_id, position)
|
||||||
|
VALUES ($1, $2)
|
||||||
|
RETURNING id, registration_id, position
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(registration.id)
|
||||||
|
.bind(position as i32)
|
||||||
|
.fetch_one(&mut *tx)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
for field in &config.participant_fields {
|
||||||
|
if let Some(record) = field_map.get(&field.id) {
|
||||||
|
if let Some(value) = values.get(&field.id) {
|
||||||
|
insert_participant_values(&mut tx, participant.id, record, value).await?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
tx.commit().await?;
|
||||||
|
|
||||||
|
registration = sqlx::query_as::<_, TournamentRegistrationRow>(
|
||||||
|
r#"
|
||||||
|
SELECT id, tournament_id, entry_label, created_at
|
||||||
|
FROM tournament_registrations
|
||||||
|
WHERE id = $1 AND tournament_id = $2
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(registration_id)
|
||||||
|
.bind(info.id)
|
||||||
|
.fetch_one(&state.db)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let detail = load_registration_detail(&state.db, info, registration).await?;
|
||||||
|
|
||||||
|
let _ = state.event_sender.send(AppEvent::TournamentUpserted {
|
||||||
|
tournament: detail.tournament.clone(),
|
||||||
|
});
|
||||||
|
|
||||||
|
Ok(Json(detail))
|
||||||
}
|
}
|
||||||
|
|
|
||||||
128
web/src/lib/client/person-search.ts
Normal file
128
web/src/lib/client/person-search.ts
Normal file
|
|
@ -0,0 +1,128 @@
|
||||||
|
import type { Person } from '$lib/types';
|
||||||
|
|
||||||
|
type NormalizedQuery = {
|
||||||
|
text: string;
|
||||||
|
digits: string;
|
||||||
|
numericId: number | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
function normalizeQuery(raw: string): NormalizedQuery {
|
||||||
|
const trimmed = raw.trim();
|
||||||
|
if (!trimmed) {
|
||||||
|
return { text: '', digits: '', numericId: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
const numericId = /^\d+$/.test(trimmed) ? Number.parseInt(trimmed, 10) : null;
|
||||||
|
const lower = trimmed.toLowerCase();
|
||||||
|
const digits = trimmed.replace(/\D/g, '');
|
||||||
|
|
||||||
|
return {
|
||||||
|
text: lower,
|
||||||
|
digits,
|
||||||
|
numericId
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function personMatchesQuery(person: Person, rawQuery: string): boolean {
|
||||||
|
const query = normalizeQuery(rawQuery);
|
||||||
|
if (!query.text) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (query.numericId !== null && person.id === query.numericId) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const idString = person.id.toString();
|
||||||
|
if (idString.includes(query.text)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const fullName = `${person.first_name} ${person.last_name}`.toLowerCase();
|
||||||
|
if (fullName.includes(query.text)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const parentName = person.parent_name?.toLowerCase() ?? '';
|
||||||
|
if (parentName && parentName.includes(query.text)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (query.digits) {
|
||||||
|
const phoneDigits = (person.parent_phone_number ?? '').replace(/\D/g, '');
|
||||||
|
if (phoneDigits.includes(query.digits)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (query.text && person.grade !== null && query.text === String(person.grade)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function scorePerson(person: Person, query: NormalizedQuery): number {
|
||||||
|
if (!query.text) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
let score = 0;
|
||||||
|
|
||||||
|
if (query.numericId !== null && person.id === query.numericId) {
|
||||||
|
return 1_000;
|
||||||
|
}
|
||||||
|
|
||||||
|
const idString = person.id.toString();
|
||||||
|
if (idString.startsWith(query.text)) {
|
||||||
|
score += 180;
|
||||||
|
} else if (idString.includes(query.text)) {
|
||||||
|
score += 140;
|
||||||
|
}
|
||||||
|
|
||||||
|
const fullName = `${person.first_name} ${person.last_name}`.toLowerCase();
|
||||||
|
if (fullName.includes(query.text)) {
|
||||||
|
score += 120;
|
||||||
|
}
|
||||||
|
|
||||||
|
const parentName = person.parent_name?.toLowerCase() ?? '';
|
||||||
|
if (parentName && parentName.includes(query.text)) {
|
||||||
|
score += 100;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (query.digits) {
|
||||||
|
const phoneDigits = (person.parent_phone_number ?? '').replace(/\D/g, '');
|
||||||
|
if (phoneDigits.includes(query.digits)) {
|
||||||
|
score += 80;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (person.grade !== null && query.text === String(person.grade)) {
|
||||||
|
score += 40;
|
||||||
|
}
|
||||||
|
|
||||||
|
return score;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sortPersonsByQuery(list: Person[], rawQuery: string): Person[] {
|
||||||
|
const query = normalizeQuery(rawQuery);
|
||||||
|
if (!query.text) {
|
||||||
|
return [...list];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [...list].sort((a, b) => {
|
||||||
|
const scoreA = scorePerson(a, query);
|
||||||
|
const scoreB = scorePerson(b, query);
|
||||||
|
if (scoreA !== scoreB) {
|
||||||
|
return scoreB - scoreA;
|
||||||
|
}
|
||||||
|
return a.id - b.id;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function filterPersonsByQuery(list: Person[], rawQuery: string): Person[] {
|
||||||
|
if (!rawQuery.trim()) {
|
||||||
|
return [...list];
|
||||||
|
}
|
||||||
|
return list.filter((person) => personMatchesQuery(person, rawQuery));
|
||||||
|
}
|
||||||
|
|
@ -9,6 +9,10 @@
|
||||||
isLowerGrade,
|
isLowerGrade,
|
||||||
personIsComplete
|
personIsComplete
|
||||||
} from '$lib/client/person-utils';
|
} from '$lib/client/person-utils';
|
||||||
|
import {
|
||||||
|
personMatchesQuery,
|
||||||
|
sortPersonsByQuery
|
||||||
|
} from '$lib/client/person-search';
|
||||||
import EditPersonModal from '$lib/components/edit-person-modal.svelte';
|
import EditPersonModal from '$lib/components/edit-person-modal.svelte';
|
||||||
|
|
||||||
let searchQuery = $state('');
|
let searchQuery = $state('');
|
||||||
|
|
@ -158,8 +162,11 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateVisibleResults() {
|
function updateVisibleResults() {
|
||||||
const filtered = searchResults.filter((person) => !person.checked_in);
|
const query = searchQuery.trim();
|
||||||
visibleResults = filtered;
|
const filtered = searchResults.filter(
|
||||||
|
(person) => !person.checked_in && (!query || personMatchesQuery(person, query))
|
||||||
|
);
|
||||||
|
visibleResults = query ? sortPersonsByQuery(filtered, query) : filtered;
|
||||||
|
|
||||||
if (searchResults.length === 0) {
|
if (searchResults.length === 0) {
|
||||||
searchInfo = 'Ingen träff på sökningen.';
|
searchInfo = 'Ingen träff på sökningen.';
|
||||||
|
|
@ -179,6 +186,7 @@
|
||||||
stop();
|
stop();
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="space-y-6">
|
<div class="space-y-6">
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@
|
||||||
import { listenToPersonEvents } from '$lib/client/person-events';
|
import { listenToPersonEvents } from '$lib/client/person-events';
|
||||||
import { updateCollection } from '$lib/client/person-collection';
|
import { updateCollection } from '$lib/client/person-collection';
|
||||||
import { gradeLabel, guardianLabel, isLowerGrade } from '$lib/client/person-utils';
|
import { gradeLabel, guardianLabel, isLowerGrade } from '$lib/client/person-utils';
|
||||||
|
import { personMatchesQuery, sortPersonsByQuery } from '$lib/client/person-search';
|
||||||
import EditPersonModal from '$lib/components/edit-person-modal.svelte';
|
import EditPersonModal from '$lib/components/edit-person-modal.svelte';
|
||||||
|
|
||||||
type StatusFilter = 'all' | 'inside' | 'outside';
|
type StatusFilter = 'all' | 'inside' | 'outside';
|
||||||
|
|
@ -49,22 +50,16 @@ type VisitorFilter = 'all' | 'besoksplats' | 'lanplats';
|
||||||
if (visitorFilter === 'besoksplats' && !person.visitor) return false;
|
if (visitorFilter === 'besoksplats' && !person.visitor) return false;
|
||||||
if (visitorFilter === 'lanplats' && person.visitor) return false;
|
if (visitorFilter === 'lanplats' && person.visitor) return false;
|
||||||
if (sleepingFilter === 'needs' && !person.sleeping_spot) return false;
|
if (sleepingFilter === 'needs' && !person.sleeping_spot) return false;
|
||||||
if (sleepingFilter === 'not-needed' && person.sleeping_spot) return false;
|
if (sleepingFilter === 'not-needed' && person.sleeping_spot) return false;
|
||||||
if (searchQuery.trim()) {
|
const trimmed = searchQuery.trim();
|
||||||
const query = searchQuery.trim().toLowerCase();
|
if (trimmed && !personMatchesQuery(person, trimmed)) return false;
|
||||||
const matchesText =
|
|
||||||
`${person.first_name} ${person.last_name}`.toLowerCase().includes(query) ||
|
|
||||||
person.parent_name?.toLowerCase().includes(query) ||
|
|
||||||
person.parent_phone_number?.toLowerCase().includes(query) ||
|
|
||||||
person.id.toString() === query;
|
|
||||||
if (!matchesText) return false;
|
|
||||||
}
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateVisiblePersons() {
|
function updateVisiblePersons() {
|
||||||
const filtered = allPersons.filter((person) => matchesFilters(person));
|
const filtered = allPersons.filter((person) => matchesFilters(person));
|
||||||
persons = filtered;
|
const query = searchQuery.trim();
|
||||||
|
persons = query ? sortPersonsByQuery(filtered, query) : filtered;
|
||||||
infoMessage = filtered.length === 0 ? 'Inga personer matchar kriterierna.' : '';
|
infoMessage = filtered.length === 0 ? 'Inga personer matchar kriterierna.' : '';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
import { listenToPersonEvents } from '$lib/client/person-events';
|
import { listenToPersonEvents } from '$lib/client/person-events';
|
||||||
import { gradeLabel, guardianLabel, isLowerGrade } from '$lib/client/person-utils';
|
import { gradeLabel, guardianLabel, isLowerGrade } from '$lib/client/person-utils';
|
||||||
|
import { personMatchesQuery, sortPersonsByQuery } from '$lib/client/person-search';
|
||||||
|
|
||||||
type GradeFilter = 'all' | 'lt4' | 'ge4';
|
type GradeFilter = 'all' | 'lt4' | 'ge4';
|
||||||
type VisitorFilter = 'all' | 'besoksplats' | 'lanplats';
|
type VisitorFilter = 'all' | 'besoksplats' | 'lanplats';
|
||||||
|
|
@ -42,17 +43,9 @@
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
const query = searchQuery.trim().toLowerCase();
|
const query = searchQuery.trim();
|
||||||
if (query) {
|
if (query && !personMatchesQuery(person, query)) {
|
||||||
const combinedName = `${person.first_name} ${person.last_name}`.toLowerCase();
|
return false;
|
||||||
const matchesText =
|
|
||||||
combinedName.includes(query) ||
|
|
||||||
person.parent_name?.toLowerCase().includes(query) ||
|
|
||||||
person.parent_phone_number?.toLowerCase().includes(query) ||
|
|
||||||
person.id.toString() === query;
|
|
||||||
if (!matchesText) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
|
|
@ -176,7 +169,8 @@
|
||||||
|
|
||||||
function updateVisibleResults() {
|
function updateVisibleResults() {
|
||||||
const filtered = searchResults.filter((person) => matchesFilters(person));
|
const filtered = searchResults.filter((person) => matchesFilters(person));
|
||||||
visibleResults = filtered;
|
const query = searchQuery.trim();
|
||||||
|
visibleResults = query ? sortPersonsByQuery(filtered, query) : filtered;
|
||||||
|
|
||||||
if (searchResults.length === 0 && !searchQuery.trim()) {
|
if (searchResults.length === 0 && !searchQuery.trim()) {
|
||||||
searchInfo = 'Inga personer hämtades.';
|
searchInfo = 'Inga personer hämtades.';
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@
|
||||||
import { listenToPersonEvents } from '$lib/client/person-events';
|
import { listenToPersonEvents } from '$lib/client/person-events';
|
||||||
import { updateCollection } from '$lib/client/person-collection';
|
import { updateCollection } from '$lib/client/person-collection';
|
||||||
import { gradeLabel, guardianLabel, isLowerGrade } from '$lib/client/person-utils';
|
import { gradeLabel, guardianLabel, isLowerGrade } from '$lib/client/person-utils';
|
||||||
|
import { personMatchesQuery, sortPersonsByQuery } from '$lib/client/person-search';
|
||||||
|
|
||||||
type StatusFilter = 'all' | 'inside' | 'outside';
|
type StatusFilter = 'all' | 'inside' | 'outside';
|
||||||
|
|
||||||
|
|
@ -33,21 +34,15 @@
|
||||||
if (!person.checked_in) return false;
|
if (!person.checked_in) return false;
|
||||||
if (statusFilter === 'inside' && !person.inside) return false;
|
if (statusFilter === 'inside' && !person.inside) return false;
|
||||||
if (statusFilter === 'outside' && person.inside) return false;
|
if (statusFilter === 'outside' && person.inside) return false;
|
||||||
const query = searchQuery.trim().toLowerCase();
|
const query = searchQuery.trim();
|
||||||
if (query) {
|
if (query && !personMatchesQuery(person, query)) return false;
|
||||||
const matchesText =
|
|
||||||
`${person.first_name} ${person.last_name}`.toLowerCase().includes(query) ||
|
|
||||||
person.parent_name?.toLowerCase().includes(query) ||
|
|
||||||
person.parent_phone_number?.toLowerCase().includes(query) ||
|
|
||||||
person.id.toString() === query;
|
|
||||||
if (!matchesText) return false;
|
|
||||||
}
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
function applyFilteredList(list: Person[]) {
|
function applyFilteredList(list: Person[]) {
|
||||||
const filtered = list.filter(matchesFilters);
|
const filtered = list.filter(matchesFilters);
|
||||||
persons = filtered;
|
const query = searchQuery.trim();
|
||||||
|
persons = query ? sortPersonsByQuery(filtered, query) : filtered;
|
||||||
if (persons.length === 0) {
|
if (persons.length === 0) {
|
||||||
infoMessage = 'Inga incheckade personer matchar kriterierna.';
|
infoMessage = 'Inga incheckade personer matchar kriterierna.';
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -57,6 +52,10 @@
|
||||||
|
|
||||||
function handlePersonUpdate(person: Person) {
|
function handlePersonUpdate(person: Person) {
|
||||||
persons = updateCollection(persons, person, matchesFilters);
|
persons = updateCollection(persons, person, matchesFilters);
|
||||||
|
const query = searchQuery.trim();
|
||||||
|
if (query) {
|
||||||
|
persons = sortPersonsByQuery(persons, query);
|
||||||
|
}
|
||||||
if (persons.length === 0) {
|
if (persons.length === 0) {
|
||||||
infoMessage = 'Inga incheckade personer matchar kriterierna.';
|
infoMessage = 'Inga incheckade personer matchar kriterierna.';
|
||||||
} else {
|
} else {
|
||||||
|
|
|
||||||
|
|
@ -1,25 +1,17 @@
|
||||||
import { error } from '@sveltejs/kit';
|
import { error } from '@sveltejs/kit';
|
||||||
import type { PageServerLoad } from './$types';
|
import type { PageServerLoad } from './$types';
|
||||||
import type { TournamentRegistrationList } from '$lib/types';
|
import { ApiRequestError, fetchRegistrationList, parseApiMessage } from './helpers.server';
|
||||||
|
|
||||||
|
const loadErrorFallback = 'Kunde inte hämta anmälningar.';
|
||||||
|
|
||||||
export const load: PageServerLoad = async ({ fetch, params }) => {
|
export const load: PageServerLoad = async ({ fetch, params }) => {
|
||||||
const response = await fetch(`/api/tournament/slug/${params.slug}/registrations`);
|
try {
|
||||||
const text = await response.text();
|
return await fetchRegistrationList(fetch, params.slug);
|
||||||
|
} catch (err) {
|
||||||
if (!response.ok) {
|
if (err instanceof ApiRequestError) {
|
||||||
let message = 'Kunde inte hämta anmälningar.';
|
const message = parseApiMessage(err.body, loadErrorFallback);
|
||||||
try {
|
throw error(err.status, message);
|
||||||
const body = JSON.parse(text);
|
|
||||||
message = body.message ?? message;
|
|
||||||
} catch {
|
|
||||||
if (text) message = text;
|
|
||||||
}
|
}
|
||||||
throw error(response.status, message);
|
throw err;
|
||||||
}
|
}
|
||||||
|
|
||||||
const data = JSON.parse(text) as TournamentRegistrationList;
|
|
||||||
return {
|
|
||||||
tournament: data.tournament,
|
|
||||||
registrations: data.registrations
|
|
||||||
};
|
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,107 @@
|
||||||
|
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { TournamentRegistrationList, TournamentSignupField } from '$lib/types';
|
import { onMount } from 'svelte';
|
||||||
|
import { listenToTournamentEvents } from '$lib/client/tournament-events';
|
||||||
|
import type {
|
||||||
|
TournamentRegistrationList,
|
||||||
|
TournamentRegistrationItem,
|
||||||
|
TournamentSignupField
|
||||||
|
} from '$lib/types';
|
||||||
|
|
||||||
const props = $props<{ data: TournamentRegistrationList }>();
|
const props = $props<{ data: TournamentRegistrationList }>();
|
||||||
const data = props.data;
|
let tournament = $state(props.data.tournament);
|
||||||
const tournament = data.tournament;
|
let registrations = $state(props.data.registrations ?? []);
|
||||||
const registrations = data.registrations;
|
let refreshing = $state(false);
|
||||||
|
let loadError = $state('');
|
||||||
|
|
||||||
const entryFields = tournament.signup_config.entry_fields ?? [];
|
type RegistrationResponse = {
|
||||||
const participantFields = tournament.signup_config.participant_fields ?? [];
|
tournament: TournamentRegistrationList['tournament'];
|
||||||
|
registrations: TournamentRegistrationItem[];
|
||||||
|
};
|
||||||
|
|
||||||
|
type RegistrationPayload = RegistrationResponse & { warning?: string };
|
||||||
|
|
||||||
|
function applyResult(result: RegistrationResponse) {
|
||||||
|
tournament = result.tournament;
|
||||||
|
registrations = result.registrations ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
async function callEndpoint(
|
||||||
|
endpoint: string,
|
||||||
|
payload: unknown,
|
||||||
|
defaultMessage: string
|
||||||
|
): Promise<RegistrationPayload> {
|
||||||
|
if (!tournament.slug) {
|
||||||
|
throw new Error('Turneringen saknar slug.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const init: RequestInit = { method: 'POST' };
|
||||||
|
if (payload !== undefined) {
|
||||||
|
init.headers = { 'content-type': 'application/json' };
|
||||||
|
init.body = JSON.stringify(payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch(
|
||||||
|
`/admin/tournament/${tournament.slug}/registrations/${endpoint}`,
|
||||||
|
init
|
||||||
|
);
|
||||||
|
const text = await response.text();
|
||||||
|
const trimmed = text.trim();
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
let message = defaultMessage;
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(text);
|
||||||
|
if (parsed?.message) {
|
||||||
|
message = parsed.message;
|
||||||
|
} else if (trimmed) {
|
||||||
|
message = trimmed;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
if (trimmed) {
|
||||||
|
message = trimmed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw new Error(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!trimmed) {
|
||||||
|
throw new Error('Tomt svar från servern.');
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const data = JSON.parse(text) as RegistrationPayload;
|
||||||
|
if (
|
||||||
|
!data ||
|
||||||
|
typeof data !== 'object' ||
|
||||||
|
!('tournament' in data) ||
|
||||||
|
!('registrations' in data)
|
||||||
|
) {
|
||||||
|
throw new Error();
|
||||||
|
}
|
||||||
|
return data;
|
||||||
|
} catch {
|
||||||
|
throw new Error('Kunde inte tolka svaret från servern.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let editingId = $state<number | null>(null);
|
||||||
|
let editEntry = $state<Record<string, string>>({});
|
||||||
|
let editParticipants = $state<Record<string, string>[]>([]);
|
||||||
|
let editError = $state('');
|
||||||
|
let editSaving = $state(false);
|
||||||
|
let deletingId = $state<number | null>(null);
|
||||||
|
let deleteError = $state('');
|
||||||
|
|
||||||
|
const entryFields = $derived(() => tournament.signup_config.entry_fields ?? []);
|
||||||
|
const participantFields = $derived(() => tournament.signup_config.participant_fields ?? []);
|
||||||
|
const signupConfig = $derived(() => tournament.signup_config);
|
||||||
|
const minParticipants = $derived(() =>
|
||||||
|
signupConfig().mode === 'team' ? Math.max(1, signupConfig().team_size.min) : 0
|
||||||
|
);
|
||||||
|
const maxParticipants = $derived(() =>
|
||||||
|
signupConfig().mode === 'team' ? Math.max(1, signupConfig().team_size.max) : 0
|
||||||
|
);
|
||||||
|
|
||||||
function formatDateTime(value: string | null) {
|
function formatDateTime(value: string | null) {
|
||||||
if (!value) return null;
|
if (!value) return null;
|
||||||
|
|
@ -27,6 +121,243 @@
|
||||||
const value = map[field.id];
|
const value = map[field.id];
|
||||||
return value ?? '';
|
return value ?? '';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function blankEntryMap() {
|
||||||
|
const map: Record<string, string> = {};
|
||||||
|
for (const field of entryFields()) {
|
||||||
|
map[field.id] = '';
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}
|
||||||
|
|
||||||
|
function blankParticipantMap() {
|
||||||
|
const map: Record<string, string> = {};
|
||||||
|
for (const field of participantFields()) {
|
||||||
|
map[field.id] = '';
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}
|
||||||
|
|
||||||
|
function fieldInputType(fieldType: string) {
|
||||||
|
switch (fieldType) {
|
||||||
|
case 'email':
|
||||||
|
return 'email';
|
||||||
|
case 'tel':
|
||||||
|
return 'tel';
|
||||||
|
default:
|
||||||
|
return 'text';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureParticipantBounds(list: Record<string, string>[]) {
|
||||||
|
if (signupConfig().mode !== 'team') {
|
||||||
|
return list;
|
||||||
|
}
|
||||||
|
let next = [...list];
|
||||||
|
const min = minParticipants();
|
||||||
|
const max = Math.max(min, maxParticipants());
|
||||||
|
if (next.length < min) {
|
||||||
|
for (let i = next.length; i < min; i += 1) {
|
||||||
|
next = [...next, blankParticipantMap()];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (next.length > max) {
|
||||||
|
next = next.slice(0, max);
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
|
||||||
|
function beginEdit(registration: TournamentRegistrationItem) {
|
||||||
|
editingId = registration.id;
|
||||||
|
editEntry = {
|
||||||
|
...blankEntryMap(),
|
||||||
|
...registration.entry
|
||||||
|
};
|
||||||
|
editParticipants = ensureParticipantBounds(
|
||||||
|
(registration.participants ?? []).map((participant) => ({
|
||||||
|
...blankParticipantMap(),
|
||||||
|
...participant
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
editError = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function cancelEdit() {
|
||||||
|
editingId = null;
|
||||||
|
editEntry = {};
|
||||||
|
editParticipants = [];
|
||||||
|
editError = '';
|
||||||
|
editSaving = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateEntryField(fieldId: string, value: string) {
|
||||||
|
editEntry = { ...editEntry, [fieldId]: value };
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateParticipantField(index: number, fieldId: string, value: string) {
|
||||||
|
editParticipants = editParticipants.map((participant, idx) =>
|
||||||
|
idx === index ? { ...participant, [fieldId]: value } : participant
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function addParticipant() {
|
||||||
|
if (signupConfig().mode !== 'team') return;
|
||||||
|
if (maxParticipants() > 0 && editParticipants.length >= maxParticipants()) return;
|
||||||
|
editParticipants = [...editParticipants, blankParticipantMap()];
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeParticipant(index: number) {
|
||||||
|
if (signupConfig().mode !== 'team') return;
|
||||||
|
const min = minParticipants();
|
||||||
|
if (editParticipants.length <= Math.max(1, min)) return;
|
||||||
|
editParticipants = editParticipants.filter((_, idx) => idx !== index);
|
||||||
|
}
|
||||||
|
|
||||||
|
function participantLabel(index: number) {
|
||||||
|
if (signupConfig().mode === 'team') {
|
||||||
|
return `Spelare ${index + 1}`;
|
||||||
|
}
|
||||||
|
return 'Spelare';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveEdit(registration: TournamentRegistrationItem) {
|
||||||
|
if (!tournament.slug || editingId !== registration.id) return;
|
||||||
|
editSaving = true;
|
||||||
|
editError = '';
|
||||||
|
const payload = {
|
||||||
|
entry: { ...editEntry },
|
||||||
|
participants: editParticipants.map((participant) => ({ ...participant }))
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
const result = await callEndpoint(
|
||||||
|
'update',
|
||||||
|
{
|
||||||
|
registration_id: registration.id,
|
||||||
|
entry: payload.entry,
|
||||||
|
participants: payload.participants
|
||||||
|
},
|
||||||
|
'Kunde inte uppdatera anmälan.'
|
||||||
|
);
|
||||||
|
applyResult(result);
|
||||||
|
loadError = result.warning ?? '';
|
||||||
|
cancelEdit();
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to update registration', err);
|
||||||
|
editError = err instanceof Error ? err.message : 'Ett oväntat fel inträffade.';
|
||||||
|
} finally {
|
||||||
|
editSaving = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function exportRegistrations() {
|
||||||
|
if (typeof window === 'undefined') return;
|
||||||
|
const lines: string[] = [];
|
||||||
|
const timestamp = formatDateTime(new Date().toISOString()) ?? new Date().toISOString();
|
||||||
|
lines.push(`${tournament.title} – ${registrations.length} anmälningar`);
|
||||||
|
lines.push(`Genererad ${timestamp}`);
|
||||||
|
lines.push('');
|
||||||
|
|
||||||
|
if (registrations.length === 0) {
|
||||||
|
lines.push('Inga anmälningar.');
|
||||||
|
} else {
|
||||||
|
for (const registration of registrations) {
|
||||||
|
lines.push(`Anmälan #${registration.id}`);
|
||||||
|
const created = formatDateTime(registration.created_at) ?? registration.created_at;
|
||||||
|
lines.push(`Skapad: ${created}`);
|
||||||
|
|
||||||
|
if (entryFields().length > 0) {
|
||||||
|
lines.push('Lag / deltagare:');
|
||||||
|
for (const field of entryFields()) {
|
||||||
|
lines.push(` ${field.label}: ${fieldValue(registration.entry, field) || '—'}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (participantFields().length === 0) {
|
||||||
|
if (registration.participants.length > 0) {
|
||||||
|
lines.push(`Spelare: ${registration.participants.length}`);
|
||||||
|
}
|
||||||
|
} else if (registration.participants.length === 0) {
|
||||||
|
lines.push('Spelare: inga angivna');
|
||||||
|
} else {
|
||||||
|
lines.push('Spelare:');
|
||||||
|
registration.participants.forEach((participant, index) => {
|
||||||
|
lines.push(` Spelare ${index + 1}`);
|
||||||
|
for (const field of participantFields()) {
|
||||||
|
lines.push(` ${field.label}: ${fieldValue(participant, field) || '—'}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
lines.push('');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const blob = new Blob([lines.join('\n')], { type: 'text/plain;charset=utf-8' });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const link = document.createElement('a');
|
||||||
|
link.href = url;
|
||||||
|
link.download = `${tournament.slug?.trim() || 'turnering'}-registreringar.txt`;
|
||||||
|
document.body.appendChild(link);
|
||||||
|
link.click();
|
||||||
|
document.body.removeChild(link);
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function removeRegistration(registration: TournamentRegistrationItem) {
|
||||||
|
if (!tournament.slug) return;
|
||||||
|
if (!window.confirm('Ta bort denna anmälan? Det går inte att ångra.')) return;
|
||||||
|
deletingId = registration.id;
|
||||||
|
deleteError = '';
|
||||||
|
try {
|
||||||
|
const result = await callEndpoint(
|
||||||
|
'delete',
|
||||||
|
{ registration_id: registration.id },
|
||||||
|
'Kunde inte ta bort anmälan.'
|
||||||
|
);
|
||||||
|
applyResult(result);
|
||||||
|
loadError = result.warning ?? '';
|
||||||
|
if (editingId === registration.id) {
|
||||||
|
cancelEdit();
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to delete registration', err);
|
||||||
|
deleteError = err instanceof Error ? err.message : 'Ett oväntat fel inträffade.';
|
||||||
|
} finally {
|
||||||
|
deletingId = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refreshRegistrations() {
|
||||||
|
if (!tournament.slug) return;
|
||||||
|
refreshing = true;
|
||||||
|
loadError = '';
|
||||||
|
try {
|
||||||
|
const result = await callEndpoint('refresh', undefined, 'Kunde inte hämta anmälningarna.');
|
||||||
|
applyResult(result);
|
||||||
|
loadError = result.warning ?? '';
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to refresh registrations', err);
|
||||||
|
loadError = err instanceof Error ? err.message : 'Ett oväntat fel inträffade.';
|
||||||
|
} finally {
|
||||||
|
refreshing = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
const stop = listenToTournamentEvents((updated) => {
|
||||||
|
if (updated.id === tournament.id && editingId === null) {
|
||||||
|
void refreshRegistrations();
|
||||||
|
}
|
||||||
|
}, (deletedId) => {
|
||||||
|
if (deletedId === tournament.id) {
|
||||||
|
registrations = [];
|
||||||
|
loadError = 'Turneringen har tagits bort.';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
stop();
|
||||||
|
};
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:head>
|
<svelte:head>
|
||||||
|
|
@ -73,21 +404,40 @@
|
||||||
<div class="rounded-lg border border-slate-200 bg-slate-50 p-4">
|
<div class="rounded-lg border border-slate-200 bg-slate-50 p-4">
|
||||||
<p class="text-xs uppercase tracking-wide text-slate-500">Format</p>
|
<p class="text-xs uppercase tracking-wide text-slate-500">Format</p>
|
||||||
<p class="mt-1 text-sm text-slate-800">
|
<p class="mt-1 text-sm text-slate-800">
|
||||||
{tournament.signup_config.mode === 'team'
|
{signupConfig().mode === 'team'
|
||||||
? `Lag (${tournament.signup_config.team_size.min}–${tournament.signup_config.team_size.max} spelare)`
|
? `Lag (${signupConfig().team_size.min}–${signupConfig().team_size.max} spelare)`
|
||||||
: 'Individuell'}
|
: 'Individuell'}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="rounded-lg border border-slate-200 bg-white p-6 shadow-sm">
|
<section class="rounded-lg border border-slate-200 bg-white p-6 shadow-sm">
|
||||||
<header class="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
<header class="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
||||||
<h2 class="text-lg font-semibold text-slate-900">Registreringar</h2>
|
<h2 class="text-lg font-semibold text-slate-900">Registreringar</h2>
|
||||||
{#if registrations.length > 0}
|
<div class="flex flex-wrap items-center gap-2">
|
||||||
<p class="text-sm text-slate-500">Senaste: {formatDateTime(registrations[0].created_at) ?? registrations[0].created_at}</p>
|
{#if registrations.length > 0}
|
||||||
{/if}
|
<p class="text-sm text-slate-500">Senaste: {formatDateTime(registrations[0].created_at) ?? registrations[0].created_at}</p>
|
||||||
</header>
|
{/if}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={exportRegistrations}
|
||||||
|
class="rounded-full border border-slate-300 px-4 py-2 text-xs font-semibold uppercase tracking-wide text-slate-600 transition hover:bg-slate-100"
|
||||||
|
>
|
||||||
|
Exportera .txt
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{#if loadError}
|
||||||
|
<p class="mt-2 rounded-md border border-red-200 bg-red-500/10 px-4 py-2 text-sm text-red-600">{loadError}</p>
|
||||||
|
{/if}
|
||||||
|
{#if deleteError}
|
||||||
|
<p class="mt-2 rounded-md border border-red-200 bg-red-500/10 px-4 py-2 text-sm text-red-600">{deleteError}</p>
|
||||||
|
{/if}
|
||||||
|
{#if refreshing && !loadError}
|
||||||
|
<p class="mt-2 text-xs text-slate-500">Uppdaterar…</p>
|
||||||
|
{/if}
|
||||||
|
|
||||||
{#if registrations.length === 0}
|
{#if registrations.length === 0}
|
||||||
<p class="mt-4 rounded-md border border-dashed border-slate-300 px-4 py-6 text-center text-sm text-slate-500">
|
<p class="mt-4 rounded-md border border-dashed border-slate-300 px-4 py-6 text-center text-sm text-slate-500">
|
||||||
|
|
@ -97,52 +447,188 @@
|
||||||
<div class="mt-6 space-y-5">
|
<div class="mt-6 space-y-5">
|
||||||
{#each registrations as registration}
|
{#each registrations as registration}
|
||||||
<article class="space-y-4 rounded-lg border border-slate-200 bg-slate-50 p-4">
|
<article class="space-y-4 rounded-lg border border-slate-200 bg-slate-50 p-4">
|
||||||
<header class="flex flex-col gap-1 sm:flex-row sm:items-center sm:justify-between">
|
<header class="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
||||||
<h3 class="text-base font-semibold text-slate-900">Anmälan #{registration.id}</h3>
|
<div>
|
||||||
<p class="text-xs uppercase tracking-wide text-slate-500">
|
<h3 class="text-base font-semibold text-slate-900">Anmälan #{registration.id}</h3>
|
||||||
Skapad {formatDateTime(registration.created_at) ?? registration.created_at}
|
<p class="text-xs uppercase tracking-wide text-slate-500">
|
||||||
</p>
|
Skapad {formatDateTime(registration.created_at) ?? registration.created_at}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
{#if editingId === registration.id}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={cancelEdit}
|
||||||
|
class="rounded-full border border-slate-300 px-3 py-1 text-xs font-semibold text-slate-600 transition hover:bg-slate-100"
|
||||||
|
>
|
||||||
|
Avbryt
|
||||||
|
</button>
|
||||||
|
{:else}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={() => beginEdit(registration)}
|
||||||
|
class="rounded-full border border-indigo-300 px-3 py-1 text-xs font-semibold text-indigo-600 transition hover:border-indigo-400 hover:bg-indigo-50"
|
||||||
|
>
|
||||||
|
Redigera
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={() => removeRegistration(registration)}
|
||||||
|
disabled={deletingId === registration.id || editSaving}
|
||||||
|
class="rounded-full border border-red-300 px-3 py-1 text-xs font-semibold text-red-600 transition hover:border-red-400 hover:bg-red-50 disabled:cursor-not-allowed disabled:opacity-60"
|
||||||
|
>
|
||||||
|
{deletingId === registration.id ? 'Tar bort…' : 'Ta bort'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
{#if entryFields.length > 0}
|
{#if editingId === registration.id}
|
||||||
<div class="grid gap-3 md:grid-cols-2">
|
<form class="space-y-4" onsubmit={(event) => {
|
||||||
{#each entryFields as field}
|
event.preventDefault();
|
||||||
<div class="rounded-md border border-slate-200 bg-white p-3">
|
saveEdit(registration);
|
||||||
<p class="text-xs uppercase tracking-wide text-slate-500">{field.label}</p>
|
}}>
|
||||||
<p class="mt-1 text-sm text-slate-800">{fieldValue(registration.entry, field) || '—'}</p>
|
{#if entryFields().length > 0}
|
||||||
|
<div class="space-y-3">
|
||||||
|
<h4 class="text-sm font-semibold uppercase tracking-wide text-slate-500">Lag / deltagare</h4>
|
||||||
|
<div class="grid gap-3 md:grid-cols-2">
|
||||||
|
{#each entryFields() as field}
|
||||||
|
<label class="flex flex-col gap-1 text-sm font-medium text-slate-700">
|
||||||
|
<span>{field.label}</span>
|
||||||
|
<input
|
||||||
|
type={fieldInputType(field.field_type)}
|
||||||
|
value={editEntry[field.id] ?? ''}
|
||||||
|
oninput={(event) => updateEntryField(field.id, (event.currentTarget as HTMLInputElement).value)}
|
||||||
|
placeholder={field.placeholder ?? ''}
|
||||||
|
disabled={editSaving}
|
||||||
|
class="rounded-md border border-slate-300 px-3 py-2 text-sm text-slate-900 focus:border-indigo-500 focus:outline-none focus:ring focus:ring-indigo-500/40"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<section class="space-y-2">
|
|
||||||
<h4 class="text-sm font-semibold text-slate-800">Spelare</h4>
|
|
||||||
{#if participantFields.length === 0}
|
|
||||||
{#if registration.participants.length === 0}
|
|
||||||
<p class="text-xs text-slate-500">Inga spelare angivna.</p>
|
|
||||||
{:else}
|
|
||||||
<p class="text-xs text-slate-500">Antal spelare: {registration.participants.length}</p>
|
|
||||||
{/if}
|
{/if}
|
||||||
{:else if registration.participants.length === 0}
|
|
||||||
<p class="text-xs text-slate-500">Inga spelare angivna.</p>
|
<section class="space-y-3">
|
||||||
{:else}
|
<div class="flex items-center justify-between">
|
||||||
<div class="space-y-3">
|
<h4 class="text-sm font-semibold uppercase tracking-wide text-slate-500">Spelare</h4>
|
||||||
{#each registration.participants as participant, index}
|
{#if signupConfig().mode === 'team'}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={addParticipant}
|
||||||
|
disabled={editSaving || (maxParticipants() > 0 && editParticipants.length >= maxParticipants())}
|
||||||
|
class="rounded-full border border-indigo-300 px-3 py-1 text-xs font-semibold text-indigo-600 transition hover:border-indigo-400 hover:bg-indigo-50 disabled:cursor-not-allowed disabled:opacity-60"
|
||||||
|
>
|
||||||
|
Lägg till spelare
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if participantFields().length === 0}
|
||||||
|
<p class="text-xs text-slate-500">Inga spelarspecifika fält att redigera.</p>
|
||||||
|
{:else if editParticipants.length === 0}
|
||||||
|
<p class="text-xs text-slate-500">Inga spelare angivna.</p>
|
||||||
|
{:else}
|
||||||
|
<div class="space-y-3">
|
||||||
|
{#each editParticipants as participant, index}
|
||||||
|
<div class="space-y-3 rounded-md border border-slate-200 bg-white p-3">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<p class="text-xs font-semibold uppercase tracking-wide text-slate-500">{participantLabel(index)}</p>
|
||||||
|
{#if signupConfig().mode === 'team'}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={() => removeParticipant(index)}
|
||||||
|
disabled={editSaving || editParticipants.length <= Math.max(1, minParticipants())}
|
||||||
|
class="rounded-full border border-red-200 px-3 py-1 text-xs font-semibold text-red-600 transition hover:border-red-400 hover:bg-red-50 disabled:cursor-not-allowed disabled:opacity-60"
|
||||||
|
>
|
||||||
|
Ta bort
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
<div class="grid gap-3 md:grid-cols-2">
|
||||||
|
{#each participantFields() as field}
|
||||||
|
<label class="flex flex-col gap-1 text-sm font-medium text-slate-700">
|
||||||
|
<span>{field.label}</span>
|
||||||
|
<input
|
||||||
|
type={fieldInputType(field.field_type)}
|
||||||
|
value={participant[field.id] ?? ''}
|
||||||
|
oninput={(event) => updateParticipantField(index, field.id, (event.currentTarget as HTMLInputElement).value)}
|
||||||
|
placeholder={field.placeholder ?? ''}
|
||||||
|
disabled={editSaving}
|
||||||
|
class="rounded-md border border-slate-300 px-3 py-2 text-sm text-slate-900 focus:border-indigo-500 focus:outline-none focus:ring focus:ring-indigo-500/40"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{#if editError}
|
||||||
|
<p class="rounded-md border border-red-200 bg-red-500/10 px-3 py-2 text-sm text-red-600">{editError}</p>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div class="flex flex-wrap items-center gap-3 pt-2">
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={editSaving}
|
||||||
|
class="inline-flex items-center justify-center rounded-full bg-indigo-500 px-5 py-2 text-sm font-semibold text-white transition hover:bg-indigo-600 disabled:cursor-not-allowed disabled:opacity-60"
|
||||||
|
>
|
||||||
|
{editSaving ? 'Sparar…' : 'Spara ändringar'}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={cancelEdit}
|
||||||
|
disabled={editSaving}
|
||||||
|
class="inline-flex items-center justify-center rounded-full border border-slate-300 px-5 py-2 text-sm font-semibold text-slate-600 transition hover:bg-slate-100 disabled:cursor-not-allowed disabled:opacity-60"
|
||||||
|
>
|
||||||
|
Avbryt
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
{:else}
|
||||||
|
{#if entryFields().length > 0}
|
||||||
|
<div class="grid gap-3 md:grid-cols-2">
|
||||||
|
{#each entryFields() as field}
|
||||||
<div class="rounded-md border border-slate-200 bg-white p-3">
|
<div class="rounded-md border border-slate-200 bg-white p-3">
|
||||||
<p class="text-xs font-semibold uppercase tracking-wide text-slate-500">Spelare {index + 1}</p>
|
<p class="text-xs uppercase tracking-wide text-slate-500">{field.label}</p>
|
||||||
<ul class="mt-2 space-y-1 text-sm text-slate-800">
|
<p class="mt-1 text-sm text-slate-800">{fieldValue(registration.entry, field) || '—'}</p>
|
||||||
{#each participantFields as field}
|
|
||||||
<li>
|
|
||||||
<span class="font-medium text-slate-600">{field.label}:</span>
|
|
||||||
<span class="ml-1">{fieldValue(participant, field) || '—'}</span>
|
|
||||||
</li>
|
|
||||||
{/each}
|
|
||||||
</ul>
|
|
||||||
</div>
|
</div>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</section>
|
|
||||||
|
<section class="space-y-2">
|
||||||
|
<h4 class="text-sm font-semibold text-slate-800">Spelare</h4>
|
||||||
|
{#if participantFields().length === 0}
|
||||||
|
{#if registration.participants.length === 0}
|
||||||
|
<p class="text-xs text-slate-500">Inga spelare angivna.</p>
|
||||||
|
{:else}
|
||||||
|
<p class="text-xs text-slate-500">Antal spelare: {registration.participants.length}</p>
|
||||||
|
{/if}
|
||||||
|
{:else if registration.participants.length === 0}
|
||||||
|
<p class="text-xs text-slate-500">Inga spelare angivna.</p>
|
||||||
|
{:else}
|
||||||
|
<div class="space-y-3">
|
||||||
|
{#each registration.participants as participant, index}
|
||||||
|
<div class="rounded-md border border-slate-200 bg-white p-3">
|
||||||
|
<p class="text-xs font-semibold uppercase tracking-wide text-slate-500">Spelare {index + 1}</p>
|
||||||
|
<ul class="mt-2 space-y-1 text-sm text-slate-800">
|
||||||
|
{#each participantFields() as field}
|
||||||
|
<li>
|
||||||
|
<span class="font-medium text-slate-600">{field.label}:</span>
|
||||||
|
<span class="ml-1">{fieldValue(participant, field) || '—'}</span>
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</section>
|
||||||
|
{/if}
|
||||||
</article>
|
</article>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,109 @@
|
||||||
|
import { json } from '@sveltejs/kit';
|
||||||
|
import type { RequestHandler } from './$types';
|
||||||
|
import { ApiRequestError, fetchRegistrationList, parseApiMessage } from '../helpers.server';
|
||||||
|
import type { TournamentInfo } from '$lib/types';
|
||||||
|
|
||||||
|
const deleteErrorFallback = 'Kunde inte ta bort anmälan.';
|
||||||
|
|
||||||
|
export const POST: RequestHandler = async ({ fetch, params, request }) => {
|
||||||
|
try {
|
||||||
|
const snapshot = await fetchRegistrationList(fetch, params.slug);
|
||||||
|
const body = await request.json().catch(() => null);
|
||||||
|
const registrationId =
|
||||||
|
body && typeof body.registration_id === 'number'
|
||||||
|
? body.registration_id
|
||||||
|
: Number.parseInt(body?.registration_id ?? '', 10);
|
||||||
|
|
||||||
|
if (!Number.isFinite(registrationId)) {
|
||||||
|
return json({ message: 'Ogiltigt anmälnings-ID.' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const apiResponse = await fetch(
|
||||||
|
`/api/tournament/slug/${params.slug}/registrations/${registrationId}`,
|
||||||
|
{ method: 'DELETE' }
|
||||||
|
);
|
||||||
|
const text = await apiResponse.text();
|
||||||
|
if (!apiResponse.ok) {
|
||||||
|
const message = parseApiMessage(text, deleteErrorFallback);
|
||||||
|
try {
|
||||||
|
const data = await fetchRegistrationList(fetch, params.slug);
|
||||||
|
const removed = !data.registrations.some(
|
||||||
|
(registration) => registration.id === registrationId
|
||||||
|
);
|
||||||
|
if (removed) {
|
||||||
|
return json({ ...data, warning: message }, { status: 200 });
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof ApiRequestError) {
|
||||||
|
const fallbackRegistration = snapshot.registrations.find(
|
||||||
|
(registration) => registration.id === registrationId
|
||||||
|
);
|
||||||
|
const filteredRegistrations = snapshot.registrations.filter(
|
||||||
|
(registration) => registration.id !== registrationId
|
||||||
|
);
|
||||||
|
const participantAdjustment = fallbackRegistration?.participants?.length ?? 0;
|
||||||
|
const fallbackTournament: TournamentInfo = {
|
||||||
|
...snapshot.tournament,
|
||||||
|
total_registrations: Math.max(snapshot.tournament.total_registrations - 1, 0),
|
||||||
|
total_participants: Math.max(
|
||||||
|
snapshot.tournament.total_participants - participantAdjustment,
|
||||||
|
0
|
||||||
|
),
|
||||||
|
updated_at: new Date().toISOString()
|
||||||
|
};
|
||||||
|
return json(
|
||||||
|
{
|
||||||
|
warning: message,
|
||||||
|
tournament: fallbackTournament,
|
||||||
|
registrations: filteredRegistrations
|
||||||
|
},
|
||||||
|
{ status: 200 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
return json({ message }, { status: apiResponse.status });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const data = await fetchRegistrationList(fetch, params.slug);
|
||||||
|
return json(data);
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof ApiRequestError) {
|
||||||
|
const message = parseApiMessage(err.body, deleteErrorFallback);
|
||||||
|
const fallbackRegistration = snapshot.registrations.find(
|
||||||
|
(registration) => registration.id === registrationId
|
||||||
|
);
|
||||||
|
const filteredRegistrations = snapshot.registrations.filter(
|
||||||
|
(registration) => registration.id !== registrationId
|
||||||
|
);
|
||||||
|
const participantAdjustment = fallbackRegistration?.participants?.length ?? 0;
|
||||||
|
const fallbackTournament: TournamentInfo = {
|
||||||
|
...snapshot.tournament,
|
||||||
|
total_registrations: Math.max(snapshot.tournament.total_registrations - 1, 0),
|
||||||
|
total_participants: Math.max(
|
||||||
|
snapshot.tournament.total_participants - participantAdjustment,
|
||||||
|
0
|
||||||
|
),
|
||||||
|
updated_at: new Date().toISOString()
|
||||||
|
};
|
||||||
|
return json(
|
||||||
|
{
|
||||||
|
warning: message,
|
||||||
|
tournament: fallbackTournament,
|
||||||
|
registrations: filteredRegistrations
|
||||||
|
},
|
||||||
|
{ status: 200 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof ApiRequestError) {
|
||||||
|
const message = parseApiMessage(err.body, deleteErrorFallback);
|
||||||
|
return json({ message }, { status: err.status });
|
||||||
|
}
|
||||||
|
console.error('Unexpected error deleting registration', err);
|
||||||
|
return json({ message: 'Ett oväntat fel inträffade.' }, { status: 500 });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,62 @@
|
||||||
|
import type { TournamentRegistrationList, TournamentRegistrationItem, TournamentInfo } from '$lib/types';
|
||||||
|
|
||||||
|
export class ApiRequestError extends Error {
|
||||||
|
status: number;
|
||||||
|
body: string;
|
||||||
|
|
||||||
|
constructor(status: number, body: string) {
|
||||||
|
super(`API request failed with status ${status}`);
|
||||||
|
this.status = status;
|
||||||
|
this.body = body;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type FetchFn = (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>;
|
||||||
|
|
||||||
|
export function parseApiMessage(body: string, fallback: string): string {
|
||||||
|
if (!body) return fallback;
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(body) as { message?: string } | undefined;
|
||||||
|
if (parsed?.message && parsed.message.trim()) {
|
||||||
|
return parsed.message;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// ignore JSON parse errors
|
||||||
|
}
|
||||||
|
const trimmed = body.trim();
|
||||||
|
return trimmed || fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchRegistrationList(
|
||||||
|
fetchFn: FetchFn,
|
||||||
|
slug: string
|
||||||
|
): Promise<{ tournament: TournamentInfo; registrations: TournamentRegistrationItem[] }> {
|
||||||
|
const response = await fetchFn(`/api/tournament/slug/${slug}/registrations`, {
|
||||||
|
method: 'GET'
|
||||||
|
});
|
||||||
|
const text = await response.text();
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new ApiRequestError(response.status, text);
|
||||||
|
}
|
||||||
|
const data = JSON.parse(text) as TournamentRegistrationList;
|
||||||
|
return {
|
||||||
|
tournament: data.tournament,
|
||||||
|
registrations: data.registrations ?? []
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeRecord(value: unknown): Record<string, string> {
|
||||||
|
if (!value || typeof value !== 'object') return {};
|
||||||
|
const record: Record<string, string> = {};
|
||||||
|
for (const [key, entry] of Object.entries(value as Record<string, unknown>)) {
|
||||||
|
if (typeof entry === 'string') {
|
||||||
|
record[key] = entry;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return record;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeParticipants(value: unknown): Record<string, string>[] {
|
||||||
|
if (!Array.isArray(value)) return [];
|
||||||
|
return value.map((item) => normalizeRecord(item));
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,19 @@
|
||||||
|
import { json } from '@sveltejs/kit';
|
||||||
|
import type { RequestHandler } from './$types';
|
||||||
|
import { ApiRequestError, fetchRegistrationList, parseApiMessage } from '../helpers.server';
|
||||||
|
|
||||||
|
const loadErrorFallback = 'Kunde inte hämta anmälningar.';
|
||||||
|
|
||||||
|
export const POST: RequestHandler = async ({ fetch, params }) => {
|
||||||
|
try {
|
||||||
|
const data = await fetchRegistrationList(fetch, params.slug);
|
||||||
|
return json(data);
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof ApiRequestError) {
|
||||||
|
const message = parseApiMessage(err.body, loadErrorFallback);
|
||||||
|
return json({ message }, { status: err.status });
|
||||||
|
}
|
||||||
|
console.error('Unexpected error refreshing registrations', err);
|
||||||
|
return json({ message: 'Ett oväntat fel inträffade.' }, { status: 500 });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,54 @@
|
||||||
|
import { json } from '@sveltejs/kit';
|
||||||
|
import type { RequestHandler } from './$types';
|
||||||
|
import {
|
||||||
|
ApiRequestError,
|
||||||
|
fetchRegistrationList,
|
||||||
|
parseApiMessage,
|
||||||
|
normalizeRecord,
|
||||||
|
normalizeParticipants
|
||||||
|
} from '../helpers.server';
|
||||||
|
|
||||||
|
const updateErrorFallback = 'Kunde inte uppdatera anmälan.';
|
||||||
|
|
||||||
|
export const POST: RequestHandler = async ({ fetch, params, request }) => {
|
||||||
|
try {
|
||||||
|
const raw = await request.json().catch(() => null);
|
||||||
|
const registrationId =
|
||||||
|
raw && typeof raw.registration_id === 'number'
|
||||||
|
? raw.registration_id
|
||||||
|
: Number.parseInt(raw?.registration_id ?? '', 10);
|
||||||
|
|
||||||
|
if (!Number.isFinite(registrationId)) {
|
||||||
|
return json({ message: 'Ogiltigt anmälnings-ID.' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const entry = normalizeRecord(raw?.entry);
|
||||||
|
const participants = normalizeParticipants(raw?.participants);
|
||||||
|
|
||||||
|
const payload = { entry, participants };
|
||||||
|
|
||||||
|
const apiResponse = await fetch(
|
||||||
|
`/api/tournament/slug/${params.slug}/registrations/${registrationId}`,
|
||||||
|
{
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'content-type': 'application/json' },
|
||||||
|
body: JSON.stringify(payload)
|
||||||
|
}
|
||||||
|
);
|
||||||
|
const text = await apiResponse.text();
|
||||||
|
if (!apiResponse.ok) {
|
||||||
|
const message = parseApiMessage(text, updateErrorFallback);
|
||||||
|
return json({ message }, { status: apiResponse.status });
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await fetchRegistrationList(fetch, params.slug);
|
||||||
|
return json(data);
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof ApiRequestError) {
|
||||||
|
const message = parseApiMessage(err.body, updateErrorFallback);
|
||||||
|
return json({ message }, { status: err.status });
|
||||||
|
}
|
||||||
|
console.error('Unexpected error updating registration', err);
|
||||||
|
return json({ message: 'Ett oväntat fel inträffade.' }, { status: 500 });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
@ -1,26 +1,36 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
import { listenToTournamentEvents } from '$lib/client/tournament-events';
|
||||||
import type { TournamentInfo } from '$lib/types';
|
import type { TournamentInfo } from '$lib/types';
|
||||||
|
|
||||||
const props = $props<{ data: { tournaments: TournamentInfo[] } }>();
|
const props = $props<{ data: { tournaments: TournamentInfo[] } }>();
|
||||||
const tournaments = props.data.tournaments ?? [];
|
let tournamentList = $state(props.data.tournaments ?? []);
|
||||||
|
|
||||||
function pickFeatured(list: TournamentInfo[]) {
|
function sortTournaments(list: TournamentInfo[]): TournamentInfo[] {
|
||||||
if (list.length === 0) return null;
|
return [...list].sort((a, b) => {
|
||||||
const now = Date.now();
|
const timeA = a.start_at ? new Date(a.start_at).getTime() : Number.POSITIVE_INFINITY;
|
||||||
const withDate = list
|
const timeB = b.start_at ? new Date(b.start_at).getTime() : Number.POSITIVE_INFINITY;
|
||||||
.filter((item) => item.start_at)
|
if (!Number.isNaN(timeA) && !Number.isNaN(timeB) && timeA !== timeB) {
|
||||||
.map((item) => ({ item, time: new Date(item.start_at as string).getTime() }))
|
return timeA - timeB;
|
||||||
.filter(({ time }) => !Number.isNaN(time));
|
|
||||||
if (withDate.length > 0) {
|
|
||||||
const upcoming = withDate
|
|
||||||
.filter(({ time }) => time >= now)
|
|
||||||
.sort((a, b) => a.time - b.time);
|
|
||||||
if (upcoming.length > 0) {
|
|
||||||
return upcoming[0].item;
|
|
||||||
}
|
}
|
||||||
return withDate.sort((a, b) => a.time - b.time)[0].item;
|
return a.title.localeCompare(b.title, 'sv');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const tournaments = $derived(() => sortTournaments(tournamentList));
|
||||||
|
|
||||||
|
function upsertTournament(tournament: TournamentInfo) {
|
||||||
|
const index = tournamentList.findIndex((item) => item.id === tournament.id);
|
||||||
|
if (index >= 0) {
|
||||||
|
tournamentList = tournamentList.map((item, idx) => (idx === index ? tournament : item));
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
return list[0];
|
tournamentList = [...tournamentList, tournament];
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeTournament(id: number) {
|
||||||
|
if (!tournamentList.some((item) => item.id === id)) return;
|
||||||
|
tournamentList = tournamentList.filter((item) => item.id !== id);
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatDate(value: string | null) {
|
function formatDate(value: string | null) {
|
||||||
|
|
@ -37,105 +47,108 @@
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const featuredTournament = pickFeatured(tournaments);
|
function registrationSummary(tournament: TournamentInfo) {
|
||||||
const otherTournaments = tournaments.filter(
|
const teams = tournament.total_registrations ?? 0;
|
||||||
(item: TournamentInfo) => item.id !== (featuredTournament?.id ?? -1)
|
const participants = tournament.total_participants ?? 0;
|
||||||
);
|
if (tournament.signup_config.mode === 'team') {
|
||||||
|
if (teams === 0) {
|
||||||
|
return 'Inga lag anmälda ännu';
|
||||||
|
}
|
||||||
|
return `${teams} ${teams === 1 ? 'lag' : 'lag'} · ${participants} ${participants === 1 ? 'spelare' : 'spelare'}`;
|
||||||
|
}
|
||||||
|
const count = Math.max(participants, teams);
|
||||||
|
if (count === 0) {
|
||||||
|
return 'Inga spelare anmälda ännu';
|
||||||
|
}
|
||||||
|
return `${count} ${count === 1 ? 'spelare' : 'spelare'}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
const stop = listenToTournamentEvents(upsertTournament, removeTournament);
|
||||||
|
return () => {
|
||||||
|
stop();
|
||||||
|
};
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:head>
|
<svelte:head>
|
||||||
<title>LAN Tournament</title>
|
<title>Turneringar – VBytes LAN</title>
|
||||||
</svelte:head>
|
</svelte:head>
|
||||||
|
|
||||||
<div class="min-h-screen bg-slate-950 text-slate-100">
|
<div class="min-h-screen bg-slate-950 text-slate-100">
|
||||||
<div class="mx-auto flex min-h-screen max-w-5xl flex-col items-center justify-center gap-10 px-4 text-center">
|
<div class="mx-auto max-w-6xl space-y-12 px-4 py-16 sm:py-20">
|
||||||
<div class="space-y-4">
|
<header class="space-y-3 text-center">
|
||||||
<p class="text-sm uppercase tracking-[0.4em] text-indigo-300">VBytes LAN</p>
|
<p class="text-xs uppercase tracking-[0.4em] text-indigo-300">VBytes LAN</p>
|
||||||
<p class="text-xs uppercase tracking-[0.4em] text-indigo-300">{featuredTournament?.game ?? 'Turnering'}</p>
|
<h1 class="text-4xl font-bold sm:text-5xl">Turneringar</h1>
|
||||||
<h1 class="text-4xl font-bold sm:text-5xl">{featuredTournament?.title ?? 'Turnering & Community'}</h1>
|
<p class="mx-auto max-w-2xl text-base text-slate-300">
|
||||||
{#if featuredTournament?.tagline}
|
Samla laget, följ brackets i realtid och håll koll på allt som händer under turneringarna.
|
||||||
<p class="mx-auto max-w-2xl text-lg text-slate-300">{featuredTournament.tagline}</p>
|
</p>
|
||||||
{:else}
|
</header>
|
||||||
<p class="mx-auto max-w-2xl text-lg text-slate-300">
|
|
||||||
Samla laget, följ brackets i realtid och håll koll på allt som händer under turneringen.
|
|
||||||
</p>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{#if featuredTournament}
|
{#if tournaments().length > 0}
|
||||||
<div class="w-full max-w-3xl space-y-4 rounded-2xl bg-slate-900/70 p-6 text-left shadow-lg">
|
<div class="flex flex-wrap justify-center gap-8">
|
||||||
{#if featuredTournament.start_at}
|
{#each tournaments() as tournament}
|
||||||
<p class="text-sm font-semibold text-indigo-200">Start: {formatDate(featuredTournament.start_at) ?? featuredTournament.start_at}</p>
|
<article class="group w-full max-w-md flex flex-col rounded-3xl border border-slate-800 bg-gradient-to-br from-slate-900/80 via-slate-900/70 to-slate-900/50 p-8 shadow-xl transition duration-200 hover:-translate-y-1 hover:border-indigo-400/70 hover:shadow-indigo-500/25">
|
||||||
{/if}
|
<div class="flex items-center justify-between gap-3 text-xs uppercase tracking-wide">
|
||||||
{#if featuredTournament.location}
|
<span class="font-semibold text-indigo-200">{tournament.game}</span>
|
||||||
<p class="text-sm text-slate-200">Plats: {featuredTournament.location}</p>
|
<span class="rounded-full border border-slate-700 px-3 py-1 text-[0.7rem] font-semibold text-slate-300">
|
||||||
{/if}
|
{registrationSummary(tournament)}
|
||||||
{#if featuredTournament.description}
|
</span>
|
||||||
<p class="whitespace-pre-line text-sm text-slate-200">{featuredTournament.description}</p>
|
</div>
|
||||||
{/if}
|
<h2 class="mt-4 text-3xl font-semibold text-slate-50">{tournament.title}</h2>
|
||||||
<div class="flex flex-wrap gap-3">
|
{#if tournament.tagline}
|
||||||
{#if featuredTournament.slug}
|
<p class="mt-3 text-base text-slate-300">{tournament.tagline}</p>
|
||||||
<a
|
{:else if tournament.description}
|
||||||
href={`/tournament/${featuredTournament.slug}`}
|
<p class="mt-3 text-base text-slate-400">{tournament.description}</p>
|
||||||
target="_blank"
|
{/if}
|
||||||
rel="noreferrer"
|
<dl class="mt-6 space-y-3 text-sm text-slate-300">
|
||||||
class="rounded-full bg-indigo-500 px-4 py-2 text-xs font-semibold uppercase tracking-wide text-white transition hover:bg-indigo-600"
|
|
||||||
>
|
|
||||||
Anmäl laget
|
|
||||||
</a>
|
|
||||||
{/if}
|
|
||||||
{#if featuredTournament.contact}
|
|
||||||
<span class="rounded-full border border-slate-600 px-4 py-2 text-xs font-semibold uppercase tracking-wide text-slate-200">
|
|
||||||
Kontakt: {featuredTournament.contact}
|
|
||||||
</span>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
{#if otherTournaments.length > 0}
|
|
||||||
<div class="w-full max-w-4xl space-y-3 text-left">
|
|
||||||
<h2 class="text-base font-semibold text-slate-300">Fler event</h2>
|
|
||||||
<div class="grid gap-3 md:grid-cols-2">
|
|
||||||
{#each otherTournaments as tournament}
|
|
||||||
<div class="space-y-2 rounded-xl border border-slate-800 bg-slate-900/60 p-4">
|
|
||||||
<h3 class="text-lg font-semibold text-slate-100">{tournament.title}</h3>
|
|
||||||
{#if tournament.start_at}
|
{#if tournament.start_at}
|
||||||
<p class="text-xs uppercase tracking-wide text-indigo-200">{formatDate(tournament.start_at) ?? tournament.start_at}</p>
|
<div class="flex items-center gap-3 text-[0.95rem]">
|
||||||
|
<span class="text-indigo-200">Start:</span>
|
||||||
|
<span>{formatDate(tournament.start_at) ?? tournament.start_at}</span>
|
||||||
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
{#if tournament.tagline}
|
{#if tournament.location}
|
||||||
<p class="text-sm text-slate-300">{tournament.tagline}</p>
|
<div class="flex items-center gap-3 text-[0.95rem]">
|
||||||
{:else if tournament.description}
|
<span class="text-indigo-200">Plats:</span>
|
||||||
<p class="text-sm text-slate-400">{tournament.description}</p>
|
<span>{tournament.location}</span>
|
||||||
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
<div class="flex flex-wrap gap-2">
|
{#if tournament.contact}
|
||||||
|
<div class="flex items-center gap-3 text-[0.95rem]">
|
||||||
|
<span class="text-indigo-200">Kontakt:</span>
|
||||||
|
<span>{tournament.contact}</span>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</dl>
|
||||||
|
<div class="mt-auto pt-8">
|
||||||
{#if tournament.slug}
|
{#if tournament.slug}
|
||||||
<a
|
<a
|
||||||
href={`/tournament/${tournament.slug}`}
|
href={`/tournament/${tournament.slug}`}
|
||||||
target="_blank"
|
class="inline-flex items-center justify-center rounded-full bg-indigo-500 px-5 py-2 text-sm font-semibold uppercase tracking-wide text-white transition hover:bg-indigo-600"
|
||||||
rel="noreferrer"
|
|
||||||
class="rounded-full border border-indigo-400 px-3 py-1 text-xs font-semibold text-indigo-200"
|
|
||||||
>
|
>
|
||||||
Anmälan
|
Visa turnering
|
||||||
</a>
|
</a>
|
||||||
|
{:else}
|
||||||
|
<span class="text-xs text-slate-500">Ingen publik sida tillgänglig.</span>
|
||||||
{/if}
|
{/if}
|
||||||
{#if tournament.contact}
|
|
||||||
<span class="rounded-full border border-slate-600 px-3 py-1 text-xs font-semibold text-slate-300">
|
|
||||||
Kontakt: {tournament.contact}
|
|
||||||
</span>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
{/each}
|
</article>
|
||||||
</div>
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
|
{:else}
|
||||||
|
<p class="rounded-2xl border border-dashed border-slate-700 bg-slate-900/40 px-6 py-12 text-center text-sm text-slate-400">
|
||||||
|
Inga turneringar är publicerade ännu. Kom tillbaka senare!
|
||||||
|
</p>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<a
|
<div class="text-center">
|
||||||
href="/admin"
|
<a
|
||||||
class="rounded-full bg-indigo-500 px-6 py-3 text-sm font-semibold uppercase tracking-wide text-white transition hover:bg-indigo-600"
|
href="/admin"
|
||||||
>
|
class="inline-flex items-center justify-center rounded-full bg-indigo-500 px-6 py-3 text-sm font-semibold uppercase tracking-wide text-white transition hover:bg-indigo-600"
|
||||||
Till admin
|
>
|
||||||
</a>
|
Till admin
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -467,11 +467,11 @@
|
||||||
<div class="space-y-3 rounded-md border border-slate-800 bg-slate-900/60 p-4">
|
<div class="space-y-3 rounded-md border border-slate-800 bg-slate-900/60 p-4">
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<span class="text-sm font-semibold text-slate-200">{participantDisplayName(index)}</span>
|
<span class="text-sm font-semibold text-slate-200">{participantDisplayName(index)}</span>
|
||||||
{#if signupConfig.mode === 'team'}
|
{#if signupConfig.mode === 'team' && canRemoveParticipant()}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onclick={() => removeParticipant(index)}
|
onclick={() => removeParticipant(index)}
|
||||||
disabled={!canRemoveParticipant() || signup.submitting}
|
disabled={signup.submitting}
|
||||||
class="rounded-full border border-red-300 px-3 py-1 text-xs font-semibold text-red-200 transition hover:border-red-400 hover:bg-red-500/10 disabled:cursor-not-allowed disabled:opacity-60"
|
class="rounded-full border border-red-300 px-3 py-1 text-xs font-semibold text-red-200 transition hover:border-red-400 hover:bg-red-500/10 disabled:cursor-not-allowed disabled:opacity-60"
|
||||||
>
|
>
|
||||||
Ta bort
|
Ta bort
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,3 @@
|
||||||
import { error } from '@sveltejs/kit';
|
|
||||||
import type { RequestHandler } from './$types';
|
import type { RequestHandler } from './$types';
|
||||||
import { proxyRequest } from '$lib/server/backend';
|
import { proxyRequest } from '$lib/server/backend';
|
||||||
|
|
||||||
|
|
@ -15,6 +14,12 @@ export const GET: RequestHandler = async (event) => {
|
||||||
if (contentType) headers.set('content-type', contentType);
|
if (contentType) headers.set('content-type', contentType);
|
||||||
for (const cookie of setCookies) headers.append('set-cookie', cookie);
|
for (const cookie of setCookies) headers.append('set-cookie', cookie);
|
||||||
|
|
||||||
|
if (response.status >= 400) {
|
||||||
|
console.error(
|
||||||
|
`GET /tournament/slug/${event.params.slug}/registrations failed with ${response.status}: ${text}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
let message = 'Kunde inte hämta anmälningar.';
|
let message = 'Kunde inte hämta anmälningar.';
|
||||||
try {
|
try {
|
||||||
|
|
@ -23,7 +28,11 @@ export const GET: RequestHandler = async (event) => {
|
||||||
} catch {
|
} catch {
|
||||||
if (text) message = text;
|
if (text) message = text;
|
||||||
}
|
}
|
||||||
throw error(response.status, message);
|
headers.set('content-type', 'application/json');
|
||||||
|
return new Response(JSON.stringify({ message }), {
|
||||||
|
status: response.status,
|
||||||
|
headers
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return new Response(text, { status: response.status, headers });
|
return new Response(text, { status: response.status, headers });
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,3 @@
|
||||||
import { error } from '@sveltejs/kit';
|
|
||||||
import type { RequestHandler } from './$types';
|
import type { RequestHandler } from './$types';
|
||||||
import { proxyRequest } from '$lib/server/backend';
|
import { proxyRequest } from '$lib/server/backend';
|
||||||
|
|
||||||
|
|
@ -21,6 +20,11 @@ export const GET: RequestHandler = async (event) => {
|
||||||
const { response, setCookies } = await proxyRequest(event, path, { method: 'GET' });
|
const { response, setCookies } = await proxyRequest(event, path, { method: 'GET' });
|
||||||
const text = await response.text();
|
const text = await response.text();
|
||||||
const headers = buildHeaders(response, setCookies);
|
const headers = buildHeaders(response, setCookies);
|
||||||
|
if (response.status >= 400) {
|
||||||
|
console.error(
|
||||||
|
`GET /tournament/slug/${event.params.slug}/registrations/${event.params.registration} failed with ${response.status}: ${text}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
let message = 'Kunde inte hämta anmälan.';
|
let message = 'Kunde inte hämta anmälan.';
|
||||||
|
|
@ -30,8 +34,78 @@ export const GET: RequestHandler = async (event) => {
|
||||||
} catch {
|
} catch {
|
||||||
if (text) message = text;
|
if (text) message = text;
|
||||||
}
|
}
|
||||||
throw error(response.status, message);
|
const errorHeaders = new Headers(headers);
|
||||||
|
errorHeaders.set('content-type', 'application/json');
|
||||||
|
return new Response(JSON.stringify({ message }), {
|
||||||
|
status: response.status,
|
||||||
|
headers: errorHeaders
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return new Response(text, { status: response.status, headers });
|
return new Response(text, { status: response.status, headers });
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const PUT: RequestHandler = async (event) => {
|
||||||
|
const body = await event.request.text();
|
||||||
|
const path = `/tournament/slug/${event.params.slug}/registrations/${event.params.registration}`;
|
||||||
|
const { response, setCookies } = await proxyRequest(event, path, {
|
||||||
|
method: 'PUT',
|
||||||
|
body
|
||||||
|
});
|
||||||
|
const text = await response.text();
|
||||||
|
const headers = buildHeaders(response, setCookies);
|
||||||
|
if (response.status >= 400) {
|
||||||
|
console.error(
|
||||||
|
`PUT /tournament/slug/${event.params.slug}/registrations/${event.params.registration} failed with ${response.status}: ${text}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
let message = 'Kunde inte uppdatera anmälan.';
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(text);
|
||||||
|
message = parsed.message ?? message;
|
||||||
|
} catch {
|
||||||
|
if (text) message = text;
|
||||||
|
}
|
||||||
|
const errorHeaders = new Headers(headers);
|
||||||
|
errorHeaders.set('content-type', 'application/json');
|
||||||
|
return new Response(JSON.stringify({ message }), {
|
||||||
|
status: response.status,
|
||||||
|
headers: errorHeaders
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Response(text, { status: response.status, headers });
|
||||||
|
};
|
||||||
|
|
||||||
|
export const DELETE: RequestHandler = async (event) => {
|
||||||
|
const path = `/tournament/slug/${event.params.slug}/registrations/${event.params.registration}`;
|
||||||
|
const { response, setCookies } = await proxyRequest(event, path, { method: 'DELETE' });
|
||||||
|
const text = await response.text();
|
||||||
|
const headers = buildHeaders(response, setCookies);
|
||||||
|
|
||||||
|
if (response.status >= 400) {
|
||||||
|
console.error(
|
||||||
|
`DELETE /tournament/slug/${event.params.slug}/registrations/${event.params.registration} failed with ${response.status}: ${text}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
let message = 'Kunde inte ta bort anmälan.';
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(text);
|
||||||
|
message = parsed.message ?? message;
|
||||||
|
} catch {
|
||||||
|
if (text) message = text;
|
||||||
|
}
|
||||||
|
const errorHeaders = new Headers(headers);
|
||||||
|
errorHeaders.set('content-type', 'application/json');
|
||||||
|
return new Response(JSON.stringify({ message }), {
|
||||||
|
status: response.status,
|
||||||
|
headers: errorHeaders
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Response(text || '', { status: response.status, headers });
|
||||||
|
};
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue