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:
parent
35c7430c50
commit
7c2ca0ccef
14 changed files with 522 additions and 185 deletions
4
.env
4
.env
|
|
@ -1,8 +1,8 @@
|
|||
POSTGRES_PASSWORD=postgrespass123
|
||||
JWT_SECRET=supersecretjwtkey
|
||||
ADMIN_USERNAME=admin
|
||||
ADMIN_PASSWORD=AdminPass!234
|
||||
ADMIN_PASSWORD=admin
|
||||
JWT_COOKIE_SECURE=false
|
||||
ENABLE_HTTPS_REDIRECT=false
|
||||
WEB_PORT=3000
|
||||
CSRF_ALLOWED_ORIGINS=http://192.168.68.61:3000
|
||||
CSRF_ALLOWED_ORIGINS=http://192.168.1.201:3000
|
||||
|
|
|
|||
|
|
@ -1,11 +1,17 @@
|
|||
CREATE TABLE IF NOT EXISTS persons (
|
||||
id SERIAL PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
age INTEGER NOT NULL,
|
||||
phone_number TEXT NOT NULL,
|
||||
first_name TEXT NOT NULL,
|
||||
last_name TEXT NOT NULL,
|
||||
grade INTEGER,
|
||||
parent_name TEXT,
|
||||
parent_phone_number TEXT,
|
||||
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 IF NOT EXISTS idx_persons_phone_number ON persons (phone_number);
|
||||
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);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
230
api/src/main.rs
230
api/src/main.rs
|
|
@ -371,39 +371,48 @@ async fn create_person(
|
|||
state: &State<AppState>,
|
||||
payload: Json<NewPersonRequest>,
|
||||
) -> 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() {
|
||||
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() {
|
||||
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."));
|
||||
}
|
||||
|
||||
let parent_name = payload.parent_name.trim();
|
||||
if parent_name.is_empty() {
|
||||
return Err(ApiError::bad_request("Kontaktperson krävs."));
|
||||
}
|
||||
let parent_name = normalize_optional_string(&parent_name);
|
||||
let parent_phone_number = normalize_optional_string(&parent_phone_number);
|
||||
|
||||
let parent_phone_number = payload.parent_phone_number.trim();
|
||||
if parent_phone_number.is_empty() {
|
||||
let checked_in = checked_in.unwrap_or(false);
|
||||
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(
|
||||
"Kontaktpersonens telefonnummer krävs.",
|
||||
"Kontaktperson, telefon och klass krävs innan incheckning.",
|
||||
));
|
||||
}
|
||||
|
||||
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 {
|
||||
let person = match id {
|
||||
Some(id) => sqlx::query_as::<_, Person>(
|
||||
r#"
|
||||
INSERT INTO persons (
|
||||
|
|
@ -433,11 +442,11 @@ async fn create_person(
|
|||
"#,
|
||||
)
|
||||
.bind(id)
|
||||
.bind(first_name)
|
||||
.bind(last_name)
|
||||
.bind(&first_name)
|
||||
.bind(&last_name)
|
||||
.bind(grade)
|
||||
.bind(parent_name)
|
||||
.bind(parent_phone_number)
|
||||
.bind(parent_name.as_deref())
|
||||
.bind(parent_phone_number.as_deref())
|
||||
.bind(checked_in)
|
||||
.bind(inside)
|
||||
.bind(visitor)
|
||||
|
|
@ -472,11 +481,11 @@ async fn create_person(
|
|||
sleeping_spot
|
||||
"#,
|
||||
)
|
||||
.bind(first_name)
|
||||
.bind(last_name)
|
||||
.bind(&first_name)
|
||||
.bind(&last_name)
|
||||
.bind(grade)
|
||||
.bind(parent_name)
|
||||
.bind(parent_phone_number)
|
||||
.bind(parent_name.as_deref())
|
||||
.bind(parent_phone_number.as_deref())
|
||||
.bind(checked_in)
|
||||
.bind(inside)
|
||||
.bind(visitor)
|
||||
|
|
@ -500,34 +509,38 @@ async fn update_person(
|
|||
id: i32,
|
||||
payload: Json<UpdatePersonRequest>,
|
||||
) -> 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() {
|
||||
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() {
|
||||
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."));
|
||||
}
|
||||
|
||||
let parent_name = payload.parent_name.trim();
|
||||
if parent_name.is_empty() {
|
||||
return Err(ApiError::bad_request("Vårdnadshavare krävs."));
|
||||
}
|
||||
let parent_name_present = parent_name.is_some();
|
||||
let parent_phone_present = parent_phone_number.is_some();
|
||||
let grade_present = grade.is_some();
|
||||
|
||||
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 parent_name = normalize_optional_string(&parent_name);
|
||||
let parent_phone_number = normalize_optional_string(&parent_phone_number);
|
||||
|
||||
let (final_checked_in, final_inside) = match (checked_in, inside) {
|
||||
(Some(false), _) => (Some(false), Some(false)),
|
||||
|
|
@ -537,6 +550,57 @@ async fn update_person(
|
|||
(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>(
|
||||
r#"
|
||||
UPDATE persons
|
||||
|
|
@ -564,11 +628,11 @@ async fn update_person(
|
|||
"#,
|
||||
)
|
||||
.bind(id)
|
||||
.bind(first_name)
|
||||
.bind(last_name)
|
||||
.bind(payload.grade)
|
||||
.bind(parent_name)
|
||||
.bind(parent_phone_number)
|
||||
.bind(&first_name)
|
||||
.bind(&last_name)
|
||||
.bind(grade)
|
||||
.bind(parent_name.as_deref())
|
||||
.bind(parent_phone_number.as_deref())
|
||||
.bind(final_checked_in)
|
||||
.bind(final_inside)
|
||||
.bind(visitor)
|
||||
|
|
@ -593,6 +657,38 @@ async fn update_checked_in(
|
|||
id: i32,
|
||||
value: bool,
|
||||
) -> 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>(
|
||||
r#"
|
||||
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) {
|
||||
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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -14,9 +14,9 @@ pub struct Person {
|
|||
pub id: i32,
|
||||
pub first_name: String,
|
||||
pub last_name: String,
|
||||
pub grade: i32,
|
||||
pub parent_name: String,
|
||||
pub parent_phone_number: String,
|
||||
pub grade: Option<i32>,
|
||||
pub parent_name: Option<String>,
|
||||
pub parent_phone_number: Option<String>,
|
||||
pub checked_in: bool,
|
||||
pub inside: bool,
|
||||
pub visitor: bool,
|
||||
|
|
@ -42,9 +42,9 @@ pub struct PersonResponse {
|
|||
pub id: i32,
|
||||
pub first_name: String,
|
||||
pub last_name: String,
|
||||
pub grade: i32,
|
||||
pub parent_name: String,
|
||||
pub parent_phone_number: String,
|
||||
pub grade: Option<i32>,
|
||||
pub parent_name: Option<String>,
|
||||
pub parent_phone_number: Option<String>,
|
||||
pub checked_in: bool,
|
||||
pub inside: bool,
|
||||
pub visitor: bool,
|
||||
|
|
@ -85,9 +85,12 @@ pub struct PersonActionResponse {
|
|||
pub struct NewPersonRequest {
|
||||
pub first_name: String,
|
||||
pub last_name: String,
|
||||
pub grade: i32,
|
||||
pub parent_name: String,
|
||||
pub parent_phone_number: String,
|
||||
#[serde(default)]
|
||||
pub grade: Option<i32>,
|
||||
#[serde(default)]
|
||||
pub parent_name: Option<String>,
|
||||
#[serde(default)]
|
||||
pub parent_phone_number: Option<String>,
|
||||
#[serde(default)]
|
||||
pub id: Option<i32>,
|
||||
#[serde(default)]
|
||||
|
|
@ -105,9 +108,12 @@ pub struct NewPersonRequest {
|
|||
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 grade: Option<i32>,
|
||||
#[serde(default)]
|
||||
pub parent_name: Option<String>,
|
||||
#[serde(default)]
|
||||
pub parent_phone_number: Option<String>,
|
||||
#[serde(default)]
|
||||
pub checked_in: Option<bool>,
|
||||
#[serde(default)]
|
||||
|
|
|
|||
36
web/src/lib/client/person-utils.ts
Normal file
36
web/src/lib/client/person-utils.ts
Normal 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})`;
|
||||
}
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
<script lang="ts">
|
||||
'use runes';
|
||||
import type { Person } from '$lib/types';
|
||||
import { hasValue } from '$lib/client/person-utils';
|
||||
import { createEventDispatcher } from 'svelte';
|
||||
|
||||
const props = $props<{ person: Person | null; open: boolean }>();
|
||||
|
|
@ -34,6 +35,116 @@
|
|||
let errorMessage = $state('');
|
||||
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(() => {
|
||||
const person = props.person;
|
||||
if (!person) {
|
||||
|
|
@ -46,9 +157,9 @@
|
|||
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,
|
||||
grade: person.grade != null ? 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,
|
||||
|
|
@ -73,34 +184,57 @@
|
|||
event.preventDefault();
|
||||
if (!props.person) return;
|
||||
|
||||
const gradeNumber = Number.parseInt(form.grade, 10);
|
||||
if (Number.isNaN(gradeNumber) || gradeNumber < 0) {
|
||||
const firstName = form.first_name.trim();
|
||||
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.';
|
||||
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 = {
|
||||
first_name: form.first_name.trim(),
|
||||
last_name: form.last_name.trim(),
|
||||
first_name: firstName,
|
||||
last_name: lastName,
|
||||
grade: gradeNumber,
|
||||
parent_name: form.parent_name.trim(),
|
||||
parent_phone_number: form.parent_phone_number.trim(),
|
||||
parent_name: hasValue(parentName) ? parentName : null,
|
||||
parent_phone_number: phone.hasValue ? phone.value : null,
|
||||
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 = '';
|
||||
|
||||
|
|
@ -183,12 +317,15 @@
|
|||
>
|
||||
<input
|
||||
id="edit-grade"
|
||||
type="number"
|
||||
min="0"
|
||||
type="text"
|
||||
inputmode="numeric"
|
||||
pattern="[0-9]*"
|
||||
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"
|
||||
/>
|
||||
{#if gradeError}
|
||||
<p class="text-sm text-red-600">{gradeError}</p>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="space-y-1">
|
||||
<label class="text-sm font-medium text-slate-600" for="edit-parent-name"
|
||||
|
|
@ -198,9 +335,11 @@
|
|||
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"
|
||||
/>
|
||||
{#if parentNameMissing}
|
||||
<p class="text-sm text-red-600">Ange en kontaktperson innan incheckning.</p>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="space-y-1 md:col-span-2">
|
||||
<label class="text-sm font-medium text-slate-600" for="edit-parent-phone"
|
||||
|
|
@ -210,9 +349,13 @@
|
|||
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"
|
||||
/>
|
||||
{#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>
|
||||
|
||||
|
|
@ -266,7 +409,7 @@
|
|||
</button>
|
||||
<button
|
||||
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"
|
||||
>
|
||||
{loading ? 'Sparar…' : 'Spara'}
|
||||
|
|
|
|||
|
|
@ -2,9 +2,9 @@ export interface Person {
|
|||
id: number;
|
||||
first_name: string;
|
||||
last_name: string;
|
||||
grade: number;
|
||||
parent_name: string;
|
||||
parent_phone_number: string;
|
||||
grade: number | null;
|
||||
parent_name: string | null;
|
||||
parent_phone_number: string | null;
|
||||
checked_in: boolean;
|
||||
inside: boolean;
|
||||
visitor: boolean;
|
||||
|
|
|
|||
|
|
@ -3,6 +3,12 @@
|
|||
import type { Person } from '$lib/types';
|
||||
import { onMount } from 'svelte';
|
||||
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';
|
||||
|
||||
let searchQuery = $state('');
|
||||
|
|
@ -18,9 +24,6 @@
|
|||
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);
|
||||
|
|
@ -133,6 +136,12 @@
|
|||
actionInfo = '';
|
||||
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' });
|
||||
if (!response) return;
|
||||
|
||||
|
|
@ -211,10 +220,10 @@
|
|||
<div>
|
||||
<h4 class="text-base font-semibold text-slate-800">{fullName(person)}</h4>
|
||||
<p class="text-sm text-slate-500">
|
||||
ID: {person.id} · Klass: {person.grade}
|
||||
ID: {person.id} · Klass: {gradeLabel(person)}
|
||||
</p>
|
||||
<p class="text-sm text-slate-500">
|
||||
Vårdnadshavare: {person.parent_name} ({person.parent_phone_number})
|
||||
Vårdnadshavare: {guardianLabel(person)}
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@
|
|||
import { onMount } from 'svelte';
|
||||
import { listenToPersonEvents } from '$lib/client/person-events';
|
||||
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';
|
||||
|
||||
type StatusFilter = 'all' | 'inside' | 'outside';
|
||||
|
|
@ -29,10 +30,6 @@ type VisitorFilter = 'all' | 'besoksplats' | 'lanplats';
|
|||
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);
|
||||
if (response.status === 401) {
|
||||
|
|
@ -47,8 +44,8 @@ type VisitorFilter = 'all' | 'besoksplats' | 'lanplats';
|
|||
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 (gradeFilter === 'lt4' && (person.grade == null || 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 === 'lanplats' && person.visitor) 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 matchesText =
|
||||
`${person.first_name} ${person.last_name}`.toLowerCase().includes(query) ||
|
||||
person.parent_name.toLowerCase().includes(query) ||
|
||||
person.parent_phone_number.toLowerCase().includes(query) ||
|
||||
person.parent_name?.toLowerCase().includes(query) ||
|
||||
person.parent_phone_number?.toLowerCase().includes(query) ||
|
||||
person.id.toString() === query;
|
||||
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">
|
||||
<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">ID: {person.id} · Klass: {gradeLabel(person)}</p>
|
||||
<p class="text-sm text-slate-500">
|
||||
Vårdnadshavare: {person.parent_name} ({person.parent_phone_number})
|
||||
Vårdnadshavare: {guardianLabel(person)}
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@
|
|||
import type { Person } from '$lib/types';
|
||||
import { onMount } from 'svelte';
|
||||
import { listenToPersonEvents } from '$lib/client/person-events';
|
||||
import { gradeLabel, guardianLabel, isLowerGrade } from '$lib/client/person-utils';
|
||||
|
||||
type GradeFilter = 'all' | 'lt4' | 'ge4';
|
||||
type VisitorFilter = 'all' | 'besoksplats' | 'lanplats';
|
||||
|
|
@ -21,19 +22,16 @@
|
|||
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) {
|
||||
const gradeValue = person.grade;
|
||||
if (gradeFilter === 'lt4' && (gradeValue == null || gradeValue > 3)) {
|
||||
return false;
|
||||
}
|
||||
if (gradeFilter === 'ge4' && person.grade <= 3) {
|
||||
if (gradeFilter === 'ge4' && (gradeValue == null || gradeValue <= 3)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
|
|
@ -46,12 +44,12 @@
|
|||
|
||||
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;
|
||||
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;
|
||||
}
|
||||
|
|
@ -291,10 +289,10 @@
|
|||
<div>
|
||||
<h4 class="text-base font-semibold text-slate-800">{fullName(person)}</h4>
|
||||
<p class="text-sm text-slate-500">
|
||||
ID: {person.id} · Klass: {person.grade}
|
||||
ID: {person.id} · Klass: {gradeLabel(person)}
|
||||
</p>
|
||||
<p class="text-sm text-slate-500">
|
||||
Vårdnadshavare: {person.parent_name} ({person.parent_phone_number})
|
||||
Vårdnadshavare: {guardianLabel(person)}
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
|
|
|
|||
|
|
@ -15,6 +15,9 @@ export const actions: Actions = {
|
|||
const visitor = formData.get('visitor') === '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 = {
|
||||
first_name: firstName,
|
||||
last_name: lastName,
|
||||
|
|
@ -42,24 +45,54 @@ export const actions: Actions = {
|
|||
});
|
||||
}
|
||||
|
||||
const parsedGrade = Number.parseInt(gradeRaw, 10);
|
||||
if (Number.isNaN(parsedGrade) || parsedGrade < 0) {
|
||||
let parsedGrade: number | null = null;
|
||||
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, {
|
||||
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
|
||||
});
|
||||
}
|
||||
|
||||
if (!parentName) {
|
||||
const requiresComplete = checkedIn || inside;
|
||||
if (requiresComplete && parsedGrade == null) {
|
||||
return fail(400, {
|
||||
errors: { parent_name: 'Ange en kontaktperson.' },
|
||||
errors: { grade: 'Ange en klass innan personen kan checkas in.' },
|
||||
values
|
||||
});
|
||||
}
|
||||
|
||||
if (!parentPhone) {
|
||||
if (requiresComplete && !hasValue(parentName)) {
|
||||
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
|
||||
});
|
||||
}
|
||||
|
|
@ -68,8 +101,8 @@ export const actions: Actions = {
|
|||
first_name: firstName,
|
||||
last_name: lastName,
|
||||
grade: parsedGrade,
|
||||
parent_name: parentName,
|
||||
parent_phone_number: parentPhone,
|
||||
parent_name: hasValue(parentName) ? parentName : null,
|
||||
parent_phone_number: hasValue(parentPhone) ? parentPhone : null,
|
||||
checked_in: checkedIn,
|
||||
inside,
|
||||
visitor,
|
||||
|
|
|
|||
|
|
@ -51,12 +51,12 @@
|
|||
<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>
|
||||
<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>
|
||||
<form method="POST" class="grid gap-4 md:grid-cols-2">
|
||||
<div>
|
||||
<label class="mb-1 block text-sm font-medium text-slate-600" for="first-name"
|
||||
>Förnamn</label
|
||||
<label class="mb-1 block text-sm font-medium text-slate-600" for="first-name">Förnamn</label
|
||||
>
|
||||
<input
|
||||
type="text"
|
||||
|
|
@ -64,7 +64,7 @@
|
|||
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"
|
||||
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}
|
||||
<p class="mt-1 text-sm text-red-600">{errors.first_name}</p>
|
||||
|
|
@ -80,24 +80,21 @@
|
|||
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"
|
||||
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}
|
||||
<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
|
||||
>
|
||||
<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"
|
||||
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.grade}
|
||||
<p class="mt-1 text-sm text-red-600">{errors.grade}</p>
|
||||
|
|
@ -113,45 +110,45 @@
|
|||
name="manual_id"
|
||||
min="0"
|
||||
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}
|
||||
<p class="mt-1 text-sm text-red-600">{errors.manual_id}</p>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="md:col-span-2">
|
||||
<label class="mb-1 block text-sm font-medium text-slate-600" for="parent-name"
|
||||
>Vårdnadshavare</label
|
||||
<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
|
||||
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"
|
||||
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.parent_name}
|
||||
<p class="mt-1 text-sm text-red-600">{errors.parent_name}</p>
|
||||
{/if}
|
||||
</div>
|
||||
<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
|
||||
<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
|
||||
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"
|
||||
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.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">
|
||||
<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">
|
||||
<input
|
||||
type="checkbox"
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@
|
|||
import { onMount } from 'svelte';
|
||||
import { listenToPersonEvents } from '$lib/client/person-events';
|
||||
import { updateCollection } from '$lib/client/person-collection';
|
||||
import { gradeLabel, guardianLabel, isLowerGrade } from '$lib/client/person-utils';
|
||||
|
||||
type StatusFilter = 'all' | 'inside' | 'outside';
|
||||
|
||||
|
|
@ -19,10 +20,6 @@
|
|||
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) {
|
||||
|
|
@ -40,8 +37,8 @@
|
|||
if (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.parent_name?.toLowerCase().includes(query) ||
|
||||
person.parent_phone_number?.toLowerCase().includes(query) ||
|
||||
person.id.toString() === query;
|
||||
if (!matchesText) return false;
|
||||
}
|
||||
|
|
@ -244,9 +241,9 @@
|
|||
<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">ID: {person.id} · Klass: {gradeLabel(person)}</p>
|
||||
<p class="text-sm text-slate-500">
|
||||
Vårdnadshavare: {person.parent_name} ({person.parent_phone_number})
|
||||
Vårdnadshavare: {guardianLabel(person)}
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
|
|
|
|||
Loading…
Reference in a new issue