233 lines
6.8 KiB
Svelte
233 lines
6.8 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';
|
||
|
||
let searchQuery = $state('');
|
||
let searchResults = $state<Person[]>([]);
|
||
let visibleResults = $state<Person[]>([]);
|
||
let searchLoading = $state(false);
|
||
let searchError = $state('');
|
||
let searchInfo = $state('');
|
||
let actionInfo = $state('');
|
||
|
||
async function apiFetch(url: string, init?: RequestInit) {
|
||
const response = await fetch(url, init);
|
||
if (response.status === 401) {
|
||
await goto('/login');
|
||
return null;
|
||
}
|
||
return response;
|
||
}
|
||
|
||
async function loadDefaultList() {
|
||
searchLoading = true;
|
||
searchError = '';
|
||
actionInfo = '';
|
||
try {
|
||
const response = await apiFetch('/api/persons/checked-in?checked=false');
|
||
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 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 handleCheckIn(person: Person) {
|
||
actionInfo = '';
|
||
if (person.checked_in) return;
|
||
|
||
const response = await apiFetch(`/api/persons/${person.id}/checkin`, { method: 'POST' });
|
||
if (!response) return;
|
||
|
||
if (!response.ok) {
|
||
const body = await response.json().catch(() => ({}));
|
||
searchError = body.message ?? 'Kunde inte checka in personen.';
|
||
return;
|
||
}
|
||
|
||
const data = await response.json();
|
||
searchError = '';
|
||
updatePersonList(data.person);
|
||
actionInfo = 'Personen är nu incheckad.';
|
||
}
|
||
|
||
function updateVisibleResults() {
|
||
const filtered = searchResults.filter((person) => !person.checked_in);
|
||
visibleResults = filtered;
|
||
|
||
if (searchResults.length === 0) {
|
||
searchInfo = 'Ingen träff på sökningen.';
|
||
} else if (filtered.length === 0) {
|
||
searchInfo = 'Alla personer i listan är redan incheckade.';
|
||
} else {
|
||
searchInfo = '';
|
||
}
|
||
}
|
||
|
||
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 in</h3>
|
||
<p class="mb-4 text-sm text-slate-500">
|
||
Listan visar automatiskt alla som inte är incheckade. Sök för att begränsa listan.
|
||
</p>
|
||
<form class="flex flex-col gap-3 sm:flex-row" onsubmit={handleSearch}>
|
||
<input
|
||
type="text"
|
||
placeholder="Exempel: Anna, 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"
|
||
/>
|
||
<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"
|
||
>
|
||
{searchLoading ? 'Laddar…' : 'Sök'}
|
||
</button>
|
||
</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">{person.name}</h4>
|
||
<p class="text-sm text-slate-500">
|
||
ID: {person.id} · Telefon: {person.phone_number}
|
||
</p>
|
||
</div>
|
||
<div class="flex flex-wrap gap-2">
|
||
<span
|
||
class="rounded-full bg-slate-100 px-3 py-1 text-xs font-semibold text-slate-600"
|
||
>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
|
||
>
|
||
</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.
|
||
</p>
|
||
{/if}
|
||
<div class="mt-4 flex gap-3">
|
||
<button
|
||
type="button"
|
||
onclick={() => handleCheckIn(person)}
|
||
disabled={person.checked_in || searchLoading}
|
||
class={`rounded-md px-4 py-2 text-sm font-semibold transition-colors ${
|
||
person.checked_in
|
||
? 'cursor-not-allowed bg-slate-200 text-slate-600'
|
||
: 'bg-emerald-600 text-white hover:bg-emerald-700'
|
||
}`}
|
||
>
|
||
Checka in
|
||
</button>
|
||
</div>
|
||
</li>
|
||
{/each}
|
||
</ul>
|
||
{/if}
|
||
</section>
|
||
</div>
|