Veranstaltungsorte: Verwaltung, Ausfälle, Ort-Picker in Terminen, Warnhinweise
This commit is contained in:
parent
3ac17b2645
commit
b8e2a69912
4 changed files with 554 additions and 17 deletions
|
|
@ -53,6 +53,25 @@ export interface Gruppe {
|
||||||
trainer_ids: string[];
|
trainer_ids: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type OrtTyp = 'halle' | 'platz' | 'gebaeude' | 'sonstiges';
|
||||||
|
|
||||||
|
export interface Veranstaltungsort {
|
||||||
|
id: string;
|
||||||
|
verein_id: string;
|
||||||
|
name: string;
|
||||||
|
adresse?: string;
|
||||||
|
typ?: OrtTyp;
|
||||||
|
aktiv: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface OrtAusfall {
|
||||||
|
id: string;
|
||||||
|
ort_id: string;
|
||||||
|
von: string;
|
||||||
|
bis: string;
|
||||||
|
grund?: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface Einladung {
|
export interface Einladung {
|
||||||
id: string;
|
id: string;
|
||||||
verein_id: string;
|
verein_id: string;
|
||||||
|
|
@ -92,6 +111,7 @@ export interface Termin {
|
||||||
verfuegbarkeit?: Verfuegbarkeit;
|
verfuegbarkeit?: Verfuegbarkeit;
|
||||||
rrule?: string;
|
rrule?: string;
|
||||||
serie_id?: string;
|
serie_id?: string;
|
||||||
|
ort_id?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Nachricht {
|
export interface Nachricht {
|
||||||
|
|
|
||||||
346
app/src/routes/(app)/orte/+page.svelte
Normal file
346
app/src/routes/(app)/orte/+page.svelte
Normal file
|
|
@ -0,0 +1,346 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import { pb } from '$lib/pb';
|
||||||
|
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 () => {
|
||||||
|
const vid = pb.authStore.record?.verein_id as string;
|
||||||
|
[orte, ausfaelle] = await Promise.all([
|
||||||
|
pb.collection('veranstaltungsorte').getFullList<Veranstaltungsort>({ sort: 'name', filter: `verein_id = "${vid}"` }),
|
||||||
|
pb.collection('ort_ausfaelle').getFullList<OrtAusfall>({ 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 vid = pb.authStore.record?.verein_id as string;
|
||||||
|
const data = { verein_id: vid, name: fName.trim(), adresse: fAdresse.trim() || null, typ: fTyp, aktiv: fAktiv };
|
||||||
|
if (editOrtId) {
|
||||||
|
const u = await pb.collection('veranstaltungsorte').update<Veranstaltungsort>(editOrtId, data);
|
||||||
|
orte = orte.map(o => o.id === editOrtId ? u : o);
|
||||||
|
} else {
|
||||||
|
const n = await pb.collection('veranstaltungsorte').create<Veranstaltungsort>(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 pb.collection('veranstaltungsorte').delete(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 pb.collection('ort_ausfaelle').create<OrtAusfall>({
|
||||||
|
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 pb.collection('ort_ausfaelle').delete(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; }
|
||||||
|
.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>
|
||||||
|
|
@ -2,15 +2,17 @@
|
||||||
import { pb } from '$lib/pb';
|
import { pb } from '$lib/pb';
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
import { RRule } from 'rrule';
|
import { RRule } from 'rrule';
|
||||||
import type { Termin, Gruppe, Verfuegbarkeit } from '$lib/types';
|
import type { Termin, Gruppe, Verfuegbarkeit, Veranstaltungsort, OrtAusfall } from '$lib/types';
|
||||||
|
|
||||||
const isAdmin = () => !pb.authStore.record?.rolle || pb.authStore.record?.rolle === 'admin';
|
const isAdmin = () => !pb.authStore.record?.rolle || pb.authStore.record?.rolle === 'admin';
|
||||||
const userId = () => pb.authStore.record?.id as string;
|
const userId = () => pb.authStore.record?.id as string;
|
||||||
|
|
||||||
let termine = $state<Termin[]>([]);
|
let termine = $state<Termin[]>([]);
|
||||||
let gruppen = $state<Gruppe[]>([]);
|
let gruppen = $state<Gruppe[]>([]);
|
||||||
let alleUser = $state<any[]>([]);
|
let alleUser = $state<any[]>([]);
|
||||||
let loading = $state(true);
|
let orte = $state<Veranstaltungsort[]>([]);
|
||||||
|
let ausfaelle = $state<OrtAusfall[]>([]);
|
||||||
|
let loading = $state(true);
|
||||||
|
|
||||||
// Formular
|
// Formular
|
||||||
let showForm = $state(false);
|
let showForm = $state(false);
|
||||||
|
|
@ -22,6 +24,7 @@
|
||||||
let fOrt = $state('');
|
let fOrt = $state('');
|
||||||
let fGruppeIds = $state<string[]>([]);
|
let fGruppeIds = $state<string[]>([]);
|
||||||
let fDurchfuehrenderId = $state('');
|
let fDurchfuehrenderId = $state('');
|
||||||
|
let fOrtId = $state('');
|
||||||
let fWiederholung = $state(false);
|
let fWiederholung = $state(false);
|
||||||
let fRhythmus = $state<'woechentlich' | 'zweıwoechentlich' | 'monatlich'>('woechentlich');
|
let fRhythmus = $state<'woechentlich' | 'zweıwoechentlich' | 'monatlich'>('woechentlich');
|
||||||
let fBis = $state('');
|
let fBis = $state('');
|
||||||
|
|
@ -50,12 +53,14 @@
|
||||||
|
|
||||||
onMount(async () => {
|
onMount(async () => {
|
||||||
const vid = pb.authStore.record?.verein_id as string;
|
const vid = pb.authStore.record?.verein_id as string;
|
||||||
[termine, gruppen, alleUser] = await Promise.all([
|
[termine, gruppen, alleUser, orte, ausfaelle] = await Promise.all([
|
||||||
pb.collection('termine').getFullList<Termin>({ sort: 'beginn' }),
|
pb.collection('termine').getFullList<Termin>({ sort: 'beginn' }),
|
||||||
pb.collection('gruppen').getFullList<Gruppe>({ sort: 'name' }),
|
pb.collection('gruppen').getFullList<Gruppe>({ sort: 'name' }),
|
||||||
isAdmin()
|
isAdmin()
|
||||||
? pb.collection('users').getFullList({ filter: `verein_id = "${vid}" && rolle = "trainer"` })
|
? pb.collection('users').getFullList({ filter: `verein_id = "${vid}" && rolle = "trainer"` })
|
||||||
: Promise.resolve([]),
|
: Promise.resolve([]),
|
||||||
|
pb.collection('veranstaltungsorte').getFullList<Veranstaltungsort>({ sort: 'name', filter: `verein_id = "${vid}" && aktiv = true` }),
|
||||||
|
pb.collection('ort_ausfaelle').getFullList<OrtAusfall>({ sort: 'von' }),
|
||||||
]);
|
]);
|
||||||
loading = false;
|
loading = false;
|
||||||
});
|
});
|
||||||
|
|
@ -71,7 +76,7 @@
|
||||||
|
|
||||||
function neuerTermin() {
|
function neuerTermin() {
|
||||||
editId = null; fTitel = ''; fBeschr = ''; fBeginn = ''; fEnde = '';
|
editId = null; fTitel = ''; fBeschr = ''; fBeginn = ''; fEnde = '';
|
||||||
fOrt = ''; fGruppeIds = []; fDurchfuehrenderId = '';
|
fOrt = ''; fOrtId = ''; fGruppeIds = []; fDurchfuehrenderId = '';
|
||||||
fWiederholung = false; fRhythmus = 'woechentlich'; fBis = '';
|
fWiederholung = false; fRhythmus = 'woechentlich'; fBis = '';
|
||||||
formError = ''; showForm = true;
|
formError = ''; showForm = true;
|
||||||
}
|
}
|
||||||
|
|
@ -79,19 +84,32 @@
|
||||||
function bearbeiten(t: Termin) {
|
function bearbeiten(t: Termin) {
|
||||||
editId = t.id; fTitel = t.titel; fBeschr = t.beschreibung ?? '';
|
editId = t.id; fTitel = t.titel; fBeschr = t.beschreibung ?? '';
|
||||||
fBeginn = toLocal(t.beginn); fEnde = toLocal(t.ende);
|
fBeginn = toLocal(t.beginn); fEnde = toLocal(t.ende);
|
||||||
fOrt = t.ort ?? ''; fGruppeIds = t.gruppe_ids ?? [];
|
fOrt = t.ort ?? ''; fOrtId = t.ort_id ?? ''; fGruppeIds = t.gruppe_ids ?? [];
|
||||||
fDurchfuehrenderId = t.durchfuehrender_id ?? '';
|
fDurchfuehrenderId = t.durchfuehrender_id ?? '';
|
||||||
fWiederholung = false; // Einzeltermin bearbeiten, nie die ganze Serie
|
fWiederholung = false;
|
||||||
formError = ''; showForm = true;
|
formError = ''; showForm = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Ausfall-Check für einen Termin
|
||||||
|
function ortAusfall(t: Termin): OrtAusfall | undefined {
|
||||||
|
if (!t.ort_id) return undefined;
|
||||||
|
const d = t.beginn.slice(0, 10);
|
||||||
|
return ausfaelle.find(a => a.ort_id === t.ort_id && a.von <= d && a.bis >= d);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ortNameById(id: string | undefined): string {
|
||||||
|
if (!id) return '';
|
||||||
|
return orte.find(o => o.id === id)?.name ?? '';
|
||||||
|
}
|
||||||
|
|
||||||
function generiereTerminDaten(beginn: Date, rruleStr: string | null) {
|
function generiereTerminDaten(beginn: Date, rruleStr: string | null) {
|
||||||
const vid = pb.authStore.record?.verein_id as string;
|
const vid = pb.authStore.record?.verein_id as string;
|
||||||
return {
|
return {
|
||||||
verein_id: vid,
|
verein_id: vid,
|
||||||
titel: fTitel.trim(),
|
titel: fTitel.trim(),
|
||||||
beschreibung: fBeschr.trim() || null,
|
beschreibung: fBeschr.trim() || null,
|
||||||
ort: fOrt.trim() || null,
|
ort: fOrtId ? null : (fOrt.trim() || null),
|
||||||
|
ort_id: fOrtId || null,
|
||||||
gruppe_ids: fGruppeIds,
|
gruppe_ids: fGruppeIds,
|
||||||
durchfuehrender_id: fDurchfuehrenderId || null,
|
durchfuehrender_id: fDurchfuehrenderId || null,
|
||||||
verfuegbarkeit: fDurchfuehrenderId ? 'offen' : null,
|
verfuegbarkeit: fDurchfuehrenderId ? 'offen' : null,
|
||||||
|
|
@ -220,9 +238,12 @@
|
||||||
|
|
||||||
<div class="top">
|
<div class="top">
|
||||||
<h1>Termine</h1>
|
<h1>Termine</h1>
|
||||||
{#if isAdmin()}
|
<div class="top-rechts">
|
||||||
<button class="btn-primary" onclick={neuerTermin}>+ Termin</button>
|
{#if isAdmin()}
|
||||||
{/if}
|
<a href="/orte" class="btn-orte">Orte</a>
|
||||||
|
<button class="btn-primary" onclick={neuerTermin}>+ Termin</button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if isAdmin() && offene.length > 0}
|
{#if isAdmin() && offene.length > 0}
|
||||||
|
|
@ -254,9 +275,16 @@
|
||||||
<span class="karte-meta">
|
<span class="karte-meta">
|
||||||
{formatZeit(t.beginn)}{t.ende ? ' – ' + formatZeit(t.ende) : ''}{t.ort ? ' · ' + t.ort : ''}
|
{formatZeit(t.beginn)}{t.ende ? ' – ' + formatZeit(t.ende) : ''}{t.ort ? ' · ' + t.ort : ''}
|
||||||
</span>
|
</span>
|
||||||
|
{#if t.ort_id || t.ort}
|
||||||
|
<span class="karte-meta">{ortNameById(t.ort_id) || t.ort}</span>
|
||||||
|
{/if}
|
||||||
{#if t.gruppe_ids?.length}
|
{#if t.gruppe_ids?.length}
|
||||||
<span class="karte-sub">{gruppenLabel(t.gruppe_ids)}</span>
|
<span class="karte-sub">{gruppenLabel(t.gruppe_ids)}</span>
|
||||||
{/if}
|
{/if}
|
||||||
|
{#if ortAusfall(t)}
|
||||||
|
{@const af = ortAusfall(t)!}
|
||||||
|
<span class="ausfall-warn">⚠ Ort gesperrt{af.grund ? ': ' + af.grund : ''}</span>
|
||||||
|
{/if}
|
||||||
|
|
||||||
{#if t.durchfuehrender_id}
|
{#if t.durchfuehrender_id}
|
||||||
<div class="verfueg-zeile">
|
<div class="verfueg-zeile">
|
||||||
|
|
@ -352,10 +380,28 @@
|
||||||
<input id="fende" type="datetime-local" bind:value={fEnde} />
|
<input id="fende" type="datetime-local" bind:value={fEnde} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="field">
|
{#if orte.length > 0}
|
||||||
<label for="fort">Ort</label>
|
<div class="field">
|
||||||
<input id="fort" type="text" bind:value={fOrt} />
|
<label for="fort-id">Ort</label>
|
||||||
</div>
|
<select id="fort-id" bind:value={fOrtId}>
|
||||||
|
<option value="">— Freitext —</option>
|
||||||
|
{#each orte as o (o.id)}
|
||||||
|
<option value={o.id}>{o.name}</option>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
{#if !fOrtId}
|
||||||
|
<div class="field">
|
||||||
|
<label for="fort">Ort (Freitext)</label>
|
||||||
|
<input id="fort" type="text" bind:value={fOrt} placeholder="z. B. Vereinsheim" />
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{:else}
|
||||||
|
<div class="field">
|
||||||
|
<label for="fort">Ort <a class="ort-link" href="/orte">Orte verwalten →</a></label>
|
||||||
|
<input id="fort" type="text" bind:value={fOrt} />
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label for="fbeschr">Beschreibung</label>
|
<label for="fbeschr">Beschreibung</label>
|
||||||
<textarea id="fbeschr" bind:value={fBeschr} rows="2"></textarea>
|
<textarea id="fbeschr" bind:value={fBeschr} rows="2"></textarea>
|
||||||
|
|
@ -451,7 +497,15 @@
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
.top { display: flex; align-items: center; justify-content: space-between; margin-bottom: 1rem; }
|
.top { display: flex; align-items: center; justify-content: space-between; margin-bottom: 1rem; }
|
||||||
|
.top-rechts { display: flex; align-items: center; gap: 0.5rem; }
|
||||||
h1 { font-size: 1.4rem; font-weight: 700; color: #1e293b; }
|
h1 { font-size: 1.4rem; font-weight: 700; color: #1e293b; }
|
||||||
|
.btn-orte {
|
||||||
|
padding: 0.5rem 0.85rem; background: none;
|
||||||
|
border: 1.5px solid #e2e8f0; border-radius: 8px;
|
||||||
|
font-size: 0.85rem; color: #475569; text-decoration: none;
|
||||||
|
}
|
||||||
|
.ausfall-warn { font-size: 0.75rem; color: #dc2626; font-weight: 600; }
|
||||||
|
.ort-link { font-size: 0.75rem; color: #1e40af; margin-left: 0.5rem; }
|
||||||
.hint { color: #94a3b8; font-size: 0.95rem; text-align: center; margin-top: 3rem; }
|
.hint { color: #94a3b8; font-size: 0.95rem; text-align: center; margin-top: 3rem; }
|
||||||
|
|
||||||
.warnung {
|
.warnung {
|
||||||
|
|
|
||||||
117
pocketbase/pb_migrations/1779230800_veranstaltungsorte.js
Normal file
117
pocketbase/pb_migrations/1779230800_veranstaltungsorte.js
Normal file
|
|
@ -0,0 +1,117 @@
|
||||||
|
/// <reference path="../pb_data/types.d.ts" />
|
||||||
|
migrate((app) => {
|
||||||
|
|
||||||
|
// Veranstaltungsorte
|
||||||
|
{
|
||||||
|
const c = new Collection({
|
||||||
|
"createRule": "@request.auth.verein_id = verein_id",
|
||||||
|
"deleteRule": "@request.auth.verein_id = verein_id",
|
||||||
|
"listRule": "@request.auth.verein_id = verein_id",
|
||||||
|
"viewRule": "@request.auth.verein_id = verein_id",
|
||||||
|
"updateRule": "@request.auth.verein_id = verein_id",
|
||||||
|
"fields": [
|
||||||
|
{
|
||||||
|
"autogeneratePattern": "[a-z0-9]{15}", "id": "text3208210256",
|
||||||
|
"max": 15, "min": 15, "name": "id", "pattern": "^[a-z0-9]+$",
|
||||||
|
"primaryKey": true, "required": true, "system": true, "type": "text",
|
||||||
|
"help": "", "hidden": false, "presentable": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "relation", "id": "relation2001000100", "name": "verein_id",
|
||||||
|
"help": "", "hidden": false, "presentable": false, "required": true, "system": false,
|
||||||
|
"cascadeDelete": true, "collectionId": "pbc_3589557411", "maxSelect": 1, "minSelect": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "text", "id": "text2001000101", "name": "name",
|
||||||
|
"help": "", "hidden": false, "presentable": true, "required": true, "system": false,
|
||||||
|
"autogeneratePattern": "", "min": 0, "max": 0, "pattern": ""
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "text", "id": "text2001000102", "name": "adresse",
|
||||||
|
"help": "", "hidden": false, "presentable": false, "required": false, "system": false,
|
||||||
|
"autogeneratePattern": "", "min": 0, "max": 0, "pattern": ""
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "select", "id": "select2001000103", "name": "typ",
|
||||||
|
"help": "", "hidden": false, "presentable": false, "required": false, "system": false,
|
||||||
|
"maxSelect": 1, "values": ["halle", "platz", "gebaeude", "sonstiges"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "bool", "id": "bool2001000104", "name": "aktiv",
|
||||||
|
"help": "", "hidden": false, "presentable": false, "required": false, "system": false
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"id": "pbc_veranstaltungsorte",
|
||||||
|
"indexes": [],
|
||||||
|
"name": "veranstaltungsorte",
|
||||||
|
"system": false,
|
||||||
|
"type": "base"
|
||||||
|
})
|
||||||
|
app.save(c)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ort-Ausfälle
|
||||||
|
{
|
||||||
|
const c = new Collection({
|
||||||
|
"createRule": "@request.auth.verein_id = ort_id.verein_id",
|
||||||
|
"deleteRule": "@request.auth.verein_id = ort_id.verein_id",
|
||||||
|
"listRule": "@request.auth.verein_id = ort_id.verein_id",
|
||||||
|
"viewRule": "@request.auth.verein_id = ort_id.verein_id",
|
||||||
|
"updateRule": "@request.auth.verein_id = ort_id.verein_id",
|
||||||
|
"fields": [
|
||||||
|
{
|
||||||
|
"autogeneratePattern": "[a-z0-9]{15}", "id": "text3208210256",
|
||||||
|
"max": 15, "min": 15, "name": "id", "pattern": "^[a-z0-9]+$",
|
||||||
|
"primaryKey": true, "required": true, "system": true, "type": "text",
|
||||||
|
"help": "", "hidden": false, "presentable": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "relation", "id": "relation2001000110", "name": "ort_id",
|
||||||
|
"help": "", "hidden": false, "presentable": false, "required": true, "system": false,
|
||||||
|
"cascadeDelete": true, "collectionId": "pbc_veranstaltungsorte", "maxSelect": 1, "minSelect": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "date", "id": "date2001000111", "name": "von",
|
||||||
|
"help": "", "hidden": false, "presentable": false, "required": true, "system": false,
|
||||||
|
"min": "", "max": ""
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "date", "id": "date2001000112", "name": "bis",
|
||||||
|
"help": "", "hidden": false, "presentable": false, "required": true, "system": false,
|
||||||
|
"min": "", "max": ""
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "text", "id": "text2001000113", "name": "grund",
|
||||||
|
"help": "", "hidden": false, "presentable": false, "required": false, "system": false,
|
||||||
|
"autogeneratePattern": "", "min": 0, "max": 0, "pattern": ""
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"id": "pbc_ort_ausfaelle",
|
||||||
|
"indexes": [],
|
||||||
|
"name": "ort_ausfaelle",
|
||||||
|
"system": false,
|
||||||
|
"type": "base"
|
||||||
|
})
|
||||||
|
app.save(c)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Termine: +ort_id (Relation zu veranstaltungsorte, optional)
|
||||||
|
{
|
||||||
|
const c = app.findCollectionByNameOrId("pbc_2279568741")
|
||||||
|
c.fields.addAt(99, new Field({
|
||||||
|
"type": "relation", "id": "relation2001000120", "name": "ort_id",
|
||||||
|
"help": "", "hidden": false, "presentable": false, "required": false, "system": false,
|
||||||
|
"cascadeDelete": false, "collectionId": "pbc_veranstaltungsorte", "maxSelect": 1, "minSelect": 0
|
||||||
|
}))
|
||||||
|
app.save(c)
|
||||||
|
}
|
||||||
|
|
||||||
|
}, (app) => {
|
||||||
|
{
|
||||||
|
const c = app.findCollectionByNameOrId("pbc_2279568741")
|
||||||
|
c.fields.removeById("relation2001000120")
|
||||||
|
app.save(c)
|
||||||
|
}
|
||||||
|
try { app.delete(app.findCollectionByNameOrId("pbc_ort_ausfaelle")) } catch(_) {}
|
||||||
|
try { app.delete(app.findCollectionByNameOrId("pbc_veranstaltungsorte")) } catch(_) {}
|
||||||
|
})
|
||||||
Loading…
Add table
Add a link
Reference in a new issue