Compare commits
2 commits
c59f1d271c
...
35c7430c50
| Author | SHA1 | Date | |
|---|---|---|---|
| 35c7430c50 | |||
| 9de3c4a482 |
19 changed files with 1296 additions and 264 deletions
2
.env
2
.env
|
|
@ -5,4 +5,4 @@ ADMIN_PASSWORD=AdminPass!234
|
|||
JWT_COOKIE_SECURE=false
|
||||
ENABLE_HTTPS_REDIRECT=false
|
||||
WEB_PORT=3000
|
||||
CSRF_ALLOWED_ORIGINS=http://192.168.68.64:3000
|
||||
CSRF_ALLOWED_ORIGINS=http://192.168.68.61:3000
|
||||
|
|
|
|||
23
api/migrations/20250101002000_update_persons_schema.sql
Normal file
23
api/migrations/20250101002000_update_persons_schema.sql
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
BEGIN;
|
||||
|
||||
DROP TABLE IF EXISTS persons;
|
||||
|
||||
CREATE TABLE persons (
|
||||
id SERIAL PRIMARY KEY,
|
||||
first_name TEXT NOT NULL,
|
||||
last_name TEXT NOT NULL,
|
||||
grade INTEGER NOT NULL,
|
||||
parent_name TEXT NOT NULL,
|
||||
parent_phone_number TEXT NOT NULL,
|
||||
checked_in BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
inside BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
visitor BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
sleeping_spot BOOLEAN NOT NULL DEFAULT FALSE
|
||||
);
|
||||
|
||||
CREATE INDEX idx_persons_first_name_trgm ON persons USING GIN (first_name gin_trgm_ops);
|
||||
CREATE INDEX idx_persons_last_name_trgm ON persons USING GIN (last_name gin_trgm_ops);
|
||||
CREATE INDEX idx_persons_parent_name_trgm ON persons USING GIN (parent_name gin_trgm_ops);
|
||||
CREATE INDEX idx_persons_parent_phone_number ON persons (parent_phone_number);
|
||||
|
||||
COMMIT;
|
||||
296
api/src/main.rs
296
api/src/main.rs
|
|
@ -9,15 +9,14 @@ use config::AppConfig;
|
|||
use error::ApiError;
|
||||
use models::{
|
||||
LoginRequest, LoginResponse, NewPersonRequest, Person, PersonActionResponse, PersonResponse,
|
||||
PersonsResponse, User,
|
||||
PersonsResponse, UpdatePersonRequest, User,
|
||||
};
|
||||
|
||||
use rocket::http::{Cookie, CookieJar, SameSite, Status};
|
||||
use rocket::response::stream::{Event, EventStream};
|
||||
use rocket::serde::json::Json;
|
||||
use rocket::time::Duration;
|
||||
use rocket::tokio::sync::broadcast::{self, error::RecvError};
|
||||
use rocket::{get, post, routes, State};
|
||||
use rocket::{get, post, put, routes, State};
|
||||
use sqlx::postgres::PgPoolOptions;
|
||||
use sqlx::{PgPool, QueryBuilder};
|
||||
|
||||
|
|
@ -48,7 +47,6 @@ async fn main() -> Result<(), rocket::Error> {
|
|||
.await
|
||||
.expect("Misslyckades att köra migrationer");
|
||||
|
||||
// Seed för testning
|
||||
seed::run(&pool, &config.admin_username, &config.admin_password)
|
||||
.await
|
||||
.expect("Misslyckades att seed:a databasen");
|
||||
|
|
@ -78,7 +76,8 @@ async fn main() -> Result<(), rocket::Error> {
|
|||
checkout_person,
|
||||
mark_inside,
|
||||
mark_outside,
|
||||
create_person
|
||||
create_person,
|
||||
update_person
|
||||
],
|
||||
);
|
||||
|
||||
|
|
@ -189,13 +188,25 @@ async fn search_persons(
|
|||
let persons = if let Some(id) = id_value {
|
||||
sqlx::query_as::<_, Person>(
|
||||
r#"
|
||||
SELECT id, name, age, phone_number, checked_in, inside
|
||||
SELECT
|
||||
id,
|
||||
first_name,
|
||||
last_name,
|
||||
grade,
|
||||
parent_name,
|
||||
parent_phone_number,
|
||||
checked_in,
|
||||
inside,
|
||||
visitor,
|
||||
sleeping_spot
|
||||
FROM persons
|
||||
WHERE name ILIKE $1
|
||||
OR phone_number ILIKE $1
|
||||
WHERE first_name ILIKE $1
|
||||
OR last_name ILIKE $1
|
||||
OR parent_name ILIKE $1
|
||||
OR parent_phone_number ILIKE $1
|
||||
OR (first_name || ' ' || last_name) ILIKE $1
|
||||
OR id = $2
|
||||
ORDER BY name
|
||||
LIMIT 100
|
||||
ORDER BY last_name, first_name
|
||||
"#,
|
||||
)
|
||||
.bind(&like_pattern)
|
||||
|
|
@ -205,12 +216,24 @@ async fn search_persons(
|
|||
} else {
|
||||
sqlx::query_as::<_, Person>(
|
||||
r#"
|
||||
SELECT id, name, age, phone_number, checked_in, inside
|
||||
SELECT
|
||||
id,
|
||||
first_name,
|
||||
last_name,
|
||||
grade,
|
||||
parent_name,
|
||||
parent_phone_number,
|
||||
checked_in,
|
||||
inside,
|
||||
visitor,
|
||||
sleeping_spot
|
||||
FROM persons
|
||||
WHERE name ILIKE $1
|
||||
OR phone_number ILIKE $1
|
||||
ORDER BY name
|
||||
LIMIT 100
|
||||
WHERE first_name ILIKE $1
|
||||
OR last_name ILIKE $1
|
||||
OR parent_name ILIKE $1
|
||||
OR parent_phone_number ILIKE $1
|
||||
OR (first_name || ' ' || last_name) ILIKE $1
|
||||
ORDER BY last_name, first_name
|
||||
"#,
|
||||
)
|
||||
.bind(&like_pattern)
|
||||
|
|
@ -254,7 +277,7 @@ async fn list_checked_in(
|
|||
let id_value = search_term.as_ref().and_then(|s| s.parse::<i32>().ok());
|
||||
|
||||
let mut qb = QueryBuilder::<sqlx::Postgres>::new(
|
||||
"SELECT id, name, age, phone_number, checked_in, inside FROM persons",
|
||||
"SELECT id, first_name, last_name, grade, parent_name, parent_phone_number, checked_in, inside, visitor, sleeping_spot FROM persons",
|
||||
);
|
||||
|
||||
let mut first_condition = true;
|
||||
|
|
@ -280,8 +303,12 @@ async fn list_checked_in(
|
|||
if let Some(like) = like_pattern.as_ref() {
|
||||
append_condition(&mut qb);
|
||||
qb.push("(");
|
||||
qb.push("name ILIKE ").push_bind(like);
|
||||
qb.push(" OR phone_number ILIKE ").push_bind(like);
|
||||
qb.push("first_name ILIKE ").push_bind(like);
|
||||
qb.push(" OR last_name ILIKE ").push_bind(like);
|
||||
qb.push(" OR parent_name ILIKE ").push_bind(like);
|
||||
qb.push(" OR parent_phone_number ILIKE ").push_bind(like);
|
||||
qb.push(" OR (first_name || ' ' || last_name) ILIKE ")
|
||||
.push_bind(like);
|
||||
if let Some(id) = id_value {
|
||||
qb.push(" OR id = ").push_bind(id);
|
||||
}
|
||||
|
|
@ -291,12 +318,9 @@ async fn list_checked_in(
|
|||
if let Some(id) = id_value {
|
||||
qb.push(" ORDER BY CASE WHEN id = ")
|
||||
.push_bind(id)
|
||||
.push(" THEN 0 ELSE 1 END, id, name");
|
||||
.push(" THEN 0 ELSE 1 END, id, last_name, first_name");
|
||||
} else {
|
||||
qb.push(" ORDER BY id, name");
|
||||
}
|
||||
if like_pattern.is_some() {
|
||||
qb.push(" LIMIT 1000");
|
||||
qb.push(" ORDER BY id, last_name, first_name");
|
||||
}
|
||||
|
||||
let persons = qb.build_query_as::<Person>().fetch_all(&state.db).await?;
|
||||
|
|
@ -347,53 +371,116 @@ async fn create_person(
|
|||
state: &State<AppState>,
|
||||
payload: Json<NewPersonRequest>,
|
||||
) -> Result<Json<PersonActionResponse>, ApiError> {
|
||||
let name = payload.name.trim();
|
||||
if name.is_empty() {
|
||||
return Err(ApiError::bad_request("Namn får inte vara tomt."));
|
||||
let first_name = payload.first_name.trim();
|
||||
if first_name.is_empty() {
|
||||
return Err(ApiError::bad_request("Förnamn får inte vara tomt."));
|
||||
}
|
||||
|
||||
if payload.age < 0 {
|
||||
return Err(ApiError::bad_request("Ålder måste vara noll eller högre."));
|
||||
let last_name = payload.last_name.trim();
|
||||
if last_name.is_empty() {
|
||||
return Err(ApiError::bad_request("Efternamn får inte vara tomt."));
|
||||
}
|
||||
|
||||
let phone_number = payload.phone_number.trim();
|
||||
if phone_number.is_empty() {
|
||||
return Err(ApiError::bad_request("Telefonnummer krävs."));
|
||||
if payload.grade < 0 {
|
||||
return Err(ApiError::bad_request("Klass måste vara noll eller högre."));
|
||||
}
|
||||
|
||||
let age = payload.age;
|
||||
let parent_name = payload.parent_name.trim();
|
||||
if parent_name.is_empty() {
|
||||
return Err(ApiError::bad_request("Kontaktperson krävs."));
|
||||
}
|
||||
|
||||
let parent_phone_number = payload.parent_phone_number.trim();
|
||||
if parent_phone_number.is_empty() {
|
||||
return Err(ApiError::bad_request(
|
||||
"Kontaktpersonens telefonnummer krävs.",
|
||||
));
|
||||
}
|
||||
|
||||
let grade = payload.grade;
|
||||
let checked_in = payload.checked_in.unwrap_or(false);
|
||||
let inside = payload.inside.unwrap_or(false);
|
||||
let visitor = payload.visitor;
|
||||
let sleeping_spot = payload.sleeping_spot;
|
||||
|
||||
let person = match payload.id {
|
||||
Some(id) => sqlx::query_as::<_, Person>(
|
||||
r#"
|
||||
INSERT INTO persons (id, name, age, phone_number, checked_in, inside)
|
||||
VALUES ($1, $2, $3, $4, $5, $6)
|
||||
RETURNING id, name, age, phone_number, checked_in, inside
|
||||
INSERT INTO persons (
|
||||
id,
|
||||
first_name,
|
||||
last_name,
|
||||
grade,
|
||||
parent_name,
|
||||
parent_phone_number,
|
||||
checked_in,
|
||||
inside,
|
||||
visitor,
|
||||
sleeping_spot
|
||||
)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
|
||||
RETURNING
|
||||
id,
|
||||
first_name,
|
||||
last_name,
|
||||
grade,
|
||||
parent_name,
|
||||
parent_phone_number,
|
||||
checked_in,
|
||||
inside,
|
||||
visitor,
|
||||
sleeping_spot
|
||||
"#,
|
||||
)
|
||||
.bind(id)
|
||||
.bind(name)
|
||||
.bind(age)
|
||||
.bind(phone_number)
|
||||
.bind(first_name)
|
||||
.bind(last_name)
|
||||
.bind(grade)
|
||||
.bind(parent_name)
|
||||
.bind(parent_phone_number)
|
||||
.bind(checked_in)
|
||||
.bind(inside)
|
||||
.bind(visitor)
|
||||
.bind(sleeping_spot)
|
||||
.fetch_one(&state.db)
|
||||
.await
|
||||
.map_err(|err| map_db_error(err, "Kunde inte skapa person"))?,
|
||||
None => sqlx::query_as::<_, Person>(
|
||||
r#"
|
||||
INSERT INTO persons (name, age, phone_number, checked_in, inside)
|
||||
VALUES ($1, $2, $3, $4, $5)
|
||||
RETURNING id, name, age, phone_number, checked_in, inside
|
||||
INSERT INTO persons (
|
||||
first_name,
|
||||
last_name,
|
||||
grade,
|
||||
parent_name,
|
||||
parent_phone_number,
|
||||
checked_in,
|
||||
inside,
|
||||
visitor,
|
||||
sleeping_spot
|
||||
)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
|
||||
RETURNING
|
||||
id,
|
||||
first_name,
|
||||
last_name,
|
||||
grade,
|
||||
parent_name,
|
||||
parent_phone_number,
|
||||
checked_in,
|
||||
inside,
|
||||
visitor,
|
||||
sleeping_spot
|
||||
"#,
|
||||
)
|
||||
.bind(name)
|
||||
.bind(age)
|
||||
.bind(phone_number)
|
||||
.bind(first_name)
|
||||
.bind(last_name)
|
||||
.bind(grade)
|
||||
.bind(parent_name)
|
||||
.bind(parent_phone_number)
|
||||
.bind(checked_in)
|
||||
.bind(inside)
|
||||
.bind(visitor)
|
||||
.bind(sleeping_spot)
|
||||
.fetch_one(&state.db)
|
||||
.await
|
||||
.map_err(|err| map_db_error(err, "Kunde inte skapa person"))?,
|
||||
|
|
@ -406,6 +493,101 @@ async fn create_person(
|
|||
Ok(Json(response))
|
||||
}
|
||||
|
||||
#[put("/<id>", data = "<payload>")]
|
||||
async fn update_person(
|
||||
_user: AuthUser,
|
||||
state: &State<AppState>,
|
||||
id: i32,
|
||||
payload: Json<UpdatePersonRequest>,
|
||||
) -> Result<Json<PersonActionResponse>, ApiError> {
|
||||
let first_name = payload.first_name.trim();
|
||||
if first_name.is_empty() {
|
||||
return Err(ApiError::bad_request("Förnamn får inte vara tomt."));
|
||||
}
|
||||
|
||||
let last_name = payload.last_name.trim();
|
||||
if last_name.is_empty() {
|
||||
return Err(ApiError::bad_request("Efternamn får inte vara tomt."));
|
||||
}
|
||||
|
||||
if payload.grade < 0 {
|
||||
return Err(ApiError::bad_request("Klass måste vara noll eller högre."));
|
||||
}
|
||||
|
||||
let parent_name = payload.parent_name.trim();
|
||||
if parent_name.is_empty() {
|
||||
return Err(ApiError::bad_request("Vårdnadshavare krävs."));
|
||||
}
|
||||
|
||||
let parent_phone_number = payload.parent_phone_number.trim();
|
||||
if parent_phone_number.is_empty() {
|
||||
return Err(ApiError::bad_request("Vårdnadshavarens telefon krävs."));
|
||||
}
|
||||
|
||||
let checked_in = payload.checked_in;
|
||||
let inside = payload.inside;
|
||||
let visitor = payload.visitor;
|
||||
let sleeping_spot = payload.sleeping_spot;
|
||||
|
||||
let (final_checked_in, final_inside) = match (checked_in, inside) {
|
||||
(Some(false), _) => (Some(false), Some(false)),
|
||||
(Some(true), Some(value)) => (Some(true), Some(value)),
|
||||
(Some(true), None) => (Some(true), Some(true)),
|
||||
(None, Some(value)) => (None, Some(value)),
|
||||
(None, None) => (None, None),
|
||||
};
|
||||
|
||||
let person = sqlx::query_as::<_, Person>(
|
||||
r#"
|
||||
UPDATE persons
|
||||
SET first_name = $2,
|
||||
last_name = $3,
|
||||
grade = $4,
|
||||
parent_name = $5,
|
||||
parent_phone_number = $6,
|
||||
checked_in = COALESCE($7, checked_in),
|
||||
inside = COALESCE($8, inside),
|
||||
visitor = $9,
|
||||
sleeping_spot = $10
|
||||
WHERE id = $1
|
||||
RETURNING
|
||||
id,
|
||||
first_name,
|
||||
last_name,
|
||||
grade,
|
||||
parent_name,
|
||||
parent_phone_number,
|
||||
checked_in,
|
||||
inside,
|
||||
visitor,
|
||||
sleeping_spot
|
||||
"#,
|
||||
)
|
||||
.bind(id)
|
||||
.bind(first_name)
|
||||
.bind(last_name)
|
||||
.bind(payload.grade)
|
||||
.bind(parent_name)
|
||||
.bind(parent_phone_number)
|
||||
.bind(final_checked_in)
|
||||
.bind(final_inside)
|
||||
.bind(visitor)
|
||||
.bind(sleeping_spot)
|
||||
.fetch_optional(&state.db)
|
||||
.await?;
|
||||
|
||||
match person {
|
||||
Some(person) => {
|
||||
let response = PersonActionResponse {
|
||||
person: person.into(),
|
||||
};
|
||||
broadcast_person_update(state, &response);
|
||||
Ok(Json(response))
|
||||
}
|
||||
None => Err(ApiError::not_found("Personen hittades inte.")),
|
||||
}
|
||||
}
|
||||
|
||||
async fn update_checked_in(
|
||||
state: &State<AppState>,
|
||||
id: i32,
|
||||
|
|
@ -417,7 +599,17 @@ async fn update_checked_in(
|
|||
SET checked_in = $2,
|
||||
inside = $2
|
||||
WHERE id = $1
|
||||
RETURNING id, name, age, phone_number, checked_in, inside
|
||||
RETURNING
|
||||
id,
|
||||
first_name,
|
||||
last_name,
|
||||
grade,
|
||||
parent_name,
|
||||
parent_phone_number,
|
||||
checked_in,
|
||||
inside,
|
||||
visitor,
|
||||
sleeping_spot
|
||||
"#,
|
||||
)
|
||||
.bind(id)
|
||||
|
|
@ -448,7 +640,17 @@ async fn update_inside(
|
|||
SET inside = $2
|
||||
WHERE id = $1
|
||||
AND checked_in = TRUE
|
||||
RETURNING id, name, age, phone_number, checked_in, inside
|
||||
RETURNING
|
||||
id,
|
||||
first_name,
|
||||
last_name,
|
||||
grade,
|
||||
parent_name,
|
||||
parent_phone_number,
|
||||
checked_in,
|
||||
inside,
|
||||
visitor,
|
||||
sleeping_spot
|
||||
"#,
|
||||
)
|
||||
.bind(id)
|
||||
|
|
@ -486,7 +688,7 @@ fn map_db_error(err: sqlx::Error, context: &str) -> ApiError {
|
|||
if let Some(code) = db_err.code() {
|
||||
if code == "23505" {
|
||||
return ApiError::bad_request(
|
||||
"Krock i databasen – kontrollera id eller telefonnummer.",
|
||||
"Krock i databasen – kontrollera id eller kontaktuppgifter.",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,11 +12,15 @@ pub struct User {
|
|||
#[derive(Debug, FromRow)]
|
||||
pub struct Person {
|
||||
pub id: i32,
|
||||
pub name: String,
|
||||
pub age: i32,
|
||||
pub phone_number: String,
|
||||
pub first_name: String,
|
||||
pub last_name: String,
|
||||
pub grade: i32,
|
||||
pub parent_name: String,
|
||||
pub parent_phone_number: String,
|
||||
pub checked_in: bool,
|
||||
pub inside: bool,
|
||||
pub visitor: bool,
|
||||
pub sleeping_spot: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
|
|
@ -36,25 +40,30 @@ pub struct LoginResponse {
|
|||
#[serde(crate = "rocket::serde")]
|
||||
pub struct PersonResponse {
|
||||
pub id: i32,
|
||||
pub name: String,
|
||||
pub age: i32,
|
||||
pub phone_number: String,
|
||||
pub first_name: String,
|
||||
pub last_name: String,
|
||||
pub grade: i32,
|
||||
pub parent_name: String,
|
||||
pub parent_phone_number: String,
|
||||
pub checked_in: bool,
|
||||
pub inside: bool,
|
||||
pub under_ten: bool,
|
||||
pub visitor: bool,
|
||||
pub sleeping_spot: bool,
|
||||
}
|
||||
|
||||
impl From<Person> for PersonResponse {
|
||||
fn from(person: Person) -> Self {
|
||||
let under_ten = person.age < 10;
|
||||
PersonResponse {
|
||||
id: person.id,
|
||||
name: person.name,
|
||||
age: person.age,
|
||||
phone_number: person.phone_number,
|
||||
first_name: person.first_name,
|
||||
last_name: person.last_name,
|
||||
grade: person.grade,
|
||||
parent_name: person.parent_name,
|
||||
parent_phone_number: person.parent_phone_number,
|
||||
checked_in: person.checked_in,
|
||||
inside: person.inside,
|
||||
under_ten,
|
||||
visitor: person.visitor,
|
||||
sleeping_spot: person.sleeping_spot,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -74,13 +83,35 @@ pub struct PersonActionResponse {
|
|||
#[derive(Debug, Deserialize)]
|
||||
#[serde(crate = "rocket::serde")]
|
||||
pub struct NewPersonRequest {
|
||||
pub name: String,
|
||||
pub age: i32,
|
||||
pub phone_number: String,
|
||||
pub first_name: String,
|
||||
pub last_name: String,
|
||||
pub grade: i32,
|
||||
pub parent_name: String,
|
||||
pub parent_phone_number: String,
|
||||
#[serde(default)]
|
||||
pub id: Option<i32>,
|
||||
#[serde(default)]
|
||||
pub checked_in: Option<bool>,
|
||||
#[serde(default)]
|
||||
pub inside: Option<bool>,
|
||||
#[serde(default)]
|
||||
pub visitor: Option<bool>,
|
||||
#[serde(default)]
|
||||
pub sleeping_spot: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(crate = "rocket::serde")]
|
||||
pub struct UpdatePersonRequest {
|
||||
pub first_name: String,
|
||||
pub last_name: String,
|
||||
pub grade: i32,
|
||||
pub parent_name: String,
|
||||
pub parent_phone_number: String,
|
||||
#[serde(default)]
|
||||
pub checked_in: Option<bool>,
|
||||
#[serde(default)]
|
||||
pub inside: Option<bool>,
|
||||
pub visitor: bool,
|
||||
pub sleeping_spot: bool,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -45,13 +45,30 @@ async fn ensure_person_seed(pool: &PgPool) -> anyhow::Result<()> {
|
|||
|
||||
for person in persons {
|
||||
sqlx::query(
|
||||
"INSERT INTO persons (name, age, phone_number, checked_in, inside) VALUES ($1, $2, $3, $4, $5)",
|
||||
r#"
|
||||
INSERT INTO persons (
|
||||
first_name,
|
||||
last_name,
|
||||
grade,
|
||||
parent_name,
|
||||
parent_phone_number,
|
||||
checked_in,
|
||||
inside,
|
||||
visitor,
|
||||
sleeping_spot
|
||||
)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
|
||||
"#,
|
||||
)
|
||||
.bind(&person.name)
|
||||
.bind(person.age)
|
||||
.bind(&person.phone_number)
|
||||
.bind(&person.first_name)
|
||||
.bind(&person.last_name)
|
||||
.bind(person.grade)
|
||||
.bind(&person.parent_name)
|
||||
.bind(&person.parent_phone_number)
|
||||
.bind(person.checked_in)
|
||||
.bind(person.inside)
|
||||
.bind(person.visitor)
|
||||
.bind(person.sleeping_spot)
|
||||
.execute(pool)
|
||||
.await
|
||||
.context("Misslyckades att lägga till seed-person")?;
|
||||
|
|
@ -61,20 +78,24 @@ async fn ensure_person_seed(pool: &PgPool) -> anyhow::Result<()> {
|
|||
}
|
||||
|
||||
struct PersonSeed {
|
||||
name: String,
|
||||
age: i32,
|
||||
phone_number: String,
|
||||
first_name: String,
|
||||
last_name: String,
|
||||
grade: i32,
|
||||
parent_name: String,
|
||||
parent_phone_number: String,
|
||||
checked_in: bool,
|
||||
inside: bool,
|
||||
visitor: bool,
|
||||
sleeping_spot: bool,
|
||||
}
|
||||
|
||||
fn generate_people() -> Vec<PersonSeed> {
|
||||
let first_names = vec![
|
||||
let first_names = [
|
||||
"Alex", "Bianca", "Cecilia", "David", "Elias", "Fatima", "Gabriel", "Hanna", "Isak",
|
||||
"Johanna", "Karin", "Liam", "Maja", "Nils", "Olivia",
|
||||
];
|
||||
|
||||
let last_names = vec![
|
||||
let last_names = [
|
||||
"Andersson",
|
||||
"Berg",
|
||||
"Carlsson",
|
||||
|
|
@ -87,20 +108,52 @@ fn generate_people() -> Vec<PersonSeed> {
|
|||
"Johansson",
|
||||
];
|
||||
|
||||
let guardian_first_names = [
|
||||
"Anna",
|
||||
"Bertil",
|
||||
"Charlotte",
|
||||
"Daniel",
|
||||
"Emma",
|
||||
"Fredrik",
|
||||
"Greta",
|
||||
"Henrik",
|
||||
"Ingrid",
|
||||
"Jakob",
|
||||
"Klara",
|
||||
"Lars",
|
||||
"Maria",
|
||||
"Niklas",
|
||||
"Petra",
|
||||
];
|
||||
|
||||
let guardian_last_names = [
|
||||
"Lind", "Sandberg", "Forsberg", "Nyström", "Sjöberg", "Viklund", "Ågren", "Öberg",
|
||||
"Boström", "Engman",
|
||||
];
|
||||
|
||||
let mut people = Vec::with_capacity(first_names.len() * last_names.len());
|
||||
|
||||
let mut idx: usize = 0;
|
||||
for first in &first_names {
|
||||
for last in &last_names {
|
||||
let name = format!("{} {}", first, last);
|
||||
let age = 5 + ((idx * 11) % 60) as i32;
|
||||
let phone_number = format!("070{:03}{:04}", idx % 1_000, (idx * 37) % 10_000);
|
||||
let grade = 1 + ((idx * 3) % 9) as i32; // Grades between 1 and 9
|
||||
let parent_first = guardian_first_names[idx % guardian_first_names.len()];
|
||||
let parent_last = guardian_last_names[idx % guardian_last_names.len()];
|
||||
let parent_name = format!("{} {}", parent_first, parent_last);
|
||||
let parent_phone_number = format!("070{:03}{:04}", idx % 1_000, (idx * 37) % 10_000);
|
||||
let visitor = idx % 7 == 0;
|
||||
let sleeping_spot = !visitor && grade >= 3 && idx % 5 == 0;
|
||||
|
||||
people.push(PersonSeed {
|
||||
name,
|
||||
age,
|
||||
phone_number,
|
||||
first_name: (*first).to_string(),
|
||||
last_name: (*last).to_string(),
|
||||
grade,
|
||||
parent_name,
|
||||
parent_phone_number,
|
||||
checked_in: false,
|
||||
inside: false,
|
||||
visitor,
|
||||
sleeping_spot,
|
||||
});
|
||||
idx += 1;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,7 +8,6 @@
|
|||
"@sveltejs/kit": "^2.22.0",
|
||||
"@sveltejs/vite-plugin-svelte": "^6.0.0",
|
||||
"@tailwindcss/vite": "^4.0.0",
|
||||
"dotenv": "^16.4.5",
|
||||
"prettier": "^3.4.2",
|
||||
"prettier-plugin-svelte": "^3.3.3",
|
||||
"prettier-plugin-tailwindcss": "^0.6.11",
|
||||
|
|
@ -211,8 +210,6 @@
|
|||
|
||||
"devalue": ["devalue@5.3.2", "", {}, "sha512-UDsjUbpQn9kvm68slnrs+mfxwFkIflOhkanmyabZ8zOYk8SMEIbJ3TK+88g70hSIeytu4y18f0z/hYHMTrXIWw=="],
|
||||
|
||||
"dotenv": ["dotenv@16.6.1", "", {}, "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow=="],
|
||||
|
||||
"enhanced-resolve": ["enhanced-resolve@5.18.3", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.2.0" } }, "sha512-d4lC8xfavMeBjzGr2vECC3fsGXziXZQyJxD868h2M/mBI3PwAuODxAkLkq5HYuvrPYcUtiLzsTo8U3PgX3Ocww=="],
|
||||
|
||||
"esbuild": ["esbuild@0.25.10", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.10", "@esbuild/android-arm": "0.25.10", "@esbuild/android-arm64": "0.25.10", "@esbuild/android-x64": "0.25.10", "@esbuild/darwin-arm64": "0.25.10", "@esbuild/darwin-x64": "0.25.10", "@esbuild/freebsd-arm64": "0.25.10", "@esbuild/freebsd-x64": "0.25.10", "@esbuild/linux-arm": "0.25.10", "@esbuild/linux-arm64": "0.25.10", "@esbuild/linux-ia32": "0.25.10", "@esbuild/linux-loong64": "0.25.10", "@esbuild/linux-mips64el": "0.25.10", "@esbuild/linux-ppc64": "0.25.10", "@esbuild/linux-riscv64": "0.25.10", "@esbuild/linux-s390x": "0.25.10", "@esbuild/linux-x64": "0.25.10", "@esbuild/netbsd-arm64": "0.25.10", "@esbuild/netbsd-x64": "0.25.10", "@esbuild/openbsd-arm64": "0.25.10", "@esbuild/openbsd-x64": "0.25.10", "@esbuild/openharmony-arm64": "0.25.10", "@esbuild/sunos-x64": "0.25.10", "@esbuild/win32-arm64": "0.25.10", "@esbuild/win32-ia32": "0.25.10", "@esbuild/win32-x64": "0.25.10" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-9RiGKvCwaqxO2owP61uQ4BgNborAQskMR6QusfWzQqv7AZOg5oGehdY2pRJMTKuwxd1IDBP4rSbI5lHzU7SMsQ=="],
|
||||
|
|
|
|||
278
web/src/lib/components/edit-person-modal.svelte
Normal file
278
web/src/lib/components/edit-person-modal.svelte
Normal file
|
|
@ -0,0 +1,278 @@
|
|||
<script lang="ts">
|
||||
'use runes';
|
||||
import type { Person } from '$lib/types';
|
||||
import { createEventDispatcher } from 'svelte';
|
||||
|
||||
const props = $props<{ person: Person | null; open: boolean }>();
|
||||
const dispatch = createEventDispatcher<{ close: void; saved: Person }>();
|
||||
|
||||
type FormState = {
|
||||
first_name: string;
|
||||
last_name: string;
|
||||
grade: string;
|
||||
parent_name: string;
|
||||
parent_phone_number: string;
|
||||
checked_in: boolean;
|
||||
inside: boolean;
|
||||
visitor: boolean;
|
||||
sleeping_spot: boolean;
|
||||
};
|
||||
|
||||
const defaults: FormState = {
|
||||
first_name: '',
|
||||
last_name: '',
|
||||
grade: '',
|
||||
parent_name: '',
|
||||
parent_phone_number: '',
|
||||
checked_in: false,
|
||||
inside: false,
|
||||
visitor: false,
|
||||
sleeping_spot: false
|
||||
};
|
||||
|
||||
let form = $state<FormState>({ ...defaults });
|
||||
let errorMessage = $state('');
|
||||
let loading = $state(false);
|
||||
|
||||
$effect(() => {
|
||||
const person = props.person;
|
||||
if (!person) {
|
||||
form = { ...defaults };
|
||||
errorMessage = '';
|
||||
loading = false;
|
||||
return;
|
||||
}
|
||||
|
||||
form = {
|
||||
first_name: person.first_name,
|
||||
last_name: person.last_name,
|
||||
grade: String(person.grade),
|
||||
parent_name: person.parent_name,
|
||||
parent_phone_number: person.parent_phone_number,
|
||||
checked_in: person.checked_in,
|
||||
inside: person.inside,
|
||||
visitor: person.visitor,
|
||||
sleeping_spot: person.sleeping_spot
|
||||
};
|
||||
errorMessage = '';
|
||||
loading = false;
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (!form.checked_in && form.inside) {
|
||||
form = { ...form, inside: false };
|
||||
}
|
||||
});
|
||||
|
||||
function closeModal() {
|
||||
if (loading) return;
|
||||
dispatch('close');
|
||||
}
|
||||
|
||||
async function handleSubmit(event: SubmitEvent) {
|
||||
event.preventDefault();
|
||||
if (!props.person) return;
|
||||
|
||||
const gradeNumber = Number.parseInt(form.grade, 10);
|
||||
if (Number.isNaN(gradeNumber) || gradeNumber < 0) {
|
||||
errorMessage = 'Klass måste vara ett heltal större än eller lika med 0.';
|
||||
return;
|
||||
}
|
||||
|
||||
const payload = {
|
||||
first_name: form.first_name.trim(),
|
||||
last_name: form.last_name.trim(),
|
||||
grade: gradeNumber,
|
||||
parent_name: form.parent_name.trim(),
|
||||
parent_phone_number: form.parent_phone_number.trim(),
|
||||
checked_in: form.checked_in,
|
||||
inside: form.inside,
|
||||
visitor: form.visitor,
|
||||
sleeping_spot: form.sleeping_spot
|
||||
};
|
||||
|
||||
if (!payload.first_name || !payload.last_name) {
|
||||
errorMessage = 'För- och efternamn krävs.';
|
||||
return;
|
||||
}
|
||||
|
||||
if (!payload.parent_name || !payload.parent_phone_number) {
|
||||
errorMessage = 'Vårdnadshavare och telefon krävs.';
|
||||
return;
|
||||
}
|
||||
|
||||
loading = true;
|
||||
errorMessage = '';
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/persons/${props.person.id}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
|
||||
const text = await response.text();
|
||||
if (!response.ok) {
|
||||
try {
|
||||
const body = JSON.parse(text);
|
||||
errorMessage = body.message ?? 'Kunde inte uppdatera personen.';
|
||||
} catch {
|
||||
errorMessage = text || 'Kunde inte uppdatera personen.';
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const body = JSON.parse(text) as { person: Person };
|
||||
dispatch('saved', body.person);
|
||||
dispatch('close');
|
||||
} catch (err) {
|
||||
console.error('Failed to update person', err);
|
||||
errorMessage = 'Ett oväntat fel inträffade.';
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if props.open && props.person}
|
||||
<div class="fixed inset-0 z-50 flex items-center justify-center bg-slate-900/60 p-4">
|
||||
<div class="w-full max-w-xl rounded-lg bg-white shadow-xl">
|
||||
<header class="flex items-center justify-between border-b border-slate-200 px-6 py-4">
|
||||
<div>
|
||||
<h3 class="text-lg font-semibold text-slate-800">Redigera person</h3>
|
||||
<p class="text-sm text-slate-500">ID: {props.person.id}</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onclick={closeModal}
|
||||
disabled={loading}
|
||||
class="rounded-md p-2 text-sm text-slate-500 transition hover:bg-slate-100 disabled:opacity-60"
|
||||
>
|
||||
Stäng
|
||||
</button>
|
||||
</header>
|
||||
<form class="grid gap-4 px-6 py-6" onsubmit={handleSubmit}>
|
||||
<div class="grid gap-4 md:grid-cols-2">
|
||||
<div class="space-y-1">
|
||||
<label class="text-sm font-medium text-slate-600" for="edit-first-name"
|
||||
>Förnamn</label
|
||||
>
|
||||
<input
|
||||
id="edit-first-name"
|
||||
type="text"
|
||||
bind:value={form.first_name}
|
||||
required
|
||||
class="w-full rounded-md border border-slate-300 px-3 py-2 text-slate-900 focus:border-indigo-500 focus:outline-none focus:ring focus:ring-indigo-100"
|
||||
/>
|
||||
</div>
|
||||
<div class="space-y-1">
|
||||
<label class="text-sm font-medium text-slate-600" for="edit-last-name"
|
||||
>Efternamn</label
|
||||
>
|
||||
<input
|
||||
id="edit-last-name"
|
||||
type="text"
|
||||
bind:value={form.last_name}
|
||||
required
|
||||
class="w-full rounded-md border border-slate-300 px-3 py-2 text-slate-900 focus:border-indigo-500 focus:outline-none focus:ring focus:ring-indigo-100"
|
||||
/>
|
||||
</div>
|
||||
<div class="space-y-1">
|
||||
<label class="text-sm font-medium text-slate-600" for="edit-grade"
|
||||
>Årskurs</label
|
||||
>
|
||||
<input
|
||||
id="edit-grade"
|
||||
type="number"
|
||||
min="0"
|
||||
bind:value={form.grade}
|
||||
required
|
||||
class="w-full rounded-md border border-slate-300 px-3 py-2 text-slate-900 focus:border-indigo-500 focus:outline-none focus:ring focus:ring-indigo-100"
|
||||
/>
|
||||
</div>
|
||||
<div class="space-y-1">
|
||||
<label class="text-sm font-medium text-slate-600" for="edit-parent-name"
|
||||
>Vårdnadshavare</label
|
||||
>
|
||||
<input
|
||||
id="edit-parent-name"
|
||||
type="text"
|
||||
bind:value={form.parent_name}
|
||||
required
|
||||
class="w-full rounded-md border border-slate-300 px-3 py-2 text-slate-900 focus:border-indigo-500 focus:outline-none focus:ring focus:ring-indigo-100"
|
||||
/>
|
||||
</div>
|
||||
<div class="space-y-1 md:col-span-2">
|
||||
<label class="text-sm font-medium text-slate-600" for="edit-parent-phone"
|
||||
>Vårdnadshavare – telefon</label
|
||||
>
|
||||
<input
|
||||
id="edit-parent-phone"
|
||||
type="tel"
|
||||
bind:value={form.parent_phone_number}
|
||||
required
|
||||
class="w-full rounded-md border border-slate-300 px-3 py-2 text-slate-900 focus:border-indigo-500 focus:outline-none focus:ring focus:ring-indigo-100"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-3 rounded-md border border-slate-200 bg-slate-50 p-4 md:grid-cols-2">
|
||||
<label class="flex items-center gap-2 text-sm text-slate-700">
|
||||
<input
|
||||
type="checkbox"
|
||||
bind:checked={form.checked_in}
|
||||
class="h-4 w-4 rounded border-slate-300 text-indigo-600 focus:ring-indigo-500"
|
||||
/>
|
||||
<span>Incheckad</span>
|
||||
</label>
|
||||
<label class="flex items-center gap-2 text-sm text-slate-700">
|
||||
<input
|
||||
type="checkbox"
|
||||
bind:checked={form.inside}
|
||||
class="h-4 w-4 rounded border-slate-300 text-indigo-600 focus:ring-indigo-500"
|
||||
/>
|
||||
<span>Markerad inne</span>
|
||||
</label>
|
||||
<label class="flex items-center gap-2 text-sm text-slate-700">
|
||||
<input
|
||||
type="checkbox"
|
||||
bind:checked={form.visitor}
|
||||
class="h-4 w-4 rounded border-slate-300 text-indigo-600 focus:ring-indigo-500"
|
||||
/>
|
||||
<span>Besöksplats</span>
|
||||
</label>
|
||||
<label class="flex items-center gap-2 text-sm text-slate-700">
|
||||
<input
|
||||
type="checkbox"
|
||||
bind:checked={form.sleeping_spot}
|
||||
class="h-4 w-4 rounded border-slate-300 text-indigo-600 focus:ring-indigo-500"
|
||||
/>
|
||||
<span>Behöver sovplats</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{#if errorMessage}
|
||||
<p class="rounded-md bg-red-50 px-3 py-2 text-sm text-red-600">{errorMessage}</p>
|
||||
{/if}
|
||||
|
||||
<div class="flex items-center justify-end gap-3 border-t border-slate-200 pt-4">
|
||||
<button
|
||||
type="button"
|
||||
onclick={closeModal}
|
||||
disabled={loading}
|
||||
class="rounded-md border border-slate-300 px-4 py-2 text-sm font-medium text-slate-600 transition hover:bg-slate-100 disabled:cursor-not-allowed disabled:opacity-60"
|
||||
>
|
||||
Avbryt
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
class="rounded-md bg-indigo-600 px-4 py-2 text-sm font-semibold text-white transition hover:bg-indigo-700 disabled:cursor-not-allowed disabled:opacity-70"
|
||||
>
|
||||
{loading ? 'Sparar…' : 'Spara'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
|
@ -1,9 +1,12 @@
|
|||
export interface Person {
|
||||
id: number;
|
||||
name: string;
|
||||
age: number;
|
||||
phone_number: string;
|
||||
first_name: string;
|
||||
last_name: string;
|
||||
grade: number;
|
||||
parent_name: string;
|
||||
parent_phone_number: string;
|
||||
checked_in: boolean;
|
||||
inside: boolean;
|
||||
under_ten: boolean;
|
||||
visitor: boolean;
|
||||
sleeping_spot: boolean;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@
|
|||
import type { Person } from '$lib/types';
|
||||
import { onMount } from 'svelte';
|
||||
import { listenToPersonEvents } from '$lib/client/person-events';
|
||||
import EditPersonModal from '$lib/components/edit-person-modal.svelte';
|
||||
|
||||
let searchQuery = $state('');
|
||||
let searchResults = $state<Person[]>([]);
|
||||
|
|
@ -11,6 +12,15 @@
|
|||
let searchError = $state('');
|
||||
let searchInfo = $state('');
|
||||
let actionInfo = $state('');
|
||||
let editor = $state<{ open: boolean; person: Person | null }>({ open: false, person: null });
|
||||
|
||||
function fullName(person: Person) {
|
||||
return `${person.first_name} ${person.last_name}`.trim();
|
||||
}
|
||||
|
||||
function isLowerGrade(person: Person) {
|
||||
return person.grade <= 3;
|
||||
}
|
||||
|
||||
async function apiFetch(url: string, init?: RequestInit) {
|
||||
const response = await fetch(url, init);
|
||||
|
|
@ -106,6 +116,19 @@
|
|||
updateVisibleResults();
|
||||
}
|
||||
|
||||
function openEditor(person: Person) {
|
||||
editor = { open: true, person };
|
||||
}
|
||||
|
||||
function closeEditor() {
|
||||
editor = { open: false, person: null };
|
||||
}
|
||||
|
||||
function handleEditorSaved(updated: Person) {
|
||||
updatePersonList(updated);
|
||||
actionInfo = 'Personen uppdaterades.';
|
||||
}
|
||||
|
||||
async function handleCheckIn(person: Person) {
|
||||
actionInfo = '';
|
||||
if (person.checked_in) return;
|
||||
|
|
@ -158,7 +181,7 @@
|
|||
<form class="flex flex-col gap-3 sm:flex-row" onsubmit={handleSearch}>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Exempel: Anna, 42 eller 0701234567"
|
||||
placeholder="Exempel: Anna Andersson, 42 eller 0701234567"
|
||||
bind:value={searchQuery}
|
||||
class="w-full rounded-md border border-slate-300 px-3 py-2 text-slate-900 focus:border-indigo-500 focus:ring focus:ring-indigo-100 focus:outline-none"
|
||||
/>
|
||||
|
|
@ -186,9 +209,12 @@
|
|||
<li class="rounded-lg border border-slate-200 p-4 shadow-sm">
|
||||
<div class="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<h4 class="text-base font-semibold text-slate-800">{person.name}</h4>
|
||||
<h4 class="text-base font-semibold text-slate-800">{fullName(person)}</h4>
|
||||
<p class="text-sm text-slate-500">
|
||||
ID: {person.id} · Telefon: {person.phone_number}
|
||||
ID: {person.id} · Klass: {person.grade}
|
||||
</p>
|
||||
<p class="text-sm text-slate-500">
|
||||
Vårdnadshavare: {person.parent_name} ({person.parent_phone_number})
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
|
|
@ -201,14 +227,21 @@
|
|||
person.inside ? 'bg-blue-100 text-blue-700' : 'bg-slate-100 text-slate-600'
|
||||
}`}>{person.inside ? 'Inne' : 'Ute'}</span
|
||||
>
|
||||
{#if person.visitor}
|
||||
<span class="rounded-full bg-amber-100 px-3 py-1 text-xs font-semibold text-amber-700">
|
||||
Besöksplats
|
||||
</span>
|
||||
{/if}
|
||||
{#if person.sleeping_spot}
|
||||
<span class="rounded-full bg-indigo-100 px-3 py-1 text-xs font-semibold text-indigo-700">
|
||||
Behöver sovplats
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
<p class="mt-2 text-sm text-slate-600">Ålder: {person.age} år</p>
|
||||
{#if person.under_ten}
|
||||
<p
|
||||
class="mt-2 rounded-md bg-amber-100 px-3 py-2 text-sm font-semibold text-amber-800"
|
||||
>
|
||||
VARNING: Person under 10 år – kompletterande information krävs innan incheckning.
|
||||
{#if isLowerGrade(person)}
|
||||
<p class="mt-2 rounded-md bg-amber-100 px-3 py-2 text-sm font-semibold text-amber-800">
|
||||
Observera: elev i årskurs 3 eller yngre – ska hem senast 22:00.
|
||||
</p>
|
||||
{/if}
|
||||
<div class="mt-4 flex gap-3">
|
||||
|
|
@ -224,6 +257,14 @@
|
|||
>
|
||||
Checka in
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => openEditor(person)}
|
||||
disabled={searchLoading}
|
||||
class="rounded-md border border-slate-300 px-4 py-2 text-sm font-medium text-slate-700 transition hover:bg-slate-100 disabled:cursor-not-allowed disabled:opacity-70"
|
||||
>
|
||||
Redigera
|
||||
</button>
|
||||
</div>
|
||||
</li>
|
||||
{/each}
|
||||
|
|
@ -231,3 +272,10 @@
|
|||
{/if}
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<EditPersonModal
|
||||
open={editor.open}
|
||||
person={editor.person}
|
||||
on:close={closeEditor}
|
||||
on:saved={(event) => handleEditorSaved(event.detail)}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { error } from '@sveltejs/kit';
|
||||
import { json, error } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types';
|
||||
import { proxyRequest } from '$lib/server/backend';
|
||||
|
||||
|
|
|
|||
45
web/src/routes/api/persons/[id]/+server.ts
Normal file
45
web/src/routes/api/persons/[id]/+server.ts
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
import { json, error } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types';
|
||||
import { proxyRequest } from '$lib/server/backend';
|
||||
|
||||
export const PUT: RequestHandler = async (event) => {
|
||||
const { id } = event.params;
|
||||
if (!id) {
|
||||
throw error(400, 'Ogiltigt id.');
|
||||
}
|
||||
|
||||
const payload = await event.request.json();
|
||||
const { response, setCookies } = await proxyRequest(event, `/persons/${id}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
|
||||
const headers = new Headers();
|
||||
for (const cookie of setCookies) {
|
||||
headers.append('set-cookie', cookie);
|
||||
}
|
||||
|
||||
const contentType = response.headers.get('content-type');
|
||||
if (contentType) {
|
||||
headers.set('content-type', contentType);
|
||||
} else {
|
||||
headers.set('content-type', 'application/json');
|
||||
}
|
||||
|
||||
const text = await response.text();
|
||||
if (!response.ok) {
|
||||
let message = 'Kunde inte uppdatera personen.';
|
||||
try {
|
||||
const body = JSON.parse(text);
|
||||
message = body.message ?? message;
|
||||
} catch {
|
||||
if (text) {
|
||||
message = text;
|
||||
}
|
||||
}
|
||||
|
||||
throw error(response.status, message);
|
||||
}
|
||||
|
||||
return new Response(text, { status: response.status, headers });
|
||||
};
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
import { error } from '@sveltejs/kit';
|
||||
import { json, error } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types';
|
||||
import { proxyRequest } from '$lib/server/backend';
|
||||
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ export const GET: RequestHandler = async (event) => {
|
|||
const params = new URLSearchParams();
|
||||
const q = event.url.searchParams.get('q');
|
||||
const status = event.url.searchParams.get('status');
|
||||
const checked = event.url.searchParams.get('checked');
|
||||
|
||||
if (q) {
|
||||
params.set('q', q);
|
||||
|
|
@ -13,6 +14,9 @@ export const GET: RequestHandler = async (event) => {
|
|||
if (status) {
|
||||
params.set('status', status);
|
||||
}
|
||||
if (checked) {
|
||||
params.set('checked', checked);
|
||||
}
|
||||
|
||||
const path = params.toString() ? `/persons/checked-in?${params}` : '/persons/checked-in';
|
||||
const { response, setCookies } = await proxyRequest(event, path, { method: 'GET' });
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { error } from '@sveltejs/kit';
|
||||
import { json, error } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types';
|
||||
import { proxyRequest } from '$lib/server/backend';
|
||||
|
||||
|
|
@ -20,7 +20,6 @@ export const GET: RequestHandler = async (event) => {
|
|||
for (const cookie of setCookies) {
|
||||
headers.append('set-cookie', cookie);
|
||||
}
|
||||
|
||||
const contentType = response.headers.get('content-type');
|
||||
if (contentType) {
|
||||
headers.set('content-type', contentType);
|
||||
|
|
|
|||
|
|
@ -4,10 +4,15 @@
|
|||
import { onMount } from 'svelte';
|
||||
import { listenToPersonEvents } from '$lib/client/person-events';
|
||||
import { updateCollection } from '$lib/client/person-collection';
|
||||
import EditPersonModal from '$lib/components/edit-person-modal.svelte';
|
||||
|
||||
type StatusFilter = 'all' | 'inside' | 'outside';
|
||||
type CheckedFilter = 'all' | 'checked-in' | 'not-checked-in';
|
||||
type GradeFilter = 'all' | 'lt4' | 'ge4';
|
||||
type VisitorFilter = 'all' | 'besoksplats' | 'lanplats';
|
||||
type SleepingFilter = 'all' | 'needs' | 'not-needed';
|
||||
|
||||
let allPersons = $state<Person[]>([]);
|
||||
let persons = $state<Person[]>([]);
|
||||
let loading = $state(false);
|
||||
let errorMessage = $state('');
|
||||
|
|
@ -15,6 +20,18 @@
|
|||
let searchQuery = $state('');
|
||||
let statusFilter = $state<StatusFilter>('all');
|
||||
let checkedFilter = $state<CheckedFilter>('checked-in');
|
||||
let gradeFilter = $state<GradeFilter>('all');
|
||||
let visitorFilter = $state<VisitorFilter>('all');
|
||||
let sleepingFilter = $state<SleepingFilter>('all');
|
||||
let editor = $state<{ open: boolean; person: Person | null }>({ open: false, person: null });
|
||||
|
||||
function fullName(person: Person) {
|
||||
return `${person.first_name} ${person.last_name}`.trim();
|
||||
}
|
||||
|
||||
function isLowerGrade(person: Person) {
|
||||
return person.grade <= 3;
|
||||
}
|
||||
|
||||
async function apiFetch(url: string) {
|
||||
const response = await fetch(url);
|
||||
|
|
@ -26,44 +43,55 @@
|
|||
}
|
||||
|
||||
function matchesFilters(person: Person) {
|
||||
if (checkedFilter === 'checked-in' && !person.checked_in) return false;
|
||||
if (checkedFilter === 'not-checked-in' && person.checked_in) return false;
|
||||
if (statusFilter === 'inside' && !person.inside) return false;
|
||||
if (statusFilter === 'outside' && person.inside) return false;
|
||||
if (checkedFilter === 'checked-in' && !person.checked_in) return false;
|
||||
if (checkedFilter === 'not-checked-in' && person.checked_in) return false;
|
||||
if (statusFilter === 'inside' && !person.inside) return false;
|
||||
if (statusFilter === 'outside' && person.inside) return false;
|
||||
if (gradeFilter === 'lt4' && person.grade > 3) return false;
|
||||
if (gradeFilter === 'ge4' && person.grade <= 3) return false;
|
||||
if (visitorFilter === 'besoksplats' && !person.visitor) return false;
|
||||
if (visitorFilter === 'lanplats' && person.visitor) return false;
|
||||
if (sleepingFilter === 'needs' && !person.sleeping_spot) return false;
|
||||
if (sleepingFilter === 'not-needed' && person.sleeping_spot) return false;
|
||||
if (searchQuery.trim()) {
|
||||
const query = searchQuery.trim().toLowerCase();
|
||||
const matchesText =
|
||||
person.name.toLowerCase().includes(query) ||
|
||||
person.phone_number.toLowerCase().includes(query) ||
|
||||
`${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;
|
||||
}
|
||||
|
||||
function applyFilteredList(list: Person[]) {
|
||||
const filtered = list.filter((person) => {
|
||||
if (statusFilter === 'inside' && !person.inside) return false;
|
||||
if (statusFilter === 'outside' && person.inside) return false;
|
||||
if (checkedFilter === 'checked-in' && !person.checked_in) return false;
|
||||
if (checkedFilter === 'not-checked-in' && person.checked_in) return false;
|
||||
return true;
|
||||
});
|
||||
function updateVisiblePersons() {
|
||||
const filtered = allPersons.filter((person) => matchesFilters(person));
|
||||
persons = filtered;
|
||||
if (persons.length === 0) {
|
||||
infoMessage = 'Inga personer matchar kriterierna.';
|
||||
} else {
|
||||
infoMessage = '';
|
||||
}
|
||||
infoMessage = filtered.length === 0 ? 'Inga personer matchar kriterierna.' : '';
|
||||
}
|
||||
|
||||
function applyFetchedList(list: Person[]) {
|
||||
allPersons = list;
|
||||
updateVisiblePersons();
|
||||
}
|
||||
|
||||
function handlePersonUpdate(person: Person) {
|
||||
persons = updateCollection(persons, person, matchesFilters);
|
||||
if (persons.length === 0) {
|
||||
infoMessage = 'Inga personer matchar kriterierna.';
|
||||
} else {
|
||||
infoMessage = '';
|
||||
}
|
||||
allPersons = updateCollection(allPersons, person, () => true);
|
||||
updateVisiblePersons();
|
||||
}
|
||||
|
||||
function openEditor(person: Person) {
|
||||
editor = { open: true, person };
|
||||
}
|
||||
|
||||
function closeEditor() {
|
||||
editor = { open: false, person: null };
|
||||
}
|
||||
|
||||
function handleEditorSaved(person: Person) {
|
||||
handlePersonUpdate(person);
|
||||
infoMessage = 'Personen uppdaterades.';
|
||||
}
|
||||
|
||||
async function fetchCheckedIn() {
|
||||
|
|
@ -99,16 +127,18 @@
|
|||
} catch {
|
||||
errorMessage = text || 'Kunde inte hämta personer.';
|
||||
}
|
||||
allPersons = [];
|
||||
persons = [];
|
||||
return;
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
const list: Person[] = data.persons ?? [];
|
||||
applyFilteredList(list);
|
||||
applyFetchedList(list);
|
||||
} catch (err) {
|
||||
console.error('Fetch checked-in failed', err);
|
||||
errorMessage = 'Ett oväntat fel inträffade när listan skulle hämtas.';
|
||||
allPersons = [];
|
||||
persons = [];
|
||||
} finally {
|
||||
loading = false;
|
||||
|
|
@ -128,6 +158,24 @@
|
|||
await fetchCheckedIn();
|
||||
}
|
||||
|
||||
function handleGradeChange(event: Event) {
|
||||
const value = (event.currentTarget as HTMLSelectElement).value as GradeFilter;
|
||||
gradeFilter = value;
|
||||
updateVisiblePersons();
|
||||
}
|
||||
|
||||
function handleVisitorChange(event: Event) {
|
||||
const value = (event.currentTarget as HTMLSelectElement).value as VisitorFilter;
|
||||
visitorFilter = value;
|
||||
updateVisiblePersons();
|
||||
}
|
||||
|
||||
function handleSleepingChange(event: Event) {
|
||||
const value = (event.currentTarget as HTMLSelectElement).value as SleepingFilter;
|
||||
sleepingFilter = value;
|
||||
updateVisiblePersons();
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
void fetchCheckedIn();
|
||||
const stop = listenToPersonEvents((person) => {
|
||||
|
|
@ -158,13 +206,16 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<form class="mt-4 grid gap-4 md:grid-cols-[2fr_1fr_1fr_auto]" onsubmit={handleSearch}>
|
||||
<form
|
||||
class="mt-4 grid gap-4 md:grid-cols-[2fr_1fr_1fr_1fr_1fr_1fr_auto]"
|
||||
onsubmit={handleSearch}
|
||||
>
|
||||
<div>
|
||||
<label for="checked-query" class="mb-1 block text-sm font-medium text-slate-600">Sök</label>
|
||||
<input
|
||||
type="text"
|
||||
id="checked-query"
|
||||
placeholder="Exempel: 42, Anna eller 0701234567"
|
||||
placeholder="Exempel: Anna Andersson, 42 eller 0701234567"
|
||||
bind:value={searchQuery}
|
||||
class="w-full rounded-md border border-slate-300 px-3 py-2 text-slate-900 focus:border-indigo-500 focus:ring focus:ring-indigo-100 focus:outline-none"
|
||||
/>
|
||||
|
|
@ -197,6 +248,51 @@
|
|||
<option value="all">Alla</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label for="overview-grade" class="mb-1 block text-sm font-medium text-slate-600"
|
||||
>Årskurs</label
|
||||
>
|
||||
<select
|
||||
id="overview-grade"
|
||||
bind:value={gradeFilter}
|
||||
onchange={handleGradeChange}
|
||||
class="w-full rounded-md border border-slate-300 px-3 py-2 text-sm text-slate-900 focus:border-indigo-500 focus:ring focus:ring-indigo-100 focus:outline-none"
|
||||
>
|
||||
<option value="all">Alla</option>
|
||||
<option value="lt4">Årskurs 3 eller yngre</option>
|
||||
<option value="ge4">Årskurs 4 eller äldre</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label for="overview-visitor" class="mb-1 block text-sm font-medium text-slate-600"
|
||||
>Plats</label
|
||||
>
|
||||
<select
|
||||
id="overview-visitor"
|
||||
bind:value={visitorFilter}
|
||||
onchange={handleVisitorChange}
|
||||
class="w-full rounded-md border border-slate-300 px-3 py-2 text-sm text-slate-900 focus:border-indigo-500 focus:ring focus:ring-indigo-100 focus:outline-none"
|
||||
>
|
||||
<option value="all">Alla</option>
|
||||
<option value="besoksplats">Besöksplats</option>
|
||||
<option value="lanplats">Lanplats</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label for="overview-sleep" class="mb-1 block text-sm font-medium text-slate-600"
|
||||
>Sovplats</label
|
||||
>
|
||||
<select
|
||||
id="overview-sleep"
|
||||
bind:value={sleepingFilter}
|
||||
onchange={handleSleepingChange}
|
||||
class="w-full rounded-md border border-slate-300 px-3 py-2 text-sm text-slate-900 focus:border-indigo-500 focus:ring focus:ring-indigo-100 focus:outline-none"
|
||||
>
|
||||
<option value="all">Alla</option>
|
||||
<option value="needs">Behöver sovplats</option>
|
||||
<option value="not-needed">Behöver inte</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="flex items-end">
|
||||
<button
|
||||
type="submit"
|
||||
|
|
@ -219,11 +315,14 @@
|
|||
<section class="space-y-4">
|
||||
{#each persons as person}
|
||||
<article class="rounded-lg border border-slate-200 bg-white p-5 shadow-sm">
|
||||
<header class="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<h3 class="text-base font-semibold text-slate-800">{person.name}</h3>
|
||||
<p class="text-sm text-slate-500">ID: {person.id} · Telefon: {person.phone_number}</p>
|
||||
</div>
|
||||
<header class="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<h3 class="text-base font-semibold text-slate-800">{fullName(person)}</h3>
|
||||
<p class="text-sm text-slate-500">ID: {person.id} · Klass: {person.grade}</p>
|
||||
<p class="text-sm text-slate-500">
|
||||
Vårdnadshavare: {person.parent_name} ({person.parent_phone_number})
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<span
|
||||
class={`rounded-full px-3 py-1 text-xs font-semibold ${
|
||||
|
|
@ -237,15 +336,40 @@
|
|||
person.inside ? 'bg-blue-100 text-blue-700' : 'bg-slate-100 text-slate-600'
|
||||
}`}>{person.inside ? 'Inne' : 'Ute'}</span
|
||||
>
|
||||
{#if person.visitor}
|
||||
<span class="rounded-full bg-amber-100 px-3 py-1 text-xs font-semibold text-amber-700">
|
||||
Besöksplats
|
||||
</span>
|
||||
{/if}
|
||||
{#if person.sleeping_spot}
|
||||
<span class="rounded-full bg-indigo-100 px-3 py-1 text-xs font-semibold text-indigo-700">
|
||||
Behöver sovplats
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
</header>
|
||||
<p class="mt-3 text-sm text-slate-600">Ålder: {person.age} år</p>
|
||||
{#if person.under_ten}
|
||||
<p class="mt-3 rounded-md bg-amber-100 px-3 py-2 text-sm font-semibold text-amber-800">
|
||||
VARNING: Person under 10 år – kompletterande information krävs.
|
||||
</p>
|
||||
{/if}
|
||||
</article>
|
||||
{/each}
|
||||
</section>
|
||||
{#if isLowerGrade(person)}
|
||||
<p class="mt-3 rounded-md bg-amber-100 px-3 py-2 text-sm font-semibold text-amber-800">
|
||||
Observera: elev i årskurs 3 eller yngre – ska hem senast 22:00.
|
||||
</p>
|
||||
{/if}
|
||||
<div class="mt-4">
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => openEditor(person)}
|
||||
class="rounded-md border border-slate-300 px-4 py-2 text-sm font-medium text-slate-700 transition hover:bg-slate-100"
|
||||
>
|
||||
Redigera
|
||||
</button>
|
||||
</div>
|
||||
</article>
|
||||
{/each}
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<EditPersonModal
|
||||
open={editor.open}
|
||||
person={editor.person}
|
||||
on:close={closeEditor}
|
||||
on:saved={(event) => handleEditorSaved(event.detail)}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -4,6 +4,9 @@
|
|||
import { onMount } from 'svelte';
|
||||
import { listenToPersonEvents } from '$lib/client/person-events';
|
||||
|
||||
type GradeFilter = 'all' | 'lt4' | 'ge4';
|
||||
type VisitorFilter = 'all' | 'besoksplats' | 'lanplats';
|
||||
|
||||
let searchQuery = $state('');
|
||||
let searchResults = $state<Person[]>([]);
|
||||
let visibleResults = $state<Person[]>([]);
|
||||
|
|
@ -11,6 +14,51 @@
|
|||
let searchError = $state('');
|
||||
let searchInfo = $state('');
|
||||
let actionInfo = $state('');
|
||||
let gradeFilter = $state<GradeFilter>('all');
|
||||
let visitorFilter = $state<VisitorFilter>('all');
|
||||
|
||||
function fullName(person: Person) {
|
||||
return `${person.first_name} ${person.last_name}`.trim();
|
||||
}
|
||||
|
||||
function isLowerGrade(person: Person) {
|
||||
return person.grade <= 3;
|
||||
}
|
||||
|
||||
function matchesFilters(person: Person) {
|
||||
if (!person.checked_in) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (gradeFilter === 'lt4' && person.grade > 3) {
|
||||
return false;
|
||||
}
|
||||
if (gradeFilter === 'ge4' && person.grade <= 3) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (visitorFilter === 'besoksplats' && !person.visitor) {
|
||||
return false;
|
||||
}
|
||||
if (visitorFilter === 'lanplats' && person.visitor) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const query = searchQuery.trim().toLowerCase();
|
||||
if (query) {
|
||||
const combinedName = `${person.first_name} ${person.last_name}`.toLowerCase();
|
||||
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;
|
||||
}
|
||||
|
||||
async function apiFetch(url: string, init?: RequestInit) {
|
||||
const response = await fetch(url, init);
|
||||
|
|
@ -101,7 +149,7 @@
|
|||
}
|
||||
return person;
|
||||
});
|
||||
if (!found) {
|
||||
if (!found && updated.checked_in) {
|
||||
searchResults = [updated, ...searchResults];
|
||||
}
|
||||
updateVisibleResults();
|
||||
|
|
@ -129,18 +177,30 @@
|
|||
}
|
||||
|
||||
function updateVisibleResults() {
|
||||
const filtered = searchResults.filter((person) => person.checked_in);
|
||||
const filtered = searchResults.filter((person) => matchesFilters(person));
|
||||
visibleResults = filtered;
|
||||
|
||||
if (searchResults.length === 0) {
|
||||
searchInfo = 'Ingen träff på sökningen.';
|
||||
if (searchResults.length === 0 && !searchQuery.trim()) {
|
||||
searchInfo = 'Inga personer hämtades.';
|
||||
} else if (filtered.length === 0) {
|
||||
searchInfo = 'Inga personer kan checkas ut just nu.';
|
||||
searchInfo = 'Ingen person matchar de valda filtren just nu.';
|
||||
} else {
|
||||
searchInfo = '';
|
||||
}
|
||||
}
|
||||
|
||||
function handleGradeFilterChange(event: Event) {
|
||||
const value = (event.currentTarget as HTMLSelectElement).value as GradeFilter;
|
||||
gradeFilter = value;
|
||||
updateVisibleResults();
|
||||
}
|
||||
|
||||
function handleVisitorFilterChange(event: Event) {
|
||||
const value = (event.currentTarget as HTMLSelectElement).value as VisitorFilter;
|
||||
visitorFilter = value;
|
||||
updateVisibleResults();
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
void loadDefaultList();
|
||||
const stop = listenToPersonEvents((person) => {
|
||||
|
|
@ -156,22 +216,62 @@
|
|||
<section class="rounded-lg border border-slate-200 bg-white p-6 shadow-sm">
|
||||
<h3 class="text-lg font-semibold text-slate-800">Checka ut</h3>
|
||||
<p class="mb-4 text-sm text-slate-500">
|
||||
Sök på namn, id eller telefonnummer för att checka ut personer som är incheckade.
|
||||
Sök på namn, id, vårdnadshavare eller telefonnummer för att hitta personer som ska
|
||||
checkas ut.
|
||||
</p>
|
||||
<form class="flex flex-col gap-3 sm:flex-row" onsubmit={handleSearch}>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Exempel: Anna, 42 eller 0701234567"
|
||||
bind:value={searchQuery}
|
||||
class="w-full rounded-md border border-slate-300 px-3 py-2 text-slate-900 focus:border-indigo-500 focus:ring focus:ring-indigo-100 focus:outline-none"
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={searchLoading}
|
||||
class="rounded-md bg-indigo-600 px-4 py-2 text-sm font-semibold text-white transition-colors hover:bg-indigo-700 disabled:cursor-not-allowed disabled:opacity-70"
|
||||
>
|
||||
{searchLoading ? 'Söker…' : 'Sök'}
|
||||
</button>
|
||||
<form
|
||||
class="grid gap-3 md:grid-cols-[2fr_1fr_1fr_auto]"
|
||||
onsubmit={handleSearch}
|
||||
>
|
||||
<div class="flex flex-col gap-1">
|
||||
<label for="checkout-query" class="text-sm font-medium text-slate-600">Sök</label>
|
||||
<input
|
||||
type="text"
|
||||
id="checkout-query"
|
||||
placeholder="Exempel: Anna Andersson, 42 eller 0701234567"
|
||||
bind:value={searchQuery}
|
||||
class="w-full rounded-md border border-slate-300 px-3 py-2 text-slate-900 focus:border-indigo-500 focus:ring focus:ring-indigo-100 focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex flex-col gap-1">
|
||||
<label for="checkout-grade" class="text-sm font-medium text-slate-600"
|
||||
>Årskurs</label
|
||||
>
|
||||
<select
|
||||
id="checkout-grade"
|
||||
bind:value={gradeFilter}
|
||||
onchange={handleGradeFilterChange}
|
||||
class="w-full rounded-md border border-slate-300 px-3 py-2 text-sm text-slate-900 focus:border-indigo-500 focus:ring focus:ring-indigo-100 focus:outline-none"
|
||||
>
|
||||
<option value="all">Alla</option>
|
||||
<option value="lt4">Årskurs 3 eller yngre</option>
|
||||
<option value="ge4">Årskurs 4 eller äldre</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="flex flex-col gap-1">
|
||||
<label for="checkout-visitor" class="text-sm font-medium text-slate-600"
|
||||
>Plats</label
|
||||
>
|
||||
<select
|
||||
id="checkout-visitor"
|
||||
bind:value={visitorFilter}
|
||||
onchange={handleVisitorFilterChange}
|
||||
class="w-full rounded-md border border-slate-300 px-3 py-2 text-sm text-slate-900 focus:border-indigo-500 focus:ring focus:ring-indigo-100 focus:outline-none"
|
||||
>
|
||||
<option value="all">Alla</option>
|
||||
<option value="besoksplats">Besöksplats</option>
|
||||
<option value="lanplats">Lanplats</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="flex items-end">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={searchLoading}
|
||||
class="w-full rounded-md bg-indigo-600 px-4 py-2 text-sm font-semibold text-white transition-colors hover:bg-indigo-700 disabled:cursor-not-allowed disabled:opacity-70"
|
||||
>
|
||||
{searchLoading ? 'Söker…' : 'Sök'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
{#if searchError}
|
||||
<p class="mt-4 rounded-md bg-red-50 px-3 py-2 text-sm text-red-600">{searchError}</p>
|
||||
|
|
@ -189,9 +289,12 @@
|
|||
<li class="rounded-lg border border-slate-200 p-4 shadow-sm">
|
||||
<div class="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<h4 class="text-base font-semibold text-slate-800">{person.name}</h4>
|
||||
<h4 class="text-base font-semibold text-slate-800">{fullName(person)}</h4>
|
||||
<p class="text-sm text-slate-500">
|
||||
ID: {person.id} · Telefon: {person.phone_number}
|
||||
ID: {person.id} · Klass: {person.grade}
|
||||
</p>
|
||||
<p class="text-sm text-slate-500">
|
||||
Vårdnadshavare: {person.parent_name} ({person.parent_phone_number})
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
|
|
@ -207,14 +310,21 @@
|
|||
person.inside ? 'bg-blue-100 text-blue-700' : 'bg-slate-100 text-slate-600'
|
||||
}`}>{person.inside ? 'Inne' : 'Ute'}</span
|
||||
>
|
||||
{#if person.visitor}
|
||||
<span class="rounded-full bg-amber-100 px-3 py-1 text-xs font-semibold text-amber-700">
|
||||
Besöksplats
|
||||
</span>
|
||||
{/if}
|
||||
{#if person.sleeping_spot}
|
||||
<span class="rounded-full bg-indigo-100 px-3 py-1 text-xs font-semibold text-indigo-700">
|
||||
Behöver sovplats
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
<p class="mt-2 text-sm text-slate-600">Ålder: {person.age} år</p>
|
||||
{#if person.under_ten}
|
||||
<p
|
||||
class="mt-2 rounded-md bg-amber-100 px-3 py-2 text-sm font-semibold text-amber-800"
|
||||
>
|
||||
VARNING: Person under 10 år – kompletterande information krävs innan utcheckning.
|
||||
{#if isLowerGrade(person)}
|
||||
<p class="mt-2 rounded-md bg-amber-100 px-3 py-2 text-sm font-semibold text-amber-800">
|
||||
Observera: elev i årskurs 3 eller yngre – ska hem senast 22:00.
|
||||
</p>
|
||||
{/if}
|
||||
<div class="mt-4 flex gap-3">
|
||||
|
|
|
|||
|
|
@ -4,50 +4,76 @@ export const actions: Actions = {
|
|||
default: async ({ request, fetch }) => {
|
||||
const formData = await request.formData();
|
||||
|
||||
const name = formData.get('name')?.toString().trim() ?? '';
|
||||
const ageRaw = formData.get('age')?.toString().trim() ?? '';
|
||||
const phone = formData.get('phone_number')?.toString().trim() ?? '';
|
||||
const firstName = formData.get('first_name')?.toString().trim() ?? '';
|
||||
const lastName = formData.get('last_name')?.toString().trim() ?? '';
|
||||
const gradeRaw = formData.get('grade')?.toString().trim() ?? '';
|
||||
const parentName = formData.get('parent_name')?.toString().trim() ?? '';
|
||||
const parentPhone = formData.get('parent_phone_number')?.toString().trim() ?? '';
|
||||
const manualId = formData.get('manual_id')?.toString().trim() ?? '';
|
||||
const checkedIn = formData.get('checked_in') === 'on';
|
||||
const inside = formData.get('inside') === 'on';
|
||||
const visitor = formData.get('visitor') === 'on';
|
||||
const sleepingSpot = formData.get('sleeping_spot') === 'on';
|
||||
|
||||
const values = {
|
||||
name,
|
||||
age: ageRaw,
|
||||
phone_number: phone,
|
||||
first_name: firstName,
|
||||
last_name: lastName,
|
||||
grade: gradeRaw,
|
||||
parent_name: parentName,
|
||||
parent_phone_number: parentPhone,
|
||||
manual_id: manualId,
|
||||
checked_in: checkedIn,
|
||||
inside
|
||||
inside,
|
||||
visitor,
|
||||
sleeping_spot: sleepingSpot
|
||||
};
|
||||
|
||||
if (!name) {
|
||||
if (!firstName) {
|
||||
return fail(400, {
|
||||
errors: { name: 'Ange ett namn.' },
|
||||
errors: { first_name: 'Ange ett förnamn.' },
|
||||
values
|
||||
});
|
||||
}
|
||||
|
||||
const parsedAge = Number.parseInt(ageRaw, 10);
|
||||
if (Number.isNaN(parsedAge) || parsedAge < 0) {
|
||||
if (!lastName) {
|
||||
return fail(400, {
|
||||
errors: { age: 'Ålder måste vara ett heltal större än eller lika med 0.' },
|
||||
errors: { last_name: 'Ange ett efternamn.' },
|
||||
values
|
||||
});
|
||||
}
|
||||
|
||||
if (!phone) {
|
||||
const parsedGrade = Number.parseInt(gradeRaw, 10);
|
||||
if (Number.isNaN(parsedGrade) || parsedGrade < 0) {
|
||||
return fail(400, {
|
||||
errors: { phone_number: 'Ange ett telefonnummer.' },
|
||||
errors: { grade: 'Klass måste vara ett heltal större än eller lika med 0.' },
|
||||
values
|
||||
});
|
||||
}
|
||||
|
||||
if (!parentName) {
|
||||
return fail(400, {
|
||||
errors: { parent_name: 'Ange en kontaktperson.' },
|
||||
values
|
||||
});
|
||||
}
|
||||
|
||||
if (!parentPhone) {
|
||||
return fail(400, {
|
||||
errors: { parent_phone_number: 'Ange kontaktpersonens telefonnummer.' },
|
||||
values
|
||||
});
|
||||
}
|
||||
|
||||
const payload: Record<string, unknown> = {
|
||||
name,
|
||||
age: parsedAge,
|
||||
phone_number: phone,
|
||||
first_name: firstName,
|
||||
last_name: lastName,
|
||||
grade: parsedGrade,
|
||||
parent_name: parentName,
|
||||
parent_phone_number: parentPhone,
|
||||
checked_in: checkedIn,
|
||||
inside
|
||||
inside,
|
||||
visitor,
|
||||
sleeping_spot: sleepingSpot
|
||||
};
|
||||
|
||||
if (manualId.length > 0) {
|
||||
|
|
|
|||
|
|
@ -3,29 +3,39 @@
|
|||
const props = $props<import('./$types').PageData>();
|
||||
|
||||
type FormValues = {
|
||||
name: string;
|
||||
age: string;
|
||||
phone_number: string;
|
||||
first_name: string;
|
||||
last_name: string;
|
||||
grade: string;
|
||||
parent_name: string;
|
||||
parent_phone_number: string;
|
||||
manual_id: string;
|
||||
checked_in: boolean;
|
||||
inside: boolean;
|
||||
visitor: boolean;
|
||||
sleeping_spot: boolean;
|
||||
};
|
||||
|
||||
type FormErrors = {
|
||||
name?: string;
|
||||
age?: string;
|
||||
phone_number?: string;
|
||||
first_name?: string;
|
||||
last_name?: string;
|
||||
grade?: string;
|
||||
parent_name?: string;
|
||||
parent_phone_number?: string;
|
||||
manual_id?: string;
|
||||
general?: string;
|
||||
};
|
||||
|
||||
const defaults: FormValues = {
|
||||
name: '',
|
||||
age: '',
|
||||
phone_number: '',
|
||||
first_name: '',
|
||||
last_name: '',
|
||||
grade: '',
|
||||
parent_name: '',
|
||||
parent_phone_number: '',
|
||||
manual_id: '',
|
||||
checked_in: false,
|
||||
inside: false
|
||||
inside: false,
|
||||
visitor: false,
|
||||
sleeping_spot: false
|
||||
};
|
||||
|
||||
const values = $derived({
|
||||
|
|
@ -44,49 +54,53 @@
|
|||
Fyll i uppgifterna nedan. Om ID lämnas tomt skapas ett automatiskt.
|
||||
</p>
|
||||
<form method="POST" class="grid gap-4 md:grid-cols-2">
|
||||
<div class="md:col-span-2">
|
||||
<label class="mb-1 block text-sm font-medium text-slate-600" for="name">Namn</label>
|
||||
<input
|
||||
type="text"
|
||||
id="name"
|
||||
name="name"
|
||||
value={values.name}
|
||||
required
|
||||
class="w-full rounded-md border border-slate-300 px-3 py-2 text-slate-900 focus:border-indigo-500 focus:ring focus:ring-indigo-100 focus:outline-none"
|
||||
/>
|
||||
{#if errors.name}
|
||||
<p class="mt-1 text-sm text-red-600">{errors.name}</p>
|
||||
{/if}
|
||||
</div>
|
||||
<div>
|
||||
<label class="mb-1 block text-sm font-medium text-slate-600" for="age">Ålder</label>
|
||||
<input
|
||||
type="number"
|
||||
id="age"
|
||||
name="age"
|
||||
min="0"
|
||||
value={values.age}
|
||||
required
|
||||
class="w-full rounded-md border border-slate-300 px-3 py-2 text-slate-900 focus:border-indigo-500 focus:ring focus:ring-indigo-100 focus:outline-none"
|
||||
/>
|
||||
{#if errors.age}
|
||||
<p class="mt-1 text-sm text-red-600">{errors.age}</p>
|
||||
{/if}
|
||||
</div>
|
||||
<div>
|
||||
<label class="mb-1 block text-sm font-medium text-slate-600" for="phone"
|
||||
>Telefonnummer</label
|
||||
<label class="mb-1 block text-sm font-medium text-slate-600" for="first-name"
|
||||
>Förnamn</label
|
||||
>
|
||||
<input
|
||||
type="tel"
|
||||
id="phone"
|
||||
name="phone_number"
|
||||
value={values.phone_number}
|
||||
type="text"
|
||||
id="first-name"
|
||||
name="first_name"
|
||||
value={values.first_name}
|
||||
required
|
||||
class="w-full rounded-md border border-slate-300 px-3 py-2 text-slate-900 focus:border-indigo-500 focus:ring focus:ring-indigo-100 focus:outline-none"
|
||||
/>
|
||||
{#if errors.phone_number}
|
||||
<p class="mt-1 text-sm text-red-600">{errors.phone_number}</p>
|
||||
{#if errors.first_name}
|
||||
<p class="mt-1 text-sm text-red-600">{errors.first_name}</p>
|
||||
{/if}
|
||||
</div>
|
||||
<div>
|
||||
<label class="mb-1 block text-sm font-medium text-slate-600" for="last-name"
|
||||
>Efternamn</label
|
||||
>
|
||||
<input
|
||||
type="text"
|
||||
id="last-name"
|
||||
name="last_name"
|
||||
value={values.last_name}
|
||||
required
|
||||
class="w-full rounded-md border border-slate-300 px-3 py-2 text-slate-900 focus:border-indigo-500 focus:ring focus:ring-indigo-100 focus:outline-none"
|
||||
/>
|
||||
{#if errors.last_name}
|
||||
<p class="mt-1 text-sm text-red-600">{errors.last_name}</p>
|
||||
{/if}
|
||||
</div>
|
||||
<div>
|
||||
<label class="mb-1 block text-sm font-medium text-slate-600" for="grade"
|
||||
>Klass (år)</label
|
||||
>
|
||||
<input
|
||||
type="number"
|
||||
id="grade"
|
||||
name="grade"
|
||||
min="0"
|
||||
value={values.grade}
|
||||
required
|
||||
class="w-full rounded-md border border-slate-300 px-3 py-2 text-slate-900 focus:border-indigo-500 focus:ring focus:ring-indigo-100 focus:outline-none"
|
||||
/>
|
||||
{#if errors.grade}
|
||||
<p class="mt-1 text-sm text-red-600">{errors.grade}</p>
|
||||
{/if}
|
||||
</div>
|
||||
<div>
|
||||
|
|
@ -105,28 +119,81 @@
|
|||
<p class="mt-1 text-sm text-red-600">{errors.manual_id}</p>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="md:col-span-2">
|
||||
<label class="mb-1 block text-sm font-medium text-slate-600" for="parent-name"
|
||||
>Vårdnadshavare</label
|
||||
>
|
||||
<input
|
||||
id="checked-in"
|
||||
type="checkbox"
|
||||
name="checked_in"
|
||||
value="on"
|
||||
checked={values.checked_in}
|
||||
class="h-4 w-4 rounded border-slate-300 text-indigo-600 focus:ring-indigo-500"
|
||||
type="text"
|
||||
id="parent-name"
|
||||
name="parent_name"
|
||||
value={values.parent_name}
|
||||
required
|
||||
class="w-full rounded-md border border-slate-300 px-3 py-2 text-slate-900 focus:border-indigo-500 focus:ring focus:ring-indigo-100 focus:outline-none"
|
||||
/>
|
||||
<label for="checked-in" class="text-sm text-slate-700">Markera som incheckad</label>
|
||||
{#if errors.parent_name}
|
||||
<p class="mt-1 text-sm text-red-600">{errors.parent_name}</p>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="md:col-span-2">
|
||||
<label class="mb-1 block text-sm font-medium text-slate-600" for="parent-phone"
|
||||
>Vårdnadshavare – telefon</label
|
||||
>
|
||||
<input
|
||||
id="inside"
|
||||
type="checkbox"
|
||||
name="inside"
|
||||
value="on"
|
||||
checked={values.inside}
|
||||
class="h-4 w-4 rounded border-slate-300 text-indigo-600 focus:ring-indigo-500"
|
||||
type="tel"
|
||||
id="parent-phone"
|
||||
name="parent_phone_number"
|
||||
value={values.parent_phone_number}
|
||||
required
|
||||
class="w-full rounded-md border border-slate-300 px-3 py-2 text-slate-900 focus:border-indigo-500 focus:ring focus:ring-indigo-100 focus:outline-none"
|
||||
/>
|
||||
<label for="inside" class="text-sm text-slate-700">Markera som inne</label>
|
||||
{#if errors.parent_phone_number}
|
||||
<p class="mt-1 text-sm text-red-600">{errors.parent_phone_number}</p>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="md:col-span-2 grid gap-3 rounded-md border border-slate-200 bg-slate-50 p-4 sm:grid-cols-2">
|
||||
<label class="flex items-center gap-2 text-sm text-slate-700">
|
||||
<input
|
||||
type="checkbox"
|
||||
name="checked_in"
|
||||
value="on"
|
||||
checked={values.checked_in}
|
||||
class="h-4 w-4 rounded border-slate-300 text-indigo-600 focus:ring-indigo-500"
|
||||
/>
|
||||
<span>Markera som incheckad</span>
|
||||
</label>
|
||||
<label class="flex items-center gap-2 text-sm text-slate-700">
|
||||
<input
|
||||
type="checkbox"
|
||||
name="inside"
|
||||
value="on"
|
||||
checked={values.inside}
|
||||
class="h-4 w-4 rounded border-slate-300 text-indigo-600 focus:ring-indigo-500"
|
||||
/>
|
||||
<span>Markera som inne</span>
|
||||
</label>
|
||||
<label class="flex items-center gap-2 text-sm text-slate-700">
|
||||
<input
|
||||
type="checkbox"
|
||||
name="visitor"
|
||||
value="on"
|
||||
checked={values.visitor}
|
||||
class="h-4 w-4 rounded border-slate-300 text-indigo-600 focus:ring-indigo-500"
|
||||
/>
|
||||
<span>Markera som besökare</span>
|
||||
</label>
|
||||
<label class="flex items-center gap-2 text-sm text-slate-700">
|
||||
<input
|
||||
type="checkbox"
|
||||
name="sleeping_spot"
|
||||
value="on"
|
||||
checked={values.sleeping_spot}
|
||||
class="h-4 w-4 rounded border-slate-300 text-indigo-600 focus:ring-indigo-500"
|
||||
/>
|
||||
<span>Behöver sovplats</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{#if errors.general}
|
||||
<p class="rounded-md bg-red-50 px-3 py-2 text-sm text-red-600 md:col-span-2">
|
||||
{errors.general}
|
||||
|
|
|
|||
|
|
@ -15,6 +15,14 @@
|
|||
let searchQuery = $state('');
|
||||
let statusFilter = $state<StatusFilter>('all');
|
||||
|
||||
function fullName(person: Person) {
|
||||
return `${person.first_name} ${person.last_name}`.trim();
|
||||
}
|
||||
|
||||
function isLowerGrade(person: Person) {
|
||||
return person.grade <= 3;
|
||||
}
|
||||
|
||||
async function apiFetch(url: string, init?: RequestInit) {
|
||||
const response = await fetch(url, init);
|
||||
if (response.status === 401) {
|
||||
|
|
@ -30,10 +38,11 @@
|
|||
if (statusFilter === 'outside' && person.inside) return false;
|
||||
const query = searchQuery.trim().toLowerCase();
|
||||
if (query) {
|
||||
const matchesText =
|
||||
person.name.toLowerCase().includes(query) ||
|
||||
person.phone_number.toLowerCase().includes(query) ||
|
||||
person.id.toString() === query;
|
||||
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;
|
||||
|
|
@ -139,9 +148,10 @@
|
|||
if (body.person) {
|
||||
const updated = body.person;
|
||||
handlePersonUpdate(updated);
|
||||
const name = fullName(updated);
|
||||
actionMessage = updated.inside
|
||||
? `${updated.name} är nu markerad som inne.`
|
||||
: `${updated.name} är nu markerad som ute.`;
|
||||
? `${name} är nu markerad som inne.`
|
||||
: `${name} är nu markerad som ute.`;
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Parsing toggle response failed', err);
|
||||
|
|
@ -184,7 +194,7 @@
|
|||
<input
|
||||
type="text"
|
||||
id="inside-query"
|
||||
placeholder="Exempel: 42, Anna eller 0701234567"
|
||||
placeholder="Exempel: Anna Andersson, 42 eller 0701234567"
|
||||
bind:value={searchQuery}
|
||||
class="w-full rounded-md border border-slate-300 px-3 py-2 text-slate-900 focus:border-indigo-500 focus:ring focus:ring-indigo-100 focus:outline-none"
|
||||
/>
|
||||
|
|
@ -233,8 +243,11 @@
|
|||
<article class="rounded-lg border border-slate-200 bg-white p-5 shadow-sm">
|
||||
<header class="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<h3 class="text-base font-semibold text-slate-800">{person.name}</h3>
|
||||
<p class="text-sm text-slate-500">ID: {person.id} · Telefon: {person.phone_number}</p>
|
||||
<h3 class="text-base font-semibold text-slate-800">{fullName(person)}</h3>
|
||||
<p class="text-sm text-slate-500">ID: {person.id} · Klass: {person.grade}</p>
|
||||
<p class="text-sm text-slate-500">
|
||||
Vårdnadshavare: {person.parent_name} ({person.parent_phone_number})
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<span
|
||||
|
|
@ -249,14 +262,23 @@
|
|||
person.inside ? 'bg-blue-100 text-blue-700' : 'bg-slate-100 text-slate-600'
|
||||
}`}>{person.inside ? 'Inne' : 'Ute'}</span
|
||||
>
|
||||
{#if person.visitor}
|
||||
<span class="rounded-full bg-amber-100 px-3 py-1 text-xs font-semibold text-amber-700">
|
||||
Besöksplats
|
||||
</span>
|
||||
{/if}
|
||||
{#if person.sleeping_spot}
|
||||
<span class="rounded-full bg-indigo-100 px-3 py-1 text-xs font-semibold text-indigo-700">
|
||||
Behöver sovplats
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
</header>
|
||||
<p class="mt-3 text-sm text-slate-600">Ålder: {person.age} år</p>
|
||||
{#if person.under_ten}
|
||||
<p class="mt-3 rounded-md bg-amber-100 px-3 py-2 text-sm font-semibold text-amber-800">
|
||||
VARNING: Person under 10 år – kompletterande information krävs.
|
||||
</p>
|
||||
{/if}
|
||||
{#if isLowerGrade(person)}
|
||||
<p class="mt-3 rounded-md bg-amber-100 px-3 py-2 text-sm font-semibold text-amber-800">
|
||||
Observera: elev i årskurs 3 eller yngre – ska hem senast 22:00.
|
||||
</p>
|
||||
{/if}
|
||||
<div class="mt-4 flex flex-wrap gap-3">
|
||||
<button
|
||||
type="button"
|
||||
|
|
|
|||
Loading…
Reference in a new issue