added channels for automatic updates on every device and some security settings for hosting
This commit is contained in:
parent
19df7c8962
commit
464af45107
18 changed files with 516 additions and 207 deletions
38
README.md
Normal file
38
README.md
Normal file
|
|
@ -0,0 +1,38 @@
|
||||||
|
# VBytes Attendance
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
- Docker Engine (with Compose v2)
|
||||||
|
|
||||||
|
## Setup
|
||||||
|
1. Copy the provided `.env` file (already checked in). Adjust if needed:
|
||||||
|
```env
|
||||||
|
POSTGRES_PASSWORD=postgrespass123
|
||||||
|
JWT_SECRET=supersecretjwtkey
|
||||||
|
ADMIN_USERNAME=admin
|
||||||
|
ADMIN_PASSWORD=AdminPass!234
|
||||||
|
JWT_COOKIE_SECURE=false
|
||||||
|
ENABLE_HTTPS_REDIRECT=false
|
||||||
|
WEB_PORT=3000
|
||||||
|
CSRF_ALLOWED_ORIGINS=http://192.168.68.61:3000
|
||||||
|
```
|
||||||
|
- Change `JWT_SECRET` and `ADMIN_PASSWORD` before production use.
|
||||||
|
- Keep `JWT_COOKIE_SECURE=false` and `ENABLE_HTTPS_REDIRECT=false` unless you run behind HTTPS.
|
||||||
|
- Update `CSRF_ALLOWED_ORIGINS` to the host/port you’ll use to access the web app.
|
||||||
|
|
||||||
|
2. Start the stack:
|
||||||
|
```bash
|
||||||
|
docker compose up -d --build
|
||||||
|
```
|
||||||
|
|
||||||
|
3. Open the web app at `http://<this-machine-ip>:3000` using the admin credentials (`ADMIN_USERNAME`, `ADMIN_PASSWORD`).
|
||||||
|
|
||||||
|
4. Stop the stack:
|
||||||
|
```bash
|
||||||
|
docker compose down
|
||||||
|
```
|
||||||
|
To wipe Postgres data (e.g., after upgrading versions), also remove the volume:
|
||||||
|
```bash
|
||||||
|
docker volume rm vbytes_postgres-data
|
||||||
|
```
|
||||||
|
|
||||||
|
That’s it—the Compose file starts Postgres, the Rust API, and the SvelteKit frontend using the values from `.env`.
|
||||||
21
api/Dockerfile
Normal file
21
api/Dockerfile
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
FROM rustlang/rust:nightly as builder
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Cache dependencies
|
||||||
|
COPY Cargo.toml Cargo.lock ./
|
||||||
|
RUN mkdir src && echo "fn main() {}" > src/main.rs
|
||||||
|
RUN cargo build --release
|
||||||
|
|
||||||
|
# Build application
|
||||||
|
COPY . .
|
||||||
|
RUN cargo build --release
|
||||||
|
|
||||||
|
FROM debian:bookworm-slim as runtime
|
||||||
|
RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
WORKDIR /app
|
||||||
|
COPY --from=builder /app/target/release/api /usr/local/bin/api
|
||||||
|
ENV ROCKET_ADDRESS=0.0.0.0
|
||||||
|
ENV ROCKET_PORT=8080
|
||||||
|
EXPOSE 8080
|
||||||
|
CMD ["api"]
|
||||||
|
|
@ -32,7 +32,7 @@ pub struct LoginResponse {
|
||||||
pub username: String,
|
pub username: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Serialize)]
|
#[derive(Debug, Serialize, Clone)]
|
||||||
#[serde(crate = "rocket::serde")]
|
#[serde(crate = "rocket::serde")]
|
||||||
pub struct PersonResponse {
|
pub struct PersonResponse {
|
||||||
pub id: i32,
|
pub id: i32,
|
||||||
|
|
@ -59,13 +59,13 @@ impl From<Person> for PersonResponse {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Serialize)]
|
#[derive(Debug, Serialize, Clone)]
|
||||||
#[serde(crate = "rocket::serde")]
|
#[serde(crate = "rocket::serde")]
|
||||||
pub struct PersonsResponse {
|
pub struct PersonsResponse {
|
||||||
pub persons: Vec<PersonResponse>,
|
pub persons: Vec<PersonResponse>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Serialize)]
|
#[derive(Debug, Serialize, Clone)]
|
||||||
#[serde(crate = "rocket::serde")]
|
#[serde(crate = "rocket::serde")]
|
||||||
pub struct PersonActionResponse {
|
pub struct PersonActionResponse {
|
||||||
pub person: PersonResponse,
|
pub person: PersonResponse,
|
||||||
|
|
|
||||||
5
package.json
Normal file
5
package.json
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
{
|
||||||
|
"devDependencies": {
|
||||||
|
"dotenv": "^17.2.2"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -18,6 +18,7 @@
|
||||||
"@sveltejs/kit": "^2.22.0",
|
"@sveltejs/kit": "^2.22.0",
|
||||||
"@sveltejs/vite-plugin-svelte": "^6.0.0",
|
"@sveltejs/vite-plugin-svelte": "^6.0.0",
|
||||||
"@tailwindcss/vite": "^4.0.0",
|
"@tailwindcss/vite": "^4.0.0",
|
||||||
|
"dotenv": "^16.4.5",
|
||||||
"prettier": "^3.4.2",
|
"prettier": "^3.4.2",
|
||||||
"prettier-plugin-svelte": "^3.3.3",
|
"prettier-plugin-svelte": "^3.3.3",
|
||||||
"prettier-plugin-tailwindcss": "^0.6.11",
|
"prettier-plugin-tailwindcss": "^0.6.11",
|
||||||
|
|
|
||||||
29
web/src/lib/client/person-collection.ts
Normal file
29
web/src/lib/client/person-collection.ts
Normal file
|
|
@ -0,0 +1,29 @@
|
||||||
|
import type { Person } from '$lib/types';
|
||||||
|
|
||||||
|
type IncludeFn = (person: Person) => boolean;
|
||||||
|
|
||||||
|
export function updateCollection(
|
||||||
|
current: Person[],
|
||||||
|
updated: Person,
|
||||||
|
shouldInclude: IncludeFn
|
||||||
|
): Person[] {
|
||||||
|
const include = shouldInclude(updated);
|
||||||
|
const index = current.findIndex((person) => person.id === updated.id);
|
||||||
|
|
||||||
|
if (include) {
|
||||||
|
if (index >= 0) {
|
||||||
|
const next = current.slice();
|
||||||
|
next[index] = updated;
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
return [updated, ...current];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (index >= 0) {
|
||||||
|
const next = current.slice();
|
||||||
|
next.splice(index, 1);
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
|
||||||
|
return current;
|
||||||
|
}
|
||||||
40
web/src/lib/client/person-events.ts
Normal file
40
web/src/lib/client/person-events.ts
Normal file
|
|
@ -0,0 +1,40 @@
|
||||||
|
import type { Person } from '$lib/types';
|
||||||
|
|
||||||
|
export type PersonEvent = {
|
||||||
|
person: Person;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function listenToPersonEvents(onPerson: (person: Person) => void) {
|
||||||
|
let stopped = false;
|
||||||
|
let source: EventSource | null = null;
|
||||||
|
|
||||||
|
function connect() {
|
||||||
|
if (stopped) return;
|
||||||
|
source = new EventSource('/api/events');
|
||||||
|
source.onmessage = (event) => {
|
||||||
|
try {
|
||||||
|
const data = JSON.parse(event.data) as PersonEvent;
|
||||||
|
if (data.person) {
|
||||||
|
onPerson(data.person);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to parse person event', err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
source.onerror = () => {
|
||||||
|
source?.close();
|
||||||
|
source = null;
|
||||||
|
if (stopped) return;
|
||||||
|
setTimeout(connect, 2000);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
connect();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
stopped = true;
|
||||||
|
source?.close();
|
||||||
|
source = null;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -30,9 +30,10 @@ export async function proxyRequest(
|
||||||
raw?: () => Record<string, string[]>;
|
raw?: () => Record<string, string[]>;
|
||||||
};
|
};
|
||||||
|
|
||||||
const setCookies = typeof headerUtils.getSetCookie === 'function'
|
const setCookies =
|
||||||
|
typeof headerUtils.getSetCookie === 'function'
|
||||||
? headerUtils.getSetCookie()
|
? headerUtils.getSetCookie()
|
||||||
: headerUtils.raw?.()['set-cookie'] ?? [];
|
: (headerUtils.raw?.()['set-cookie'] ?? []);
|
||||||
|
|
||||||
return { response, setCookies };
|
return { response, setCookies };
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -46,14 +46,24 @@
|
||||||
|
|
||||||
<div class="min-h-screen bg-slate-100 text-slate-900">
|
<div class="min-h-screen bg-slate-100 text-slate-900">
|
||||||
<header class="border-b border-slate-200 bg-white">
|
<header class="border-b border-slate-200 bg-white">
|
||||||
<div class="mx-auto flex max-w-5xl flex-col gap-4 px-4 py-4 sm:flex-row sm:items-center sm:justify-between">
|
<div class="mx-auto flex max-w-5xl flex-col gap-4 px-4 py-4">
|
||||||
|
<div class="flex items-start justify-between gap-3 sm:items-center">
|
||||||
<div>
|
<div>
|
||||||
<p class="text-sm font-medium uppercase tracking-wide text-slate-500">VBytes</p>
|
<p class="text-sm font-medium tracking-wide text-slate-500 uppercase">VBytes</p>
|
||||||
<h1 class="text-xl font-semibold text-slate-900">Gästhantering</h1>
|
<h1 class="text-xl font-semibold text-slate-900">Gästhantering</h1>
|
||||||
</div>
|
</div>
|
||||||
{#if ui.loggedIn}
|
{#if ui.loggedIn}
|
||||||
<div class="flex flex-col items-start gap-3 sm:flex-row sm:items-center sm:gap-4">
|
<button
|
||||||
<nav class="flex items-center gap-2">
|
onclick={handleLogout}
|
||||||
|
disabled={ui.loggingOut}
|
||||||
|
class="rounded-md border border-slate-300 px-4 py-2 text-sm font-medium text-slate-700 transition-colors hover:bg-slate-100 disabled:cursor-not-allowed disabled:opacity-60"
|
||||||
|
>
|
||||||
|
{ui.loggingOut ? 'Loggar ut…' : 'Logga ut'}
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{#if ui.loggedIn}
|
||||||
|
<nav class="flex flex-wrap items-center justify-center gap-2 sm:justify-between">
|
||||||
<a
|
<a
|
||||||
href="/"
|
href="/"
|
||||||
class={`rounded-md px-3 py-2 text-sm font-medium transition-colors ${
|
class={`rounded-md px-3 py-2 text-sm font-medium transition-colors ${
|
||||||
|
|
@ -105,14 +115,6 @@
|
||||||
Översikt
|
Översikt
|
||||||
</a>
|
</a>
|
||||||
</nav>
|
</nav>
|
||||||
<button
|
|
||||||
onclick={handleLogout}
|
|
||||||
disabled={ui.loggingOut}
|
|
||||||
class="rounded-md border border-slate-300 px-4 py-2 text-sm font-medium text-slate-700 transition-colors hover:bg-slate-100 disabled:cursor-not-allowed disabled:opacity-60"
|
|
||||||
>
|
|
||||||
{ui.loggingOut ? 'Loggar ut…' : 'Logga ut'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{#if ui.message}
|
{#if ui.message}
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@
|
||||||
import { goto } from '$app/navigation';
|
import { goto } from '$app/navigation';
|
||||||
import type { Person } from '$lib/types';
|
import type { Person } from '$lib/types';
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
|
import { listenToPersonEvents } from '$lib/client/person-events';
|
||||||
|
|
||||||
let searchQuery = $state('');
|
let searchQuery = $state('');
|
||||||
let searchResults = $state<Person[]>([]);
|
let searchResults = $state<Person[]>([]);
|
||||||
|
|
@ -139,19 +140,27 @@
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
void loadDefaultList();
|
void loadDefaultList();
|
||||||
|
const stop = listenToPersonEvents((person) => {
|
||||||
|
updatePersonList(person);
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
stop();
|
||||||
|
};
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="space-y-6">
|
<div class="space-y-6">
|
||||||
<section class="rounded-lg border border-slate-200 bg-white p-6 shadow-sm">
|
<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>
|
<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>
|
<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}>
|
<form class="flex flex-col gap-3 sm:flex-row" onsubmit={handleSearch}>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="Exempel: Anna, 42 eller 0701234567"
|
placeholder="Exempel: Anna, 42 eller 0701234567"
|
||||||
bind:value={searchQuery}
|
bind:value={searchQuery}
|
||||||
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"
|
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
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
|
|
@ -178,18 +187,27 @@
|
||||||
<div class="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
<div class="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
||||||
<div>
|
<div>
|
||||||
<h4 class="text-base font-semibold text-slate-800">{person.name}</h4>
|
<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>
|
<p class="text-sm text-slate-500">
|
||||||
|
ID: {person.id} · Telefon: {person.phone_number}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex flex-wrap gap-2">
|
<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
|
||||||
<span class={`rounded-full px-3 py-1 text-xs font-semibold ${
|
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 ? 'bg-blue-100 text-blue-700' : 'bg-slate-100 text-slate-600'
|
||||||
}`}>{person.inside ? 'Inne' : 'Ute'}</span>
|
}`}>{person.inside ? 'Inne' : 'Ute'}</span
|
||||||
|
>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<p class="mt-2 text-sm text-slate-600">Ålder: {person.age} år</p>
|
<p class="mt-2 text-sm text-slate-600">Ålder: {person.age} år</p>
|
||||||
{#if person.under_ten}
|
{#if person.under_ten}
|
||||||
<p class="mt-2 rounded-md bg-amber-100 px-3 py-2 text-sm font-semibold text-amber-800">
|
<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.
|
VARNING: Person under 10 år – kompletterande information krävs innan incheckning.
|
||||||
</p>
|
</p>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
@ -200,7 +218,7 @@
|
||||||
disabled={person.checked_in || searchLoading}
|
disabled={person.checked_in || searchLoading}
|
||||||
class={`rounded-md px-4 py-2 text-sm font-semibold transition-colors ${
|
class={`rounded-md px-4 py-2 text-sm font-semibold transition-colors ${
|
||||||
person.checked_in
|
person.checked_in
|
||||||
? 'bg-slate-200 text-slate-600 cursor-not-allowed'
|
? 'cursor-not-allowed bg-slate-200 text-slate-600'
|
||||||
: 'bg-emerald-600 text-white hover:bg-emerald-700'
|
: 'bg-emerald-600 text-white hover:bg-emerald-700'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
|
|
|
||||||
31
web/src/routes/api/events/+server.ts
Normal file
31
web/src/routes/api/events/+server.ts
Normal file
|
|
@ -0,0 +1,31 @@
|
||||||
|
import type { RequestHandler } from './$types';
|
||||||
|
import { API_BASE_URL } from '$lib/server/config';
|
||||||
|
|
||||||
|
export const GET: RequestHandler = async (event) => {
|
||||||
|
const url = new URL('/events', API_BASE_URL).toString();
|
||||||
|
const headers = new Headers();
|
||||||
|
|
||||||
|
const cookie = event.request.headers.get('cookie');
|
||||||
|
if (cookie) {
|
||||||
|
headers.set('cookie', cookie);
|
||||||
|
}
|
||||||
|
headers.set('accept', 'text/event-stream');
|
||||||
|
|
||||||
|
const response = await event.fetch(url, {
|
||||||
|
method: 'GET',
|
||||||
|
headers,
|
||||||
|
credentials: 'include'
|
||||||
|
});
|
||||||
|
|
||||||
|
const proxiedHeaders = new Headers(response.headers);
|
||||||
|
if (!proxiedHeaders.has('content-type')) {
|
||||||
|
proxiedHeaders.set('content-type', 'text/event-stream');
|
||||||
|
}
|
||||||
|
proxiedHeaders.set('cache-control', 'no-cache');
|
||||||
|
|
||||||
|
return new Response(response.body, {
|
||||||
|
status: response.status,
|
||||||
|
statusText: response.statusText,
|
||||||
|
headers: proxiedHeaders
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
@ -8,9 +8,13 @@ export const GET: RequestHandler = async (event) => {
|
||||||
throw error(400, 'Söktext krävs.');
|
throw error(400, 'Söktext krävs.');
|
||||||
}
|
}
|
||||||
|
|
||||||
const { response, setCookies } = await proxyRequest(event, `/persons/search?q=${encodeURIComponent(query)}`, {
|
const { response, setCookies } = await proxyRequest(
|
||||||
|
event,
|
||||||
|
`/persons/search?q=${encodeURIComponent(query)}`,
|
||||||
|
{
|
||||||
method: 'GET'
|
method: 'GET'
|
||||||
});
|
}
|
||||||
|
);
|
||||||
|
|
||||||
const headers = new Headers();
|
const headers = new Headers();
|
||||||
for (const cookie of setCookies) {
|
for (const cookie of setCookies) {
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,8 @@
|
||||||
import { goto } from '$app/navigation';
|
import { goto } from '$app/navigation';
|
||||||
import type { Person } from '$lib/types';
|
import type { Person } from '$lib/types';
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
|
import { listenToPersonEvents } from '$lib/client/person-events';
|
||||||
|
import { updateCollection } from '$lib/client/person-collection';
|
||||||
|
|
||||||
type StatusFilter = 'all' | 'inside' | 'outside';
|
type StatusFilter = 'all' | 'inside' | 'outside';
|
||||||
type CheckedFilter = 'all' | 'checked-in' | 'not-checked-in';
|
type CheckedFilter = 'all' | 'checked-in' | 'not-checked-in';
|
||||||
|
|
@ -23,6 +25,47 @@
|
||||||
return response;
|
return response;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function matchesFilters(person: Person) {
|
||||||
|
if (checkedFilter === 'checked-in' && !person.checked_in) return false;
|
||||||
|
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 (searchQuery.trim()) {
|
||||||
|
const query = searchQuery.trim().toLowerCase();
|
||||||
|
const matchesText =
|
||||||
|
person.name.toLowerCase().includes(query) ||
|
||||||
|
person.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;
|
||||||
|
});
|
||||||
|
persons = filtered;
|
||||||
|
if (persons.length === 0) {
|
||||||
|
infoMessage = 'Inga personer matchar kriterierna.';
|
||||||
|
} else {
|
||||||
|
infoMessage = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handlePersonUpdate(person: Person) {
|
||||||
|
persons = updateCollection(persons, person, matchesFilters);
|
||||||
|
if (persons.length === 0) {
|
||||||
|
infoMessage = 'Inga personer matchar kriterierna.';
|
||||||
|
} else {
|
||||||
|
infoMessage = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function fetchCheckedIn() {
|
async function fetchCheckedIn() {
|
||||||
loading = true;
|
loading = true;
|
||||||
errorMessage = '';
|
errorMessage = '';
|
||||||
|
|
@ -61,20 +104,8 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
let list: Person[] = data.persons ?? [];
|
const list: Person[] = data.persons ?? [];
|
||||||
|
applyFilteredList(list);
|
||||||
list = list.filter((person) => {
|
|
||||||
if (checkedFilter === 'checked-in' && !person.checked_in) return false;
|
|
||||||
if (checkedFilter === 'not-checked-in' && person.checked_in) return false;
|
|
||||||
if (statusFilter === 'inside' && !person.inside) return false;
|
|
||||||
if (statusFilter === 'outside' && person.inside) return false;
|
|
||||||
return true;
|
|
||||||
});
|
|
||||||
|
|
||||||
persons = list;
|
|
||||||
if (persons.length === 0) {
|
|
||||||
infoMessage = 'Inga personer matchar kriterierna.';
|
|
||||||
}
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Fetch checked-in failed', err);
|
console.error('Fetch checked-in failed', err);
|
||||||
errorMessage = 'Ett oväntat fel inträffade när listan skulle hämtas.';
|
errorMessage = 'Ett oväntat fel inträffade när listan skulle hämtas.';
|
||||||
|
|
@ -99,6 +130,12 @@
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
void fetchCheckedIn();
|
void fetchCheckedIn();
|
||||||
|
const stop = listenToPersonEvents((person) => {
|
||||||
|
handlePersonUpdate(person);
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
stop();
|
||||||
|
};
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|
@ -108,7 +145,8 @@
|
||||||
<div>
|
<div>
|
||||||
<h2 class="text-lg font-semibold text-slate-800">Översikt</h2>
|
<h2 class="text-lg font-semibold text-slate-800">Översikt</h2>
|
||||||
<p class="text-sm text-slate-500">
|
<p class="text-sm text-slate-500">
|
||||||
Visar personer med status, både incheckade och inte incheckade. Sök efter namn, telefonnummer eller id och filtrera på inne/ute.
|
Visar personer med status, både incheckade och inte incheckade. Sök efter namn,
|
||||||
|
telefonnummer eller id och filtrera på inne/ute.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="text-sm text-slate-500">
|
<div class="text-sm text-slate-500">
|
||||||
|
|
@ -128,7 +166,7 @@
|
||||||
id="checked-query"
|
id="checked-query"
|
||||||
placeholder="Exempel: 42, Anna eller 0701234567"
|
placeholder="Exempel: 42, Anna eller 0701234567"
|
||||||
bind:value={searchQuery}
|
bind:value={searchQuery}
|
||||||
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"
|
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>
|
||||||
<div>
|
<div>
|
||||||
|
|
@ -137,7 +175,7 @@
|
||||||
id="status"
|
id="status"
|
||||||
bind:value={statusFilter}
|
bind:value={statusFilter}
|
||||||
onchange={handleStatusChange}
|
onchange={handleStatusChange}
|
||||||
class="w-full rounded-md border border-slate-300 px-3 py-2 text-sm text-slate-900 focus:border-indigo-500 focus:outline-none focus:ring focus:ring-indigo-100"
|
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="all">Alla</option>
|
||||||
<option value="inside">Endast inne</option>
|
<option value="inside">Endast inne</option>
|
||||||
|
|
@ -145,12 +183,14 @@
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label for="checked" class="mb-1 block text-sm font-medium text-slate-600">Incheckning</label>
|
<label for="checked" class="mb-1 block text-sm font-medium text-slate-600"
|
||||||
|
>Incheckning</label
|
||||||
|
>
|
||||||
<select
|
<select
|
||||||
id="checked"
|
id="checked"
|
||||||
bind:value={checkedFilter}
|
bind:value={checkedFilter}
|
||||||
onchange={handleCheckedChange}
|
onchange={handleCheckedChange}
|
||||||
class="w-full rounded-md border border-slate-300 px-3 py-2 text-sm text-slate-900 focus:border-indigo-500 focus:outline-none focus:ring focus:ring-indigo-100"
|
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="checked-in">Endast incheckade</option>
|
<option value="checked-in">Endast incheckade</option>
|
||||||
<option value="not-checked-in">Endast ej incheckade</option>
|
<option value="not-checked-in">Endast ej incheckade</option>
|
||||||
|
|
@ -185,12 +225,18 @@
|
||||||
<p class="text-sm text-slate-500">ID: {person.id} · Telefon: {person.phone_number}</p>
|
<p class="text-sm text-slate-500">ID: {person.id} · Telefon: {person.phone_number}</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex flex-wrap gap-2">
|
<div class="flex flex-wrap gap-2">
|
||||||
<span class={`rounded-full px-3 py-1 text-xs font-semibold ${
|
<span
|
||||||
person.checked_in ? 'bg-emerald-100 text-emerald-700' : 'bg-slate-100 text-slate-600'
|
class={`rounded-full px-3 py-1 text-xs font-semibold ${
|
||||||
}`}>{person.checked_in ? 'Incheckad' : 'Inte incheckad'}</span>
|
person.checked_in
|
||||||
<span class={`rounded-full px-3 py-1 text-xs font-semibold ${
|
? '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 ? 'bg-blue-100 text-blue-700' : 'bg-slate-100 text-slate-600'
|
||||||
}`}>{person.inside ? 'Inne' : 'Ute'}</span>
|
}`}>{person.inside ? 'Inne' : 'Ute'}</span
|
||||||
|
>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
<p class="mt-3 text-sm text-slate-600">Ålder: {person.age} år</p>
|
<p class="mt-3 text-sm text-slate-600">Ålder: {person.age} år</p>
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@
|
||||||
import { goto } from '$app/navigation';
|
import { goto } from '$app/navigation';
|
||||||
import type { Person } from '$lib/types';
|
import type { Person } from '$lib/types';
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
|
import { listenToPersonEvents } from '$lib/client/person-events';
|
||||||
|
|
||||||
let searchQuery = $state('');
|
let searchQuery = $state('');
|
||||||
let searchResults = $state<Person[]>([]);
|
let searchResults = $state<Person[]>([]);
|
||||||
|
|
@ -142,19 +143,27 @@
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
void loadDefaultList();
|
void loadDefaultList();
|
||||||
|
const stop = listenToPersonEvents((person) => {
|
||||||
|
updatePersonList(person);
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
stop();
|
||||||
|
};
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="space-y-6">
|
<div class="space-y-6">
|
||||||
<section class="rounded-lg border border-slate-200 bg-white p-6 shadow-sm">
|
<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>
|
<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.</p>
|
<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.
|
||||||
|
</p>
|
||||||
<form class="flex flex-col gap-3 sm:flex-row" onsubmit={handleSearch}>
|
<form class="flex flex-col gap-3 sm:flex-row" onsubmit={handleSearch}>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="Exempel: Anna, 42 eller 0701234567"
|
placeholder="Exempel: Anna, 42 eller 0701234567"
|
||||||
bind:value={searchQuery}
|
bind:value={searchQuery}
|
||||||
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"
|
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
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
|
|
@ -181,20 +190,30 @@
|
||||||
<div class="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
<div class="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
||||||
<div>
|
<div>
|
||||||
<h4 class="text-base font-semibold text-slate-800">{person.name}</h4>
|
<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>
|
<p class="text-sm text-slate-500">
|
||||||
|
ID: {person.id} · Telefon: {person.phone_number}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex flex-wrap gap-2">
|
<div class="flex flex-wrap gap-2">
|
||||||
<span class={`rounded-full px-3 py-1 text-xs font-semibold ${
|
<span
|
||||||
person.checked_in ? 'bg-emerald-100 text-emerald-700' : 'bg-slate-100 text-slate-600'
|
class={`rounded-full px-3 py-1 text-xs font-semibold ${
|
||||||
}`}>{person.checked_in ? 'Incheckad' : 'Inte incheckad'}</span>
|
person.checked_in
|
||||||
<span class={`rounded-full px-3 py-1 text-xs font-semibold ${
|
? '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 ? 'bg-blue-100 text-blue-700' : 'bg-slate-100 text-slate-600'
|
||||||
}`}>{person.inside ? 'Inne' : 'Ute'}</span>
|
}`}>{person.inside ? 'Inne' : 'Ute'}</span
|
||||||
|
>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<p class="mt-2 text-sm text-slate-600">Ålder: {person.age} år</p>
|
<p class="mt-2 text-sm text-slate-600">Ålder: {person.age} år</p>
|
||||||
{#if person.under_ten}
|
{#if person.under_ten}
|
||||||
<p class="mt-2 rounded-md bg-amber-100 px-3 py-2 text-sm font-semibold text-amber-800">
|
<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.
|
VARNING: Person under 10 år – kompletterande information krävs innan utcheckning.
|
||||||
</p>
|
</p>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
@ -206,7 +225,7 @@
|
||||||
class={`rounded-md px-4 py-2 text-sm font-semibold transition-colors ${
|
class={`rounded-md px-4 py-2 text-sm font-semibold transition-colors ${
|
||||||
person.checked_in
|
person.checked_in
|
||||||
? 'bg-red-600 text-white hover:bg-red-700'
|
? 'bg-red-600 text-white hover:bg-red-700'
|
||||||
: 'bg-slate-200 text-slate-600 cursor-not-allowed'
|
: 'cursor-not-allowed bg-slate-200 text-slate-600'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
Checka ut
|
Checka ut
|
||||||
|
|
|
||||||
|
|
@ -40,7 +40,9 @@ const success = $derived(props.form?.success ?? null);
|
||||||
<div class="space-y-6">
|
<div class="space-y-6">
|
||||||
<section class="rounded-lg border border-slate-200 bg-white p-6 shadow-sm">
|
<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>
|
<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.</p>
|
<p class="mb-4 text-sm text-slate-500">
|
||||||
|
Fyll i uppgifterna nedan. Om ID lämnas tomt skapas ett automatiskt.
|
||||||
|
</p>
|
||||||
<form method="POST" class="grid gap-4 md:grid-cols-2">
|
<form method="POST" class="grid gap-4 md:grid-cols-2">
|
||||||
<div class="md:col-span-2">
|
<div class="md:col-span-2">
|
||||||
<label class="mb-1 block text-sm font-medium text-slate-600" for="name">Namn</label>
|
<label class="mb-1 block text-sm font-medium text-slate-600" for="name">Namn</label>
|
||||||
|
|
@ -50,7 +52,7 @@ const success = $derived(props.form?.success ?? null);
|
||||||
name="name"
|
name="name"
|
||||||
value={values.name}
|
value={values.name}
|
||||||
required
|
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"
|
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}
|
{#if errors.name}
|
||||||
<p class="mt-1 text-sm text-red-600">{errors.name}</p>
|
<p class="mt-1 text-sm text-red-600">{errors.name}</p>
|
||||||
|
|
@ -65,35 +67,39 @@ const success = $derived(props.form?.success ?? null);
|
||||||
min="0"
|
min="0"
|
||||||
value={values.age}
|
value={values.age}
|
||||||
required
|
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"
|
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}
|
{#if errors.age}
|
||||||
<p class="mt-1 text-sm text-red-600">{errors.age}</p>
|
<p class="mt-1 text-sm text-red-600">{errors.age}</p>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
<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="phone"
|
||||||
|
>Telefonnummer</label
|
||||||
|
>
|
||||||
<input
|
<input
|
||||||
type="tel"
|
type="tel"
|
||||||
id="phone"
|
id="phone"
|
||||||
name="phone_number"
|
name="phone_number"
|
||||||
value={values.phone_number}
|
value={values.phone_number}
|
||||||
required
|
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"
|
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}
|
{#if errors.phone_number}
|
||||||
<p class="mt-1 text-sm text-red-600">{errors.phone_number}</p>
|
<p class="mt-1 text-sm text-red-600">{errors.phone_number}</p>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label class="mb-1 block text-sm font-medium text-slate-600" for="manual-id">ID (valfritt)</label>
|
<label class="mb-1 block text-sm font-medium text-slate-600" for="manual-id"
|
||||||
|
>ID (valfritt)</label
|
||||||
|
>
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
id="manual-id"
|
id="manual-id"
|
||||||
name="manual_id"
|
name="manual_id"
|
||||||
min="0"
|
min="0"
|
||||||
value={values.manual_id}
|
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:outline-none focus:ring focus:ring-indigo-100"
|
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.manual_id}
|
{#if errors.manual_id}
|
||||||
<p class="mt-1 text-sm text-red-600">{errors.manual_id}</p>
|
<p class="mt-1 text-sm text-red-600">{errors.manual_id}</p>
|
||||||
|
|
@ -122,12 +128,16 @@ const success = $derived(props.form?.success ?? null);
|
||||||
<label for="inside" class="text-sm text-slate-700">Markera som inne</label>
|
<label for="inside" class="text-sm text-slate-700">Markera som inne</label>
|
||||||
</div>
|
</div>
|
||||||
{#if errors.general}
|
{#if errors.general}
|
||||||
<p class="md:col-span-2 rounded-md bg-red-50 px-3 py-2 text-sm text-red-600">{errors.general}</p>
|
<p class="rounded-md bg-red-50 px-3 py-2 text-sm text-red-600 md:col-span-2">
|
||||||
|
{errors.general}
|
||||||
|
</p>
|
||||||
{/if}
|
{/if}
|
||||||
{#if success}
|
{#if success}
|
||||||
<p class="md:col-span-2 rounded-md bg-emerald-50 px-3 py-2 text-sm text-emerald-700">{success}</p>
|
<p class="rounded-md bg-emerald-50 px-3 py-2 text-sm text-emerald-700 md:col-span-2">
|
||||||
|
{success}
|
||||||
|
</p>
|
||||||
{/if}
|
{/if}
|
||||||
<div class="md:col-span-2 flex items-center gap-3">
|
<div class="flex items-center gap-3 md:col-span-2">
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
class="rounded-md bg-indigo-600 px-4 py-2 text-sm font-semibold text-white transition-colors hover:bg-indigo-700"
|
class="rounded-md bg-indigo-600 px-4 py-2 text-sm font-semibold text-white transition-colors hover:bg-indigo-700"
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,8 @@
|
||||||
import { goto } from '$app/navigation';
|
import { goto } from '$app/navigation';
|
||||||
import type { Person } from '$lib/types';
|
import type { Person } from '$lib/types';
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
|
import { listenToPersonEvents } from '$lib/client/person-events';
|
||||||
|
import { updateCollection } from '$lib/client/person-collection';
|
||||||
|
|
||||||
type StatusFilter = 'all' | 'inside' | 'outside';
|
type StatusFilter = 'all' | 'inside' | 'outside';
|
||||||
|
|
||||||
|
|
@ -22,6 +24,40 @@
|
||||||
return response;
|
return response;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function matchesFilters(person: Person) {
|
||||||
|
if (!person.checked_in) return false;
|
||||||
|
if (statusFilter === 'inside' && !person.inside) return false;
|
||||||
|
if (statusFilter === 'outside' && person.inside) return false;
|
||||||
|
const query = searchQuery.trim().toLowerCase();
|
||||||
|
if (query) {
|
||||||
|
const matchesText =
|
||||||
|
person.name.toLowerCase().includes(query) ||
|
||||||
|
person.phone_number.toLowerCase().includes(query) ||
|
||||||
|
person.id.toString() === query;
|
||||||
|
if (!matchesText) return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyFilteredList(list: Person[]) {
|
||||||
|
const filtered = list.filter(matchesFilters);
|
||||||
|
persons = filtered;
|
||||||
|
if (persons.length === 0) {
|
||||||
|
infoMessage = 'Inga incheckade personer matchar kriterierna.';
|
||||||
|
} else {
|
||||||
|
infoMessage = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handlePersonUpdate(person: Person) {
|
||||||
|
persons = updateCollection(persons, person, matchesFilters);
|
||||||
|
if (persons.length === 0) {
|
||||||
|
infoMessage = 'Inga incheckade personer matchar kriterierna.';
|
||||||
|
} else {
|
||||||
|
infoMessage = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function fetchPersons() {
|
async function fetchPersons() {
|
||||||
loading = true;
|
loading = true;
|
||||||
errorMessage = '';
|
errorMessage = '';
|
||||||
|
|
@ -59,19 +95,8 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
let list: Person[] = data.persons ?? [];
|
const list: Person[] = data.persons ?? [];
|
||||||
|
applyFilteredList(list);
|
||||||
list = list.filter((person) => {
|
|
||||||
if (!person.checked_in) return false;
|
|
||||||
if (statusFilter === 'inside' && !person.inside) return false;
|
|
||||||
if (statusFilter === 'outside' && person.inside) return false;
|
|
||||||
return true;
|
|
||||||
});
|
|
||||||
|
|
||||||
persons = list;
|
|
||||||
if (persons.length === 0) {
|
|
||||||
infoMessage = 'Inga incheckade personer matchar kriterierna.';
|
|
||||||
}
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Fetch inside status failed', err);
|
console.error('Fetch inside status failed', err);
|
||||||
errorMessage = 'Ett oväntat fel inträffade när listan skulle hämtas.';
|
errorMessage = 'Ett oväntat fel inträffade när listan skulle hämtas.';
|
||||||
|
|
@ -113,7 +138,7 @@
|
||||||
const body = JSON.parse(text) as { person?: Person };
|
const body = JSON.parse(text) as { person?: Person };
|
||||||
if (body.person) {
|
if (body.person) {
|
||||||
const updated = body.person;
|
const updated = body.person;
|
||||||
persons = persons.map((entry) => (entry.id === updated.id ? updated : entry));
|
handlePersonUpdate(updated);
|
||||||
actionMessage = updated.inside
|
actionMessage = updated.inside
|
||||||
? `${updated.name} är nu markerad som inne.`
|
? `${updated.name} är nu markerad som inne.`
|
||||||
: `${updated.name} är nu markerad som ute.`;
|
: `${updated.name} är nu markerad som ute.`;
|
||||||
|
|
@ -126,6 +151,12 @@
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
void fetchPersons();
|
void fetchPersons();
|
||||||
|
const stop = listenToPersonEvents((person) => {
|
||||||
|
handlePersonUpdate(person);
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
stop();
|
||||||
|
};
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|
@ -155,16 +186,18 @@
|
||||||
id="inside-query"
|
id="inside-query"
|
||||||
placeholder="Exempel: 42, Anna eller 0701234567"
|
placeholder="Exempel: 42, Anna eller 0701234567"
|
||||||
bind:value={searchQuery}
|
bind:value={searchQuery}
|
||||||
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"
|
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>
|
||||||
<div>
|
<div>
|
||||||
<label for="inside-status" class="mb-1 block text-sm font-medium text-slate-600">Status</label>
|
<label for="inside-status" class="mb-1 block text-sm font-medium text-slate-600"
|
||||||
|
>Status</label
|
||||||
|
>
|
||||||
<select
|
<select
|
||||||
id="inside-status"
|
id="inside-status"
|
||||||
bind:value={statusFilter}
|
bind:value={statusFilter}
|
||||||
onchange={handleStatusChange}
|
onchange={handleStatusChange}
|
||||||
class="w-full rounded-md border border-slate-300 px-3 py-2 text-sm text-slate-900 focus:border-indigo-500 focus:outline-none focus:ring focus:ring-indigo-100"
|
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="all">Alla</option>
|
||||||
<option value="inside">Endast inne</option>
|
<option value="inside">Endast inne</option>
|
||||||
|
|
@ -189,7 +222,9 @@
|
||||||
<p class="mt-4 rounded-md bg-amber-50 px-3 py-2 text-sm text-amber-700">{infoMessage}</p>
|
<p class="mt-4 rounded-md bg-amber-50 px-3 py-2 text-sm text-amber-700">{infoMessage}</p>
|
||||||
{/if}
|
{/if}
|
||||||
{#if actionMessage && !errorMessage}
|
{#if actionMessage && !errorMessage}
|
||||||
<p class="mt-4 rounded-md bg-emerald-50 px-3 py-2 text-sm text-emerald-700">{actionMessage}</p>
|
<p class="mt-4 rounded-md bg-emerald-50 px-3 py-2 text-sm text-emerald-700">
|
||||||
|
{actionMessage}
|
||||||
|
</p>
|
||||||
{/if}
|
{/if}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
|
@ -202,12 +237,18 @@
|
||||||
<p class="text-sm text-slate-500">ID: {person.id} · Telefon: {person.phone_number}</p>
|
<p class="text-sm text-slate-500">ID: {person.id} · Telefon: {person.phone_number}</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex flex-wrap gap-2">
|
<div class="flex flex-wrap gap-2">
|
||||||
<span class={`rounded-full px-3 py-1 text-xs font-semibold ${
|
<span
|
||||||
person.checked_in ? 'bg-emerald-100 text-emerald-700' : 'bg-slate-100 text-slate-600'
|
class={`rounded-full px-3 py-1 text-xs font-semibold ${
|
||||||
}`}>{person.checked_in ? 'Incheckad' : 'Inte incheckad'}</span>
|
person.checked_in
|
||||||
<span class={`rounded-full px-3 py-1 text-xs font-semibold ${
|
? '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 ? 'bg-blue-100 text-blue-700' : 'bg-slate-100 text-slate-600'
|
||||||
}`}>{person.inside ? 'Inne' : 'Ute'}</span>
|
}`}>{person.inside ? 'Inne' : 'Ute'}</span
|
||||||
|
>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
<p class="mt-3 text-sm text-slate-600">Ålder: {person.age} år</p>
|
<p class="mt-3 text-sm text-slate-600">Ålder: {person.age} år</p>
|
||||||
|
|
|
||||||
|
|
@ -41,7 +41,10 @@
|
||||||
|
|
||||||
<div class="mx-auto flex min-h-[60vh] max-w-md flex-col justify-center gap-6">
|
<div class="mx-auto flex min-h-[60vh] max-w-md flex-col justify-center gap-6">
|
||||||
<h2 class="text-center text-2xl font-semibold text-slate-800">Logga in</h2>
|
<h2 class="text-center text-2xl font-semibold text-slate-800">Logga in</h2>
|
||||||
<form class="space-y-4 rounded-lg border border-slate-200 bg-white p-6 shadow-sm" onsubmit={handleSubmit}>
|
<form
|
||||||
|
class="space-y-4 rounded-lg border border-slate-200 bg-white p-6 shadow-sm"
|
||||||
|
onsubmit={handleSubmit}
|
||||||
|
>
|
||||||
<div class="space-y-1">
|
<div class="space-y-1">
|
||||||
<label class="text-sm font-medium text-slate-600" for="username">Användarnamn</label>
|
<label class="text-sm font-medium text-slate-600" for="username">Användarnamn</label>
|
||||||
<input
|
<input
|
||||||
|
|
@ -51,7 +54,7 @@
|
||||||
bind:value={username}
|
bind:value={username}
|
||||||
autocomplete="username"
|
autocomplete="username"
|
||||||
required
|
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"
|
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>
|
||||||
<div class="space-y-1">
|
<div class="space-y-1">
|
||||||
|
|
@ -63,7 +66,7 @@
|
||||||
bind:value={password}
|
bind:value={password}
|
||||||
autocomplete="current-password"
|
autocomplete="current-password"
|
||||||
required
|
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"
|
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>
|
||||||
{#if errorMessage}
|
{#if errorMessage}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue