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

716 lines
27 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 { 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>