Compare commits

..

2 commits

19 changed files with 1296 additions and 264 deletions

2
.env
View file

@ -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

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,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.",
);
}
}

View file

@ -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,
}

View file

@ -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;
}

View file

@ -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=="],

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 {
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;
}

View file

@ -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)}
/>

View file

@ -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';

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

@ -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';

View file

@ -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' });

View file

@ -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);

View file

@ -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)}
/>

View file

@ -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">

View file

@ -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) {

View file

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

View file

@ -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"