vereinshaus/app/src/routes/(app)/orte/+page.svelte

345 lines
13 KiB
Svelte
Raw Blame History

This file contains ambiguous Unicode characters

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

<script lang="ts">
import { api } from '$lib/api';
import { onMount } from 'svelte';
import type { Veranstaltungsort, OrtAusfall } from '$lib/types';
let orte = $state<Veranstaltungsort[]>([]);
let ausfaelle = $state<OrtAusfall[]>([]);
let loading = $state(true);
// Ort-Formular
let showOrtForm = $state(false);
let editOrtId = $state<string | null>(null);
let fName = $state('');
let fAdresse = $state('');
let fTyp = $state<Veranstaltungsort['typ']>('halle');
let fAktiv = $state(true);
let ortSaving = $state(false);
let ortError = $state('');
// Ausfall-Formular
let showAusfallForm = $state(false);
let aOrtId = $state('');
let aVon = $state('');
let aBis = $state('');
let aGrund = $state('');
let ausfallSaving = $state(false);
let ausfallError = $state('');
const typLabel: Record<string, string> = {
halle: 'Halle', platz: 'Platz', gebaeude: 'Gebäude', sonstiges: 'Sonstiges',
};
onMount(async () => {
[orte, ausfaelle] = await Promise.all([
api.get<Veranstaltungsort[]>('/orte', { sort: 'name' }),
api.get<OrtAusfall[]>('/ort-ausfaelle', { sort: 'von' }),
]);
loading = false;
});
function neuerOrt() {
editOrtId = null; fName = ''; fAdresse = ''; fTyp = 'halle'; fAktiv = true;
ortError = ''; showOrtForm = true;
}
function bearbeitenOrt(o: Veranstaltungsort) {
editOrtId = o.id; fName = o.name; fAdresse = o.adresse ?? '';
fTyp = o.typ ?? 'halle'; fAktiv = o.aktiv;
ortError = ''; showOrtForm = true;
}
async function ortSpeichern() {
if (!fName.trim()) { ortError = 'Name ist Pflichtfeld.'; return; }
ortError = ''; ortSaving = true;
try {
const data = { name: fName.trim(), adresse: fAdresse.trim() || null, typ: fTyp, aktiv: fAktiv };
if (editOrtId) {
const u = await api.put<Veranstaltungsort>('/orte/' + editOrtId, data);
orte = orte.map(o => o.id === editOrtId ? u : o);
} else {
const n = await api.post<Veranstaltungsort>('/orte', data);
orte = [...orte, n].sort((a, b) => a.name.localeCompare(b.name));
}
showOrtForm = false;
} catch (e: unknown) {
ortError = e instanceof Error ? e.message : 'Fehler.';
} finally {
ortSaving = false;
}
}
async function ortLoeschen(id: string) {
if (!confirm('Ort wirklich löschen? Alle verknüpften Termine verlieren die Ortzuordnung.')) return;
await api.del('/orte/' + id);
orte = orte.filter(o => o.id !== id);
ausfaelle = ausfaelle.filter(a => a.ort_id !== id);
}
function ausfallErstellen(ortId: string) {
aOrtId = ortId; aVon = ''; aBis = ''; aGrund = '';
ausfallError = ''; showAusfallForm = true;
}
async function ausfallSpeichern() {
if (!aVon || !aBis) { ausfallError = 'Von und Bis sind Pflichtfelder.'; return; }
if (aVon > aBis) { ausfallError = 'Bis muss nach Von liegen.'; return; }
ausfallError = ''; ausfallSaving = true;
try {
const n = await api.post<OrtAusfall>('/ort-ausfaelle', {
ort_id: aOrtId, von: aVon, bis: aBis, grund: aGrund.trim() || null,
});
ausfaelle = [...ausfaelle, n].sort((a, b) => a.von.localeCompare(b.von));
showAusfallForm = false;
} catch (e: unknown) {
ausfallError = e instanceof Error ? e.message : 'Fehler.';
} finally {
ausfallSaving = false;
}
}
async function ausfallLoeschen(id: string) {
await api.del('/ort-ausfaelle/' + id);
ausfaelle = ausfaelle.filter(a => a.id !== id);
}
function ortName(id: string) {
return orte.find(o => o.id === id)?.name ?? '—';
}
function formatDatum(iso: string) {
return new Date(iso).toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric' });
}
// Ort ist aktuell gesperrt?
function istGesperrt(o: Veranstaltungsort): OrtAusfall | undefined {
const heute = new Date().toISOString().slice(0, 10);
return ausfaelle.find(a => a.ort_id === o.id && a.von <= heute && a.bis >= heute);
}
</script>
<svelte:head><title>Veranstaltungsorte — vereins.haus</title></svelte:head>
<div class="top">
<div>
<a class="back" href="/termine">← Termine</a>
<h1>Veranstaltungsorte</h1>
</div>
<button class="btn-primary" onclick={neuerOrt}>+ Ort</button>
</div>
{#if loading}
<p class="hint">Laden…</p>
{:else if orte.length === 0}
<p class="hint">Noch keine Orte angelegt.</p>
{:else}
<ul class="liste">
{#each orte as o (o.id)}
{@const gesperrt = istGesperrt(o)}
<li class="karte" class:inaktiv={!o.aktiv}>
<div class="ort-info">
<div class="ort-name-zeile">
<span class="ort-name">{o.name}</span>
{#if !o.aktiv}
<span class="badge grau">Inaktiv</span>
{:else if gesperrt}
<span class="badge rot">Gesperrt</span>
{:else}
<span class="badge gruen">Verfügbar</span>
{/if}
</div>
{#if o.adresse}<span class="ort-meta">{o.adresse}</span>{/if}
{#if o.typ}<span class="ort-meta">{typLabel[o.typ]}</span>{/if}
{#if gesperrt}
<span class="gesperrt-hinweis">
Gesperrt bis {formatDatum(gesperrt.bis)}{gesperrt.grund ? ' · ' + gesperrt.grund : ''}
</span>
{/if}
</div>
<div class="ort-aktionen">
<button class="btn-ausfall" onclick={() => ausfallErstellen(o.id)} title="Ausfall eintragen">Ausfall</button>
<button class="btn-icon" onclick={() => bearbeitenOrt(o)}>✎</button>
<button class="btn-icon btn-icon-red" onclick={() => ortLoeschen(o.id)}>✕</button>
</div>
</li>
{/each}
</ul>
{/if}
<!-- Ausfälle -->
{#if ausfaelle.length > 0}
<h2 class="section-title">Geplante Ausfälle</h2>
<ul class="liste">
{#each ausfaelle as a (a.id)}
<li class="ausfall-karte">
<div class="ausfall-info">
<span class="ausfall-ort">{ortName(a.ort_id)}</span>
<span class="ausfall-zeitraum">{formatDatum(a.von)} {formatDatum(a.bis)}</span>
{#if a.grund}<span class="ausfall-grund">{a.grund}</span>{/if}
</div>
<button class="btn-icon btn-icon-red" onclick={() => ausfallLoeschen(a.id)}>✕</button>
</li>
{/each}
</ul>
{/if}
<!-- Ort-Formular -->
{#if showOrtForm}
<div class="overlay" role="dialog" aria-modal="true">
<div class="sheet">
<h2>{editOrtId ? 'Ort bearbeiten' : 'Neuer Ort'}</h2>
<form onsubmit={(e) => { e.preventDefault(); ortSpeichern(); }}>
<div class="field">
<label for="fname">Name *</label>
<input id="fname" type="text" bind:value={fName} placeholder="z. B. Turnhalle Schillerschule" required />
</div>
<div class="field">
<label for="fadresse">Adresse</label>
<input id="fadresse" type="text" bind:value={fAdresse} placeholder="Musterstraße 1, 12345 Stadt" />
</div>
<div class="row">
<div class="field">
<label for="ftyp">Typ</label>
<select id="ftyp" bind:value={fTyp}>
<option value="halle">Halle</option>
<option value="platz">Platz</option>
<option value="gebaeude">Gebäude</option>
<option value="sonstiges">Sonstiges</option>
</select>
</div>
<div class="field" style="justify-content:flex-end;padding-bottom:0.85rem">
<label class="toggle-label">
<input type="checkbox" bind:checked={fAktiv} />
Aktiv
</label>
</div>
</div>
{#if ortError}<p class="error">{ortError}</p>{/if}
<div class="actions">
<button type="button" class="btn-ghost" onclick={() => showOrtForm = false}>Abbrechen</button>
<button type="submit" class="btn-primary" disabled={ortSaving}>{ortSaving ? 'Speichern…' : 'Speichern'}</button>
</div>
</form>
</div>
</div>
{/if}
<!-- Ausfall-Formular -->
{#if showAusfallForm}
<div class="overlay" role="dialog" aria-modal="true">
<div class="sheet">
<h2>Ausfall eintragen</h2>
<p class="sheet-sub">{ortName(aOrtId)}</p>
<form onsubmit={(e) => { e.preventDefault(); ausfallSpeichern(); }}>
<div class="row">
<div class="field">
<label for="avon">Von *</label>
<input id="avon" type="date" bind:value={aVon} required />
</div>
<div class="field">
<label for="abis">Bis *</label>
<input id="abis" type="date" bind:value={aBis} required />
</div>
</div>
<div class="field">
<label for="agrund">Grund</label>
<input id="agrund" type="text" bind:value={aGrund} placeholder="z. B. Schulveranstaltung, Wartung" />
</div>
{#if ausfallError}<p class="error">{ausfallError}</p>{/if}
<div class="actions">
<button type="button" class="btn-ghost" onclick={() => showAusfallForm = false}>Abbrechen</button>
<button type="submit" class="btn-primary" disabled={ausfallSaving}>{ausfallSaving ? 'Speichern…' : 'Speichern'}</button>
</div>
</form>
</div>
</div>
{/if}
<style>
.top { display: flex; align-items: flex-start; justify-content: space-between; margin-bottom: 1.25rem; }
.top .btn-primary { flex: none; padding: 0.45rem 0.9rem; font-size: 0.875rem; }
.back { font-size: 0.85rem; color: #1e40af; text-decoration: none; display: block; margin-bottom: 0.25rem; }
h1 { font-size: 1.4rem; font-weight: 700; color: #1e293b; }
.hint { color: #94a3b8; font-size: 0.95rem; text-align: center; margin-top: 3rem; }
.section-title { font-size: 0.72rem; font-weight: 700; color: #94a3b8; text-transform: uppercase; letter-spacing: 0.06em; margin: 1.25rem 0 0.5rem; }
.liste { list-style: none; padding: 0; display: flex; flex-direction: column; gap: 0.5rem; margin-bottom: 1rem; }
.karte {
display: flex; align-items: flex-start; gap: 0.75rem;
padding: 0.85rem 1rem; background: #fff;
border: 1px solid #e2e8f0; border-radius: 10px;
}
.karte.inaktiv { opacity: 0.55; }
.ort-info { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 0.2rem; }
.ort-name-zeile { display: flex; align-items: center; gap: 0.5rem; flex-wrap: wrap; }
.ort-name { font-weight: 600; font-size: 0.95rem; color: #1e293b; }
.ort-meta { font-size: 0.78rem; color: #64748b; }
.gesperrt-hinweis { font-size: 0.75rem; color: #dc2626; font-weight: 500; }
.badge { font-size: 0.7rem; font-weight: 700; padding: 0.1rem 0.45rem; border-radius: 20px; }
.badge.gruen { background: #dcfce7; color: #16a34a; }
.badge.rot { background: #fee2e2; color: #dc2626; }
.badge.grau { background: #f1f5f9; color: #64748b; }
.ort-aktionen { display: flex; gap: 0.35rem; flex-shrink: 0; align-items: flex-start; }
.btn-ausfall {
padding: 0.3rem 0.6rem; background: #fef9c3; border: 1px solid #fde047;
border-radius: 6px; font-size: 0.75rem; font-weight: 600; color: #854d0e; cursor: pointer;
}
.ausfall-karte {
display: flex; align-items: center; gap: 0.75rem;
padding: 0.7rem 1rem; background: #fff;
border: 1px solid #fca5a5; border-radius: 8px;
}
.ausfall-info { flex: 1; display: flex; flex-direction: column; gap: 0.1rem; }
.ausfall-ort { font-weight: 600; font-size: 0.88rem; color: #1e293b; }
.ausfall-zeitraum { font-size: 0.78rem; color: #dc2626; }
.ausfall-grund { font-size: 0.75rem; color: #94a3b8; }
.btn-icon {
width: 1.9rem; height: 1.9rem; display: flex; align-items: center;
justify-content: center; background: none; border: 1px solid #e2e8f0;
border-radius: 6px; color: #64748b; font-size: 0.85rem; cursor: pointer;
}
.btn-icon:hover { border-color: #94a3b8; color: #1e293b; }
.btn-icon-red:hover { border-color: #fca5a5; color: #dc2626; }
.overlay {
position: fixed; inset: 0; background: rgba(0,0,0,0.4);
display: flex; align-items: flex-end; justify-content: center;
z-index: 100; padding: 1rem; padding-bottom: calc(1rem + env(safe-area-inset-bottom));
}
.sheet {
background: #fff; border-radius: 16px; padding: 1.5rem;
width: 100%; max-width: 480px; max-height: 90dvh; overflow-y: auto;
}
h2 { font-size: 1.1rem; font-weight: 700; color: #1e293b; margin-bottom: 0.25rem; }
.sheet-sub { font-size: 0.85rem; color: #64748b; margin-bottom: 1rem; }
.row { display: grid; grid-template-columns: 1fr 1fr; gap: 0.75rem; }
.field { display: flex; flex-direction: column; gap: 0.3rem; margin-bottom: 0.85rem; }
label { font-size: 0.875rem; font-weight: 500; color: #475569; }
.toggle-label { display: flex; align-items: center; gap: 0.5rem; font-size: 0.9rem; cursor: pointer; }
input, select {
padding: 0.65rem 0.85rem; border: 1.5px solid #e2e8f0; border-radius: 8px;
font-size: 1rem; background: #fff; width: 100%; box-sizing: border-box;
transition: border-color 0.15s;
}
input:focus, select:focus { outline: none; border-color: #1e40af; }
.error { color: #dc2626; font-size: 0.875rem; margin-bottom: 0.75rem; }
.actions { display: flex; gap: 0.75rem; margin-top: 0.5rem; }
.btn-primary {
flex: 1; padding: 0.75rem; background: #1e40af; color: #fff;
border: none; border-radius: 8px; font-size: 1rem; font-weight: 600; cursor: pointer;
}
.btn-primary:hover:not(:disabled) { background: #1d3a9e; }
.btn-primary:disabled { opacity: 0.55; cursor: not-allowed; }
.btn-ghost {
padding: 0.75rem 1rem; background: none;
border: 1.5px solid #e2e8f0; border-radius: 8px;
font-size: 1rem; color: #64748b; cursor: pointer;
}
</style>