vbytes-lan-attendence/web/src/routes/(admin)/admin/checkin/checkout/+page.svelte

341 lines
10 KiB
Svelte
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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