added so data can be incomplete but must be complete once checkin is tryed. this is prep for being able to load in data from csv

This commit is contained in:
Sebastian 2025-09-22 18:17:56 +02:00
parent 35c7430c50
commit 7c2ca0ccef
14 changed files with 522 additions and 185 deletions

4
.env
View file

@ -1,8 +1,8 @@
POSTGRES_PASSWORD=postgrespass123 POSTGRES_PASSWORD=postgrespass123
JWT_SECRET=supersecretjwtkey JWT_SECRET=supersecretjwtkey
ADMIN_USERNAME=admin ADMIN_USERNAME=admin
ADMIN_PASSWORD=AdminPass!234 ADMIN_PASSWORD=admin
JWT_COOKIE_SECURE=false JWT_COOKIE_SECURE=false
ENABLE_HTTPS_REDIRECT=false ENABLE_HTTPS_REDIRECT=false
WEB_PORT=3000 WEB_PORT=3000
CSRF_ALLOWED_ORIGINS=http://192.168.68.61:3000 CSRF_ALLOWED_ORIGINS=http://192.168.1.201:3000

View file

@ -1,11 +1,17 @@
CREATE TABLE IF NOT EXISTS persons ( CREATE TABLE IF NOT EXISTS persons (
id SERIAL PRIMARY KEY, id SERIAL PRIMARY KEY,
name TEXT NOT NULL, first_name TEXT NOT NULL,
age INTEGER NOT NULL, last_name TEXT NOT NULL,
phone_number TEXT NOT NULL, grade INTEGER,
parent_name TEXT,
parent_phone_number TEXT,
checked_in BOOLEAN NOT NULL DEFAULT FALSE, checked_in BOOLEAN NOT NULL DEFAULT FALSE,
inside 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 IF NOT EXISTS idx_persons_name_trgm ON persons USING GIN (name gin_trgm_ops); CREATE INDEX idx_persons_first_name_trgm ON persons USING GIN (first_name gin_trgm_ops);
CREATE INDEX IF NOT EXISTS idx_persons_phone_number ON persons (phone_number); 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);

View file

@ -1,23 +0,0 @@
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

@ -371,39 +371,48 @@ 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 first_name = payload.first_name.trim(); let NewPersonRequest {
first_name,
last_name,
grade,
parent_name,
parent_phone_number,
id,
checked_in,
inside,
visitor,
sleeping_spot,
} = payload.into_inner();
let first_name = first_name.trim().to_string();
if first_name.is_empty() { if first_name.is_empty() {
return Err(ApiError::bad_request("Förnamn får inte vara tomt.")); return Err(ApiError::bad_request("Förnamn får inte vara tomt."));
} }
let last_name = payload.last_name.trim(); let last_name = last_name.trim().to_string();
if last_name.is_empty() { if last_name.is_empty() {
return Err(ApiError::bad_request("Efternamn får inte vara tomt.")); return Err(ApiError::bad_request("Efternamn får inte vara tomt."));
} }
if payload.grade < 0 { if grade.map(|value| value < 0).unwrap_or(false) {
return Err(ApiError::bad_request("Klass måste vara noll eller högre.")); return Err(ApiError::bad_request("Klass måste vara noll eller högre."));
} }
let parent_name = payload.parent_name.trim(); let parent_name = normalize_optional_string(&parent_name);
if parent_name.is_empty() { let parent_phone_number = normalize_optional_string(&parent_phone_number);
return Err(ApiError::bad_request("Kontaktperson krävs."));
}
let parent_phone_number = payload.parent_phone_number.trim(); let checked_in = checked_in.unwrap_or(false);
if parent_phone_number.is_empty() { let inside = inside.unwrap_or(false);
let visitor = visitor.unwrap_or(false);
let sleeping_spot = sleeping_spot.unwrap_or(false);
if (checked_in || inside) && !fields_are_complete(grade, &parent_name, &parent_phone_number) {
return Err(ApiError::bad_request( return Err(ApiError::bad_request(
"Kontaktpersonens telefonnummer krävs.", "Kontaktperson, telefon och klass krävs innan incheckning.",
)); ));
} }
let grade = payload.grade; let person = match id {
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>( Some(id) => sqlx::query_as::<_, Person>(
r#" r#"
INSERT INTO persons ( INSERT INTO persons (
@ -433,11 +442,11 @@ async fn create_person(
"#, "#,
) )
.bind(id) .bind(id)
.bind(first_name) .bind(&first_name)
.bind(last_name) .bind(&last_name)
.bind(grade) .bind(grade)
.bind(parent_name) .bind(parent_name.as_deref())
.bind(parent_phone_number) .bind(parent_phone_number.as_deref())
.bind(checked_in) .bind(checked_in)
.bind(inside) .bind(inside)
.bind(visitor) .bind(visitor)
@ -472,11 +481,11 @@ async fn create_person(
sleeping_spot sleeping_spot
"#, "#,
) )
.bind(first_name) .bind(&first_name)
.bind(last_name) .bind(&last_name)
.bind(grade) .bind(grade)
.bind(parent_name) .bind(parent_name.as_deref())
.bind(parent_phone_number) .bind(parent_phone_number.as_deref())
.bind(checked_in) .bind(checked_in)
.bind(inside) .bind(inside)
.bind(visitor) .bind(visitor)
@ -500,34 +509,38 @@ async fn update_person(
id: i32, id: i32,
payload: Json<UpdatePersonRequest>, payload: Json<UpdatePersonRequest>,
) -> Result<Json<PersonActionResponse>, ApiError> { ) -> Result<Json<PersonActionResponse>, ApiError> {
let first_name = payload.first_name.trim(); let UpdatePersonRequest {
first_name,
last_name,
grade,
parent_name,
parent_phone_number,
checked_in,
inside,
visitor,
sleeping_spot,
} = payload.into_inner();
let first_name = first_name.trim().to_string();
if first_name.is_empty() { if first_name.is_empty() {
return Err(ApiError::bad_request("Förnamn får inte vara tomt.")); return Err(ApiError::bad_request("Förnamn får inte vara tomt."));
} }
let last_name = payload.last_name.trim(); let last_name = last_name.trim().to_string();
if last_name.is_empty() { if last_name.is_empty() {
return Err(ApiError::bad_request("Efternamn får inte vara tomt.")); return Err(ApiError::bad_request("Efternamn får inte vara tomt."));
} }
if payload.grade < 0 { if grade.map(|value| value < 0).unwrap_or(false) {
return Err(ApiError::bad_request("Klass måste vara noll eller högre.")); return Err(ApiError::bad_request("Klass måste vara noll eller högre."));
} }
let parent_name = payload.parent_name.trim(); let parent_name_present = parent_name.is_some();
if parent_name.is_empty() { let parent_phone_present = parent_phone_number.is_some();
return Err(ApiError::bad_request("Vårdnadshavare krävs.")); let grade_present = grade.is_some();
}
let parent_phone_number = payload.parent_phone_number.trim(); let parent_name = normalize_optional_string(&parent_name);
if parent_phone_number.is_empty() { let parent_phone_number = normalize_optional_string(&parent_phone_number);
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) { let (final_checked_in, final_inside) = match (checked_in, inside) {
(Some(false), _) => (Some(false), Some(false)), (Some(false), _) => (Some(false), Some(false)),
@ -537,6 +550,57 @@ async fn update_person(
(None, None) => (None, None), (None, None) => (None, None),
}; };
let existing = sqlx::query_as::<_, Person>(
r#"
SELECT
id,
first_name,
last_name,
grade,
parent_name,
parent_phone_number,
checked_in,
inside,
visitor,
sleeping_spot
FROM persons
WHERE id = $1
"#,
)
.bind(id)
.fetch_optional(&state.db)
.await?;
let existing = match existing {
Some(person) => person,
None => return Err(ApiError::not_found("Personen hittades inte.")),
};
let parent_name = if parent_name_present {
parent_name
} else {
existing.parent_name.clone()
};
let parent_phone_number = if parent_phone_present {
parent_phone_number
} else {
existing.parent_phone_number.clone()
};
let grade = if grade_present { grade } else { existing.grade };
let desired_checked_in = final_checked_in.unwrap_or(existing.checked_in);
let desired_inside = final_inside.unwrap_or(existing.inside);
if (desired_checked_in || desired_inside)
&& !fields_are_complete(grade, &parent_name, &parent_phone_number)
{
return Err(ApiError::bad_request(
"Kontaktperson, telefon och klass krävs innan incheckning.",
));
}
let person = sqlx::query_as::<_, Person>( let person = sqlx::query_as::<_, Person>(
r#" r#"
UPDATE persons UPDATE persons
@ -564,11 +628,11 @@ async fn update_person(
"#, "#,
) )
.bind(id) .bind(id)
.bind(first_name) .bind(&first_name)
.bind(last_name) .bind(&last_name)
.bind(payload.grade) .bind(grade)
.bind(parent_name) .bind(parent_name.as_deref())
.bind(parent_phone_number) .bind(parent_phone_number.as_deref())
.bind(final_checked_in) .bind(final_checked_in)
.bind(final_inside) .bind(final_inside)
.bind(visitor) .bind(visitor)
@ -593,6 +657,38 @@ async fn update_checked_in(
id: i32, id: i32,
value: bool, value: bool,
) -> Result<Json<PersonActionResponse>, ApiError> { ) -> Result<Json<PersonActionResponse>, ApiError> {
let existing = sqlx::query_as::<_, Person>(
r#"
SELECT
id,
first_name,
last_name,
grade,
parent_name,
parent_phone_number,
checked_in,
inside,
visitor,
sleeping_spot
FROM persons
WHERE id = $1
"#,
)
.bind(id)
.fetch_optional(&state.db)
.await?;
let existing = match existing {
Some(person) => person,
None => return Err(ApiError::not_found("Personen hittades inte.")),
};
if value && !person_is_complete(&existing) {
return Err(ApiError::bad_request(
"Kontaktperson, telefon och klass krävs innan incheckning.",
));
}
let person = sqlx::query_as::<_, Person>( let person = sqlx::query_as::<_, Person>(
r#" r#"
UPDATE persons UPDATE persons
@ -703,3 +799,45 @@ fn map_db_error(err: sqlx::Error, context: &str) -> ApiError {
fn broadcast_person_update(state: &State<AppState>, response: &PersonActionResponse) { fn broadcast_person_update(state: &State<AppState>, response: &PersonActionResponse) {
let _ = state.event_sender.send(response.clone()); let _ = state.event_sender.send(response.clone());
} }
fn normalize_optional_string(value: &Option<String>) -> Option<String> {
value.as_ref().and_then(|text| {
let trimmed = text.trim();
if trimmed.is_empty() {
None
} else {
Some(trimmed.to_string())
}
})
}
fn fields_are_complete(
grade: Option<i32>,
parent_name: &Option<String>,
parent_phone_number: &Option<String>,
) -> bool {
grade.is_some()
&& parent_name
.as_ref()
.map(|value| !value.trim().is_empty())
.unwrap_or(false)
&& phone_is_valid(parent_phone_number)
}
fn person_is_complete(person: &Person) -> bool {
fields_are_complete(
person.grade,
&person.parent_name,
&person.parent_phone_number,
)
}
fn phone_is_valid(phone: &Option<String>) -> bool {
phone
.as_ref()
.map(|value| {
let digits: String = value.chars().filter(|c| c.is_ascii_digit()).collect();
digits.len() == 10
})
.unwrap_or(false)
}

View file

@ -14,9 +14,9 @@ pub struct Person {
pub id: i32, pub id: i32,
pub first_name: String, pub first_name: String,
pub last_name: String, pub last_name: String,
pub grade: i32, pub grade: Option<i32>,
pub parent_name: String, pub parent_name: Option<String>,
pub parent_phone_number: String, pub parent_phone_number: Option<String>,
pub checked_in: bool, pub checked_in: bool,
pub inside: bool, pub inside: bool,
pub visitor: bool, pub visitor: bool,
@ -42,9 +42,9 @@ pub struct PersonResponse {
pub id: i32, pub id: i32,
pub first_name: String, pub first_name: String,
pub last_name: String, pub last_name: String,
pub grade: i32, pub grade: Option<i32>,
pub parent_name: String, pub parent_name: Option<String>,
pub parent_phone_number: String, pub parent_phone_number: Option<String>,
pub checked_in: bool, pub checked_in: bool,
pub inside: bool, pub inside: bool,
pub visitor: bool, pub visitor: bool,
@ -85,9 +85,12 @@ pub struct PersonActionResponse {
pub struct NewPersonRequest { pub struct NewPersonRequest {
pub first_name: String, pub first_name: String,
pub last_name: String, pub last_name: String,
pub grade: i32, #[serde(default)]
pub parent_name: String, pub grade: Option<i32>,
pub parent_phone_number: String, #[serde(default)]
pub parent_name: Option<String>,
#[serde(default)]
pub parent_phone_number: Option<String>,
#[serde(default)] #[serde(default)]
pub id: Option<i32>, pub id: Option<i32>,
#[serde(default)] #[serde(default)]
@ -105,9 +108,12 @@ pub struct NewPersonRequest {
pub struct UpdatePersonRequest { pub struct UpdatePersonRequest {
pub first_name: String, pub first_name: String,
pub last_name: String, pub last_name: String,
pub grade: i32, #[serde(default)]
pub parent_name: String, pub grade: Option<i32>,
pub parent_phone_number: String, #[serde(default)]
pub parent_name: Option<String>,
#[serde(default)]
pub parent_phone_number: Option<String>,
#[serde(default)] #[serde(default)]
pub checked_in: Option<bool>, pub checked_in: Option<bool>,
#[serde(default)] #[serde(default)]

View file

@ -0,0 +1,36 @@
import type { Person } from '$lib/types';
export function hasValue(value: string | null | undefined): value is string {
return typeof value === 'string' && value.trim().length > 0;
}
export function isPhoneValid(phone: string | null | undefined): boolean {
if (!hasValue(phone)) return false;
const normalized = phone.trim();
const digits = normalized.replace(/[^0-9]/g, '');
return digits.length === 10;
}
export function personIsComplete(person: Person): boolean {
return (
person.grade != null &&
hasValue(person.parent_name) &&
isPhoneValid(person.parent_phone_number)
);
}
export function isLowerGrade(person: Person): boolean {
return person.grade != null && person.grade <= 3;
}
export function gradeLabel(person: Person): string {
return person.grade != null ? String(person.grade) : '';
}
export function guardianLabel(person: Person): string {
const name = hasValue(person.parent_name) ? person.parent_name!.trim() : '';
const phone = hasValue(person.parent_phone_number)
? person.parent_phone_number!.trim()
: '';
return `${name} (${phone})`;
}

View file

@ -1,6 +1,7 @@
<script lang="ts"> <script lang="ts">
'use runes'; 'use runes';
import type { Person } from '$lib/types'; import type { Person } from '$lib/types';
import { hasValue } from '$lib/client/person-utils';
import { createEventDispatcher } from 'svelte'; import { createEventDispatcher } from 'svelte';
const props = $props<{ person: Person | null; open: boolean }>(); const props = $props<{ person: Person | null; open: boolean }>();
@ -34,6 +35,116 @@
let errorMessage = $state(''); let errorMessage = $state('');
let loading = $state(false); let loading = $state(false);
type GradeState = { value: number | null; hasValue: boolean; valid: boolean };
type PhoneState = { value: string; digits: number; hasValue: boolean; valid: boolean };
let requiresComplete = $state(false);
let gradeInfo = $state<GradeState>({ value: null, hasValue: false, valid: true });
let phoneInfo = $state<PhoneState>({ value: '', digits: 0, hasValue: false, valid: false });
let parentNameMissing = $state(false);
let gradeError = $state('');
let phoneFeedback = $state('');
let showPhoneError = $state(false);
let formCanSubmit = $state(false);
$effect(() => {
requiresComplete = form.checked_in || form.inside;
});
$effect(() => {
if (typeof form.grade === 'number') {
form = { ...form, grade: String(form.grade) };
return;
}
const trimmed = form.grade.trim();
if (!trimmed) {
gradeInfo = { value: null, hasValue: false, valid: true };
return;
}
const parsed = Number.parseInt(trimmed, 10);
if (Number.isNaN(parsed) || parsed < 0) {
gradeInfo = { value: null, hasValue: true, valid: false };
} else {
gradeInfo = { value: parsed, hasValue: true, valid: true };
}
});
$effect(() => {
const rawPhone = typeof form.parent_phone_number === 'number'
? String(form.parent_phone_number)
: form.parent_phone_number;
if (typeof form.parent_phone_number === 'number') {
form = { ...form, parent_phone_number: rawPhone };
return;
}
const trimmed = rawPhone.trim();
const digitsOnly = trimmed.replace(/[^0-9]/g, '');
phoneInfo = {
value: trimmed,
digits: digitsOnly.length,
hasValue: trimmed.length > 0,
valid: digitsOnly.length === 10
};
});
$effect(() => {
parentNameMissing = requiresComplete && !hasValue(form.parent_name);
});
$effect(() => {
if (!gradeInfo.valid) {
gradeError = 'Klass måste vara ett heltal större än eller lika med 0.';
} else if (requiresComplete && !gradeInfo.hasValue) {
gradeError = 'Ange en klass innan personen kan checkas in.';
} else {
gradeError = '';
}
});
$effect(() => {
if (!requiresComplete && !phoneInfo.hasValue) {
showPhoneError = false;
phoneFeedback = '';
return;
}
if (requiresComplete && phoneInfo.digits === 0) {
showPhoneError = true;
phoneFeedback = 'Ange ett telefonnummer innan incheckning.';
return;
}
if (phoneInfo.hasValue && !phoneInfo.valid) {
showPhoneError = true;
phoneFeedback = phoneInfo.digits < 10
? 'Telefonnumret måste innehålla tio siffror.'
: 'Telefonnumret får endast innehålla tio siffror.';
return;
}
showPhoneError = false;
phoneFeedback = phoneInfo.hasValue ? `${phoneInfo.digits} siffror angivna.` : '';
});
const phoneIsValid = () => {
if (!phoneInfo.hasValue) {
return !requiresComplete;
}
return phoneInfo.valid;
};
$effect(() => {
const firstOk = form.first_name.trim().length > 0;
const lastOk = form.last_name.trim().length > 0;
const gradeOk = gradeInfo.valid;
const phoneOk = phoneIsValid();
let canSubmit = firstOk && lastOk && gradeOk && phoneOk;
if (requiresComplete) {
canSubmit = canSubmit && gradeInfo.hasValue && hasValue(form.parent_name);
}
formCanSubmit = canSubmit;
});
$effect(() => { $effect(() => {
const person = props.person; const person = props.person;
if (!person) { if (!person) {
@ -46,9 +157,9 @@
form = { form = {
first_name: person.first_name, first_name: person.first_name,
last_name: person.last_name, last_name: person.last_name,
grade: String(person.grade), grade: person.grade != null ? String(person.grade) : '',
parent_name: person.parent_name, parent_name: person.parent_name ?? '',
parent_phone_number: person.parent_phone_number, parent_phone_number: person.parent_phone_number ?? '',
checked_in: person.checked_in, checked_in: person.checked_in,
inside: person.inside, inside: person.inside,
visitor: person.visitor, visitor: person.visitor,
@ -73,34 +184,57 @@
event.preventDefault(); event.preventDefault();
if (!props.person) return; if (!props.person) return;
const gradeNumber = Number.parseInt(form.grade, 10); const firstName = form.first_name.trim();
if (Number.isNaN(gradeNumber) || gradeNumber < 0) { if (!firstName) {
errorMessage = 'För- och efternamn krävs.';
return;
}
const lastName = form.last_name.trim();
if (!lastName) {
errorMessage = 'För- och efternamn krävs.';
return;
}
const grade = gradeInfo;
if (!grade.valid) {
errorMessage = 'Klass måste vara ett heltal större än eller lika med 0.'; errorMessage = 'Klass måste vara ett heltal större än eller lika med 0.';
return; return;
} }
const gradeNumber = grade.value;
const parentName = form.parent_name.trim();
const parentPhone = form.parent_phone_number.trim();
const phoneValid = phoneIsValid();
const phone = phoneInfo;
if (!phoneValid) {
errorMessage = requiresComplete
? 'Ange ett telefonnummer med tio siffror innan incheckning.'
: 'Telefonnumret måste innehålla tio siffror.';
return;
}
if ((form.checked_in || form.inside) && (!grade.hasValue || !hasValue(parentName) || !phoneValid)) {
errorMessage = 'För incheckning krävs klass, vårdnadshavare och ett giltigt telefonnummer (tio siffror).';
return;
}
const payload = { const payload = {
first_name: form.first_name.trim(), first_name: firstName,
last_name: form.last_name.trim(), last_name: lastName,
grade: gradeNumber, grade: gradeNumber,
parent_name: form.parent_name.trim(), parent_name: hasValue(parentName) ? parentName : null,
parent_phone_number: form.parent_phone_number.trim(), parent_phone_number: phone.hasValue ? phone.value : null,
checked_in: form.checked_in, checked_in: form.checked_in,
inside: form.inside, inside: form.inside,
visitor: form.visitor, visitor: form.visitor,
sleeping_spot: form.sleeping_spot 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; loading = true;
errorMessage = ''; errorMessage = '';
@ -183,12 +317,15 @@
> >
<input <input
id="edit-grade" id="edit-grade"
type="number" type="text"
min="0" inputmode="numeric"
pattern="[0-9]*"
bind:value={form.grade} 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" 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"
/> />
{#if gradeError}
<p class="text-sm text-red-600">{gradeError}</p>
{/if}
</div> </div>
<div class="space-y-1"> <div class="space-y-1">
<label class="text-sm font-medium text-slate-600" for="edit-parent-name" <label class="text-sm font-medium text-slate-600" for="edit-parent-name"
@ -198,9 +335,11 @@
id="edit-parent-name" id="edit-parent-name"
type="text" type="text"
bind:value={form.parent_name} 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" 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"
/> />
{#if parentNameMissing}
<p class="text-sm text-red-600">Ange en kontaktperson innan incheckning.</p>
{/if}
</div> </div>
<div class="space-y-1 md:col-span-2"> <div class="space-y-1 md:col-span-2">
<label class="text-sm font-medium text-slate-600" for="edit-parent-phone" <label class="text-sm font-medium text-slate-600" for="edit-parent-phone"
@ -210,9 +349,13 @@
id="edit-parent-phone" id="edit-parent-phone"
type="tel" type="tel"
bind:value={form.parent_phone_number} 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" 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"
/> />
{#if showPhoneError}
<p class="text-sm text-red-600">{phoneFeedback}</p>
{:else if phoneInfo.hasValue}
<p class="text-sm text-slate-500">{phoneFeedback}</p>
{/if}
</div> </div>
</div> </div>
@ -266,7 +409,7 @@
</button> </button>
<button <button
type="submit" type="submit"
disabled={loading} disabled={loading || !formCanSubmit}
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" 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'} {loading ? 'Sparar…' : 'Spara'}

View file

@ -2,9 +2,9 @@ export interface Person {
id: number; id: number;
first_name: string; first_name: string;
last_name: string; last_name: string;
grade: number; grade: number | null;
parent_name: string; parent_name: string | null;
parent_phone_number: string; parent_phone_number: string | null;
checked_in: boolean; checked_in: boolean;
inside: boolean; inside: boolean;
visitor: boolean; visitor: boolean;

View file

@ -3,6 +3,12 @@
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 {
gradeLabel,
guardianLabel,
isLowerGrade,
personIsComplete
} from '$lib/client/person-utils';
import EditPersonModal from '$lib/components/edit-person-modal.svelte'; import EditPersonModal from '$lib/components/edit-person-modal.svelte';
let searchQuery = $state(''); let searchQuery = $state('');
@ -18,9 +24,6 @@
return `${person.first_name} ${person.last_name}`.trim(); 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);
@ -133,6 +136,12 @@
actionInfo = ''; actionInfo = '';
if (person.checked_in) return; if (person.checked_in) return;
if (!personIsComplete(person)) {
searchError = 'Fyll i klass, vårdnadshavare och ett giltigt telefonnummer (tio siffror) innan incheckning.';
openEditor(person);
return;
}
const response = await apiFetch(`/api/persons/${person.id}/checkin`, { method: 'POST' }); const response = await apiFetch(`/api/persons/${person.id}/checkin`, { method: 'POST' });
if (!response) return; if (!response) return;
@ -211,10 +220,10 @@
<div> <div>
<h4 class="text-base font-semibold text-slate-800">{fullName(person)}</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} · Klass: {person.grade} ID: {person.id} · Klass: {gradeLabel(person)}
</p> </p>
<p class="text-sm text-slate-500"> <p class="text-sm text-slate-500">
Vårdnadshavare: {person.parent_name} ({person.parent_phone_number}) Vårdnadshavare: {guardianLabel(person)}
</p> </p>
</div> </div>
<div class="flex flex-wrap gap-2"> <div class="flex flex-wrap gap-2">

View file

@ -4,6 +4,7 @@
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import { listenToPersonEvents } from '$lib/client/person-events'; import { listenToPersonEvents } from '$lib/client/person-events';
import { updateCollection } from '$lib/client/person-collection'; import { updateCollection } from '$lib/client/person-collection';
import { gradeLabel, guardianLabel, isLowerGrade } from '$lib/client/person-utils';
import EditPersonModal from '$lib/components/edit-person-modal.svelte'; import EditPersonModal from '$lib/components/edit-person-modal.svelte';
type StatusFilter = 'all' | 'inside' | 'outside'; type StatusFilter = 'all' | 'inside' | 'outside';
@ -29,10 +30,6 @@ type VisitorFilter = 'all' | 'besoksplats' | 'lanplats';
return `${person.first_name} ${person.last_name}`.trim(); 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);
if (response.status === 401) { if (response.status === 401) {
@ -47,8 +44,8 @@ type VisitorFilter = 'all' | 'besoksplats' | 'lanplats';
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 === 'lt4' && (person.grade == null || person.grade > 3)) return false;
if (gradeFilter === 'ge4' && person.grade <= 3) return false; if (gradeFilter === 'ge4' && (person.grade == null || person.grade <= 3)) return false;
if (visitorFilter === 'besoksplats' && !person.visitor) return false; if (visitorFilter === 'besoksplats' && !person.visitor) return false;
if (visitorFilter === 'lanplats' && person.visitor) return false; if (visitorFilter === 'lanplats' && person.visitor) return false;
if (sleepingFilter === 'needs' && !person.sleeping_spot) return false; if (sleepingFilter === 'needs' && !person.sleeping_spot) return false;
@ -57,8 +54,8 @@ type VisitorFilter = 'all' | 'besoksplats' | 'lanplats';
const query = searchQuery.trim().toLowerCase(); const query = searchQuery.trim().toLowerCase();
const matchesText = const matchesText =
`${person.first_name} ${person.last_name}`.toLowerCase().includes(query) || `${person.first_name} ${person.last_name}`.toLowerCase().includes(query) ||
person.parent_name.toLowerCase().includes(query) || person.parent_name?.toLowerCase().includes(query) ||
person.parent_phone_number.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;
} }
@ -318,9 +315,9 @@ type VisitorFilter = 'all' | 'besoksplats' | 'lanplats';
<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">{fullName(person)}</h3> <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">ID: {person.id} · Klass: {gradeLabel(person)}</p>
<p class="text-sm text-slate-500"> <p class="text-sm text-slate-500">
Vårdnadshavare: {person.parent_name} ({person.parent_phone_number}) Vårdnadshavare: {guardianLabel(person)}
</p> </p>
</div> </div>
<div class="flex flex-wrap gap-2"> <div class="flex flex-wrap gap-2">

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 { gradeLabel, guardianLabel, isLowerGrade } from '$lib/client/person-utils';
type GradeFilter = 'all' | 'lt4' | 'ge4'; type GradeFilter = 'all' | 'lt4' | 'ge4';
type VisitorFilter = 'all' | 'besoksplats' | 'lanplats'; type VisitorFilter = 'all' | 'besoksplats' | 'lanplats';
@ -21,19 +22,16 @@
return `${person.first_name} ${person.last_name}`.trim(); return `${person.first_name} ${person.last_name}`.trim();
} }
function isLowerGrade(person: Person) {
return person.grade <= 3;
}
function matchesFilters(person: Person) { function matchesFilters(person: Person) {
if (!person.checked_in) { if (!person.checked_in) {
return false; return false;
} }
if (gradeFilter === 'lt4' && person.grade > 3) { const gradeValue = person.grade;
if (gradeFilter === 'lt4' && (gradeValue == null || gradeValue > 3)) {
return false; return false;
} }
if (gradeFilter === 'ge4' && person.grade <= 3) { if (gradeFilter === 'ge4' && (gradeValue == null || gradeValue <= 3)) {
return false; return false;
} }
@ -46,12 +44,12 @@
const query = searchQuery.trim().toLowerCase(); const query = searchQuery.trim().toLowerCase();
if (query) { if (query) {
const combinedName = `${person.first_name} ${person.last_name}`.toLowerCase(); const combinedName = `${person.first_name} ${person.last_name}`.toLowerCase();
const matchesText = const matchesText =
combinedName.includes(query) || combinedName.includes(query) ||
person.parent_name.toLowerCase().includes(query) || person.parent_name?.toLowerCase().includes(query) ||
person.parent_phone_number.toLowerCase().includes(query) || person.parent_phone_number?.toLowerCase().includes(query) ||
person.id.toString() === query; person.id.toString() === query;
if (!matchesText) { if (!matchesText) {
return false; return false;
} }
@ -291,10 +289,10 @@
<div> <div>
<h4 class="text-base font-semibold text-slate-800">{fullName(person)}</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} · Klass: {person.grade} ID: {person.id} · Klass: {gradeLabel(person)}
</p> </p>
<p class="text-sm text-slate-500"> <p class="text-sm text-slate-500">
Vårdnadshavare: {person.parent_name} ({person.parent_phone_number}) Vårdnadshavare: {guardianLabel(person)}
</p> </p>
</div> </div>
<div class="flex flex-wrap gap-2"> <div class="flex flex-wrap gap-2">

View file

@ -15,6 +15,9 @@ export const actions: Actions = {
const visitor = formData.get('visitor') === 'on'; const visitor = formData.get('visitor') === 'on';
const sleepingSpot = formData.get('sleeping_spot') === 'on'; const sleepingSpot = formData.get('sleeping_spot') === 'on';
const hasValue = (value: string) => value.trim().length > 0;
const isPhoneValid = (value: string) => value.replace(/[^0-9]/g, '').length === 10;
const values = { const values = {
first_name: firstName, first_name: firstName,
last_name: lastName, last_name: lastName,
@ -42,24 +45,54 @@ export const actions: Actions = {
}); });
} }
const parsedGrade = Number.parseInt(gradeRaw, 10); let parsedGrade: number | null = null;
if (Number.isNaN(parsedGrade) || parsedGrade < 0) { if (hasValue(gradeRaw)) {
const parsed = Number.parseInt(gradeRaw, 10);
if (Number.isNaN(parsed) || parsed < 0) {
return fail(400, {
errors: { grade: 'Klass måste vara ett heltal större än eller lika med 0.' },
values
});
}
parsedGrade = parsed;
}
if (hasValue(parentPhone) && !isPhoneValid(parentPhone)) {
return fail(400, { return fail(400, {
errors: { grade: 'Klass måste vara ett heltal större än eller lika med 0.' }, errors: { parent_phone_number: 'Ange ett giltigt telefonnummer (tio siffror).' },
values values
}); });
} }
if (!parentName) { const requiresComplete = checkedIn || inside;
if (requiresComplete && parsedGrade == null) {
return fail(400, { return fail(400, {
errors: { parent_name: 'Ange en kontaktperson.' }, errors: { grade: 'Ange en klass innan personen kan checkas in.' },
values values
}); });
} }
if (!parentPhone) { if (requiresComplete && !hasValue(parentName)) {
return fail(400, { return fail(400, {
errors: { parent_phone_number: 'Ange kontaktpersonens telefonnummer.' }, errors: { parent_name: 'Ange en kontaktperson innan incheckning.' },
values
});
}
if (requiresComplete && !hasValue(parentPhone)) {
return fail(400, {
errors: {
parent_phone_number: 'Ange ett telefonnummer innan incheckning.'
},
values
});
}
if (requiresComplete && !isPhoneValid(parentPhone)) {
return fail(400, {
errors: {
parent_phone_number: 'Telefonnumret måste innehålla tio siffror innan incheckning.'
},
values values
}); });
} }
@ -68,8 +101,8 @@ export const actions: Actions = {
first_name: firstName, first_name: firstName,
last_name: lastName, last_name: lastName,
grade: parsedGrade, grade: parsedGrade,
parent_name: parentName, parent_name: hasValue(parentName) ? parentName : null,
parent_phone_number: parentPhone, parent_phone_number: hasValue(parentPhone) ? parentPhone : null,
checked_in: checkedIn, checked_in: checkedIn,
inside, inside,
visitor, visitor,

View file

@ -51,12 +51,12 @@
<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">
<h2 class="text-lg font-semibold text-slate-800">Lägg till person</h2> <h2 class="text-lg font-semibold text-slate-800">Lägg till person</h2>
<p class="mb-4 text-sm text-slate-500"> <p class="mb-4 text-sm text-slate-500">
Fyll i uppgifterna nedan. Om ID lämnas tomt skapas ett automatiskt. Fyll i uppgifterna nedan. Om ID lämnas tomt skapas ett automatiskt. Kontaktuppgifter och klass
kan läggas till senare men krävs innan en person kan checkas in.
</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> <div>
<label class="mb-1 block text-sm font-medium text-slate-600" for="first-name" <label class="mb-1 block text-sm font-medium text-slate-600" for="first-name">Förnamn</label
>Förnamn</label
> >
<input <input
type="text" type="text"
@ -64,7 +64,7 @@
name="first_name" name="first_name"
value={values.first_name} 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:outline-none focus:ring focus:ring-indigo-100"
/> />
{#if errors.first_name} {#if errors.first_name}
<p class="mt-1 text-sm text-red-600">{errors.first_name}</p> <p class="mt-1 text-sm text-red-600">{errors.first_name}</p>
@ -80,24 +80,21 @@
name="last_name" name="last_name"
value={values.last_name} value={values.last_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:outline-none focus:ring focus:ring-indigo-100"
/> />
{#if errors.last_name} {#if errors.last_name}
<p class="mt-1 text-sm text-red-600">{errors.last_name}</p> <p class="mt-1 text-sm text-red-600">{errors.last_name}</p>
{/if} {/if}
</div> </div>
<div> <div>
<label class="mb-1 block text-sm font-medium text-slate-600" for="grade" <label class="mb-1 block text-sm font-medium text-slate-600" for="grade">Klass (år)</label>
>Klass (år)</label
>
<input <input
type="number" type="number"
id="grade" id="grade"
name="grade" name="grade"
min="0" min="0"
value={values.grade} 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:outline-none focus:ring focus:ring-indigo-100"
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} {#if errors.grade}
<p class="mt-1 text-sm text-red-600">{errors.grade}</p> <p class="mt-1 text-sm text-red-600">{errors.grade}</p>
@ -113,45 +110,45 @@
name="manual_id" name="manual_id"
min="0" min="0"
value={values.manual_id} value={values.manual_id}
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:outline-none focus:ring focus:ring-indigo-100"
/> />
{#if errors.manual_id} {#if errors.manual_id}
<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="md:col-span-2"> <div class="md:col-span-2">
<label class="mb-1 block text-sm font-medium text-slate-600" for="parent-name" <label class="mb-1 block text-sm font-medium text-slate-600" for="parent-name"
>Vårdnadshavare</label >Vårdnadshavare</label
> >
<input <input
type="text" type="text"
id="parent-name" id="parent-name"
name="parent_name" name="parent_name"
value={values.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:outline-none focus:ring focus:ring-indigo-100"
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.parent_name} {#if errors.parent_name}
<p class="mt-1 text-sm text-red-600">{errors.parent_name}</p> <p class="mt-1 text-sm text-red-600">{errors.parent_name}</p>
{/if} {/if}
</div> </div>
<div class="md:col-span-2"> <div class="md:col-span-2">
<label class="mb-1 block text-sm font-medium text-slate-600" for="parent-phone" <label class="mb-1 block text-sm font-medium text-slate-600" for="parent-phone"
>Vårdnadshavare telefon</label >Vårdnadshavare telefon</label
> >
<input <input
type="tel" type="tel"
id="parent-phone" id="parent-phone"
name="parent_phone_number" name="parent_phone_number"
value={values.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:outline-none focus:ring focus:ring-indigo-100"
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.parent_phone_number} {#if errors.parent_phone_number}
<p class="mt-1 text-sm text-red-600">{errors.parent_phone_number}</p> <p class="mt-1 text-sm text-red-600">{errors.parent_phone_number}</p>
{/if} {/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"> <div
class="grid gap-3 rounded-md border border-slate-200 bg-slate-50 p-4 sm:grid-cols-2 md:col-span-2"
>
<label class="flex items-center gap-2 text-sm text-slate-700"> <label class="flex items-center gap-2 text-sm text-slate-700">
<input <input
type="checkbox" type="checkbox"

View file

@ -4,6 +4,7 @@
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import { listenToPersonEvents } from '$lib/client/person-events'; import { listenToPersonEvents } from '$lib/client/person-events';
import { updateCollection } from '$lib/client/person-collection'; import { updateCollection } from '$lib/client/person-collection';
import { gradeLabel, guardianLabel, isLowerGrade } from '$lib/client/person-utils';
type StatusFilter = 'all' | 'inside' | 'outside'; type StatusFilter = 'all' | 'inside' | 'outside';
@ -19,10 +20,6 @@
return `${person.first_name} ${person.last_name}`.trim(); 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) {
@ -40,8 +37,8 @@
if (query) { if (query) {
const matchesText = const matchesText =
`${person.first_name} ${person.last_name}`.toLowerCase().includes(query) || `${person.first_name} ${person.last_name}`.toLowerCase().includes(query) ||
person.parent_name.toLowerCase().includes(query) || person.parent_name?.toLowerCase().includes(query) ||
person.parent_phone_number.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;
} }
@ -244,9 +241,9 @@
<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">{fullName(person)}</h3> <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">ID: {person.id} · Klass: {gradeLabel(person)}</p>
<p class="text-sm text-slate-500"> <p class="text-sm text-slate-500">
Vårdnadshavare: {person.parent_name} ({person.parent_phone_number}) Vårdnadshavare: {guardianLabel(person)}
</p> </p>
</div> </div>
<div class="flex flex-wrap gap-2"> <div class="flex flex-wrap gap-2">