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,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
#[derive(Debug, Serialize, Clone)]
|
||||
#[serde(crate = "rocket::serde")]
|
||||
pub struct PersonResponse {
|
||||
pub id: i32,
|
||||
|
|
@ -59,13 +59,13 @@ impl From<Person> for PersonResponse {
|
|||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
#[derive(Debug, Serialize, Clone)]
|
||||
#[serde(crate = "rocket::serde")]
|
||||
pub struct PersonsResponse {
|
||||
pub persons: Vec<PersonResponse>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
#[derive(Debug, Serialize, Clone)]
|
||||
#[serde(crate = "rocket::serde")]
|
||||
pub struct PersonActionResponse {
|
||||
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/vite-plugin-svelte": "^6.0.0",
|
||||
"@tailwindcss/vite": "^4.0.0",
|
||||
"dotenv": "^16.4.5",
|
||||
"prettier": "^3.4.2",
|
||||
"prettier-plugin-svelte": "^3.3.3",
|
||||
"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[]>;
|
||||
};
|
||||
|
||||
const setCookies = typeof headerUtils.getSetCookie === 'function'
|
||||
? headerUtils.getSetCookie()
|
||||
: headerUtils.raw?.()['set-cookie'] ?? [];
|
||||
const setCookies =
|
||||
typeof headerUtils.getSetCookie === 'function'
|
||||
? headerUtils.getSetCookie()
|
||||
: (headerUtils.raw?.()['set-cookie'] ?? []);
|
||||
|
||||
return { response, setCookies };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -46,24 +46,34 @@
|
|||
|
||||
<div class="min-h-screen bg-slate-100 text-slate-900">
|
||||
<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>
|
||||
<p class="text-sm font-medium uppercase tracking-wide text-slate-500">VBytes</p>
|
||||
<h1 class="text-xl font-semibold text-slate-900">Gästhantering</h1>
|
||||
</div>
|
||||
<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>
|
||||
<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>
|
||||
</div>
|
||||
{#if ui.loggedIn}
|
||||
<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>
|
||||
{/if}
|
||||
</div>
|
||||
{#if ui.loggedIn}
|
||||
<div class="flex flex-col items-start gap-3 sm:flex-row sm:items-center sm:gap-4">
|
||||
<nav class="flex items-center gap-2">
|
||||
<a
|
||||
href="/"
|
||||
class={`rounded-md px-3 py-2 text-sm font-medium transition-colors ${
|
||||
$page.url.pathname === '/'
|
||||
? 'bg-indigo-600 text-white shadow'
|
||||
: 'text-slate-600 hover:bg-slate-100'
|
||||
}`}
|
||||
>
|
||||
Checka in
|
||||
</a>
|
||||
<nav class="flex flex-wrap items-center justify-center gap-2 sm:justify-between">
|
||||
<a
|
||||
href="/"
|
||||
class={`rounded-md px-3 py-2 text-sm font-medium transition-colors ${
|
||||
$page.url.pathname === '/'
|
||||
? 'bg-indigo-600 text-white shadow'
|
||||
: 'text-slate-600 hover:bg-slate-100'
|
||||
}`}
|
||||
>
|
||||
Checka in
|
||||
</a>
|
||||
<a
|
||||
href="/checkout"
|
||||
class={`rounded-md px-3 py-2 text-sm font-medium transition-colors ${
|
||||
|
|
@ -104,20 +114,12 @@
|
|||
>
|
||||
Översikt
|
||||
</a>
|
||||
</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>
|
||||
</nav>
|
||||
{/if}
|
||||
</div>
|
||||
{#if ui.message}
|
||||
<p class="bg-red-50 px-4 py-2 text-center text-sm text-red-600">{ui.message}</p>
|
||||
{/if}
|
||||
{#if ui.message}
|
||||
<p class="bg-red-50 px-4 py-2 text-center text-sm text-red-600">{ui.message}</p>
|
||||
{/if}
|
||||
</header>
|
||||
<main class="mx-auto max-w-5xl px-4 py-6">
|
||||
{@render children?.()}
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
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[]>([]);
|
||||
|
|
@ -139,19 +140,27 @@
|
|||
|
||||
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>
|
||||
<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: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
|
||||
type="submit"
|
||||
|
|
@ -178,18 +187,27 @@
|
|||
<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>
|
||||
<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>
|
||||
<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">
|
||||
<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}
|
||||
|
|
@ -200,7 +218,7 @@
|
|||
disabled={person.checked_in || searchLoading}
|
||||
class={`rounded-md px-4 py-2 text-sm font-semibold transition-colors ${
|
||||
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'
|
||||
}`}
|
||||
>
|
||||
|
|
|
|||
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.');
|
||||
}
|
||||
|
||||
const { response, setCookies } = await proxyRequest(event, `/persons/search?q=${encodeURIComponent(query)}`, {
|
||||
method: 'GET'
|
||||
});
|
||||
const { response, setCookies } = await proxyRequest(
|
||||
event,
|
||||
`/persons/search?q=${encodeURIComponent(query)}`,
|
||||
{
|
||||
method: 'GET'
|
||||
}
|
||||
);
|
||||
|
||||
const headers = new Headers();
|
||||
for (const cookie of setCookies) {
|
||||
|
|
|
|||
|
|
@ -2,6 +2,8 @@
|
|||
import { goto } from '$app/navigation';
|
||||
import type { Person } from '$lib/types';
|
||||
import { onMount } from 'svelte';
|
||||
import { listenToPersonEvents } from '$lib/client/person-events';
|
||||
import { updateCollection } from '$lib/client/person-collection';
|
||||
|
||||
type StatusFilter = 'all' | 'inside' | 'outside';
|
||||
type CheckedFilter = 'all' | 'checked-in' | 'not-checked-in';
|
||||
|
|
@ -23,6 +25,47 @@
|
|||
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() {
|
||||
loading = true;
|
||||
errorMessage = '';
|
||||
|
|
@ -61,20 +104,8 @@
|
|||
}
|
||||
|
||||
const data = await response.json();
|
||||
let list: Person[] = data.persons ?? [];
|
||||
|
||||
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.';
|
||||
}
|
||||
const list: Person[] = data.persons ?? [];
|
||||
applyFilteredList(list);
|
||||
} catch (err) {
|
||||
console.error('Fetch checked-in failed', err);
|
||||
errorMessage = 'Ett oväntat fel inträffade när listan skulle hämtas.';
|
||||
|
|
@ -99,6 +130,12 @@
|
|||
|
||||
onMount(() => {
|
||||
void fetchCheckedIn();
|
||||
const stop = listenToPersonEvents((person) => {
|
||||
handlePersonUpdate(person);
|
||||
});
|
||||
return () => {
|
||||
stop();
|
||||
};
|
||||
});
|
||||
</script>
|
||||
|
||||
|
|
@ -108,7 +145,8 @@
|
|||
<div>
|
||||
<h2 class="text-lg font-semibold text-slate-800">Översikt</h2>
|
||||
<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>
|
||||
</div>
|
||||
<div class="text-sm text-slate-500">
|
||||
|
|
@ -128,7 +166,7 @@
|
|||
id="checked-query"
|
||||
placeholder="Exempel: 42, Anna 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: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>
|
||||
|
|
@ -137,7 +175,7 @@
|
|||
id="status"
|
||||
bind:value={statusFilter}
|
||||
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="inside">Endast inne</option>
|
||||
|
|
@ -145,12 +183,14 @@
|
|||
</select>
|
||||
</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
|
||||
id="checked"
|
||||
bind:value={checkedFilter}
|
||||
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="not-checked-in">Endast ej incheckade</option>
|
||||
|
|
@ -172,7 +212,7 @@
|
|||
<p class="mt-4 rounded-md bg-red-50 px-3 py-2 text-sm text-red-600">{errorMessage}</p>
|
||||
{/if}
|
||||
{#if infoMessage && !errorMessage}
|
||||
<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}
|
||||
</section>
|
||||
|
||||
|
|
@ -185,12 +225,18 @@
|
|||
<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 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>
|
||||
<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
|
||||
>
|
||||
</div>
|
||||
</header>
|
||||
<p class="mt-3 text-sm text-slate-600">Ålder: {person.age} år</p>
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
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[]>([]);
|
||||
|
|
@ -142,19 +143,27 @@
|
|||
|
||||
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 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}>
|
||||
<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: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
|
||||
type="submit"
|
||||
|
|
@ -181,20 +190,30 @@
|
|||
<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>
|
||||
<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 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>
|
||||
<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
|
||||
>
|
||||
</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">
|
||||
<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.
|
||||
</p>
|
||||
{/if}
|
||||
|
|
@ -206,7 +225,7 @@
|
|||
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'
|
||||
: 'bg-slate-200 text-slate-600 cursor-not-allowed'
|
||||
: 'cursor-not-allowed bg-slate-200 text-slate-600'
|
||||
}`}
|
||||
>
|
||||
Checka ut
|
||||
|
|
|
|||
|
|
@ -1,92 +1,92 @@
|
|||
import { fail, type Actions } from '@sveltejs/kit';
|
||||
|
||||
export const actions: Actions = {
|
||||
default: async ({ request, fetch }) => {
|
||||
const formData = await request.formData();
|
||||
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 manualId = formData.get('manual_id')?.toString().trim() ?? '';
|
||||
const checkedIn = formData.get('checked_in') === 'on';
|
||||
const inside = formData.get('inside') === 'on';
|
||||
const name = formData.get('name')?.toString().trim() ?? '';
|
||||
const ageRaw = formData.get('age')?.toString().trim() ?? '';
|
||||
const phone = formData.get('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 values = {
|
||||
name,
|
||||
age: ageRaw,
|
||||
phone_number: phone,
|
||||
manual_id: manualId,
|
||||
checked_in: checkedIn,
|
||||
inside
|
||||
};
|
||||
const values = {
|
||||
name,
|
||||
age: ageRaw,
|
||||
phone_number: phone,
|
||||
manual_id: manualId,
|
||||
checked_in: checkedIn,
|
||||
inside
|
||||
};
|
||||
|
||||
if (!name) {
|
||||
return fail(400, {
|
||||
errors: { name: 'Ange ett namn.' },
|
||||
values
|
||||
});
|
||||
}
|
||||
if (!name) {
|
||||
return fail(400, {
|
||||
errors: { name: 'Ange ett namn.' },
|
||||
values
|
||||
});
|
||||
}
|
||||
|
||||
const parsedAge = Number.parseInt(ageRaw, 10);
|
||||
if (Number.isNaN(parsedAge) || parsedAge < 0) {
|
||||
return fail(400, {
|
||||
errors: { age: 'Ålder måste vara ett heltal större än eller lika med 0.' },
|
||||
values
|
||||
});
|
||||
}
|
||||
const parsedAge = Number.parseInt(ageRaw, 10);
|
||||
if (Number.isNaN(parsedAge) || parsedAge < 0) {
|
||||
return fail(400, {
|
||||
errors: { age: 'Ålder måste vara ett heltal större än eller lika med 0.' },
|
||||
values
|
||||
});
|
||||
}
|
||||
|
||||
if (!phone) {
|
||||
return fail(400, {
|
||||
errors: { phone_number: 'Ange ett telefonnummer.' },
|
||||
values
|
||||
});
|
||||
}
|
||||
if (!phone) {
|
||||
return fail(400, {
|
||||
errors: { phone_number: 'Ange ett telefonnummer.' },
|
||||
values
|
||||
});
|
||||
}
|
||||
|
||||
const payload: Record<string, unknown> = {
|
||||
name,
|
||||
age: parsedAge,
|
||||
phone_number: phone,
|
||||
checked_in: checkedIn,
|
||||
inside
|
||||
};
|
||||
const payload: Record<string, unknown> = {
|
||||
name,
|
||||
age: parsedAge,
|
||||
phone_number: phone,
|
||||
checked_in: checkedIn,
|
||||
inside
|
||||
};
|
||||
|
||||
if (manualId.length > 0) {
|
||||
const parsedId = Number.parseInt(manualId, 10);
|
||||
if (Number.isNaN(parsedId) || parsedId < 0) {
|
||||
return fail(400, {
|
||||
errors: { manual_id: 'ID måste vara ett positivt heltal om det anges.' },
|
||||
values
|
||||
});
|
||||
}
|
||||
payload.id = parsedId;
|
||||
}
|
||||
if (manualId.length > 0) {
|
||||
const parsedId = Number.parseInt(manualId, 10);
|
||||
if (Number.isNaN(parsedId) || parsedId < 0) {
|
||||
return fail(400, {
|
||||
errors: { manual_id: 'ID måste vara ett positivt heltal om det anges.' },
|
||||
values
|
||||
});
|
||||
}
|
||||
payload.id = parsedId;
|
||||
}
|
||||
|
||||
const response = await fetch('/api/persons', {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
const response = await fetch('/api/persons', {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
|
||||
const text = await response.text();
|
||||
if (!response.ok) {
|
||||
let message = 'Kunde inte skapa personen.';
|
||||
try {
|
||||
const body = JSON.parse(text);
|
||||
message = body.message ?? message;
|
||||
} catch {
|
||||
if (text.trim().length > 0) {
|
||||
message = text;
|
||||
}
|
||||
}
|
||||
const text = await response.text();
|
||||
if (!response.ok) {
|
||||
let message = 'Kunde inte skapa personen.';
|
||||
try {
|
||||
const body = JSON.parse(text);
|
||||
message = body.message ?? message;
|
||||
} catch {
|
||||
if (text.trim().length > 0) {
|
||||
message = text;
|
||||
}
|
||||
}
|
||||
|
||||
return fail(response.status, {
|
||||
errors: { general: message },
|
||||
values
|
||||
});
|
||||
}
|
||||
return fail(response.status, {
|
||||
errors: { general: message },
|
||||
values
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
success: 'Personen har lagts till.'
|
||||
};
|
||||
}
|
||||
return {
|
||||
success: 'Personen har lagts till.'
|
||||
};
|
||||
}
|
||||
};
|
||||
|
|
|
|||
|
|
@ -28,19 +28,21 @@
|
|||
inside: false
|
||||
};
|
||||
|
||||
const values = $derived({
|
||||
...defaults,
|
||||
...(props.form?.values ?? {})
|
||||
} as FormValues);
|
||||
const values = $derived({
|
||||
...defaults,
|
||||
...(props.form?.values ?? {})
|
||||
} as FormValues);
|
||||
|
||||
const errors = $derived((props.form?.errors ?? {}) as FormErrors);
|
||||
const success = $derived(props.form?.success ?? null);
|
||||
const errors = $derived((props.form?.errors ?? {}) as FormErrors);
|
||||
const success = $derived(props.form?.success ?? null);
|
||||
</script>
|
||||
|
||||
<div class="space-y-6">
|
||||
<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>
|
||||
<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">
|
||||
<div class="md:col-span-2">
|
||||
<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"
|
||||
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: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}
|
||||
<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"
|
||||
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: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}
|
||||
<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="phone"
|
||||
>Telefonnummer</label
|
||||
>
|
||||
<input
|
||||
type="tel"
|
||||
id="phone"
|
||||
name="phone_number"
|
||||
value={values.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"
|
||||
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}
|
||||
</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
|
||||
type="number"
|
||||
id="manual-id"
|
||||
name="manual_id"
|
||||
min="0"
|
||||
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}
|
||||
<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>
|
||||
</div>
|
||||
{#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 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}
|
||||
<div class="md:col-span-2 flex items-center gap-3">
|
||||
<div class="flex items-center gap-3 md:col-span-2">
|
||||
<button
|
||||
type="submit"
|
||||
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 type { Person } from '$lib/types';
|
||||
import { onMount } from 'svelte';
|
||||
import { listenToPersonEvents } from '$lib/client/person-events';
|
||||
import { updateCollection } from '$lib/client/person-collection';
|
||||
|
||||
type StatusFilter = 'all' | 'inside' | 'outside';
|
||||
|
||||
|
|
@ -22,6 +24,40 @@
|
|||
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() {
|
||||
loading = true;
|
||||
errorMessage = '';
|
||||
|
|
@ -59,19 +95,8 @@
|
|||
}
|
||||
|
||||
const data = await response.json();
|
||||
let list: Person[] = data.persons ?? [];
|
||||
|
||||
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.';
|
||||
}
|
||||
const list: Person[] = data.persons ?? [];
|
||||
applyFilteredList(list);
|
||||
} catch (err) {
|
||||
console.error('Fetch inside status failed', err);
|
||||
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 };
|
||||
if (body.person) {
|
||||
const updated = body.person;
|
||||
persons = persons.map((entry) => (entry.id === updated.id ? updated : entry));
|
||||
handlePersonUpdate(updated);
|
||||
actionMessage = updated.inside
|
||||
? `${updated.name} är nu markerad som inne.`
|
||||
: `${updated.name} är nu markerad som ute.`;
|
||||
|
|
@ -126,6 +151,12 @@
|
|||
|
||||
onMount(() => {
|
||||
void fetchPersons();
|
||||
const stop = listenToPersonEvents((person) => {
|
||||
handlePersonUpdate(person);
|
||||
});
|
||||
return () => {
|
||||
stop();
|
||||
};
|
||||
});
|
||||
</script>
|
||||
|
||||
|
|
@ -155,16 +186,18 @@
|
|||
id="inside-query"
|
||||
placeholder="Exempel: 42, Anna 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: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>
|
||||
<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
|
||||
id="inside-status"
|
||||
bind:value={statusFilter}
|
||||
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="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>
|
||||
{/if}
|
||||
{#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}
|
||||
</section>
|
||||
|
||||
|
|
@ -201,14 +236,20 @@
|
|||
<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>
|
||||
</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>
|
||||
</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
|
||||
>
|
||||
</div>
|
||||
</header>
|
||||
<p class="mt-3 text-sm text-slate-600">Ålder: {person.age} år</p>
|
||||
{#if person.under_ten}
|
||||
|
|
|
|||
|
|
@ -29,7 +29,7 @@
|
|||
return;
|
||||
}
|
||||
|
||||
await goto('/', { invalidateAll: true });
|
||||
await goto('/', { invalidateAll: true });
|
||||
} catch (err) {
|
||||
console.error('Login failed', err);
|
||||
errorMessage = 'Ett oväntat fel inträffade. Försök igen.';
|
||||
|
|
@ -41,7 +41,10 @@
|
|||
|
||||
<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>
|
||||
<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">
|
||||
<label class="text-sm font-medium text-slate-600" for="username">Användarnamn</label>
|
||||
<input
|
||||
|
|
@ -51,7 +54,7 @@
|
|||
bind:value={username}
|
||||
autocomplete="username"
|
||||
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 class="space-y-1">
|
||||
|
|
@ -63,7 +66,7 @@
|
|||
bind:value={password}
|
||||
autocomplete="current-password"
|
||||
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>
|
||||
{#if errorMessage}
|
||||
|
|
|
|||
Loading…
Reference in a new issue