716 lines
27 KiB
Svelte
716 lines
27 KiB
Svelte
<script lang="ts">
|
||
import { api } from '$lib/api';
|
||
import { user } from '$lib/user';
|
||
import { get } from 'svelte/store';
|
||
import { onMount } from 'svelte';
|
||
import { RRule } from 'rrule';
|
||
import { Calendar, TimeGrid, DayGrid } from '@event-calendar/core';
|
||
import '@event-calendar/core/index.css';
|
||
import type { Termin, Gruppe, Verfuegbarkeit, Veranstaltungsort, OrtAusfall } from '$lib/types';
|
||
|
||
const isAdmin = () => { const u = get(user); return !u?.rolle || u.rolle === 'admin'; };
|
||
const userId = () => get(user)?.id as string;
|
||
|
||
let termine = $state<Termin[]>([]);
|
||
let gruppen = $state<Gruppe[]>([]);
|
||
let alleUser = $state<any[]>([]);
|
||
let orte = $state<Veranstaltungsort[]>([]);
|
||
let ausfaelle = $state<OrtAusfall[]>([]);
|
||
let loading = $state(true);
|
||
|
||
// Ansicht
|
||
type Ansicht = 'liste' | 'woche' | 'monat';
|
||
let ansicht = $state<Ansicht>('liste');
|
||
const calPlugins = [TimeGrid, DayGrid];
|
||
|
||
// 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 fDurchfuehrenderId = $state('');
|
||
let fOrtId = $state('');
|
||
let fWiederholung = $state(false);
|
||
let fRhythmus = $state<'woechentlich' | 'zweıwoechentlich' | 'monatlich'>('woechentlich');
|
||
let fBis = $state('');
|
||
let saving = $state(false);
|
||
let formError = $state('');
|
||
let showDelete = $state<string | null>(null);
|
||
let deleteSerieMode = $state(false);
|
||
|
||
const now = new Date();
|
||
|
||
const upcoming = $derived(
|
||
termine
|
||
.filter(t => new Date(t.beginn) >= now)
|
||
.sort((a, b) => new Date(a.beginn).getTime() - new Date(b.beginn).getTime())
|
||
);
|
||
const vergangen = $derived(
|
||
termine
|
||
.filter(t => new Date(t.beginn) < now)
|
||
.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, alleUser, orte, ausfaelle] = await Promise.all([
|
||
api.get<Termin[]>('/termine', { sort: 'beginn' }),
|
||
api.get<Gruppe[]>('/gruppen', { sort: 'name' }),
|
||
isAdmin()
|
||
? api.get<any[]>('/users', { rolle: 'trainer' })
|
||
: Promise.resolve([]),
|
||
api.get<Veranstaltungsort[]>('/orte', { sort: 'name', aktiv: 'true' }),
|
||
api.get<OrtAusfall[]>('/ort-ausfaelle', { sort: 'von' }),
|
||
]);
|
||
loading = false;
|
||
});
|
||
|
||
function toLocal(iso: string | undefined): string {
|
||
if (!iso) return '';
|
||
const d = new Date(iso);
|
||
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 = ''; fOrtId = ''; fGruppeIds = []; fDurchfuehrenderId = '';
|
||
fWiederholung = false; fRhythmus = 'woechentlich'; fBis = '';
|
||
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 ?? ''; fOrtId = t.ort_id ?? ''; fGruppeIds = t.gruppe_ids ?? [];
|
||
fDurchfuehrenderId = t.durchfuehrender_id ?? '';
|
||
fWiederholung = false;
|
||
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) {
|
||
return {
|
||
titel: fTitel.trim(),
|
||
beschreibung: fBeschr.trim() || null,
|
||
ort: fOrtId ? null : (fOrt.trim() || null),
|
||
ort_id: fOrtId || null,
|
||
gruppe_ids: fGruppeIds,
|
||
durchfuehrender_id: fDurchfuehrenderId || null,
|
||
verfuegbarkeit: fDurchfuehrenderId ? 'offen' : null,
|
||
rrule: rruleStr,
|
||
};
|
||
}
|
||
|
||
async function speichern() {
|
||
if (!fTitel.trim() || !fBeginn) { formError = 'Titel und Beginn sind Pflichtfelder.'; return; }
|
||
if (fWiederholung && !fBis) { formError = 'Bitte ein Enddatum für die Wiederholung angeben.'; return; }
|
||
formError = ''; saving = true;
|
||
|
||
try {
|
||
if (fWiederholung && !editId) {
|
||
// Serie anlegen: rrule generieren, Einzeltermine erstellen
|
||
const dtstart = new Date(fBeginn);
|
||
const until = new Date(fBis + 'T23:59:59');
|
||
const dauer = fEnde ? new Date(fEnde).getTime() - dtstart.getTime() : 60 * 60 * 1000;
|
||
|
||
const freq = fRhythmus === 'monatlich' ? RRule.MONTHLY : RRule.WEEKLY;
|
||
const interval = fRhythmus === 'zweıwoechentlich' ? 2 : 1;
|
||
|
||
const rule = new RRule({ freq, interval, dtstart, until });
|
||
const rruleStr = `FREQ=${fRhythmus === 'monatlich' ? 'MONTHLY' : 'WEEKLY'}${interval === 2 ? ';INTERVAL=2' : ''};UNTIL=${until.toISOString().replace(/[-:]/g, '').slice(0, 15)}Z`;
|
||
const serie_id = crypto.randomUUID();
|
||
const dates = rule.all();
|
||
|
||
const neu = await Promise.all(
|
||
dates.map(d =>
|
||
api.post<Termin>('/termine', {
|
||
...generiereTerminDaten(d, rruleStr),
|
||
beginn: d.toISOString(),
|
||
ende: fEnde ? new Date(d.getTime() + dauer).toISOString() : null,
|
||
serie_id,
|
||
}),
|
||
),
|
||
);
|
||
termine = [...termine, ...neu];
|
||
} else {
|
||
const data = {
|
||
...generiereTerminDaten(new Date(fBeginn), null),
|
||
beginn: fromLocal(fBeginn),
|
||
ende: fEnde ? fromLocal(fEnde) : null,
|
||
};
|
||
if (editId) {
|
||
const updated = await api.put<Termin>('/termine/' + editId, data);
|
||
termine = termine.map(t => t.id === editId ? updated : t);
|
||
} else {
|
||
const neu = await api.post<Termin>('/termine', data);
|
||
termine = [...termine, neu];
|
||
}
|
||
}
|
||
showForm = false;
|
||
} catch (e: unknown) {
|
||
formError = e instanceof Error ? e.message : 'Fehler beim Speichern.';
|
||
} finally {
|
||
saving = false;
|
||
}
|
||
}
|
||
|
||
async function loeschen(t: Termin, ganzeSerieLoeschen: boolean) {
|
||
if (ganzeSerieLoeschen && t.serie_id) {
|
||
const serie = termine.filter(x => x.serie_id === t.serie_id);
|
||
await Promise.all(serie.map(s => api.del('/termine/' + s.id)));
|
||
termine = termine.filter(x => x.serie_id !== t.serie_id);
|
||
} else {
|
||
await api.del('/termine/' + t.id);
|
||
termine = termine.filter(x => x.id !== t.id);
|
||
}
|
||
showDelete = null;
|
||
}
|
||
|
||
// Vorschau: wie viele Termine werden erzeugt?
|
||
const serieVorschau = $derived(() => {
|
||
if (!fWiederholung || !fBeginn || !fBis) return 0;
|
||
try {
|
||
const dtstart = new Date(fBeginn);
|
||
const until = new Date(fBis + 'T23:59:59');
|
||
const freq = fRhythmus === 'monatlich' ? RRule.MONTHLY : RRule.WEEKLY;
|
||
const interval = fRhythmus === 'zweıwoechentlich' ? 2 : 1;
|
||
return new RRule({ freq, interval, dtstart, until }).all().length;
|
||
} catch { return 0; }
|
||
});
|
||
|
||
async function setVerfuegbarkeit(t: Termin, v: Verfuegbarkeit) {
|
||
const updated = await api.put<Termin>('/termine/' + 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)
|
||
: [...fGruppeIds, id];
|
||
}
|
||
|
||
function formatDatum(iso: string): string {
|
||
return new Date(iso).toLocaleDateString('de-DE', {
|
||
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 {
|
||
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: 'var(--c-warning)' },
|
||
bestaetigt: { label: 'Bestätigt', farbe: 'var(--c-success)' },
|
||
abgesagt: { label: 'Abgesagt', farbe: 'var(--c-error)' },
|
||
vertretung_gesucht: { label: 'Vertretung gesucht', farbe: 'var(--c-accent)' },
|
||
};
|
||
|
||
function istMeinTermin(t: Termin): boolean {
|
||
return t.durchfuehrender_id === userId();
|
||
}
|
||
|
||
function verfuegbarkeitFarbe(t: Termin): string {
|
||
switch (t.verfuegbarkeit) {
|
||
case 'bestaetigt': return 'var(--c-success)';
|
||
case 'abgesagt': return 'var(--c-error)';
|
||
case 'vertretung_gesucht': return 'var(--c-accent)';
|
||
default: return 'var(--c-primary)';
|
||
}
|
||
}
|
||
|
||
const calEvents = $derived(termine.map(t => ({
|
||
id: t.id,
|
||
title: t.titel,
|
||
start: t.beginn,
|
||
end: t.ende ?? new Date(new Date(t.beginn).getTime() + 60 * 60 * 1000).toISOString(),
|
||
backgroundColor: verfuegbarkeitFarbe(t),
|
||
extendedProps: { termin: t },
|
||
})));
|
||
|
||
const calOptions = $derived({
|
||
view: ansicht === 'monat' ? 'dayGridMonth' : 'timeGridWeek',
|
||
locale: 'de',
|
||
firstDay: 1,
|
||
height: ansicht === 'monat' ? 'auto' : '72dvh',
|
||
events: calEvents,
|
||
slotMinTime: '07:00:00',
|
||
slotMaxTime: '23:00:00',
|
||
allDaySlot: false,
|
||
headerToolbar: {
|
||
start: 'prev,next today',
|
||
center: 'title',
|
||
end: '',
|
||
},
|
||
buttonText: { today: 'Heute' },
|
||
eventClick: (info: any) => {
|
||
const t: Termin = info.event.extendedProps.termin;
|
||
if (isAdmin()) bearbeiten(t);
|
||
},
|
||
});
|
||
</script>
|
||
|
||
<svelte:head><title>Termine — vereins.haus</title></svelte:head>
|
||
|
||
<div class="top">
|
||
<h1>Termine</h1>
|
||
<div class="top-rechts">
|
||
{#if isAdmin()}
|
||
<a href="/orte" class="btn-orte">Orte</a>
|
||
{/if}
|
||
<div class="ansicht-switcher">
|
||
<button class:aktiv={ansicht === 'liste'} onclick={() => ansicht = 'liste'} title="Listenansicht">☰</button>
|
||
<button class:aktiv={ansicht === 'woche'} onclick={() => ansicht = 'woche'} title="Wochenansicht">W</button>
|
||
<button class:aktiv={ansicht === 'monat'} onclick={() => ansicht = 'monat'} title="Monatsansicht">M</button>
|
||
</div>
|
||
{#if isAdmin()}
|
||
<button class="btn-primary" onclick={neuerTermin}>+ Termin</button>
|
||
{/if}
|
||
</div>
|
||
</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 ansicht !== 'liste'}
|
||
<div class="kalender-wrap">
|
||
<Calendar plugins={calPlugins} options={calOptions} />
|
||
</div>
|
||
{:else if termine.length === 0}
|
||
<p class="hint">Noch keine Termine geplant.</p>
|
||
{:else}
|
||
{#if upcoming.length > 0}
|
||
<ul class="liste">
|
||
{#each upcoming as t (t.id)}
|
||
<li class="karte">
|
||
<div class="karte-datum-col">
|
||
<span class="tag">{new Date(t.beginn).toLocaleDateString('de-DE', { weekday: 'short' })}</span>
|
||
<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">
|
||
<div class="karte-titel-zeile">
|
||
<span class="karte-titel">{t.titel}</span>
|
||
{#if t.serie_id}<span class="serie-badge" title="Wiederholungsserie">↻</span>{/if}
|
||
</div>
|
||
<span class="karte-meta">
|
||
{formatZeit(t.beginn)}{t.ende ? ' – ' + formatZeit(t.ende) : ''}{t.ort ? ' · ' + t.ort : ''}
|
||
</span>
|
||
{#if t.ort_id || t.ort}
|
||
<span class="karte-meta">{ortNameById(t.ort_id) || t.ort}</span>
|
||
{/if}
|
||
{#if t.gruppe_ids?.length}
|
||
<span class="karte-sub">{gruppenLabel(t.gruppe_ids)}</span>
|
||
{/if}
|
||
{#if ortAusfall(t)}
|
||
{@const af = ortAusfall(t)!}
|
||
<span class="ausfall-warn">⚠ Ort gesperrt{af.grund ? ': ' + af.grund : ''}</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:var(--c-warning)">● 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>
|
||
|
||
{#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; deleteSerieMode = false; }} title="Löschen">✕</button>
|
||
</div>
|
||
{/if}
|
||
</li>
|
||
{/each}
|
||
</ul>
|
||
{/if}
|
||
|
||
{#if vergangen.length > 0}
|
||
<details class="vergangen">
|
||
<summary>Vergangene Termine ({vergangen.length})</summary>
|
||
<ul class="liste vergangen-liste">
|
||
{#each vergangen as t (t.id)}
|
||
<li class="karte karte-grau">
|
||
<div class="karte-datum-col">
|
||
<span class="tag">{new Date(t.beginn).toLocaleDateString('de-DE', { weekday: 'short' })}</span>
|
||
<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">{formatDatum(t.beginn)}{t.ort ? ' · ' + t.ort : ''}</span>
|
||
</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>
|
||
</details>
|
||
{/if}
|
||
{/if}
|
||
|
||
<!-- 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} required />
|
||
</div>
|
||
<div class="row">
|
||
<div class="field">
|
||
<label for="fbeginn">Beginn *</label>
|
||
<input id="fbeginn" type="datetime-local" bind:value={fBeginn} required />
|
||
</div>
|
||
<div class="field">
|
||
<label for="fende">Ende</label>
|
||
<input id="fende" type="datetime-local" bind:value={fEnde} />
|
||
</div>
|
||
</div>
|
||
{#if orte.length > 0}
|
||
<div class="field">
|
||
<label for="fort-id">Ort</label>
|
||
<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">
|
||
<label for="fbeschr">Beschreibung</label>
|
||
<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 !editId}
|
||
<div class="field">
|
||
<label class="toggle-label">
|
||
<input type="checkbox" bind:checked={fWiederholung} />
|
||
Wiederholt sich
|
||
</label>
|
||
</div>
|
||
|
||
{#if fWiederholung}
|
||
<div class="row">
|
||
<div class="field">
|
||
<label for="frhythmus">Rhythmus</label>
|
||
<select id="frhythmus" bind:value={fRhythmus}>
|
||
<option value="woechentlich">Wöchentlich</option>
|
||
<option value="zweıwoechentlich">Zweiwöchentlich</option>
|
||
<option value="monatlich">Monatlich</option>
|
||
</select>
|
||
</div>
|
||
<div class="field">
|
||
<label for="fbis">Bis (Datum)</label>
|
||
<input id="fbis" type="date" bind:value={fBis} required />
|
||
</div>
|
||
</div>
|
||
{#if serieVorschau() > 0}
|
||
<p class="serie-vorschau">→ {serieVorschau()} Termine werden angelegt</p>
|
||
{/if}
|
||
{/if}
|
||
{/if}
|
||
|
||
{#if gruppen.length > 0}
|
||
<div class="field">
|
||
<span class="field-label">Für Gruppen</span>
|
||
<div class="checkboxes">
|
||
{#each gruppen as g (g.id)}
|
||
<label class="check-label" class:active={fGruppeIds.includes(g.id)}>
|
||
<input type="checkbox" checked={fGruppeIds.includes(g.id)} onchange={() => toggleGruppe(g.id)} />
|
||
{g.name}
|
||
</label>
|
||
{/each}
|
||
</div>
|
||
</div>
|
||
{/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>
|
||
</div>
|
||
</form>
|
||
</div>
|
||
</div>
|
||
{/if}
|
||
|
||
<!-- Lösch-Dialog -->
|
||
{#if showDelete}
|
||
{@const t = termine.find(x => x.id === showDelete)!}
|
||
<div class="overlay" role="dialog" aria-modal="true">
|
||
<div class="dialog">
|
||
<p>Termin löschen?</p>
|
||
{#if t?.serie_id}
|
||
<div class="radio-group">
|
||
<label class="radio-label">
|
||
<input type="radio" bind:group={deleteSerieMode} value={false} />
|
||
Nur diesen Termin
|
||
</label>
|
||
<label class="radio-label">
|
||
<input type="radio" bind:group={deleteSerieMode} value={true} />
|
||
Alle Termine der Serie löschen
|
||
</label>
|
||
</div>
|
||
{:else}
|
||
<p class="dialog-sub">Diese Aktion kann nicht rückgängig gemacht werden.</p>
|
||
{/if}
|
||
<div class="dialog-actions">
|
||
<button class="btn-ghost" onclick={() => showDelete = null}>Abbrechen</button>
|
||
<button class="btn-danger" onclick={() => loeschen(t, deleteSerieMode)}>Löschen</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
{/if}
|
||
|
||
<style>
|
||
.top { display: flex; align-items: center; justify-content: space-between; margin-bottom: 1rem; }
|
||
.top .btn-primary { flex: none; padding: 0.45rem 0.9rem; font-size: 0.875rem; }
|
||
.top-rechts { display: flex; align-items: center; gap: 0.5rem; flex-wrap: wrap; }
|
||
h1 { font-size: 1.4rem; font-weight: 700; color: var(--c-text); }
|
||
.btn-orte {
|
||
padding: 0.5rem 0.85rem; background: none;
|
||
border: 1.5px solid var(--c-border); border-radius: 8px;
|
||
font-size: 0.85rem; color: var(--c-text-secondary); text-decoration: none;
|
||
}
|
||
|
||
.ansicht-switcher {
|
||
display: flex; border: 1.5px solid var(--c-border); border-radius: 8px; overflow: hidden;
|
||
}
|
||
.ansicht-switcher button {
|
||
padding: 0.4rem 0.75rem; background: none; border: none;
|
||
font-size: 0.82rem; font-weight: 600; color: var(--c-text-muted); cursor: pointer;
|
||
transition: background 0.15s, color 0.15s;
|
||
}
|
||
.ansicht-switcher button + button { border-left: 1px solid var(--c-border); }
|
||
.ansicht-switcher button.aktiv { background: var(--c-primary); color: var(--c-bg-card); }
|
||
|
||
.kalender-wrap {
|
||
margin-bottom: 1rem;
|
||
--ec-today-bg-color: var(--c-primary-subtle);
|
||
--ec-event-bg-color: var(--c-primary);
|
||
--ec-border-color: var(--c-border);
|
||
--ec-text-color: var(--c-text);
|
||
--ec-button-bg-color: var(--c-bg-card);
|
||
--ec-button-border-color: var(--c-border);
|
||
--ec-button-text-color: var(--c-text-secondary);
|
||
--ec-active-bg-color: var(--c-primary);
|
||
--ec-active-text-color: var(--c-bg-card);
|
||
}
|
||
.ausfall-warn { font-size: 0.75rem; color: var(--c-error); font-weight: 600; }
|
||
.ort-link { font-size: 0.75rem; color: var(--c-primary); margin-left: 0.5rem; }
|
||
.hint { color: var(--c-text-hint); font-size: 0.95rem; text-align: center; margin-top: 3rem; }
|
||
|
||
.warnung {
|
||
background: var(--c-warning-subtle); border: 1px solid var(--c-warning-light);
|
||
border-radius: 8px; padding: 0.65rem 0.9rem;
|
||
font-size: 0.85rem; color: var(--c-warning-dark); 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: var(--c-bg-card);
|
||
border: 1px solid var(--c-border); border-radius: 10px;
|
||
}
|
||
.karte-grau { background: var(--c-bg-subtle); opacity: 0.75; }
|
||
|
||
.karte-datum-col {
|
||
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: var(--c-text-hint); text-transform: uppercase; }
|
||
.tag-zahl { font-size: 1.4rem; font-weight: 700; color: var(--c-primary); line-height: 1.1; }
|
||
.monat { font-size: 0.65rem; color: var(--c-text-hint); text-transform: uppercase; }
|
||
|
||
.karte-info { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 0.2rem; }
|
||
.karte-titel-zeile { display: flex; align-items: center; gap: 0.4rem; }
|
||
.karte-titel { font-weight: 600; font-size: 0.95rem; color: var(--c-text); }
|
||
.serie-badge { font-size: 0.75rem; color: var(--c-primary); font-weight: 700; }
|
||
.karte-meta { font-size: 0.78rem; color: var(--c-text-muted); }
|
||
.karte-sub { font-size: 0.72rem; color: var(--c-text-hint); }
|
||
|
||
.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 var(--c-border); background: var(--c-bg-card);
|
||
transition: all 0.15s;
|
||
}
|
||
.btn-aktion.bestaetigen { color: var(--c-success); }
|
||
.btn-aktion.bestaetigen.aktiv { background: var(--c-success-bg); border-color: var(--c-success); }
|
||
.btn-aktion.absagen { color: var(--c-error); }
|
||
.btn-aktion.absagen.aktiv { background: var(--c-error-bg); border-color: var(--c-error); }
|
||
.btn-aktion.vertretung { color: var(--c-accent); }
|
||
.btn-aktion.vertretung.aktiv { background: var(--c-accent-subtle); border-color: var(--c-accent); }
|
||
|
||
.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 var(--c-border);
|
||
border-radius: 6px; color: var(--c-text-muted); font-size: 0.85rem; cursor: pointer;
|
||
}
|
||
.btn-icon:hover { border-color: var(--c-text-hint); color: var(--c-text); }
|
||
.btn-icon-red:hover { border-color: var(--c-error-light); color: var(--c-error); }
|
||
|
||
.vergangen summary {
|
||
font-size: 0.85rem; color: var(--c-text-hint); cursor: pointer;
|
||
padding: 0.5rem 0; list-style: none; user-select: none;
|
||
}
|
||
.vergangen-liste { margin-top: 0.5rem; }
|
||
|
||
/* Sheet */
|
||
.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: var(--c-bg-card); border-radius: 16px; padding: 1.5rem;
|
||
width: 100%; max-width: 480px; max-height: 92dvh; overflow-y: auto;
|
||
}
|
||
h2 { font-size: 1.1rem; font-weight: 700; color: var(--c-text); 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: var(--c-text-secondary); }
|
||
input, textarea, select {
|
||
padding: 0.65rem 0.85rem; border: 1.5px solid var(--c-border); border-radius: 8px;
|
||
font-size: 1rem; background: var(--c-bg-card); width: 100%;
|
||
box-sizing: border-box; font-family: inherit; resize: vertical;
|
||
transition: border-color 0.15s;
|
||
}
|
||
input:focus, textarea:focus, select:focus { outline: none; border-color: var(--c-primary); }
|
||
.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 var(--c-border);
|
||
border-radius: 20px; font-size: 0.82rem; cursor: pointer;
|
||
}
|
||
.check-label.active { border-color: var(--c-primary); background: var(--c-primary-light); color: var(--c-primary); }
|
||
.check-label input { display: none; }
|
||
.error { color: var(--c-error); 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: var(--c-primary); color: var(--c-bg-card);
|
||
border: none; border-radius: 8px; font-size: 1rem; font-weight: 600;
|
||
cursor: pointer; transition: background 0.15s;
|
||
}
|
||
.btn-primary:hover:not(:disabled) { background: var(--c-primary-dark); }
|
||
.btn-primary:disabled { opacity: 0.55; cursor: not-allowed; }
|
||
.btn-ghost {
|
||
padding: 0.75rem 1rem; background: none;
|
||
border: 1.5px solid var(--c-border); border-radius: 8px;
|
||
font-size: 1rem; color: var(--c-text-muted); cursor: pointer;
|
||
}
|
||
.dialog {
|
||
background: var(--c-bg-card); border-radius: 16px; padding: 1.5rem;
|
||
width: 100%; max-width: 400px;
|
||
}
|
||
.dialog p { font-size: 1rem; color: var(--c-text); margin-bottom: 0.4rem; font-weight: 600; }
|
||
.dialog-sub { font-weight: 400 !important; font-size: 0.875rem; color: var(--c-text-hint); }
|
||
.radio-group { display: flex; flex-direction: column; gap: 0.5rem; margin: 0.75rem 0; }
|
||
.radio-label { display: flex; align-items: center; gap: 0.5rem; font-size: 0.9rem; color: var(--c-text); cursor: pointer; }
|
||
.dialog-actions { display: flex; gap: 0.75rem; margin-top: 1.25rem; }
|
||
.toggle-label { display: flex; align-items: center; gap: 0.5rem; font-size: 0.9rem; color: var(--c-text-secondary); cursor: pointer; }
|
||
.serie-vorschau { font-size: 0.82rem; color: var(--c-primary); margin: -0.4rem 0 0.5rem; }
|
||
.btn-danger {
|
||
flex: 1; padding: 0.75rem; background: var(--c-error); color: var(--c-bg-card);
|
||
border: none; border-radius: 8px; font-size: 0.95rem; font-weight: 600; cursor: pointer;
|
||
}
|
||
.btn-danger:hover { background: var(--c-error-dark); }
|
||
</style>
|