web updated to comply with the new datamodel and some new filtering added..
This commit is contained in:
parent
9de3c4a482
commit
35c7430c50
12 changed files with 1023 additions and 184 deletions
106
api/src/main.rs
106
api/src/main.rs
|
|
@ -9,14 +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};
|
||||
|
||||
|
|
@ -76,7 +76,8 @@ async fn main() -> Result<(), rocket::Error> {
|
|||
checkout_person,
|
||||
mark_inside,
|
||||
mark_outside,
|
||||
create_person
|
||||
create_person,
|
||||
update_person
|
||||
],
|
||||
);
|
||||
|
||||
|
|
@ -399,8 +400,8 @@ async fn create_person(
|
|||
let grade = payload.grade;
|
||||
let checked_in = payload.checked_in.unwrap_or(false);
|
||||
let inside = payload.inside.unwrap_or(false);
|
||||
let visitor = payload.visitor.unwrap_or(false);
|
||||
let sleeping_spot = payload.sleeping_spot.unwrap_or(false);
|
||||
let visitor = payload.visitor;
|
||||
let sleeping_spot = payload.sleeping_spot;
|
||||
|
||||
let person = match payload.id {
|
||||
Some(id) => sqlx::query_as::<_, Person>(
|
||||
|
|
@ -492,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,
|
||||
|
|
|
|||
|
|
@ -99,3 +99,19 @@ pub struct NewPersonRequest {
|
|||
#[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,
|
||||
}
|
||||
|
|
|
|||
278
web/src/lib/components/edit-person-modal.svelte
Normal file
278
web/src/lib/components/edit-person-modal.svelte
Normal file
|
|
@ -0,0 +1,278 @@
|
|||
<script lang="ts">
|
||||
'use runes';
|
||||
import type { Person } from '$lib/types';
|
||||
import { createEventDispatcher } from 'svelte';
|
||||
|
||||
const props = $props<{ person: Person | null; open: boolean }>();
|
||||
const dispatch = createEventDispatcher<{ close: void; saved: Person }>();
|
||||
|
||||
type FormState = {
|
||||
first_name: string;
|
||||
last_name: string;
|
||||
grade: string;
|
||||
parent_name: string;
|
||||
parent_phone_number: string;
|
||||
checked_in: boolean;
|
||||
inside: boolean;
|
||||
visitor: boolean;
|
||||
sleeping_spot: boolean;
|
||||
};
|
||||
|
||||
const defaults: FormState = {
|
||||
first_name: '',
|
||||
last_name: '',
|
||||
grade: '',
|
||||
parent_name: '',
|
||||
parent_phone_number: '',
|
||||
checked_in: false,
|
||||
inside: false,
|
||||
visitor: false,
|
||||
sleeping_spot: false
|
||||
};
|
||||
|
||||
let form = $state<FormState>({ ...defaults });
|
||||
let errorMessage = $state('');
|
||||
let loading = $state(false);
|
||||
|
||||
$effect(() => {
|
||||
const person = props.person;
|
||||
if (!person) {
|
||||
form = { ...defaults };
|
||||
errorMessage = '';
|
||||
loading = false;
|
||||
return;
|
||||
}
|
||||
|
||||
form = {
|
||||
first_name: person.first_name,
|
||||
last_name: person.last_name,
|
||||
grade: String(person.grade),
|
||||
parent_name: person.parent_name,
|
||||
parent_phone_number: person.parent_phone_number,
|
||||
checked_in: person.checked_in,
|
||||
inside: person.inside,
|
||||
visitor: person.visitor,
|
||||
sleeping_spot: person.sleeping_spot
|
||||
};
|
||||
errorMessage = '';
|
||||
loading = false;
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (!form.checked_in && form.inside) {
|
||||
form = { ...form, inside: false };
|
||||
}
|
||||
});
|
||||
|
||||
function closeModal() {
|
||||
if (loading) return;
|
||||
dispatch('close');
|
||||
}
|
||||
|
||||
async function handleSubmit(event: SubmitEvent) {
|
||||
event.preventDefault();
|
||||
if (!props.person) return;
|
||||
|
||||
const gradeNumber = Number.parseInt(form.grade, 10);
|
||||
if (Number.isNaN(gradeNumber) || gradeNumber < 0) {
|
||||
errorMessage = 'Klass måste vara ett heltal större än eller lika med 0.';
|
||||
return;
|
||||
}
|
||||
|
||||
const payload = {
|
||||
first_name: form.first_name.trim(),
|
||||
last_name: form.last_name.trim(),
|
||||
grade: gradeNumber,
|
||||
parent_name: form.parent_name.trim(),
|
||||
parent_phone_number: form.parent_phone_number.trim(),
|
||||
checked_in: form.checked_in,
|
||||
inside: form.inside,
|
||||
visitor: form.visitor,
|
||||
sleeping_spot: form.sleeping_spot
|
||||
};
|
||||
|
||||
if (!payload.first_name || !payload.last_name) {
|
||||
errorMessage = 'För- och efternamn krävs.';
|
||||
return;
|
||||
}
|
||||
|
||||
if (!payload.parent_name || !payload.parent_phone_number) {
|
||||
errorMessage = 'Vårdnadshavare och telefon krävs.';
|
||||
return;
|
||||
}
|
||||
|
||||
loading = true;
|
||||
errorMessage = '';
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/persons/${props.person.id}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
|
||||
const text = await response.text();
|
||||
if (!response.ok) {
|
||||
try {
|
||||
const body = JSON.parse(text);
|
||||
errorMessage = body.message ?? 'Kunde inte uppdatera personen.';
|
||||
} catch {
|
||||
errorMessage = text || 'Kunde inte uppdatera personen.';
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const body = JSON.parse(text) as { person: Person };
|
||||
dispatch('saved', body.person);
|
||||
dispatch('close');
|
||||
} catch (err) {
|
||||
console.error('Failed to update person', err);
|
||||
errorMessage = 'Ett oväntat fel inträffade.';
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if props.open && props.person}
|
||||
<div class="fixed inset-0 z-50 flex items-center justify-center bg-slate-900/60 p-4">
|
||||
<div class="w-full max-w-xl rounded-lg bg-white shadow-xl">
|
||||
<header class="flex items-center justify-between border-b border-slate-200 px-6 py-4">
|
||||
<div>
|
||||
<h3 class="text-lg font-semibold text-slate-800">Redigera person</h3>
|
||||
<p class="text-sm text-slate-500">ID: {props.person.id}</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onclick={closeModal}
|
||||
disabled={loading}
|
||||
class="rounded-md p-2 text-sm text-slate-500 transition hover:bg-slate-100 disabled:opacity-60"
|
||||
>
|
||||
Stäng
|
||||
</button>
|
||||
</header>
|
||||
<form class="grid gap-4 px-6 py-6" onsubmit={handleSubmit}>
|
||||
<div class="grid gap-4 md:grid-cols-2">
|
||||
<div class="space-y-1">
|
||||
<label class="text-sm font-medium text-slate-600" for="edit-first-name"
|
||||
>Förnamn</label
|
||||
>
|
||||
<input
|
||||
id="edit-first-name"
|
||||
type="text"
|
||||
bind:value={form.first_name}
|
||||
required
|
||||
class="w-full rounded-md border border-slate-300 px-3 py-2 text-slate-900 focus:border-indigo-500 focus:outline-none focus:ring focus:ring-indigo-100"
|
||||
/>
|
||||
</div>
|
||||
<div class="space-y-1">
|
||||
<label class="text-sm font-medium text-slate-600" for="edit-last-name"
|
||||
>Efternamn</label
|
||||
>
|
||||
<input
|
||||
id="edit-last-name"
|
||||
type="text"
|
||||
bind:value={form.last_name}
|
||||
required
|
||||
class="w-full rounded-md border border-slate-300 px-3 py-2 text-slate-900 focus:border-indigo-500 focus:outline-none focus:ring focus:ring-indigo-100"
|
||||
/>
|
||||
</div>
|
||||
<div class="space-y-1">
|
||||
<label class="text-sm font-medium text-slate-600" for="edit-grade"
|
||||
>Årskurs</label
|
||||
>
|
||||
<input
|
||||
id="edit-grade"
|
||||
type="number"
|
||||
min="0"
|
||||
bind:value={form.grade}
|
||||
required
|
||||
class="w-full rounded-md border border-slate-300 px-3 py-2 text-slate-900 focus:border-indigo-500 focus:outline-none focus:ring focus:ring-indigo-100"
|
||||
/>
|
||||
</div>
|
||||
<div class="space-y-1">
|
||||
<label class="text-sm font-medium text-slate-600" for="edit-parent-name"
|
||||
>Vårdnadshavare</label
|
||||
>
|
||||
<input
|
||||
id="edit-parent-name"
|
||||
type="text"
|
||||
bind:value={form.parent_name}
|
||||
required
|
||||
class="w-full rounded-md border border-slate-300 px-3 py-2 text-slate-900 focus:border-indigo-500 focus:outline-none focus:ring focus:ring-indigo-100"
|
||||
/>
|
||||
</div>
|
||||
<div class="space-y-1 md:col-span-2">
|
||||
<label class="text-sm font-medium text-slate-600" for="edit-parent-phone"
|
||||
>Vårdnadshavare – telefon</label
|
||||
>
|
||||
<input
|
||||
id="edit-parent-phone"
|
||||
type="tel"
|
||||
bind:value={form.parent_phone_number}
|
||||
required
|
||||
class="w-full rounded-md border border-slate-300 px-3 py-2 text-slate-900 focus:border-indigo-500 focus:outline-none focus:ring focus:ring-indigo-100"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-3 rounded-md border border-slate-200 bg-slate-50 p-4 md:grid-cols-2">
|
||||
<label class="flex items-center gap-2 text-sm text-slate-700">
|
||||
<input
|
||||
type="checkbox"
|
||||
bind:checked={form.checked_in}
|
||||
class="h-4 w-4 rounded border-slate-300 text-indigo-600 focus:ring-indigo-500"
|
||||
/>
|
||||
<span>Incheckad</span>
|
||||
</label>
|
||||
<label class="flex items-center gap-2 text-sm text-slate-700">
|
||||
<input
|
||||
type="checkbox"
|
||||
bind:checked={form.inside}
|
||||
class="h-4 w-4 rounded border-slate-300 text-indigo-600 focus:ring-indigo-500"
|
||||
/>
|
||||
<span>Markerad inne</span>
|
||||
</label>
|
||||
<label class="flex items-center gap-2 text-sm text-slate-700">
|
||||
<input
|
||||
type="checkbox"
|
||||
bind:checked={form.visitor}
|
||||
class="h-4 w-4 rounded border-slate-300 text-indigo-600 focus:ring-indigo-500"
|
||||
/>
|
||||
<span>Besöksplats</span>
|
||||
</label>
|
||||
<label class="flex items-center gap-2 text-sm text-slate-700">
|
||||
<input
|
||||
type="checkbox"
|
||||
bind:checked={form.sleeping_spot}
|
||||
class="h-4 w-4 rounded border-slate-300 text-indigo-600 focus:ring-indigo-500"
|
||||
/>
|
||||
<span>Behöver sovplats</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{#if errorMessage}
|
||||
<p class="rounded-md bg-red-50 px-3 py-2 text-sm text-red-600">{errorMessage}</p>
|
||||
{/if}
|
||||
|
||||
<div class="flex items-center justify-end gap-3 border-t border-slate-200 pt-4">
|
||||
<button
|
||||
type="button"
|
||||
onclick={closeModal}
|
||||
disabled={loading}
|
||||
class="rounded-md border border-slate-300 px-4 py-2 text-sm font-medium text-slate-600 transition hover:bg-slate-100 disabled:cursor-not-allowed disabled:opacity-60"
|
||||
>
|
||||
Avbryt
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
class="rounded-md bg-indigo-600 px-4 py-2 text-sm font-semibold text-white transition hover:bg-indigo-700 disabled:cursor-not-allowed disabled:opacity-70"
|
||||
>
|
||||
{loading ? 'Sparar…' : 'Spara'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
|
@ -1,9 +1,12 @@
|
|||
export interface Person {
|
||||
id: number;
|
||||
name: string;
|
||||
age: number;
|
||||
phone_number: string;
|
||||
first_name: string;
|
||||
last_name: string;
|
||||
grade: number;
|
||||
parent_name: string;
|
||||
parent_phone_number: string;
|
||||
checked_in: boolean;
|
||||
inside: boolean;
|
||||
under_ten: boolean;
|
||||
visitor: boolean;
|
||||
sleeping_spot: boolean;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@
|
|||
import type { Person } from '$lib/types';
|
||||
import { onMount } from 'svelte';
|
||||
import { listenToPersonEvents } from '$lib/client/person-events';
|
||||
import EditPersonModal from '$lib/components/edit-person-modal.svelte';
|
||||
|
||||
let searchQuery = $state('');
|
||||
let searchResults = $state<Person[]>([]);
|
||||
|
|
@ -11,6 +12,15 @@
|
|||
let searchError = $state('');
|
||||
let searchInfo = $state('');
|
||||
let actionInfo = $state('');
|
||||
let editor = $state<{ open: boolean; person: Person | null }>({ open: false, person: null });
|
||||
|
||||
function fullName(person: Person) {
|
||||
return `${person.first_name} ${person.last_name}`.trim();
|
||||
}
|
||||
|
||||
function isLowerGrade(person: Person) {
|
||||
return person.grade <= 3;
|
||||
}
|
||||
|
||||
async function apiFetch(url: string, init?: RequestInit) {
|
||||
const response = await fetch(url, init);
|
||||
|
|
@ -106,6 +116,19 @@
|
|||
updateVisibleResults();
|
||||
}
|
||||
|
||||
function openEditor(person: Person) {
|
||||
editor = { open: true, person };
|
||||
}
|
||||
|
||||
function closeEditor() {
|
||||
editor = { open: false, person: null };
|
||||
}
|
||||
|
||||
function handleEditorSaved(updated: Person) {
|
||||
updatePersonList(updated);
|
||||
actionInfo = 'Personen uppdaterades.';
|
||||
}
|
||||
|
||||
async function handleCheckIn(person: Person) {
|
||||
actionInfo = '';
|
||||
if (person.checked_in) return;
|
||||
|
|
@ -158,7 +181,7 @@
|
|||
<form class="flex flex-col gap-3 sm:flex-row" onsubmit={handleSearch}>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Exempel: Anna, 42 eller 0701234567"
|
||||
placeholder="Exempel: Anna Andersson, 42 eller 0701234567"
|
||||
bind:value={searchQuery}
|
||||
class="w-full rounded-md border border-slate-300 px-3 py-2 text-slate-900 focus:border-indigo-500 focus:ring focus:ring-indigo-100 focus:outline-none"
|
||||
/>
|
||||
|
|
@ -186,9 +209,12 @@
|
|||
<li class="rounded-lg border border-slate-200 p-4 shadow-sm">
|
||||
<div class="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<h4 class="text-base font-semibold text-slate-800">{person.name}</h4>
|
||||
<h4 class="text-base font-semibold text-slate-800">{fullName(person)}</h4>
|
||||
<p class="text-sm text-slate-500">
|
||||
ID: {person.id} · Telefon: {person.phone_number}
|
||||
ID: {person.id} · Klass: {person.grade}
|
||||
</p>
|
||||
<p class="text-sm text-slate-500">
|
||||
Vårdnadshavare: {person.parent_name} ({person.parent_phone_number})
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
|
|
@ -201,14 +227,21 @@
|
|||
person.inside ? 'bg-blue-100 text-blue-700' : 'bg-slate-100 text-slate-600'
|
||||
}`}>{person.inside ? 'Inne' : 'Ute'}</span
|
||||
>
|
||||
{#if person.visitor}
|
||||
<span class="rounded-full bg-amber-100 px-3 py-1 text-xs font-semibold text-amber-700">
|
||||
Besöksplats
|
||||
</span>
|
||||
{/if}
|
||||
{#if person.sleeping_spot}
|
||||
<span class="rounded-full bg-indigo-100 px-3 py-1 text-xs font-semibold text-indigo-700">
|
||||
Behöver sovplats
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
<p class="mt-2 text-sm text-slate-600">Ålder: {person.age} år</p>
|
||||
{#if person.under_ten}
|
||||
<p
|
||||
class="mt-2 rounded-md bg-amber-100 px-3 py-2 text-sm font-semibold text-amber-800"
|
||||
>
|
||||
VARNING: Person under 10 år – kompletterande information krävs innan incheckning.
|
||||
{#if isLowerGrade(person)}
|
||||
<p class="mt-2 rounded-md bg-amber-100 px-3 py-2 text-sm font-semibold text-amber-800">
|
||||
Observera: elev i årskurs 3 eller yngre – ska hem senast 22:00.
|
||||
</p>
|
||||
{/if}
|
||||
<div class="mt-4 flex gap-3">
|
||||
|
|
@ -224,6 +257,14 @@
|
|||
>
|
||||
Checka in
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => openEditor(person)}
|
||||
disabled={searchLoading}
|
||||
class="rounded-md border border-slate-300 px-4 py-2 text-sm font-medium text-slate-700 transition hover:bg-slate-100 disabled:cursor-not-allowed disabled:opacity-70"
|
||||
>
|
||||
Redigera
|
||||
</button>
|
||||
</div>
|
||||
</li>
|
||||
{/each}
|
||||
|
|
@ -231,3 +272,10 @@
|
|||
{/if}
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<EditPersonModal
|
||||
open={editor.open}
|
||||
person={editor.person}
|
||||
on:close={closeEditor}
|
||||
on:saved={(event) => handleEditorSaved(event.detail)}
|
||||
/>
|
||||
|
|
|
|||
45
web/src/routes/api/persons/[id]/+server.ts
Normal file
45
web/src/routes/api/persons/[id]/+server.ts
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
import { json, error } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types';
|
||||
import { proxyRequest } from '$lib/server/backend';
|
||||
|
||||
export const PUT: RequestHandler = async (event) => {
|
||||
const { id } = event.params;
|
||||
if (!id) {
|
||||
throw error(400, 'Ogiltigt id.');
|
||||
}
|
||||
|
||||
const payload = await event.request.json();
|
||||
const { response, setCookies } = await proxyRequest(event, `/persons/${id}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
|
||||
const headers = new Headers();
|
||||
for (const cookie of setCookies) {
|
||||
headers.append('set-cookie', cookie);
|
||||
}
|
||||
|
||||
const contentType = response.headers.get('content-type');
|
||||
if (contentType) {
|
||||
headers.set('content-type', contentType);
|
||||
} else {
|
||||
headers.set('content-type', 'application/json');
|
||||
}
|
||||
|
||||
const text = await response.text();
|
||||
if (!response.ok) {
|
||||
let message = 'Kunde inte uppdatera personen.';
|
||||
try {
|
||||
const body = JSON.parse(text);
|
||||
message = body.message ?? message;
|
||||
} catch {
|
||||
if (text) {
|
||||
message = text;
|
||||
}
|
||||
}
|
||||
|
||||
throw error(response.status, message);
|
||||
}
|
||||
|
||||
return new Response(text, { status: response.status, headers });
|
||||
};
|
||||
|
|
@ -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' });
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
@ -30,40 +47,51 @@
|
|||
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"
|
||||
|
|
@ -221,8 +317,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
|
||||
|
|
@ -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}
|
||||
{#if isLowerGrade(person)}
|
||||
<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.
|
||||
Observera: elev i årskurs 3 eller yngre – ska hem senast 22:00.
|
||||
</p>
|
||||
{/if}
|
||||
<div class="mt-4">
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => openEditor(person)}
|
||||
class="rounded-md border border-slate-300 px-4 py-2 text-sm font-medium text-slate-700 transition hover:bg-slate-100"
|
||||
>
|
||||
Redigera
|
||||
</button>
|
||||
</div>
|
||||
</article>
|
||||
{/each}
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<EditPersonModal
|
||||
open={editor.open}
|
||||
person={editor.person}
|
||||
on:close={closeEditor}
|
||||
on:saved={(event) => handleEditorSaved(event.detail)}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -4,6 +4,9 @@
|
|||
import { onMount } from 'svelte';
|
||||
import { listenToPersonEvents } from '$lib/client/person-events';
|
||||
|
||||
type GradeFilter = 'all' | 'lt4' | 'ge4';
|
||||
type VisitorFilter = 'all' | 'besoksplats' | 'lanplats';
|
||||
|
||||
let searchQuery = $state('');
|
||||
let searchResults = $state<Person[]>([]);
|
||||
let visibleResults = $state<Person[]>([]);
|
||||
|
|
@ -11,6 +14,51 @@
|
|||
let searchError = $state('');
|
||||
let searchInfo = $state('');
|
||||
let actionInfo = $state('');
|
||||
let gradeFilter = $state<GradeFilter>('all');
|
||||
let visitorFilter = $state<VisitorFilter>('all');
|
||||
|
||||
function fullName(person: Person) {
|
||||
return `${person.first_name} ${person.last_name}`.trim();
|
||||
}
|
||||
|
||||
function isLowerGrade(person: Person) {
|
||||
return person.grade <= 3;
|
||||
}
|
||||
|
||||
function matchesFilters(person: Person) {
|
||||
if (!person.checked_in) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (gradeFilter === 'lt4' && person.grade > 3) {
|
||||
return false;
|
||||
}
|
||||
if (gradeFilter === 'ge4' && person.grade <= 3) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (visitorFilter === 'besoksplats' && !person.visitor) {
|
||||
return false;
|
||||
}
|
||||
if (visitorFilter === 'lanplats' && person.visitor) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const query = searchQuery.trim().toLowerCase();
|
||||
if (query) {
|
||||
const combinedName = `${person.first_name} ${person.last_name}`.toLowerCase();
|
||||
const matchesText =
|
||||
combinedName.includes(query) ||
|
||||
person.parent_name.toLowerCase().includes(query) ||
|
||||
person.parent_phone_number.toLowerCase().includes(query) ||
|
||||
person.id.toString() === query;
|
||||
if (!matchesText) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
async function apiFetch(url: string, init?: RequestInit) {
|
||||
const response = await fetch(url, init);
|
||||
|
|
@ -101,7 +149,7 @@
|
|||
}
|
||||
return person;
|
||||
});
|
||||
if (!found) {
|
||||
if (!found && updated.checked_in) {
|
||||
searchResults = [updated, ...searchResults];
|
||||
}
|
||||
updateVisibleResults();
|
||||
|
|
@ -129,18 +177,30 @@
|
|||
}
|
||||
|
||||
function updateVisibleResults() {
|
||||
const filtered = searchResults.filter((person) => person.checked_in);
|
||||
const filtered = searchResults.filter((person) => matchesFilters(person));
|
||||
visibleResults = filtered;
|
||||
|
||||
if (searchResults.length === 0) {
|
||||
searchInfo = 'Ingen träff på sökningen.';
|
||||
if (searchResults.length === 0 && !searchQuery.trim()) {
|
||||
searchInfo = 'Inga personer hämtades.';
|
||||
} else if (filtered.length === 0) {
|
||||
searchInfo = 'Inga personer kan checkas ut just nu.';
|
||||
searchInfo = 'Ingen person matchar de valda filtren just nu.';
|
||||
} else {
|
||||
searchInfo = '';
|
||||
}
|
||||
}
|
||||
|
||||
function handleGradeFilterChange(event: Event) {
|
||||
const value = (event.currentTarget as HTMLSelectElement).value as GradeFilter;
|
||||
gradeFilter = value;
|
||||
updateVisibleResults();
|
||||
}
|
||||
|
||||
function handleVisitorFilterChange(event: Event) {
|
||||
const value = (event.currentTarget as HTMLSelectElement).value as VisitorFilter;
|
||||
visitorFilter = value;
|
||||
updateVisibleResults();
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
void loadDefaultList();
|
||||
const stop = listenToPersonEvents((person) => {
|
||||
|
|
@ -156,22 +216,62 @@
|
|||
<section class="rounded-lg border border-slate-200 bg-white p-6 shadow-sm">
|
||||
<h3 class="text-lg font-semibold text-slate-800">Checka ut</h3>
|
||||
<p class="mb-4 text-sm text-slate-500">
|
||||
Sök på namn, id eller telefonnummer för att checka ut personer som är incheckade.
|
||||
Sök på namn, id, vårdnadshavare eller telefonnummer för att hitta personer som ska
|
||||
checkas ut.
|
||||
</p>
|
||||
<form class="flex flex-col gap-3 sm:flex-row" onsubmit={handleSearch}>
|
||||
<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"
|
||||
placeholder="Exempel: Anna, 42 eller 0701234567"
|
||||
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="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"
|
||||
class="w-full rounded-md bg-indigo-600 px-4 py-2 text-sm font-semibold text-white transition-colors hover:bg-indigo-700 disabled:cursor-not-allowed disabled:opacity-70"
|
||||
>
|
||||
{searchLoading ? 'Söker…' : 'Sök'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
{#if searchError}
|
||||
<p class="mt-4 rounded-md bg-red-50 px-3 py-2 text-sm text-red-600">{searchError}</p>
|
||||
|
|
@ -189,9 +289,12 @@
|
|||
<li class="rounded-lg border border-slate-200 p-4 shadow-sm">
|
||||
<div class="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<h4 class="text-base font-semibold text-slate-800">{person.name}</h4>
|
||||
<h4 class="text-base font-semibold text-slate-800">{fullName(person)}</h4>
|
||||
<p class="text-sm text-slate-500">
|
||||
ID: {person.id} · Telefon: {person.phone_number}
|
||||
ID: {person.id} · Klass: {person.grade}
|
||||
</p>
|
||||
<p class="text-sm text-slate-500">
|
||||
Vårdnadshavare: {person.parent_name} ({person.parent_phone_number})
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
|
|
@ -207,14 +310,21 @@
|
|||
person.inside ? 'bg-blue-100 text-blue-700' : 'bg-slate-100 text-slate-600'
|
||||
}`}>{person.inside ? 'Inne' : 'Ute'}</span
|
||||
>
|
||||
{#if person.visitor}
|
||||
<span class="rounded-full bg-amber-100 px-3 py-1 text-xs font-semibold text-amber-700">
|
||||
Besöksplats
|
||||
</span>
|
||||
{/if}
|
||||
{#if person.sleeping_spot}
|
||||
<span class="rounded-full bg-indigo-100 px-3 py-1 text-xs font-semibold text-indigo-700">
|
||||
Behöver sovplats
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
<p class="mt-2 text-sm text-slate-600">Ålder: {person.age} år</p>
|
||||
{#if person.under_ten}
|
||||
<p
|
||||
class="mt-2 rounded-md bg-amber-100 px-3 py-2 text-sm font-semibold text-amber-800"
|
||||
>
|
||||
VARNING: Person under 10 år – kompletterande information krävs innan utcheckning.
|
||||
{#if isLowerGrade(person)}
|
||||
<p class="mt-2 rounded-md bg-amber-100 px-3 py-2 text-sm font-semibold text-amber-800">
|
||||
Observera: elev i årskurs 3 eller yngre – ska hem senast 22:00.
|
||||
</p>
|
||||
{/if}
|
||||
<div class="mt-4 flex gap-3">
|
||||
|
|
|
|||
|
|
@ -4,50 +4,76 @@ export const actions: Actions = {
|
|||
default: async ({ request, fetch }) => {
|
||||
const formData = await request.formData();
|
||||
|
||||
const name = formData.get('name')?.toString().trim() ?? '';
|
||||
const ageRaw = formData.get('age')?.toString().trim() ?? '';
|
||||
const phone = formData.get('phone_number')?.toString().trim() ?? '';
|
||||
const firstName = formData.get('first_name')?.toString().trim() ?? '';
|
||||
const lastName = formData.get('last_name')?.toString().trim() ?? '';
|
||||
const gradeRaw = formData.get('grade')?.toString().trim() ?? '';
|
||||
const parentName = formData.get('parent_name')?.toString().trim() ?? '';
|
||||
const parentPhone = formData.get('parent_phone_number')?.toString().trim() ?? '';
|
||||
const manualId = formData.get('manual_id')?.toString().trim() ?? '';
|
||||
const checkedIn = formData.get('checked_in') === 'on';
|
||||
const inside = formData.get('inside') === 'on';
|
||||
const visitor = formData.get('visitor') === 'on';
|
||||
const sleepingSpot = formData.get('sleeping_spot') === 'on';
|
||||
|
||||
const values = {
|
||||
name,
|
||||
age: ageRaw,
|
||||
phone_number: phone,
|
||||
first_name: firstName,
|
||||
last_name: lastName,
|
||||
grade: gradeRaw,
|
||||
parent_name: parentName,
|
||||
parent_phone_number: parentPhone,
|
||||
manual_id: manualId,
|
||||
checked_in: checkedIn,
|
||||
inside
|
||||
inside,
|
||||
visitor,
|
||||
sleeping_spot: sleepingSpot
|
||||
};
|
||||
|
||||
if (!name) {
|
||||
if (!firstName) {
|
||||
return fail(400, {
|
||||
errors: { name: 'Ange ett namn.' },
|
||||
errors: { first_name: 'Ange ett förnamn.' },
|
||||
values
|
||||
});
|
||||
}
|
||||
|
||||
const parsedAge = Number.parseInt(ageRaw, 10);
|
||||
if (Number.isNaN(parsedAge) || parsedAge < 0) {
|
||||
if (!lastName) {
|
||||
return fail(400, {
|
||||
errors: { age: 'Ålder måste vara ett heltal större än eller lika med 0.' },
|
||||
errors: { last_name: 'Ange ett efternamn.' },
|
||||
values
|
||||
});
|
||||
}
|
||||
|
||||
if (!phone) {
|
||||
const parsedGrade = Number.parseInt(gradeRaw, 10);
|
||||
if (Number.isNaN(parsedGrade) || parsedGrade < 0) {
|
||||
return fail(400, {
|
||||
errors: { phone_number: 'Ange ett telefonnummer.' },
|
||||
errors: { grade: 'Klass måste vara ett heltal större än eller lika med 0.' },
|
||||
values
|
||||
});
|
||||
}
|
||||
|
||||
if (!parentName) {
|
||||
return fail(400, {
|
||||
errors: { parent_name: 'Ange en kontaktperson.' },
|
||||
values
|
||||
});
|
||||
}
|
||||
|
||||
if (!parentPhone) {
|
||||
return fail(400, {
|
||||
errors: { parent_phone_number: 'Ange kontaktpersonens telefonnummer.' },
|
||||
values
|
||||
});
|
||||
}
|
||||
|
||||
const payload: Record<string, unknown> = {
|
||||
name,
|
||||
age: parsedAge,
|
||||
phone_number: phone,
|
||||
first_name: firstName,
|
||||
last_name: lastName,
|
||||
grade: parsedGrade,
|
||||
parent_name: parentName,
|
||||
parent_phone_number: parentPhone,
|
||||
checked_in: checkedIn,
|
||||
inside
|
||||
inside,
|
||||
visitor,
|
||||
sleeping_spot: sleepingSpot
|
||||
};
|
||||
|
||||
if (manualId.length > 0) {
|
||||
|
|
|
|||
|
|
@ -3,29 +3,39 @@
|
|||
const props = $props<import('./$types').PageData>();
|
||||
|
||||
type FormValues = {
|
||||
name: string;
|
||||
age: string;
|
||||
phone_number: string;
|
||||
first_name: string;
|
||||
last_name: string;
|
||||
grade: string;
|
||||
parent_name: string;
|
||||
parent_phone_number: string;
|
||||
manual_id: string;
|
||||
checked_in: boolean;
|
||||
inside: boolean;
|
||||
visitor: boolean;
|
||||
sleeping_spot: boolean;
|
||||
};
|
||||
|
||||
type FormErrors = {
|
||||
name?: string;
|
||||
age?: string;
|
||||
phone_number?: string;
|
||||
first_name?: string;
|
||||
last_name?: string;
|
||||
grade?: string;
|
||||
parent_name?: string;
|
||||
parent_phone_number?: string;
|
||||
manual_id?: string;
|
||||
general?: string;
|
||||
};
|
||||
|
||||
const defaults: FormValues = {
|
||||
name: '',
|
||||
age: '',
|
||||
phone_number: '',
|
||||
first_name: '',
|
||||
last_name: '',
|
||||
grade: '',
|
||||
parent_name: '',
|
||||
parent_phone_number: '',
|
||||
manual_id: '',
|
||||
checked_in: false,
|
||||
inside: false
|
||||
inside: false,
|
||||
visitor: false,
|
||||
sleeping_spot: false
|
||||
};
|
||||
|
||||
const values = $derived({
|
||||
|
|
@ -44,49 +54,53 @@
|
|||
Fyll i uppgifterna nedan. Om ID lämnas tomt skapas ett automatiskt.
|
||||
</p>
|
||||
<form method="POST" class="grid gap-4 md:grid-cols-2">
|
||||
<div class="md:col-span-2">
|
||||
<label class="mb-1 block text-sm font-medium text-slate-600" for="name">Namn</label>
|
||||
<input
|
||||
type="text"
|
||||
id="name"
|
||||
name="name"
|
||||
value={values.name}
|
||||
required
|
||||
class="w-full rounded-md border border-slate-300 px-3 py-2 text-slate-900 focus:border-indigo-500 focus:ring focus:ring-indigo-100 focus:outline-none"
|
||||
/>
|
||||
{#if errors.name}
|
||||
<p class="mt-1 text-sm text-red-600">{errors.name}</p>
|
||||
{/if}
|
||||
</div>
|
||||
<div>
|
||||
<label class="mb-1 block text-sm font-medium text-slate-600" for="age">Ålder</label>
|
||||
<input
|
||||
type="number"
|
||||
id="age"
|
||||
name="age"
|
||||
min="0"
|
||||
value={values.age}
|
||||
required
|
||||
class="w-full rounded-md border border-slate-300 px-3 py-2 text-slate-900 focus:border-indigo-500 focus:ring focus:ring-indigo-100 focus:outline-none"
|
||||
/>
|
||||
{#if errors.age}
|
||||
<p class="mt-1 text-sm text-red-600">{errors.age}</p>
|
||||
{/if}
|
||||
</div>
|
||||
<div>
|
||||
<label class="mb-1 block text-sm font-medium text-slate-600" for="phone"
|
||||
>Telefonnummer</label
|
||||
<label class="mb-1 block text-sm font-medium text-slate-600" for="first-name"
|
||||
>Förnamn</label
|
||||
>
|
||||
<input
|
||||
type="tel"
|
||||
id="phone"
|
||||
name="phone_number"
|
||||
value={values.phone_number}
|
||||
type="text"
|
||||
id="first-name"
|
||||
name="first_name"
|
||||
value={values.first_name}
|
||||
required
|
||||
class="w-full rounded-md border border-slate-300 px-3 py-2 text-slate-900 focus:border-indigo-500 focus:ring focus:ring-indigo-100 focus:outline-none"
|
||||
/>
|
||||
{#if errors.phone_number}
|
||||
<p class="mt-1 text-sm text-red-600">{errors.phone_number}</p>
|
||||
{#if errors.first_name}
|
||||
<p class="mt-1 text-sm text-red-600">{errors.first_name}</p>
|
||||
{/if}
|
||||
</div>
|
||||
<div>
|
||||
<label class="mb-1 block text-sm font-medium text-slate-600" for="last-name"
|
||||
>Efternamn</label
|
||||
>
|
||||
<input
|
||||
type="text"
|
||||
id="last-name"
|
||||
name="last_name"
|
||||
value={values.last_name}
|
||||
required
|
||||
class="w-full rounded-md border border-slate-300 px-3 py-2 text-slate-900 focus:border-indigo-500 focus:ring focus:ring-indigo-100 focus:outline-none"
|
||||
/>
|
||||
{#if errors.last_name}
|
||||
<p class="mt-1 text-sm text-red-600">{errors.last_name}</p>
|
||||
{/if}
|
||||
</div>
|
||||
<div>
|
||||
<label class="mb-1 block text-sm font-medium text-slate-600" for="grade"
|
||||
>Klass (år)</label
|
||||
>
|
||||
<input
|
||||
type="number"
|
||||
id="grade"
|
||||
name="grade"
|
||||
min="0"
|
||||
value={values.grade}
|
||||
required
|
||||
class="w-full rounded-md border border-slate-300 px-3 py-2 text-slate-900 focus:border-indigo-500 focus:ring focus:ring-indigo-100 focus:outline-none"
|
||||
/>
|
||||
{#if errors.grade}
|
||||
<p class="mt-1 text-sm text-red-600">{errors.grade}</p>
|
||||
{/if}
|
||||
</div>
|
||||
<div>
|
||||
|
|
@ -105,28 +119,81 @@
|
|||
<p class="mt-1 text-sm text-red-600">{errors.manual_id}</p>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="md:col-span-2">
|
||||
<label class="mb-1 block text-sm font-medium text-slate-600" for="parent-name"
|
||||
>Vårdnadshavare</label
|
||||
>
|
||||
<input
|
||||
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"
|
||||
/>
|
||||
{#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
|
||||
>
|
||||
<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"
|
||||
/>
|
||||
{#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
|
||||
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"
|
||||
/>
|
||||
<label for="checked-in" class="text-sm text-slate-700">Markera som incheckad</label>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<span>Markera som incheckad</span>
|
||||
</label>
|
||||
<label class="flex items-center gap-2 text-sm text-slate-700">
|
||||
<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"
|
||||
/>
|
||||
<label for="inside" class="text-sm text-slate-700">Markera som inne</label>
|
||||
<span>Markera som inne</span>
|
||||
</label>
|
||||
<label class="flex items-center gap-2 text-sm text-slate-700">
|
||||
<input
|
||||
type="checkbox"
|
||||
name="visitor"
|
||||
value="on"
|
||||
checked={values.visitor}
|
||||
class="h-4 w-4 rounded border-slate-300 text-indigo-600 focus:ring-indigo-500"
|
||||
/>
|
||||
<span>Markera som besökare</span>
|
||||
</label>
|
||||
<label class="flex items-center gap-2 text-sm text-slate-700">
|
||||
<input
|
||||
type="checkbox"
|
||||
name="sleeping_spot"
|
||||
value="on"
|
||||
checked={values.sleeping_spot}
|
||||
class="h-4 w-4 rounded border-slate-300 text-indigo-600 focus:ring-indigo-500"
|
||||
/>
|
||||
<span>Behöver sovplats</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{#if errors.general}
|
||||
<p class="rounded-md bg-red-50 px-3 py-2 text-sm text-red-600 md:col-span-2">
|
||||
{errors.general}
|
||||
|
|
|
|||
|
|
@ -15,6 +15,14 @@
|
|||
let searchQuery = $state('');
|
||||
let statusFilter = $state<StatusFilter>('all');
|
||||
|
||||
function fullName(person: Person) {
|
||||
return `${person.first_name} ${person.last_name}`.trim();
|
||||
}
|
||||
|
||||
function isLowerGrade(person: Person) {
|
||||
return person.grade <= 3;
|
||||
}
|
||||
|
||||
async function apiFetch(url: string, init?: RequestInit) {
|
||||
const response = await fetch(url, init);
|
||||
if (response.status === 401) {
|
||||
|
|
@ -31,8 +39,9 @@
|
|||
const query = searchQuery.trim().toLowerCase();
|
||||
if (query) {
|
||||
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;
|
||||
}
|
||||
|
|
@ -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,12 +262,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>
|
||||
</header>
|
||||
<p class="mt-3 text-sm text-slate-600">Ålder: {person.age} år</p>
|
||||
{#if person.under_ten}
|
||||
{#if isLowerGrade(person)}
|
||||
<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.
|
||||
Observera: elev i årskurs 3 eller yngre – ska hem senast 22:00.
|
||||
</p>
|
||||
{/if}
|
||||
<div class="mt-4 flex flex-wrap gap-3">
|
||||
|
|
|
|||
Loading…
Reference in a new issue