Durchführender: Verfügbarkeit pro Termin, Umbenennung Trainer→Durchführender

This commit is contained in:
rene 2026-05-20 19:43:46 +02:00
parent 59aa3cbcce
commit c23ac90d35
4 changed files with 213 additions and 117 deletions

View file

@ -43,6 +43,7 @@ export interface Mitglied {
}
export type Rolle = 'admin' | 'trainer';
export type Verfuegbarkeit = 'offen' | 'bestaetigt' | 'abgesagt' | 'vertretung_gesucht';
export interface Gruppe {
id: string;
@ -87,6 +88,8 @@ export interface Termin {
ende?: string;
ort?: string;
gruppe_ids: string[];
durchfuehrender_id?: string;
verfuegbarkeit?: Verfuegbarkeit;
}
export interface Nachricht {

View file

@ -208,7 +208,7 @@
{#if isAdmin()}
<section>
<h2>Trainer</h2>
<h2>Durchführende</h2>
{#if trainer.length === 0}
<p class="sepa-hint">Noch keine Trainer eingeladen.</p>
{:else}

View file

@ -1,25 +1,29 @@
<script lang="ts">
import { pb } from '$lib/pb';
import { onMount } from 'svelte';
import type { Termin, Gruppe } from '$lib/types';
import type { Termin, Gruppe, Verfuegbarkeit } from '$lib/types';
const isAdmin = () => !pb.authStore.record?.rolle || pb.authStore.record?.rolle === 'admin';
const userId = () => pb.authStore.record?.id as string;
let termine = $state<Termin[]>([]);
let gruppen = $state<Gruppe[]>([]);
let alleUser = $state<any[]>([]);
let loading = $state(true);
// Formular
let showForm = $state(false);
let editId = $state<string | null>(null);
let fTitel = $state('');
let fBeschr = $state('');
let fBeginn = $state('');
let fEnde = $state('');
let fOrt = $state('');
let fGruppeIds = $state<string[]>([]);
let saving = $state(false);
let formError = $state('');
let showDelete = $state<string | null>(null);
let showForm = $state(false);
let editId = $state<string | null>(null);
let fTitel = $state('');
let fBeschr = $state('');
let fBeginn = $state('');
let fEnde = $state('');
let fOrt = $state('');
let fGruppeIds = $state<string[]>([]);
let fDurchfuehrenderId = $state('');
let saving = $state(false);
let formError = $state('');
let showDelete = $state<string | null>(null);
const now = new Date();
@ -34,10 +38,19 @@
.sort((a, b) => new Date(b.beginn).getTime() - new Date(a.beginn).getTime())
);
// Admin-Warnung: Termine ohne Bestätigung
const offene = $derived(
upcoming.filter(t => !t.verfuegbarkeit || t.verfuegbarkeit === 'offen' || t.verfuegbarkeit === 'vertretung_gesucht')
);
onMount(async () => {
[termine, gruppen] = await Promise.all([
const vid = pb.authStore.record?.verein_id as string;
[termine, gruppen, alleUser] = await Promise.all([
pb.collection('termine').getFullList<Termin>({ sort: 'beginn' }),
pb.collection('gruppen').getFullList<Gruppe>({ sort: 'name' }),
isAdmin()
? pb.collection('users').getFullList({ filter: `verein_id = "${vid}" && rolle = "trainer"` })
: Promise.resolve([]),
]);
loading = false;
});
@ -45,47 +58,38 @@
function toLocal(iso: string | undefined): string {
if (!iso) return '';
const d = new Date(iso);
// datetime-local format: YYYY-MM-DDTHH:MM
return new Date(d.getTime() - d.getTimezoneOffset() * 60000).toISOString().slice(0, 16);
}
function fromLocal(local: string): string {
return local ? new Date(local).toISOString() : '';
}
function neuerTermin() {
editId = null;
fTitel = ''; fBeschr = ''; fBeginn = ''; fEnde = ''; fOrt = ''; fGruppeIds = [];
editId = null; fTitel = ''; fBeschr = ''; fBeginn = ''; fEnde = '';
fOrt = ''; fGruppeIds = []; fDurchfuehrenderId = '';
formError = ''; showForm = true;
}
function bearbeiten(t: Termin) {
editId = t.id;
fTitel = t.titel;
fBeschr = t.beschreibung ?? '';
fBeginn = toLocal(t.beginn);
fEnde = toLocal(t.ende);
fOrt = t.ort ?? '';
fGruppeIds = t.gruppe_ids ?? [];
editId = t.id; fTitel = t.titel; fBeschr = t.beschreibung ?? '';
fBeginn = toLocal(t.beginn); fEnde = toLocal(t.ende);
fOrt = t.ort ?? ''; fGruppeIds = t.gruppe_ids ?? [];
fDurchfuehrenderId = t.durchfuehrender_id ?? '';
formError = ''; showForm = true;
}
async function speichern() {
if (!fTitel.trim() || !fBeginn) {
formError = 'Titel und Beginn sind Pflichtfelder.';
return;
}
if (!fTitel.trim() || !fBeginn) { formError = 'Titel und Beginn sind Pflichtfelder.'; return; }
formError = ''; saving = true;
try {
const vid = pb.authStore.record?.verein_id as string;
const data = {
verein_id: vid,
titel: fTitel.trim(),
verein_id: vid, titel: fTitel.trim(),
beschreibung: fBeschr.trim() || null,
beginn: fromLocal(fBeginn),
ende: fEnde ? fromLocal(fEnde) : null,
ort: fOrt.trim() || null,
gruppe_ids: fGruppeIds,
beginn: fromLocal(fBeginn), ende: fEnde ? fromLocal(fEnde) : null,
ort: fOrt.trim() || null, gruppe_ids: fGruppeIds,
durchfuehrender_id: fDurchfuehrenderId || null,
verfuegbarkeit: fDurchfuehrenderId ? 'offen' : null,
};
if (editId) {
const updated = await pb.collection('termine').update<Termin>(editId, data);
@ -108,6 +112,11 @@
showDelete = null;
}
async function setVerfuegbarkeit(t: Termin, v: Verfuegbarkeit) {
const updated = await pb.collection('termine').update<Termin>(t.id, { verfuegbarkeit: v });
termine = termine.map(x => x.id === t.id ? updated : x);
}
function toggleGruppe(id: string) {
fGruppeIds = fGruppeIds.includes(id)
? fGruppeIds.filter(g => g !== id)
@ -119,14 +128,27 @@
weekday: 'short', day: '2-digit', month: 'long', year: 'numeric',
});
}
function formatZeit(iso: string): string {
return new Date(iso).toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' });
}
function gruppenLabel(ids: string[]): string {
if (!ids?.length) return '';
return ids.map(id => gruppen.find(g => g.id === id)?.name).filter(Boolean).join(', ');
return (ids ?? []).map(id => gruppen.find(g => g.id === id)?.name).filter(Boolean).join(', ');
}
function userName(uid: string | undefined): string {
if (!uid) return '';
const u = alleUser.find((u: any) => u.id === uid);
return u?.name ?? '';
}
const verfuegbarkeitConfig: Record<Verfuegbarkeit, { label: string; farbe: string }> = {
offen: { label: 'Offen', farbe: '#f59e0b' },
bestaetigt: { label: 'Bestätigt', farbe: '#16a34a' },
abgesagt: { label: 'Abgesagt', farbe: '#dc2626' },
vertretung_gesucht: { label: 'Vertretung gesucht', farbe: '#7c3aed' },
};
function istMeinTermin(t: Termin): boolean {
return t.durchfuehrender_id === userId();
}
</script>
@ -134,13 +156,21 @@
<div class="top">
<h1>Termine</h1>
<button class="btn-primary" onclick={neuerTermin}>+ Termin</button>
{#if isAdmin()}
<button class="btn-primary" onclick={neuerTermin}>+ Termin</button>
{/if}
</div>
{#if isAdmin() && offene.length > 0}
<div class="warnung">
{offene.length} {offene.length === 1 ? 'Termin benötigt' : 'Termine benötigen'} eine Bestätigung
</div>
{/if}
{#if loading}
<p class="hint">Laden…</p>
{:else if termine.length === 0}
<p class="hint">Noch keine Termine lege den ersten an!</p>
<p class="hint">Noch keine Termine geplant.</p>
{:else}
{#if upcoming.length > 0}
<ul class="liste">
@ -151,19 +181,58 @@
<span class="tag-zahl">{new Date(t.beginn).getDate()}</span>
<span class="monat">{new Date(t.beginn).toLocaleDateString('de-DE', { month: 'short' })}</span>
</div>
<div class="karte-info">
<span class="karte-titel">{t.titel}</span>
<span class="karte-meta">
{formatZeit(t.beginn)}{t.ende ? ' ' + formatZeit(t.ende) : ''}{t.ort ? ' · ' + t.ort : ''}
</span>
{#if t.gruppe_ids?.length}
<span class="karte-gruppen">{gruppenLabel(t.gruppe_ids)}</span>
<span class="karte-sub">{gruppenLabel(t.gruppe_ids)}</span>
{/if}
{#if t.durchfuehrender_id}
<div class="verfueg-zeile">
{#if t.verfuegbarkeit && t.verfuegbarkeit !== 'offen'}
<span class="verfueg-badge" style="color:{verfuegbarkeitConfig[t.verfuegbarkeit].farbe}">
{verfuegbarkeitConfig[t.verfuegbarkeit].label}
</span>
{:else}
<span class="verfueg-badge" style="color:#f59e0b">● Offen</span>
{/if}
{#if isAdmin() && userName(t.durchfuehrender_id)}
<span class="karte-sub">{userName(t.durchfuehrender_id)}</span>
{/if}
</div>
{#if istMeinTermin(t)}
<div class="meine-aktionen">
<button
class="btn-aktion bestaetigen"
class:aktiv={t.verfuegbarkeit === 'bestaetigt'}
onclick={() => setVerfuegbarkeit(t, 'bestaetigt')}
>Ich bin dabei</button>
<button
class="btn-aktion absagen"
class:aktiv={t.verfuegbarkeit === 'abgesagt'}
onclick={() => setVerfuegbarkeit(t, 'abgesagt')}
>Kann nicht</button>
<button
class="btn-aktion vertretung"
class:aktiv={t.verfuegbarkeit === 'vertretung_gesucht'}
onclick={() => setVerfuegbarkeit(t, 'vertretung_gesucht')}
>Vertretung gesucht</button>
</div>
{/if}
{/if}
</div>
<div class="karte-aktionen">
<button class="btn-icon" onclick={() => bearbeiten(t)} title="Bearbeiten"></button>
<button class="btn-icon btn-icon-red" onclick={() => showDelete = t.id} title="Löschen"></button>
</div>
{#if isAdmin()}
<div class="karte-aktionen">
<button class="btn-icon" onclick={() => bearbeiten(t)} title="Bearbeiten"></button>
<button class="btn-icon btn-icon-red" onclick={() => showDelete = t.id} title="Löschen"></button>
</div>
{/if}
</li>
{/each}
</ul>
@ -184,9 +253,11 @@
<span class="karte-titel">{t.titel}</span>
<span class="karte-meta">{formatDatum(t.beginn)}{t.ort ? ' · ' + t.ort : ''}</span>
</div>
<div class="karte-aktionen">
<button class="btn-icon btn-icon-red" onclick={() => showDelete = t.id} title="Löschen"></button>
</div>
{#if isAdmin()}
<div class="karte-aktionen">
<button class="btn-icon btn-icon-red" onclick={() => showDelete = t.id} title="Löschen"></button>
</div>
{/if}
</li>
{/each}
</ul>
@ -194,18 +265,16 @@
{/if}
{/if}
<!-- Termin-Formular -->
{#if showForm}
<!-- Termin-Formular (nur Admin) -->
{#if showForm && isAdmin()}
<div class="overlay" role="dialog" aria-modal="true">
<div class="sheet">
<h2>{editId ? 'Termin bearbeiten' : 'Neuer Termin'}</h2>
<form onsubmit={(e) => { e.preventDefault(); speichern(); }}>
<div class="field">
<label for="ftitel">Titel *</label>
<input id="ftitel" type="text" bind:value={fTitel} placeholder="z. B. Jahreshauptversammlung" required />
<input id="ftitel" type="text" bind:value={fTitel} required />
</div>
<div class="row">
<div class="field">
<label for="fbeginn">Beginn *</label>
@ -216,17 +285,23 @@
<input id="fende" type="datetime-local" bind:value={fEnde} />
</div>
</div>
<div class="field">
<label for="fort">Ort</label>
<input id="fort" type="text" bind:value={fOrt} placeholder="z. B. Vereinsheim" />
<input id="fort" type="text" bind:value={fOrt} />
</div>
<div class="field">
<label for="fbeschr">Beschreibung</label>
<textarea id="fbeschr" bind:value={fBeschr} rows="3" placeholder="Optional"></textarea>
<textarea id="fbeschr" bind:value={fBeschr} rows="2"></textarea>
</div>
<div class="field">
<label for="fdurch">Durchführender</label>
<select id="fdurch" bind:value={fDurchfuehrenderId}>
<option value="">— nicht zugewiesen —</option>
{#each alleUser as u (u.id)}
<option value={u.id}>{u.name}</option>
{/each}
</select>
</div>
{#if gruppen.length > 0}
<div class="field">
<span class="field-label">Für Gruppen</span>
@ -240,23 +315,17 @@
</div>
</div>
{/if}
{#if formError}
<p class="error">{formError}</p>
{/if}
{#if formError}<p class="error">{formError}</p>{/if}
<div class="actions">
<button type="button" class="btn-ghost" onclick={() => showForm = false}>Abbrechen</button>
<button type="submit" class="btn-primary" disabled={saving}>
{saving ? 'Speichern…' : 'Speichern'}
</button>
<button type="submit" class="btn-primary" disabled={saving}>{saving ? 'Speichern…' : 'Speichern'}</button>
</div>
</form>
</div>
</div>
{/if}
<!-- Lösch-Bestätigung -->
<!-- Lösch-Dialog -->
{#if showDelete}
<div class="overlay" role="dialog" aria-modal="true">
<div class="dialog">
@ -271,62 +340,75 @@
{/if}
<style>
.top { display: flex; align-items: center; justify-content: space-between; margin-bottom: 1.25rem; }
.top { display: flex; align-items: center; justify-content: space-between; margin-bottom: 1rem; }
h1 { font-size: 1.4rem; font-weight: 700; color: #1e293b; }
.hint { color: #94a3b8; font-size: 0.95rem; text-align: center; margin-top: 3rem; }
.warnung {
background: #fffbeb; border: 1px solid #fde68a;
border-radius: 8px; padding: 0.65rem 0.9rem;
font-size: 0.85rem; color: #92400e; margin-bottom: 1rem;
}
.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;
display: flex; align-items: flex-start; gap: 0.75rem;
padding: 0.85rem 1rem; background: #fff;
border: 1px solid #e2e8f0; border-radius: 10px;
}
.karte-grau { background: #f8fafc; opacity: 0.75; }
.karte-datum-col {
display: flex;
flex-direction: column;
align-items: center;
min-width: 2.2rem;
padding-top: 0.1rem;
display: flex; flex-direction: column; align-items: center;
min-width: 2.2rem; padding-top: 0.1rem;
}
.tag { font-size: 0.65rem; font-weight: 600; color: #94a3b8; text-transform: uppercase; }
.tag-zahl { font-size: 1.4rem; font-weight: 700; color: #1e40af; line-height: 1.1; }
.monat { font-size: 0.65rem; color: #94a3b8; text-transform: uppercase; }
.karte-info { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 0.15rem; }
.karte-titel { font-weight: 600; font-size: 0.95rem; color: #1e293b; }
.karte-meta { font-size: 0.78rem; color: #64748b; }
.karte-gruppen { font-size: 0.72rem; color: #94a3b8; }
.karte-info { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 0.2rem; }
.karte-titel { font-weight: 600; font-size: 0.95rem; color: #1e293b; }
.karte-meta { font-size: 0.78rem; color: #64748b; }
.karte-sub { font-size: 0.72rem; color: #94a3b8; }
.verfueg-zeile { display: flex; align-items: center; gap: 0.5rem; flex-wrap: wrap; margin-top: 0.2rem; }
.verfueg-badge { font-size: 0.75rem; font-weight: 600; }
.meine-aktionen {
display: flex; gap: 0.35rem; flex-wrap: wrap; margin-top: 0.4rem;
}
.btn-aktion {
padding: 0.3rem 0.65rem; border-radius: 20px;
font-size: 0.75rem; font-weight: 600; cursor: pointer;
border: 1.5px solid #e2e8f0; background: #fff;
transition: all 0.15s;
}
.btn-aktion.bestaetigen { color: #16a34a; }
.btn-aktion.bestaetigen.aktiv { background: #dcfce7; border-color: #16a34a; }
.btn-aktion.absagen { color: #dc2626; }
.btn-aktion.absagen.aktiv { background: #fee2e2; border-color: #dc2626; }
.btn-aktion.vertretung { color: #7c3aed; }
.btn-aktion.vertretung.aktiv { background: #ede9fe; border-color: #7c3aed; }
.karte-aktionen { display: flex; gap: 0.35rem; flex-shrink: 0; }
.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;
transition: border-color 0.15s, color 0.15s;
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; }
.vergangen { margin-top: 0.5rem; }
.vergangen summary {
font-size: 0.85rem; color: #94a3b8; cursor: pointer;
padding: 0.5rem 0; list-style: none; user-select: none;
}
.vergangen-liste { margin-top: 0.5rem; }
/* Sheet & Overlay */
/* Sheet */
.overlay {
position: fixed; inset: 0;
background: rgba(0,0,0,0.4);
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));
@ -336,35 +418,26 @@
width: 100%; max-width: 480px; max-height: 92dvh; overflow-y: auto;
}
h2 { font-size: 1.1rem; font-weight: 700; color: #1e293b; 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.9rem; }
label, .field-label { font-size: 0.875rem; font-weight: 500; color: #475569; }
input, textarea {
padding: 0.65rem 0.85rem;
border: 1.5px solid #e2e8f0; border-radius: 8px;
input, textarea, 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; font-family: inherit;
transition: border-color 0.15s; resize: vertical;
box-sizing: border-box; font-family: inherit; resize: vertical;
transition: border-color 0.15s;
}
input:focus, textarea:focus { outline: none; border-color: #1e40af; }
input:focus, textarea:focus, select:focus { outline: none; border-color: #1e40af; }
.checkboxes { display: flex; flex-wrap: wrap; gap: 0.4rem; margin-top: 0.2rem; }
.check-label {
display: flex; align-items: center; gap: 0.35rem;
padding: 0.35rem 0.7rem;
border: 1.5px solid #e2e8f0; border-radius: 20px;
font-size: 0.82rem; cursor: pointer;
transition: border-color 0.15s, background 0.15s;
padding: 0.35rem 0.7rem; border: 1.5px solid #e2e8f0;
border-radius: 20px; font-size: 0.82rem; cursor: pointer;
}
.check-label.active { border-color: #1e40af; background: #e0e7ff; color: #1e40af; }
.check-label input { display: none; }
.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;
@ -372,24 +445,21 @@
}
.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;
}
.dialog {
background: #fff; border-radius: 16px; padding: 1.5rem;
width: 100%; max-width: 400px;
}
.dialog p { font-size: 1rem; color: #1e293b; margin-bottom: 0.4rem; font-weight: 600; }
.dialog-sub { font-size: 0.875rem; color: #94a3b8; font-weight: 400 !important; }
.dialog-sub { font-weight: 400 !important; font-size: 0.875rem; color: #94a3b8; }
.dialog-actions { display: flex; gap: 0.75rem; margin-top: 1.25rem; }
.btn-danger {
flex: 1; padding: 0.75rem; background: #dc2626; color: #fff;
border: none; border-radius: 8px; font-size: 0.95rem; font-weight: 600;
cursor: pointer; transition: background 0.15s;
border: none; border-radius: 8px; font-size: 0.95rem; font-weight: 600; cursor: pointer;
}
.btn-danger:hover { background: #b91c1c; }
</style>

View file

@ -0,0 +1,23 @@
/// <reference path="../pb_data/types.d.ts" />
migrate((app) => {
const c = app.findCollectionByNameOrId("pbc_2279568741") // termine
c.fields.addAt(99, new Field({
"type": "relation", "id": "relation2001000080", "name": "durchfuehrender_id",
"help": "", "hidden": false, "presentable": false, "required": false, "system": false,
"cascadeDelete": false, "collectionId": "_pb_users_auth_", "maxSelect": 1, "minSelect": 0
}))
c.fields.addAt(99, new Field({
"type": "select", "id": "select2001000081", "name": "verfuegbarkeit",
"help": "", "hidden": false, "presentable": false, "required": false, "system": false,
"maxSelect": 1, "values": ["offen", "bestaetigt", "abgesagt", "vertretung_gesucht"]
}))
app.save(c)
}, (app) => {
const c = app.findCollectionByNameOrId("pbc_2279568741")
c.fields.removeById("relation2001000080")
c.fields.removeById("select2001000081")
app.save(c)
})