341 lines
10 KiB
Svelte
341 lines
10 KiB
Svelte
<script lang="ts">
|
||
import { goto } from '$app/navigation';
|
||
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';
|
||
import { personMatchesQuery, sortPersonsByQuery } from '$lib/client/person-search';
|
||
|
||
type GradeFilter = 'all' | 'lt4' | 'ge4';
|
||
type VisitorFilter = 'all' | 'besoksplats' | 'lanplats';
|
||
|
||
let searchQuery = $state('');
|
||
let searchResults = $state<Person[]>([]);
|
||
let visibleResults = $state<Person[]>([]);
|
||
let searchLoading = $state(false);
|
||
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 matchesFilters(person: Person) {
|
||
if (!person.checked_in) {
|
||
return false;
|
||
}
|
||
|
||
const gradeValue = person.grade;
|
||
if (gradeFilter === 'lt4' && (gradeValue == null || gradeValue > 3)) {
|
||
return false;
|
||
}
|
||
if (gradeFilter === 'ge4' && (gradeValue == null || gradeValue <= 3)) {
|
||
return false;
|
||
}
|
||
|
||
if (visitorFilter === 'besoksplats' && !person.visitor) {
|
||
return false;
|
||
}
|
||
if (visitorFilter === 'lanplats' && person.visitor) {
|
||
return false;
|
||
}
|
||
|
||
const query = searchQuery.trim();
|
||
if (query && !personMatchesQuery(person, query)) {
|
||
return false;
|
||
}
|
||
|
||
return true;
|
||
}
|
||
|
||
async function apiFetch(url: string, init?: RequestInit) {
|
||
const response = await fetch(url, init);
|
||
if (response.status === 401) {
|
||
await goto('/admin/login');
|
||
return null;
|
||
}
|
||
return response;
|
||
}
|
||
|
||
async function loadDefaultList() {
|
||
searchLoading = true;
|
||
searchError = '';
|
||
actionInfo = '';
|
||
try {
|
||
const response = await apiFetch('/api/persons/checked-in?checked=true');
|
||
if (!response) return;
|
||
if (!response.ok) {
|
||
const text = await response.text();
|
||
try {
|
||
const body = JSON.parse(text);
|
||
searchError = body.message ?? 'Kunde inte hämta personer.';
|
||
} catch {
|
||
searchError = text || 'Kunde inte hämta personer.';
|
||
}
|
||
searchResults = [];
|
||
visibleResults = [];
|
||
return;
|
||
}
|
||
|
||
const data = await response.json();
|
||
searchResults = data.persons ?? [];
|
||
updateVisibleResults();
|
||
} catch (err) {
|
||
console.error('Initial checkout load failed', err);
|
||
searchError = 'Ett oväntat fel inträffade när listan skulle hämtas.';
|
||
searchResults = [];
|
||
visibleResults = [];
|
||
} finally {
|
||
searchLoading = false;
|
||
}
|
||
}
|
||
|
||
async function handleSearch(event?: SubmitEvent) {
|
||
event?.preventDefault();
|
||
actionInfo = '';
|
||
searchError = '';
|
||
searchInfo = '';
|
||
|
||
const query = searchQuery.trim();
|
||
if (!query) {
|
||
await loadDefaultList();
|
||
return;
|
||
}
|
||
|
||
searchLoading = true;
|
||
try {
|
||
const response = await apiFetch(`/api/persons/search?q=${encodeURIComponent(query)}`);
|
||
if (!response) return;
|
||
|
||
if (!response.ok) {
|
||
const body = await response.json().catch(() => ({}));
|
||
searchError = body.message ?? 'Sökningen misslyckades.';
|
||
searchResults = [];
|
||
visibleResults = [];
|
||
return;
|
||
}
|
||
|
||
const data = await response.json();
|
||
searchResults = data.persons ?? [];
|
||
updateVisibleResults();
|
||
} catch (err) {
|
||
console.error('Search failed', err);
|
||
searchError = 'Ett oväntat fel inträffade vid sökningen.';
|
||
searchResults = [];
|
||
visibleResults = [];
|
||
} finally {
|
||
searchLoading = false;
|
||
}
|
||
}
|
||
|
||
function updatePersonList(updated: Person) {
|
||
let found = false;
|
||
searchResults = searchResults.map((person) => {
|
||
if (person.id === updated.id) {
|
||
found = true;
|
||
return updated;
|
||
}
|
||
return person;
|
||
});
|
||
if (!found && updated.checked_in) {
|
||
searchResults = [updated, ...searchResults];
|
||
}
|
||
updateVisibleResults();
|
||
}
|
||
|
||
async function handleCheckout(person: Person) {
|
||
actionInfo = '';
|
||
if (!person.checked_in) {
|
||
return;
|
||
}
|
||
const response = await apiFetch(`/api/persons/${person.id}/checkout`, { method: 'POST' });
|
||
actionInfo = '';
|
||
if (!response) return;
|
||
|
||
if (!response.ok) {
|
||
const body = await response.json().catch(() => ({}));
|
||
searchError = body.message ?? 'Kunde inte checka ut personen.';
|
||
return;
|
||
}
|
||
|
||
const data = await response.json();
|
||
searchError = '';
|
||
updatePersonList(data.person);
|
||
actionInfo = 'Personen är nu utcheckad.';
|
||
}
|
||
|
||
function updateVisibleResults() {
|
||
const filtered = searchResults.filter((person) => matchesFilters(person));
|
||
const query = searchQuery.trim();
|
||
visibleResults = query ? sortPersonsByQuery(filtered, query) : filtered;
|
||
|
||
if (searchResults.length === 0 && !searchQuery.trim()) {
|
||
searchInfo = 'Inga personer hämtades.';
|
||
} else if (filtered.length === 0) {
|
||
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) => {
|
||
updatePersonList(person);
|
||
});
|
||
return () => {
|
||
stop();
|
||
};
|
||
});
|
||
</script>
|
||
|
||
<div class="space-y-6">
|
||
<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, vårdnadshavare eller telefonnummer för att hitta personer som ska
|
||
checkas ut.
|
||
</p>
|
||
<form
|
||
class="grid gap-3 md:grid-cols-[2fr_1fr_1fr_auto]"
|
||
onsubmit={handleSearch}
|
||
>
|
||
<div class="flex flex-col gap-1">
|
||
<label for="checkout-query" class="text-sm font-medium text-slate-600">Sök</label>
|
||
<input
|
||
type="text"
|
||
id="checkout-query"
|
||
placeholder="Exempel: Anna Andersson, 42 eller 0701234567"
|
||
bind:value={searchQuery}
|
||
class="w-full rounded-md border border-slate-300 px-3 py-2 text-slate-900 focus:border-indigo-500 focus:ring focus:ring-indigo-100 focus:outline-none"
|
||
/>
|
||
</div>
|
||
<div class="flex flex-col gap-1">
|
||
<label for="checkout-grade" class="text-sm font-medium text-slate-600"
|
||
>Årskurs</label
|
||
>
|
||
<select
|
||
id="checkout-grade"
|
||
bind:value={gradeFilter}
|
||
onchange={handleGradeFilterChange}
|
||
class="w-full rounded-md border border-slate-300 px-3 py-2 text-sm text-slate-900 focus:border-indigo-500 focus:ring focus:ring-indigo-100 focus:outline-none"
|
||
>
|
||
<option value="all">Alla</option>
|
||
<option value="lt4">Årskurs 3 eller yngre</option>
|
||
<option value="ge4">Årskurs 4 eller äldre</option>
|
||
</select>
|
||
</div>
|
||
<div class="flex flex-col gap-1">
|
||
<label for="checkout-visitor" class="text-sm font-medium text-slate-600"
|
||
>Plats</label
|
||
>
|
||
<select
|
||
id="checkout-visitor"
|
||
bind:value={visitorFilter}
|
||
onchange={handleVisitorFilterChange}
|
||
class="w-full rounded-md border border-slate-300 px-3 py-2 text-sm text-slate-900 focus:border-indigo-500 focus:ring focus:ring-indigo-100 focus:outline-none"
|
||
>
|
||
<option value="all">Alla</option>
|
||
<option value="besoksplats">Besöksplats</option>
|
||
<option value="lanplats">Lanplats</option>
|
||
</select>
|
||
</div>
|
||
<div class="flex items-end">
|
||
<button
|
||
type="submit"
|
||
disabled={searchLoading}
|
||
class="w-full rounded-md bg-indigo-600 px-4 py-2 text-sm font-semibold text-white transition-colors hover:bg-indigo-700 disabled:cursor-not-allowed disabled:opacity-70"
|
||
>
|
||
{searchLoading ? 'Söker…' : 'Sök'}
|
||
</button>
|
||
</div>
|
||
</form>
|
||
{#if searchError}
|
||
<p class="mt-4 rounded-md bg-red-50 px-3 py-2 text-sm text-red-600">{searchError}</p>
|
||
{/if}
|
||
{#if searchInfo}
|
||
<p class="mt-4 rounded-md bg-amber-50 px-3 py-2 text-sm text-amber-700">{searchInfo}</p>
|
||
{/if}
|
||
{#if actionInfo}
|
||
<p class="mt-4 rounded-md bg-emerald-50 px-3 py-2 text-sm text-emerald-700">{actionInfo}</p>
|
||
{/if}
|
||
|
||
{#if visibleResults.length > 0}
|
||
<ul class="mt-6 space-y-4">
|
||
{#each visibleResults as person}
|
||
<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">{fullName(person)}</h4>
|
||
<p class="text-sm text-slate-500">
|
||
ID: {person.id} · Klass: {gradeLabel(person)}
|
||
</p>
|
||
<p class="text-sm text-slate-500">
|
||
Vårdnadshavare: {guardianLabel(person)}
|
||
</p>
|
||
</div>
|
||
<div class="flex flex-wrap gap-2">
|
||
<span
|
||
class={`rounded-full px-3 py-1 text-xs font-semibold ${
|
||
person.checked_in
|
||
? 'bg-emerald-100 text-emerald-700'
|
||
: 'bg-slate-100 text-slate-600'
|
||
}`}>{person.checked_in ? 'Incheckad' : 'Inte incheckad'}</span
|
||
>
|
||
<span
|
||
class={`rounded-full px-3 py-1 text-xs font-semibold ${
|
||
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>
|
||
{#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">
|
||
<button
|
||
type="button"
|
||
onclick={() => handleCheckout(person)}
|
||
disabled={!person.checked_in || searchLoading}
|
||
class={`rounded-md px-4 py-2 text-sm font-semibold transition-colors ${
|
||
person.checked_in
|
||
? 'bg-red-600 text-white hover:bg-red-700'
|
||
: 'cursor-not-allowed bg-slate-200 text-slate-600'
|
||
}`}
|
||
>
|
||
Checka ut
|
||
</button>
|
||
</div>
|
||
</li>
|
||
{/each}
|
||
</ul>
|
||
{/if}
|
||
</section>
|
||
</div>
|