Compare commits

...

2 commits

14 changed files with 1292 additions and 254 deletions

View 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;

View file

@ -9,14 +9,14 @@ use config::AppConfig;
use error::ApiError; use error::ApiError;
use models::{ use models::{
LoginRequest, LoginResponse, NewPersonRequest, Person, PersonActionResponse, PersonResponse, LoginRequest, LoginResponse, NewPersonRequest, Person, PersonActionResponse, PersonResponse,
PersonsResponse, User, PersonsResponse, UpdatePersonRequest, User,
}; };
use rocket::http::{Cookie, CookieJar, SameSite, Status}; use rocket::http::{Cookie, CookieJar, SameSite, Status};
use rocket::response::stream::{Event, EventStream}; use rocket::response::stream::{Event, EventStream};
use rocket::serde::json::Json; use rocket::serde::json::Json;
use rocket::time::Duration; use rocket::time::Duration;
use rocket::tokio::sync::broadcast::{self, error::RecvError}; 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::postgres::PgPoolOptions;
use sqlx::{PgPool, QueryBuilder}; use sqlx::{PgPool, QueryBuilder};
@ -76,7 +76,8 @@ async fn main() -> Result<(), rocket::Error> {
checkout_person, checkout_person,
mark_inside, mark_inside,
mark_outside, mark_outside,
create_person create_person,
update_person
], ],
); );
@ -187,13 +188,25 @@ async fn search_persons(
let persons = if let Some(id) = id_value { let persons = if let Some(id) = id_value {
sqlx::query_as::<_, Person>( sqlx::query_as::<_, Person>(
r#" 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 FROM persons
WHERE name ILIKE $1 WHERE first_name ILIKE $1
OR phone_number 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 OR id = $2
ORDER BY name ORDER BY last_name, first_name
LIMIT 100
"#, "#,
) )
.bind(&like_pattern) .bind(&like_pattern)
@ -203,12 +216,24 @@ async fn search_persons(
} else { } else {
sqlx::query_as::<_, Person>( sqlx::query_as::<_, Person>(
r#" 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 FROM persons
WHERE name ILIKE $1 WHERE first_name ILIKE $1
OR phone_number ILIKE $1 OR last_name ILIKE $1
ORDER BY name OR parent_name ILIKE $1
LIMIT 100 OR parent_phone_number ILIKE $1
OR (first_name || ' ' || last_name) ILIKE $1
ORDER BY last_name, first_name
"#, "#,
) )
.bind(&like_pattern) .bind(&like_pattern)
@ -252,7 +277,7 @@ async fn list_checked_in(
let id_value = search_term.as_ref().and_then(|s| s.parse::<i32>().ok()); let id_value = search_term.as_ref().and_then(|s| s.parse::<i32>().ok());
let mut qb = QueryBuilder::<sqlx::Postgres>::new( 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; let mut first_condition = true;
@ -278,8 +303,12 @@ async fn list_checked_in(
if let Some(like) = like_pattern.as_ref() { if let Some(like) = like_pattern.as_ref() {
append_condition(&mut qb); append_condition(&mut qb);
qb.push("("); qb.push("(");
qb.push("name ILIKE ").push_bind(like); qb.push("first_name ILIKE ").push_bind(like);
qb.push(" OR phone_number 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 { if let Some(id) = id_value {
qb.push(" OR id = ").push_bind(id); qb.push(" OR id = ").push_bind(id);
} }
@ -289,12 +318,9 @@ async fn list_checked_in(
if let Some(id) = id_value { if let Some(id) = id_value {
qb.push(" ORDER BY CASE WHEN id = ") qb.push(" ORDER BY CASE WHEN id = ")
.push_bind(id) .push_bind(id)
.push(" THEN 0 ELSE 1 END, id, name"); .push(" THEN 0 ELSE 1 END, id, last_name, first_name");
} else { } else {
qb.push(" ORDER BY id, name"); qb.push(" ORDER BY id, last_name, first_name");
}
if like_pattern.is_some() {
qb.push(" LIMIT 1000");
} }
let persons = qb.build_query_as::<Person>().fetch_all(&state.db).await?; let persons = qb.build_query_as::<Person>().fetch_all(&state.db).await?;
@ -345,53 +371,116 @@ async fn create_person(
state: &State<AppState>, state: &State<AppState>,
payload: Json<NewPersonRequest>, payload: Json<NewPersonRequest>,
) -> Result<Json<PersonActionResponse>, ApiError> { ) -> Result<Json<PersonActionResponse>, ApiError> {
let name = payload.name.trim(); let first_name = payload.first_name.trim();
if name.is_empty() { if first_name.is_empty() {
return Err(ApiError::bad_request("Namn får inte vara tomt.")); return Err(ApiError::bad_request("Förnamn får inte vara tomt."));
} }
if payload.age < 0 { let last_name = payload.last_name.trim();
return Err(ApiError::bad_request("Ålder måste vara noll eller högre.")); if last_name.is_empty() {
return Err(ApiError::bad_request("Efternamn får inte vara tomt."));
} }
let phone_number = payload.phone_number.trim(); if payload.grade < 0 {
if phone_number.is_empty() { return Err(ApiError::bad_request("Klass måste vara noll eller högre."));
return Err(ApiError::bad_request("Telefonnummer krävs."));
} }
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 checked_in = payload.checked_in.unwrap_or(false);
let inside = payload.inside.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 { let person = match payload.id {
Some(id) => sqlx::query_as::<_, Person>( Some(id) => sqlx::query_as::<_, Person>(
r#" r#"
INSERT INTO persons (id, name, age, phone_number, checked_in, inside) INSERT INTO persons (
VALUES ($1, $2, $3, $4, $5, $6) id,
RETURNING id, name, age, phone_number, checked_in, inside 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(id)
.bind(name) .bind(first_name)
.bind(age) .bind(last_name)
.bind(phone_number) .bind(grade)
.bind(parent_name)
.bind(parent_phone_number)
.bind(checked_in) .bind(checked_in)
.bind(inside) .bind(inside)
.bind(visitor)
.bind(sleeping_spot)
.fetch_one(&state.db) .fetch_one(&state.db)
.await .await
.map_err(|err| map_db_error(err, "Kunde inte skapa person"))?, .map_err(|err| map_db_error(err, "Kunde inte skapa person"))?,
None => sqlx::query_as::<_, Person>( None => sqlx::query_as::<_, Person>(
r#" r#"
INSERT INTO persons (name, age, phone_number, checked_in, inside) INSERT INTO persons (
VALUES ($1, $2, $3, $4, $5) first_name,
RETURNING id, name, age, phone_number, checked_in, inside 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(first_name)
.bind(age) .bind(last_name)
.bind(phone_number) .bind(grade)
.bind(parent_name)
.bind(parent_phone_number)
.bind(checked_in) .bind(checked_in)
.bind(inside) .bind(inside)
.bind(visitor)
.bind(sleeping_spot)
.fetch_one(&state.db) .fetch_one(&state.db)
.await .await
.map_err(|err| map_db_error(err, "Kunde inte skapa person"))?, .map_err(|err| map_db_error(err, "Kunde inte skapa person"))?,
@ -404,6 +493,101 @@ async fn create_person(
Ok(Json(response)) 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( async fn update_checked_in(
state: &State<AppState>, state: &State<AppState>,
id: i32, id: i32,
@ -415,7 +599,17 @@ async fn update_checked_in(
SET checked_in = $2, SET checked_in = $2,
inside = $2 inside = $2
WHERE id = $1 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) .bind(id)
@ -446,7 +640,17 @@ async fn update_inside(
SET inside = $2 SET inside = $2
WHERE id = $1 WHERE id = $1
AND checked_in = TRUE 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) .bind(id)
@ -484,7 +688,7 @@ fn map_db_error(err: sqlx::Error, context: &str) -> ApiError {
if let Some(code) = db_err.code() { if let Some(code) = db_err.code() {
if code == "23505" { if code == "23505" {
return ApiError::bad_request( return ApiError::bad_request(
"Krock i databasen kontrollera id eller telefonnummer.", "Krock i databasen kontrollera id eller kontaktuppgifter.",
); );
} }
} }

View file

@ -12,11 +12,15 @@ pub struct User {
#[derive(Debug, FromRow)] #[derive(Debug, FromRow)]
pub struct Person { pub struct Person {
pub id: i32, pub id: i32,
pub name: String, pub first_name: String,
pub age: i32, pub last_name: String,
pub phone_number: String, pub grade: i32,
pub parent_name: String,
pub parent_phone_number: String,
pub checked_in: bool, pub checked_in: bool,
pub inside: bool, pub inside: bool,
pub visitor: bool,
pub sleeping_spot: bool,
} }
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
@ -36,25 +40,30 @@ pub struct LoginResponse {
#[serde(crate = "rocket::serde")] #[serde(crate = "rocket::serde")]
pub struct PersonResponse { pub struct PersonResponse {
pub id: i32, pub id: i32,
pub name: String, pub first_name: String,
pub age: i32, pub last_name: String,
pub phone_number: String, pub grade: i32,
pub parent_name: String,
pub parent_phone_number: String,
pub checked_in: bool, pub checked_in: bool,
pub inside: bool, pub inside: bool,
pub under_ten: bool, pub visitor: bool,
pub sleeping_spot: bool,
} }
impl From<Person> for PersonResponse { impl From<Person> for PersonResponse {
fn from(person: Person) -> Self { fn from(person: Person) -> Self {
let under_ten = person.age < 10;
PersonResponse { PersonResponse {
id: person.id, id: person.id,
name: person.name, first_name: person.first_name,
age: person.age, last_name: person.last_name,
phone_number: person.phone_number, grade: person.grade,
parent_name: person.parent_name,
parent_phone_number: person.parent_phone_number,
checked_in: person.checked_in, checked_in: person.checked_in,
inside: person.inside, inside: person.inside,
under_ten, visitor: person.visitor,
sleeping_spot: person.sleeping_spot,
} }
} }
} }
@ -74,13 +83,35 @@ pub struct PersonActionResponse {
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
#[serde(crate = "rocket::serde")] #[serde(crate = "rocket::serde")]
pub struct NewPersonRequest { pub struct NewPersonRequest {
pub name: String, pub first_name: String,
pub age: i32, pub last_name: String,
pub phone_number: String, pub grade: i32,
pub parent_name: String,
pub parent_phone_number: String,
#[serde(default)] #[serde(default)]
pub id: Option<i32>, pub id: Option<i32>,
#[serde(default)] #[serde(default)]
pub checked_in: Option<bool>, pub checked_in: Option<bool>,
#[serde(default)] #[serde(default)]
pub inside: Option<bool>, 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,
} }

View file

@ -45,13 +45,30 @@ async fn ensure_person_seed(pool: &PgPool) -> anyhow::Result<()> {
for person in persons { for person in persons {
sqlx::query( 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.first_name)
.bind(person.age) .bind(&person.last_name)
.bind(&person.phone_number) .bind(person.grade)
.bind(&person.parent_name)
.bind(&person.parent_phone_number)
.bind(person.checked_in) .bind(person.checked_in)
.bind(person.inside) .bind(person.inside)
.bind(person.visitor)
.bind(person.sleeping_spot)
.execute(pool) .execute(pool)
.await .await
.context("Misslyckades att lägga till seed-person")?; .context("Misslyckades att lägga till seed-person")?;
@ -61,20 +78,24 @@ async fn ensure_person_seed(pool: &PgPool) -> anyhow::Result<()> {
} }
struct PersonSeed { struct PersonSeed {
name: String, first_name: String,
age: i32, last_name: String,
phone_number: String, grade: i32,
parent_name: String,
parent_phone_number: String,
checked_in: bool, checked_in: bool,
inside: bool, inside: bool,
visitor: bool,
sleeping_spot: bool,
} }
fn generate_people() -> Vec<PersonSeed> { fn generate_people() -> Vec<PersonSeed> {
let first_names = vec![ let first_names = [
"Alex", "Bianca", "Cecilia", "David", "Elias", "Fatima", "Gabriel", "Hanna", "Isak", "Alex", "Bianca", "Cecilia", "David", "Elias", "Fatima", "Gabriel", "Hanna", "Isak",
"Johanna", "Karin", "Liam", "Maja", "Nils", "Olivia", "Johanna", "Karin", "Liam", "Maja", "Nils", "Olivia",
]; ];
let last_names = vec![ let last_names = [
"Andersson", "Andersson",
"Berg", "Berg",
"Carlsson", "Carlsson",
@ -87,20 +108,52 @@ fn generate_people() -> Vec<PersonSeed> {
"Johansson", "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 people = Vec::with_capacity(first_names.len() * last_names.len());
let mut idx: usize = 0; let mut idx: usize = 0;
for first in &first_names { for first in &first_names {
for last in &last_names { for last in &last_names {
let name = format!("{} {}", first, last); let grade = 1 + ((idx * 3) % 9) as i32; // Grades between 1 and 9
let age = 5 + ((idx * 11) % 60) as i32; let parent_first = guardian_first_names[idx % guardian_first_names.len()];
let phone_number = format!("070{:03}{:04}", idx % 1_000, (idx * 37) % 10_000); 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 { people.push(PersonSeed {
name, first_name: (*first).to_string(),
age, last_name: (*last).to_string(),
phone_number, grade,
parent_name,
parent_phone_number,
checked_in: false, checked_in: false,
inside: false, inside: false,
visitor,
sleeping_spot,
}); });
idx += 1; idx += 1;
} }

View 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}

View file

@ -1,9 +1,12 @@
export interface Person { export interface Person {
id: number; id: number;
name: string; first_name: string;
age: number; last_name: string;
phone_number: string; grade: number;
parent_name: string;
parent_phone_number: string;
checked_in: boolean; checked_in: boolean;
inside: boolean; inside: boolean;
under_ten: boolean; visitor: boolean;
sleeping_spot: boolean;
} }

View file

@ -3,6 +3,7 @@
import type { Person } from '$lib/types'; import type { Person } from '$lib/types';
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import { listenToPersonEvents } from '$lib/client/person-events'; import { listenToPersonEvents } from '$lib/client/person-events';
import EditPersonModal from '$lib/components/edit-person-modal.svelte';
let searchQuery = $state(''); let searchQuery = $state('');
let searchResults = $state<Person[]>([]); let searchResults = $state<Person[]>([]);
@ -11,6 +12,15 @@
let searchError = $state(''); let searchError = $state('');
let searchInfo = $state(''); let searchInfo = $state('');
let actionInfo = $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) { async function apiFetch(url: string, init?: RequestInit) {
const response = await fetch(url, init); const response = await fetch(url, init);
@ -106,6 +116,19 @@
updateVisibleResults(); 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) { async function handleCheckIn(person: Person) {
actionInfo = ''; actionInfo = '';
if (person.checked_in) return; if (person.checked_in) return;
@ -158,7 +181,7 @@
<form class="flex flex-col gap-3 sm:flex-row" onsubmit={handleSearch}> <form class="flex flex-col gap-3 sm:flex-row" onsubmit={handleSearch}>
<input <input
type="text" type="text"
placeholder="Exempel: Anna, 42 eller 0701234567" placeholder="Exempel: Anna Andersson, 42 eller 0701234567"
bind:value={searchQuery} 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" 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"> <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 class="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<div> <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"> <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> </p>
</div> </div>
<div class="flex flex-wrap gap-2"> <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 ? 'bg-blue-100 text-blue-700' : 'bg-slate-100 text-slate-600'
}`}>{person.inside ? 'Inne' : 'Ute'}</span }`}>{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>
</div> </div>
<p class="mt-2 text-sm text-slate-600">Ålder: {person.age} år</p> {#if isLowerGrade(person)}
{#if person.under_ten} <p class="mt-2 rounded-md bg-amber-100 px-3 py-2 text-sm font-semibold text-amber-800">
<p Observera: elev i årskurs 3 eller yngre ska hem senast 22:00.
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.
</p> </p>
{/if} {/if}
<div class="mt-4 flex gap-3"> <div class="mt-4 flex gap-3">
@ -224,6 +257,14 @@
> >
Checka in Checka in
</button> </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> </div>
</li> </li>
{/each} {/each}
@ -231,3 +272,10 @@
{/if} {/if}
</section> </section>
</div> </div>
<EditPersonModal
open={editor.open}
person={editor.person}
on:close={closeEditor}
on:saved={(event) => handleEditorSaved(event.detail)}
/>

View 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 });
};

View file

@ -6,6 +6,7 @@ export const GET: RequestHandler = async (event) => {
const params = new URLSearchParams(); const params = new URLSearchParams();
const q = event.url.searchParams.get('q'); const q = event.url.searchParams.get('q');
const status = event.url.searchParams.get('status'); const status = event.url.searchParams.get('status');
const checked = event.url.searchParams.get('checked');
if (q) { if (q) {
params.set('q', q); params.set('q', q);
@ -13,6 +14,9 @@ export const GET: RequestHandler = async (event) => {
if (status) { if (status) {
params.set('status', status); params.set('status', status);
} }
if (checked) {
params.set('checked', checked);
}
const path = params.toString() ? `/persons/checked-in?${params}` : '/persons/checked-in'; const path = params.toString() ? `/persons/checked-in?${params}` : '/persons/checked-in';
const { response, setCookies } = await proxyRequest(event, path, { method: 'GET' }); const { response, setCookies } = await proxyRequest(event, path, { method: 'GET' });

View file

@ -4,10 +4,15 @@
import { onMount } from 'svelte'; import { onMount } from 'svelte';
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 EditPersonModal from '$lib/components/edit-person-modal.svelte';
type StatusFilter = 'all' | 'inside' | 'outside'; type StatusFilter = 'all' | 'inside' | 'outside';
type CheckedFilter = 'all' | 'checked-in' | 'not-checked-in'; 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 persons = $state<Person[]>([]);
let loading = $state(false); let loading = $state(false);
let errorMessage = $state(''); let errorMessage = $state('');
@ -15,6 +20,18 @@
let searchQuery = $state(''); let searchQuery = $state('');
let statusFilter = $state<StatusFilter>('all'); let statusFilter = $state<StatusFilter>('all');
let checkedFilter = $state<CheckedFilter>('checked-in'); 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) { async function apiFetch(url: string) {
const response = await fetch(url); const response = await fetch(url);
@ -26,44 +43,55 @@
} }
function matchesFilters(person: Person) { function matchesFilters(person: Person) {
if (checkedFilter === 'checked-in' && !person.checked_in) return false; if (checkedFilter === 'checked-in' && !person.checked_in) return false;
if (checkedFilter === 'not-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 === 'inside' && !person.inside) return false;
if (statusFilter === 'outside' && 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()) { if (searchQuery.trim()) {
const query = searchQuery.trim().toLowerCase(); const query = searchQuery.trim().toLowerCase();
const matchesText = const matchesText =
person.name.toLowerCase().includes(query) || `${person.first_name} ${person.last_name}`.toLowerCase().includes(query) ||
person.phone_number.toLowerCase().includes(query) || person.parent_name.toLowerCase().includes(query) ||
person.parent_phone_number.toLowerCase().includes(query) ||
person.id.toString() === query; person.id.toString() === query;
if (!matchesText) return false; if (!matchesText) return false;
} }
return true; return true;
} }
function applyFilteredList(list: Person[]) { function updateVisiblePersons() {
const filtered = list.filter((person) => { const filtered = allPersons.filter((person) => matchesFilters(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;
});
persons = filtered; persons = filtered;
if (persons.length === 0) { infoMessage = filtered.length === 0 ? 'Inga personer matchar kriterierna.' : '';
infoMessage = 'Inga personer matchar kriterierna.'; }
} else {
infoMessage = ''; function applyFetchedList(list: Person[]) {
} allPersons = list;
updateVisiblePersons();
} }
function handlePersonUpdate(person: Person) { function handlePersonUpdate(person: Person) {
persons = updateCollection(persons, person, matchesFilters); allPersons = updateCollection(allPersons, person, () => true);
if (persons.length === 0) { updateVisiblePersons();
infoMessage = 'Inga personer matchar kriterierna.'; }
} else {
infoMessage = ''; 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() { async function fetchCheckedIn() {
@ -99,16 +127,18 @@
} catch { } catch {
errorMessage = text || 'Kunde inte hämta personer.'; errorMessage = text || 'Kunde inte hämta personer.';
} }
allPersons = [];
persons = []; persons = [];
return; return;
} }
const data = await response.json(); const data = await response.json();
const list: Person[] = data.persons ?? []; const list: Person[] = data.persons ?? [];
applyFilteredList(list); applyFetchedList(list);
} catch (err) { } catch (err) {
console.error('Fetch checked-in failed', err); console.error('Fetch checked-in failed', err);
errorMessage = 'Ett oväntat fel inträffade när listan skulle hämtas.'; errorMessage = 'Ett oväntat fel inträffade när listan skulle hämtas.';
allPersons = [];
persons = []; persons = [];
} finally { } finally {
loading = false; loading = false;
@ -128,6 +158,24 @@
await fetchCheckedIn(); 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(() => { onMount(() => {
void fetchCheckedIn(); void fetchCheckedIn();
const stop = listenToPersonEvents((person) => { const stop = listenToPersonEvents((person) => {
@ -158,13 +206,16 @@
</div> </div>
</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> <div>
<label for="checked-query" class="mb-1 block text-sm font-medium text-slate-600">Sök</label> <label for="checked-query" class="mb-1 block text-sm font-medium text-slate-600">Sök</label>
<input <input
type="text" type="text"
id="checked-query" id="checked-query"
placeholder="Exempel: 42, Anna eller 0701234567" placeholder="Exempel: Anna Andersson, 42 eller 0701234567"
bind:value={searchQuery} 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" 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> <option value="all">Alla</option>
</select> </select>
</div> </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"> <div class="flex items-end">
<button <button
type="submit" type="submit"
@ -219,11 +315,14 @@
<section class="space-y-4"> <section class="space-y-4">
{#each persons as person} {#each persons as person}
<article class="rounded-lg border border-slate-200 bg-white p-5 shadow-sm"> <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"> <header class="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<div> <div>
<h3 class="text-base font-semibold text-slate-800">{person.name}</h3> <h3 class="text-base font-semibold text-slate-800">{fullName(person)}</h3>
<p class="text-sm text-slate-500">ID: {person.id} · Telefon: {person.phone_number}</p> <p class="text-sm text-slate-500">ID: {person.id} · Klass: {person.grade}</p>
</div> <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"> <div class="flex flex-wrap gap-2">
<span <span
class={`rounded-full px-3 py-1 text-xs font-semibold ${ 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 ? 'bg-blue-100 text-blue-700' : 'bg-slate-100 text-slate-600'
}`}>{person.inside ? 'Inne' : 'Ute'}</span }`}>{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>
</header> </header>
<p class="mt-3 text-sm text-slate-600">Ålder: {person.age} år</p> {#if isLowerGrade(person)}
{#if person.under_ten} <p class="mt-3 rounded-md bg-amber-100 px-3 py-2 text-sm font-semibold text-amber-800">
<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.
VARNING: Person under 10 år kompletterande information krävs. </p>
</p> {/if}
{/if} <div class="mt-4">
</article> <button
{/each} type="button"
</section> 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> </div>
<EditPersonModal
open={editor.open}
person={editor.person}
on:close={closeEditor}
on:saved={(event) => handleEditorSaved(event.detail)}
/>

View file

@ -4,6 +4,9 @@
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import { listenToPersonEvents } from '$lib/client/person-events'; import { listenToPersonEvents } from '$lib/client/person-events';
type GradeFilter = 'all' | 'lt4' | 'ge4';
type VisitorFilter = 'all' | 'besoksplats' | 'lanplats';
let searchQuery = $state(''); let searchQuery = $state('');
let searchResults = $state<Person[]>([]); let searchResults = $state<Person[]>([]);
let visibleResults = $state<Person[]>([]); let visibleResults = $state<Person[]>([]);
@ -11,6 +14,51 @@
let searchError = $state(''); let searchError = $state('');
let searchInfo = $state(''); let searchInfo = $state('');
let actionInfo = $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) { async function apiFetch(url: string, init?: RequestInit) {
const response = await fetch(url, init); const response = await fetch(url, init);
@ -101,7 +149,7 @@
} }
return person; return person;
}); });
if (!found) { if (!found && updated.checked_in) {
searchResults = [updated, ...searchResults]; searchResults = [updated, ...searchResults];
} }
updateVisibleResults(); updateVisibleResults();
@ -129,18 +177,30 @@
} }
function updateVisibleResults() { function updateVisibleResults() {
const filtered = searchResults.filter((person) => person.checked_in); const filtered = searchResults.filter((person) => matchesFilters(person));
visibleResults = filtered; visibleResults = filtered;
if (searchResults.length === 0) { if (searchResults.length === 0 && !searchQuery.trim()) {
searchInfo = 'Ingen träff på sökningen.'; searchInfo = 'Inga personer hämtades.';
} else if (filtered.length === 0) { } else if (filtered.length === 0) {
searchInfo = 'Inga personer kan checkas ut just nu.'; searchInfo = 'Ingen person matchar de valda filtren just nu.';
} else { } else {
searchInfo = ''; 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(() => { onMount(() => {
void loadDefaultList(); void loadDefaultList();
const stop = listenToPersonEvents((person) => { const stop = listenToPersonEvents((person) => {
@ -156,22 +216,62 @@
<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">
<h3 class="text-lg font-semibold text-slate-800">Checka ut</h3> <h3 class="text-lg font-semibold text-slate-800">Checka ut</h3>
<p class="mb-4 text-sm text-slate-500"> <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> </p>
<form class="flex flex-col gap-3 sm:flex-row" onsubmit={handleSearch}> <form
<input class="grid gap-3 md:grid-cols-[2fr_1fr_1fr_auto]"
type="text" onsubmit={handleSearch}
placeholder="Exempel: Anna, 42 eller 0701234567" >
bind:value={searchQuery} <div class="flex flex-col gap-1">
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="checkout-query" class="text-sm font-medium text-slate-600">Sök</label>
/> <input
<button type="text"
type="submit" id="checkout-query"
disabled={searchLoading} placeholder="Exempel: Anna Andersson, 42 eller 0701234567"
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" 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"
{searchLoading ? 'Söker…' : 'Sök'} />
</button> </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> </form>
{#if searchError} {#if searchError}
<p class="mt-4 rounded-md bg-red-50 px-3 py-2 text-sm text-red-600">{searchError}</p> <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"> <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 class="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<div> <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"> <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> </p>
</div> </div>
<div class="flex flex-wrap gap-2"> <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 ? 'bg-blue-100 text-blue-700' : 'bg-slate-100 text-slate-600'
}`}>{person.inside ? 'Inne' : 'Ute'}</span }`}>{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>
</div> </div>
<p class="mt-2 text-sm text-slate-600">Ålder: {person.age} år</p> {#if isLowerGrade(person)}
{#if person.under_ten} <p class="mt-2 rounded-md bg-amber-100 px-3 py-2 text-sm font-semibold text-amber-800">
<p Observera: elev i årskurs 3 eller yngre ska hem senast 22:00.
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.
</p> </p>
{/if} {/if}
<div class="mt-4 flex gap-3"> <div class="mt-4 flex gap-3">

View file

@ -4,50 +4,76 @@ export const actions: Actions = {
default: async ({ request, fetch }) => { default: async ({ request, fetch }) => {
const formData = await request.formData(); const formData = await request.formData();
const name = formData.get('name')?.toString().trim() ?? ''; const firstName = formData.get('first_name')?.toString().trim() ?? '';
const ageRaw = formData.get('age')?.toString().trim() ?? ''; const lastName = formData.get('last_name')?.toString().trim() ?? '';
const phone = formData.get('phone_number')?.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 manualId = formData.get('manual_id')?.toString().trim() ?? '';
const checkedIn = formData.get('checked_in') === 'on'; const checkedIn = formData.get('checked_in') === 'on';
const inside = formData.get('inside') === 'on'; const inside = formData.get('inside') === 'on';
const visitor = formData.get('visitor') === 'on';
const sleepingSpot = formData.get('sleeping_spot') === 'on';
const values = { const values = {
name, first_name: firstName,
age: ageRaw, last_name: lastName,
phone_number: phone, grade: gradeRaw,
parent_name: parentName,
parent_phone_number: parentPhone,
manual_id: manualId, manual_id: manualId,
checked_in: checkedIn, checked_in: checkedIn,
inside inside,
visitor,
sleeping_spot: sleepingSpot
}; };
if (!name) { if (!firstName) {
return fail(400, { return fail(400, {
errors: { name: 'Ange ett namn.' }, errors: { first_name: 'Ange ett förnamn.' },
values values
}); });
} }
const parsedAge = Number.parseInt(ageRaw, 10); if (!lastName) {
if (Number.isNaN(parsedAge) || parsedAge < 0) {
return fail(400, { 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 values
}); });
} }
if (!phone) { const parsedGrade = Number.parseInt(gradeRaw, 10);
if (Number.isNaN(parsedGrade) || parsedGrade < 0) {
return fail(400, { 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 values
}); });
} }
const payload: Record<string, unknown> = { const payload: Record<string, unknown> = {
name, first_name: firstName,
age: parsedAge, last_name: lastName,
phone_number: phone, grade: parsedGrade,
parent_name: parentName,
parent_phone_number: parentPhone,
checked_in: checkedIn, checked_in: checkedIn,
inside inside,
visitor,
sleeping_spot: sleepingSpot
}; };
if (manualId.length > 0) { if (manualId.length > 0) {

View file

@ -3,29 +3,39 @@
const props = $props<import('./$types').PageData>(); const props = $props<import('./$types').PageData>();
type FormValues = { type FormValues = {
name: string; first_name: string;
age: string; last_name: string;
phone_number: string; grade: string;
parent_name: string;
parent_phone_number: string;
manual_id: string; manual_id: string;
checked_in: boolean; checked_in: boolean;
inside: boolean; inside: boolean;
visitor: boolean;
sleeping_spot: boolean;
}; };
type FormErrors = { type FormErrors = {
name?: string; first_name?: string;
age?: string; last_name?: string;
phone_number?: string; grade?: string;
parent_name?: string;
parent_phone_number?: string;
manual_id?: string; manual_id?: string;
general?: string; general?: string;
}; };
const defaults: FormValues = { const defaults: FormValues = {
name: '', first_name: '',
age: '', last_name: '',
phone_number: '', grade: '',
parent_name: '',
parent_phone_number: '',
manual_id: '', manual_id: '',
checked_in: false, checked_in: false,
inside: false inside: false,
visitor: false,
sleeping_spot: false
}; };
const values = $derived({ const values = $derived({
@ -44,49 +54,53 @@
Fyll i uppgifterna nedan. Om ID lämnas tomt skapas ett automatiskt. Fyll i uppgifterna nedan. Om ID lämnas tomt skapas ett automatiskt.
</p> </p>
<form method="POST" class="grid gap-4 md:grid-cols-2"> <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> <div>
<label class="mb-1 block text-sm font-medium text-slate-600" for="age">Ålder</label> <label class="mb-1 block text-sm font-medium text-slate-600" for="first-name"
<input >Förnamn</label
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
> >
<input <input
type="tel" type="text"
id="phone" id="first-name"
name="phone_number" name="first_name"
value={values.phone_number} value={values.first_name}
required 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" 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} {#if errors.first_name}
<p class="mt-1 text-sm text-red-600">{errors.phone_number}</p> <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} {/if}
</div> </div>
<div> <div>
@ -105,28 +119,81 @@
<p class="mt-1 text-sm text-red-600">{errors.manual_id}</p> <p class="mt-1 text-sm text-red-600">{errors.manual_id}</p>
{/if} {/if}
</div> </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 <input
id="checked-in" type="text"
type="checkbox" id="parent-name"
name="checked_in" name="parent_name"
value="on" value={values.parent_name}
checked={values.checked_in} required
class="h-4 w-4 rounded border-slate-300 text-indigo-600 focus:ring-indigo-500" 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>
<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 <input
id="inside" type="tel"
type="checkbox" id="parent-phone"
name="inside" name="parent_phone_number"
value="on" value={values.parent_phone_number}
checked={values.inside} required
class="h-4 w-4 rounded border-slate-300 text-indigo-600 focus:ring-indigo-500" 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>
<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} {#if errors.general}
<p class="rounded-md bg-red-50 px-3 py-2 text-sm text-red-600 md:col-span-2"> <p class="rounded-md bg-red-50 px-3 py-2 text-sm text-red-600 md:col-span-2">
{errors.general} {errors.general}

View file

@ -15,6 +15,14 @@
let searchQuery = $state(''); let searchQuery = $state('');
let statusFilter = $state<StatusFilter>('all'); 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) { async function apiFetch(url: string, init?: RequestInit) {
const response = await fetch(url, init); const response = await fetch(url, init);
if (response.status === 401) { if (response.status === 401) {
@ -30,10 +38,11 @@
if (statusFilter === 'outside' && person.inside) return false; if (statusFilter === 'outside' && person.inside) return false;
const query = searchQuery.trim().toLowerCase(); const query = searchQuery.trim().toLowerCase();
if (query) { if (query) {
const matchesText = const matchesText =
person.name.toLowerCase().includes(query) || `${person.first_name} ${person.last_name}`.toLowerCase().includes(query) ||
person.phone_number.toLowerCase().includes(query) || person.parent_name.toLowerCase().includes(query) ||
person.id.toString() === query; person.parent_phone_number.toLowerCase().includes(query) ||
person.id.toString() === query;
if (!matchesText) return false; if (!matchesText) return false;
} }
return true; return true;
@ -139,9 +148,10 @@
if (body.person) { if (body.person) {
const updated = body.person; const updated = body.person;
handlePersonUpdate(updated); handlePersonUpdate(updated);
const name = fullName(updated);
actionMessage = updated.inside actionMessage = updated.inside
? `${updated.name} är nu markerad som inne.` ? `${name} är nu markerad som inne.`
: `${updated.name} är nu markerad som ute.`; : `${name} är nu markerad som ute.`;
} }
} catch (err) { } catch (err) {
console.error('Parsing toggle response failed', err); console.error('Parsing toggle response failed', err);
@ -184,7 +194,7 @@
<input <input
type="text" type="text"
id="inside-query" id="inside-query"
placeholder="Exempel: 42, Anna eller 0701234567" placeholder="Exempel: Anna Andersson, 42 eller 0701234567"
bind:value={searchQuery} 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" 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"> <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"> <header class="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<div> <div>
<h3 class="text-base font-semibold text-slate-800">{person.name}</h3> <h3 class="text-base font-semibold text-slate-800">{fullName(person)}</h3>
<p class="text-sm text-slate-500">ID: {person.id} · Telefon: {person.phone_number}</p> <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>
<div class="flex flex-wrap gap-2"> <div class="flex flex-wrap gap-2">
<span <span
@ -249,14 +262,23 @@
person.inside ? 'bg-blue-100 text-blue-700' : 'bg-slate-100 text-slate-600' person.inside ? 'bg-blue-100 text-blue-700' : 'bg-slate-100 text-slate-600'
}`}>{person.inside ? 'Inne' : 'Ute'}</span }`}>{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>
</header> </header>
<p class="mt-3 text-sm text-slate-600">Ålder: {person.age} år</p> {#if isLowerGrade(person)}
{#if person.under_ten} <p class="mt-3 rounded-md bg-amber-100 px-3 py-2 text-sm font-semibold text-amber-800">
<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.
VARNING: Person under 10 år kompletterande information krävs. </p>
</p> {/if}
{/if}
<div class="mt-4 flex flex-wrap gap-3"> <div class="mt-4 flex flex-wrap gap-3">
<button <button
type="button" type="button"