Compare commits
38 commits
773046c80d
...
b12a8e2caa
| Author | SHA1 | Date | |
|---|---|---|---|
| b12a8e2caa | |||
| 4fee85bd22 | |||
| e2d7655e13 | |||
| aa9ab9d776 | |||
| 50810c4b50 | |||
| 51c0fe58aa | |||
| 957c4a9707 | |||
| 39981c0d17 | |||
| 61c430f2e6 | |||
| ff8e9b2c39 | |||
| caff0feae8 | |||
| 9898581ae4 | |||
| fb54f1bd27 | |||
| e4ca28025c | |||
| 34f6a4f11d | |||
| 0aca72af53 | |||
| 74c3aa11b0 | |||
| 75cb9bfc88 | |||
| 13c6ba73ca | |||
| d4a0a75cf7 | |||
| 2514ec7496 | |||
| 6dd4d657be | |||
| a4436d70c2 | |||
| 81f34905cf | |||
| 59d94f9c47 | |||
| 95c2dc0f26 | |||
| b8e2a69912 | |||
| 3ac17b2645 | |||
| c23ac90d35 | |||
| 59aa3cbcce | |||
| 7e2e5a643d | |||
| 472979a91c | |||
| f2906f5c60 | |||
| bb0e67b2bd | |||
| 77c6f513b5 | |||
| c2c4dfd518 | |||
| 375a3305bb | |||
| 94ca36f470 |
104
Makefile
|
|
@ -1,16 +1,19 @@
|
|||
# ==============================================================
|
||||
# VEREINS.HAUS — Makefile
|
||||
# Deploy-Strategie: SSH zur DS, Docker Compose
|
||||
# Stack: SvelteKit + better-sqlite3 (kein PocketBase)
|
||||
# ==============================================================
|
||||
|
||||
DS_HOST := ds
|
||||
DS_IP := 10.47.11.10
|
||||
DS_SSH_PORT := 4711
|
||||
DS_PATH := /volume1/docker/vereinshaus
|
||||
CONTAINER_PB := vereinshaus-pocketbase
|
||||
CONTAINER_APP := vereinshaus-app
|
||||
DOCKER := sudo /usr/local/bin/docker
|
||||
|
||||
STAGING_PATH := /volume1/docker/vereinshaus-staging
|
||||
CONTAINER_APP_STAGING := vereinshaus-staging-app
|
||||
|
||||
TAR_EXCLUDE := --exclude='.git' \
|
||||
--exclude='./app/node_modules' \
|
||||
--exclude='./app/.svelte-kit' \
|
||||
|
|
@ -18,11 +21,8 @@ TAR_EXCLUDE := --exclude='.git' \
|
|||
--exclude='./.env' \
|
||||
--exclude='./.DS_Store'
|
||||
|
||||
HOOKS_SRC := pocketbase/pb_hooks
|
||||
HOOKS_DST := /volume1/docker/vereinshaus/pocketbase/data/pb_hooks
|
||||
|
||||
.PHONY: help check-ssh start stop restart status logs logs-f logs-app \
|
||||
shell-pb pb-admin deploy
|
||||
.PHONY: help check-ssh start stop restart status logs logs-app logs-f deploy \
|
||||
staging-deploy staging-reset staging-seed staging-logs staging-status staging-stop
|
||||
|
||||
# ----------------------------------------------------------
|
||||
# Hilfe
|
||||
|
|
@ -32,16 +32,16 @@ help:
|
|||
@echo " vereins.haus — verfügbare Befehle:"
|
||||
@echo ""
|
||||
@echo " make deploy App bauen + zur DS übertragen + Container neu starten"
|
||||
@echo " make start Alle Container starten"
|
||||
@echo " make stop Alle Container stoppen"
|
||||
@echo " make restart Alle Container neu starten"
|
||||
@echo " make start Container starten"
|
||||
@echo " make stop Container stoppen"
|
||||
@echo " make restart Container neu starten"
|
||||
@echo " make status Container-Status anzeigen"
|
||||
@echo " make logs App-Logs (100 Zeilen)"
|
||||
@echo " make logs-f App Live-Log"
|
||||
@echo ""
|
||||
@echo " make logs PocketBase-Logs (100 Zeilen)"
|
||||
@echo " make logs-app App-Logs (100 Zeilen)"
|
||||
@echo " make logs-f PocketBase Live-Log"
|
||||
@echo " make shell-pb Shell in PocketBase-Container"
|
||||
@echo " make pb-admin PocketBase Admin-URL anzeigen"
|
||||
@echo " make staging-deploy Staging deployen"
|
||||
@echo " make staging-seed Testdaten einfügen"
|
||||
@echo " make staging-reset Staging-DB löschen (Neustart)"
|
||||
@echo ""
|
||||
|
||||
# ----------------------------------------------------------
|
||||
|
|
@ -56,20 +56,14 @@ check-ssh:
|
|||
fi
|
||||
|
||||
# ----------------------------------------------------------
|
||||
# DEPLOY
|
||||
# DEPLOY (Production)
|
||||
# ----------------------------------------------------------
|
||||
deploy: check-ssh
|
||||
@echo "→ Sync zu DS..."
|
||||
@COPYFILE_DISABLE=1 tar czf - $(TAR_EXCLUDE) . | ssh $(DS_HOST) "tar xzf - -C $(DS_PATH)/"
|
||||
@echo "→ .env auf DS aktualisieren..."
|
||||
@if [ -f .env ]; then \
|
||||
grep -E "BREVO_KEY" .env | ssh $(DS_HOST) "cat > $(DS_PATH)/.env"; \
|
||||
fi
|
||||
@echo "→ PocketBase Hooks synchronisieren..."
|
||||
@if ls $(HOOKS_SRC)/*.pb.js 2>/dev/null | grep -q .; then \
|
||||
for f in $(HOOKS_SRC)/*.pb.js; do \
|
||||
cat "$$f" | ssh $(DS_HOST) "cat > $(HOOKS_DST)/$$(basename $$f)"; \
|
||||
done; \
|
||||
cat .env | ssh $(DS_HOST) "cat > $(DS_PATH)/.env"; \
|
||||
fi
|
||||
@echo "→ Docker rebuild + restart..."
|
||||
@ssh $(DS_HOST) " \
|
||||
|
|
@ -78,7 +72,7 @@ deploy: check-ssh
|
|||
$(DOCKER) compose build app && \
|
||||
$(DOCKER) compose up -d"
|
||||
@echo " ✓ Deploy fertig."
|
||||
@ssh $(DS_HOST) "$(DOCKER) logs $(CONTAINER_APP) --tail=10"
|
||||
@ssh $(DS_HOST) "$(DOCKER) logs $(CONTAINER_APP) --tail=15"
|
||||
|
||||
# ----------------------------------------------------------
|
||||
# Container-Steuerung
|
||||
|
|
@ -104,19 +98,61 @@ status: check-ssh
|
|||
# Logs
|
||||
# ----------------------------------------------------------
|
||||
logs: check-ssh
|
||||
@ssh $(DS_HOST) "$(DOCKER) logs $(CONTAINER_PB) --tail=100"
|
||||
|
||||
logs-app: check-ssh
|
||||
@ssh $(DS_HOST) "$(DOCKER) logs $(CONTAINER_APP) --tail=100"
|
||||
|
||||
logs-app: logs
|
||||
|
||||
logs-f: check-ssh
|
||||
@ssh $(DS_HOST) "$(DOCKER) logs $(CONTAINER_PB) -f"
|
||||
@ssh $(DS_HOST) "$(DOCKER) logs $(CONTAINER_APP) -f"
|
||||
|
||||
# ----------------------------------------------------------
|
||||
# Shell + Admin
|
||||
# ----------------------------------------------------------
|
||||
shell-pb: check-ssh
|
||||
@ssh -t $(DS_HOST) "$(DOCKER) exec -it $(CONTAINER_PB) sh"
|
||||
# ==============================================================
|
||||
# STAGING
|
||||
# ==============================================================
|
||||
|
||||
pb-admin:
|
||||
@echo " PocketBase Admin: https://api.vereins.haus/_/"
|
||||
staging-deploy: check-ssh
|
||||
@echo "→ Sync zu DS (Staging)..."
|
||||
@COPYFILE_DISABLE=1 tar czf - $(TAR_EXCLUDE) . | ssh $(DS_HOST) "tar xzf - -C $(STAGING_PATH)/"
|
||||
@echo "→ .env auf DS (Staging)..."
|
||||
@if [ -f .env ]; then \
|
||||
cat .env | ssh $(DS_HOST) "cat > $(STAGING_PATH)/.env"; \
|
||||
fi
|
||||
@echo "→ Docker rebuild + restart (Staging)..."
|
||||
@ssh $(DS_HOST) " \
|
||||
cd $(STAGING_PATH) && \
|
||||
$(DOCKER) compose -f docker-compose.staging.yml down && \
|
||||
$(DOCKER) compose -f docker-compose.staging.yml build app-staging && \
|
||||
$(DOCKER) compose -f docker-compose.staging.yml up -d"
|
||||
@echo " ✓ Staging bereit."
|
||||
@echo " App: https://staging.vereins.haus"
|
||||
|
||||
# Löscht die SQLite-DB auf Staging → frischer Start
|
||||
# Danach: make staging-deploy && make staging-seed
|
||||
staging-reset: check-ssh staging-stop
|
||||
@echo "→ Staging-Daten löschen..."
|
||||
@ssh $(DS_HOST) "rm -f \
|
||||
$(STAGING_PATH)/data/vereinshaus.db \
|
||||
$(STAGING_PATH)/data/vereinshaus.db-wal \
|
||||
$(STAGING_PATH)/data/vereinshaus.db-shm && \
|
||||
rm -rf $(STAGING_PATH)/data/uploads"
|
||||
@echo " ✓ Reset fertig. Jetzt: make staging-deploy && make staging-seed"
|
||||
|
||||
staging-seed:
|
||||
@echo "→ Testdaten in Staging einfügen..."
|
||||
@if [ -f .env ]; then \
|
||||
export $$(grep -v '^#' .env | xargs) && \
|
||||
APP_URL=https://staging.vereins.haus node scripts/seed.js; \
|
||||
else \
|
||||
APP_URL=https://staging.vereins.haus node scripts/seed.js; \
|
||||
fi
|
||||
|
||||
staging-logs: check-ssh
|
||||
@ssh $(DS_HOST) "$(DOCKER) logs $(CONTAINER_APP_STAGING) --tail=50"
|
||||
|
||||
staging-status: check-ssh
|
||||
@ssh $(DS_HOST) "$(DOCKER) ps \
|
||||
--filter name=vereinshaus-staging \
|
||||
--format 'table {{.Names}}\t{{.Status}}\t{{.Ports}}'"
|
||||
|
||||
staging-stop: check-ssh
|
||||
@ssh $(DS_HOST) "cd $(STAGING_PATH) && $(DOCKER) compose -f docker-compose.staging.yml down"
|
||||
@echo " ✓ Staging gestoppt."
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
FROM node:22-alpine AS builder
|
||||
RUN apk add --no-cache python3 make g++
|
||||
WORKDIR /app
|
||||
COPY package*.json ./
|
||||
RUN npm ci
|
||||
|
|
@ -6,6 +7,7 @@ COPY . .
|
|||
RUN npm run build
|
||||
|
||||
FROM node:22-alpine
|
||||
RUN apk add --no-cache python3 make g++
|
||||
WORKDIR /app
|
||||
COPY --from=builder /app/build ./build
|
||||
COPY --from=builder /app/package*.json ./
|
||||
|
|
|
|||
748
app/package-lock.json
generated
|
|
@ -15,13 +15,27 @@
|
|||
"@sveltejs/adapter-node": "^5.5.4",
|
||||
"@sveltejs/kit": "^2.57.0",
|
||||
"@sveltejs/vite-plugin-svelte": "^7.0.0",
|
||||
"@types/bcryptjs": "^2.4.6",
|
||||
"@types/better-sqlite3": "^7.6.13",
|
||||
"@types/papaparse": "^5.5.2",
|
||||
"@types/web-push": "^3.6.4",
|
||||
"svelte": "^5.55.2",
|
||||
"svelte-check": "^4.4.6",
|
||||
"typescript": "^6.0.2",
|
||||
"vite": "^8.0.7",
|
||||
"vite-plugin-pwa": "^1.3.0"
|
||||
"vite-plugin-pwa": "^1.3.0",
|
||||
"workbox-core": "^7.4.1",
|
||||
"workbox-precaching": "^7.4.1"
|
||||
},
|
||||
"dependencies": {
|
||||
"pocketbase": "^0.26.9"
|
||||
"@event-calendar/core": "^5.7.0",
|
||||
"bcryptjs": "^3.0.3",
|
||||
"better-sqlite3": "^12.10.0",
|
||||
"ical-generator": "^10.2.0",
|
||||
"jose": "^6.2.3",
|
||||
"papaparse": "^5.5.3",
|
||||
"pocketbase": "^0.26.9",
|
||||
"rrule": "^2.8.1",
|
||||
"web-push": "^3.6.7"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
15
app/src/app.d.ts
vendored
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
declare global {
|
||||
namespace App {
|
||||
interface Locals {
|
||||
user: {
|
||||
id: string;
|
||||
verein_id: string;
|
||||
rolle: string | null;
|
||||
name: string;
|
||||
email: string;
|
||||
} | null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export {};
|
||||
|
|
@ -4,13 +4,12 @@
|
|||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta name="color-scheme" content="light" />
|
||||
<meta name="theme-color" content="#1e40af" />
|
||||
<meta name="theme-color" content="#0F172A" />
|
||||
<meta name="mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<link rel="icon" type="image/x-icon" href="/favicon.ico" />
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="/icons/apple-touch-icon.png" />
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
|
||||
%sveltekit.head%
|
||||
</head>
|
||||
<body data-sveltekit-preload-data="hover">
|
||||
|
|
|
|||
66
app/src/lib/api.ts
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
import { get } from 'svelte/store';
|
||||
import { user } from './user';
|
||||
import { goto } from '$app/navigation';
|
||||
|
||||
function token() { return get(user)?.token ?? ''; }
|
||||
|
||||
function headers(extra: Record<string, string> = {}): Record<string, string> {
|
||||
return { Authorization: `Bearer ${token()}`, ...extra };
|
||||
}
|
||||
|
||||
async function handleRes<T>(res: Response): Promise<T> {
|
||||
if (res.status === 401) { user.clear(); goto('/login'); throw new Error('Nicht angemeldet'); }
|
||||
if (!res.ok) {
|
||||
const e = await res.json().catch(() => ({}));
|
||||
throw new Error((e as { message?: string }).message || res.statusText);
|
||||
}
|
||||
if (res.status === 204) return undefined as T;
|
||||
return res.json() as Promise<T>;
|
||||
}
|
||||
|
||||
export const api = {
|
||||
async get<T>(path: string, query: Record<string, string> = {}): Promise<T> {
|
||||
const url = new URL('/api' + path, location.origin);
|
||||
Object.entries(query).forEach(([k, v]) => v !== undefined && url.searchParams.set(k, v));
|
||||
return handleRes<T>(await fetch(url.toString(), { headers: headers() }));
|
||||
},
|
||||
|
||||
async post<T>(path: string, data?: unknown): Promise<T> {
|
||||
return handleRes<T>(await fetch('/api' + path, {
|
||||
method: 'POST',
|
||||
headers: headers({ 'Content-Type': 'application/json' }),
|
||||
body: JSON.stringify(data ?? {}),
|
||||
}));
|
||||
},
|
||||
|
||||
async put<T>(path: string, data?: unknown): Promise<T> {
|
||||
return handleRes<T>(await fetch('/api' + path, {
|
||||
method: 'PUT',
|
||||
headers: headers({ 'Content-Type': 'application/json' }),
|
||||
body: JSON.stringify(data ?? {}),
|
||||
}));
|
||||
},
|
||||
|
||||
async patch<T>(path: string, data?: unknown): Promise<T> {
|
||||
return handleRes<T>(await fetch('/api' + path, {
|
||||
method: 'PATCH',
|
||||
headers: headers({ 'Content-Type': 'application/json' }),
|
||||
body: JSON.stringify(data ?? {}),
|
||||
}));
|
||||
},
|
||||
|
||||
async del<T = void>(path: string): Promise<T> {
|
||||
return handleRes<T>(await fetch('/api' + path, { method: 'DELETE', headers: headers() }));
|
||||
},
|
||||
|
||||
async postForm<T>(path: string, form: FormData): Promise<T> {
|
||||
return handleRes<T>(await fetch('/api' + path, {
|
||||
method: 'POST', headers: headers(), body: form,
|
||||
}));
|
||||
},
|
||||
|
||||
fileUrl(verein_id: string, record_id: string, filename: string, thumb = false): string {
|
||||
const base = `/api/files/${verein_id}/${record_id}/${filename}`;
|
||||
return thumb ? base + '?thumb=1' : base;
|
||||
}
|
||||
};
|
||||
20
app/src/lib/components/Icon.svelte
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
<script lang="ts">
|
||||
import { icons, type IconName } from '$lib/icons';
|
||||
|
||||
let { name, size = 24, class: cls = '' }: { name: IconName; size?: number; class?: string } = $props();
|
||||
</script>
|
||||
|
||||
<span
|
||||
class="ph-icon {cls}"
|
||||
style="width:{size}px;height:{size}px;display:inline-flex;align-items:center;justify-content:center;"
|
||||
aria-hidden="true"
|
||||
>
|
||||
{@html icons[name]}
|
||||
</span>
|
||||
|
||||
<style>
|
||||
.ph-icon :global(svg) {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
</style>
|
||||
13
app/src/lib/event-calendar.d.ts
vendored
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
declare module '@event-calendar/core' {
|
||||
import type { SvelteComponent } from 'svelte';
|
||||
|
||||
export class Calendar extends SvelteComponent<{
|
||||
plugins: any[];
|
||||
options: Record<string, any>;
|
||||
}> {}
|
||||
|
||||
export const TimeGrid: any;
|
||||
export const DayGrid: any;
|
||||
export const List: any;
|
||||
export const Interaction: any;
|
||||
}
|
||||
19
app/src/lib/icons.ts
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
import house from './icons/house.svg?raw';
|
||||
import users from './icons/users.svg?raw';
|
||||
import calendar from './icons/calendar.svg?raw';
|
||||
import currencyEur from './icons/currency-eur.svg?raw';
|
||||
import envelope from './icons/envelope.svg?raw';
|
||||
import gear from './icons/gear.svg?raw';
|
||||
import images from './icons/images.svg?raw';
|
||||
|
||||
export const icons = {
|
||||
house,
|
||||
users,
|
||||
calendar,
|
||||
'currency-eur': currencyEur,
|
||||
envelope,
|
||||
gear,
|
||||
images,
|
||||
} as const;
|
||||
|
||||
export type IconName = keyof typeof icons;
|
||||
1
app/src/lib/icons/calendar.svg
Normal file
|
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256"><rect width="256" height="256" fill="none"/><rect x="40" y="40" width="176" height="176" rx="8" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="16"/><line x1="176" y1="24" x2="176" y2="56" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="16"/><line x1="80" y1="24" x2="80" y2="56" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="16"/><line x1="40" y1="88" x2="216" y2="88" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="16"/><polyline points="88 128 104 120 104 184" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="16"/><path d="M138.14,128a16,16,0,1,1,26.64,17.63L136,184h32" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="16"/></svg>
|
||||
|
After Width: | Height: | Size: 980 B |
1
app/src/lib/icons/currency-eur.svg
Normal file
|
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256"><rect width="256" height="256" fill="none"/><line x1="40" y1="112" x2="136" y2="112" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="16"/><line x1="40" y1="144" x2="120" y2="144" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="16"/><path d="M184,197.67A72,72,0,0,1,64,144V112A72,72,0,0,1,184,58.33" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="16"/></svg>
|
||||
|
After Width: | Height: | Size: 561 B |
1
app/src/lib/icons/envelope.svg
Normal file
|
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256"><rect width="256" height="256" fill="none"/><polyline points="224 56 128 144 32 56" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="16"/><path d="M32,56H224a0,0,0,0,1,0,0V192a8,8,0,0,1-8,8H40a8,8,0,0,1-8-8V56A0,0,0,0,1,32,56Z" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="16"/><line x1="110.55" y1="128" x2="34.47" y2="197.74" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="16"/><line x1="221.53" y1="197.74" x2="145.45" y2="128" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="16"/></svg>
|
||||
|
After Width: | Height: | Size: 743 B |
1
app/src/lib/icons/gear.svg
Normal file
|
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256"><rect width="256" height="256" fill="none"/><circle cx="128" cy="128" r="40" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="16"/><path d="M41.43,178.09A99.14,99.14,0,0,1,31.36,153.8l16.78-21a81.59,81.59,0,0,1,0-9.64l-16.77-21a99.43,99.43,0,0,1,10.05-24.3l26.71-3a81,81,0,0,1,6.81-6.81l3-26.7A99.14,99.14,0,0,1,102.2,31.36l21,16.78a81.59,81.59,0,0,1,9.64,0l21-16.77a99.43,99.43,0,0,1,24.3,10.05l3,26.71a81,81,0,0,1,6.81,6.81l26.7,3a99.14,99.14,0,0,1,10.07,24.29l-16.78,21a81.59,81.59,0,0,1,0,9.64l16.77,21a99.43,99.43,0,0,1-10,24.3l-26.71,3a81,81,0,0,1-6.81,6.81l-3,26.7a99.14,99.14,0,0,1-24.29,10.07l-21-16.78a81.59,81.59,0,0,1-9.64,0l-21,16.77a99.43,99.43,0,0,1-24.3-10l-3-26.71a81,81,0,0,1-6.81-6.81Z" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="16"/></svg>
|
||||
|
After Width: | Height: | Size: 920 B |
1
app/src/lib/icons/house.svg
Normal file
|
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256"><rect width="256" height="256" fill="none"/><path d="M104,216V152h48v64h64V120a8,8,0,0,0-2.34-5.66l-80-80a8,8,0,0,0-11.32,0l-80,80A8,8,0,0,0,40,120v96Z" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="16"/></svg>
|
||||
|
After Width: | Height: | Size: 321 B |
1
app/src/lib/icons/images.svg
Normal file
|
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256"><rect width="256" height="256" fill="none"/><rect x="64" y="48" width="160" height="128" rx="8" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="16"/><circle cx="172" cy="84" r="12"/><path d="M64,128.69l38.34-38.35a8,8,0,0,1,11.32,0L163.31,140,189,114.34a8,8,0,0,1,11.31,0L224,138.06" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="16"/><path d="M192,176v24a8,8,0,0,1-8,8H40a8,8,0,0,1-8-8V88a8,8,0,0,1,8-8H64" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="16"/></svg>
|
||||
|
After Width: | Height: | Size: 672 B |
1
app/src/lib/icons/users.svg
Normal file
|
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256"><rect width="256" height="256" fill="none"/><circle cx="84" cy="108" r="52" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="16"/><path d="M10.23,200a88,88,0,0,1,147.54,0" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="16"/><path d="M172,160a87.93,87.93,0,0,1,73.77,40" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="16"/><path d="M152.69,59.7A52,52,0,1,1,172,160" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="16"/></svg>
|
||||
|
After Width: | Height: | Size: 675 B |
109
app/src/lib/sepa.ts
Normal file
|
|
@ -0,0 +1,109 @@
|
|||
export interface SepaKopf {
|
||||
glaeubigerid: string;
|
||||
vereinIban: string;
|
||||
vereinBic: string;
|
||||
vereinName: string;
|
||||
einzugsdatum: string; // YYYY-MM-DD
|
||||
}
|
||||
|
||||
export interface SepaPosition {
|
||||
endToEndId: string;
|
||||
betrag: number;
|
||||
mandatsreferenz: string;
|
||||
mandatsdatum: string; // YYYY-MM-DD
|
||||
debitorName: string;
|
||||
debitorIban: string;
|
||||
debitorBic: string;
|
||||
verwendungszweck: string;
|
||||
}
|
||||
|
||||
function x(str: string): string {
|
||||
return str
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"');
|
||||
}
|
||||
|
||||
function iban(raw: string): string {
|
||||
return raw.replace(/\s/g, '').toUpperCase();
|
||||
}
|
||||
|
||||
export function generatePain008(kopf: SepaKopf, positionen: SepaPosition[]): string {
|
||||
if (positionen.length === 0) throw new Error('Keine Positionen für den SEPA-Export.');
|
||||
|
||||
const now = new Date().toISOString().slice(0, 19);
|
||||
const msgId = `VH-${Date.now()}`;
|
||||
const ctrlSum = positionen.reduce((s, p) => s + p.betrag, 0).toFixed(2);
|
||||
const nbOfTxs = positionen.length;
|
||||
|
||||
const txXml = positionen.map((p) => `
|
||||
<DrctDbtTxInf>
|
||||
<PmtId><EndToEndId>${x(p.endToEndId)}</EndToEndId></PmtId>
|
||||
<InstdAmt Ccy="EUR">${p.betrag.toFixed(2)}</InstdAmt>
|
||||
<DrctDbtTx>
|
||||
<MndtRltdInf>
|
||||
<MndtId>${x(p.mandatsreferenz)}</MndtId>
|
||||
<DtOfSgntr>${p.mandatsdatum}</DtOfSgntr>
|
||||
</MndtRltdInf>
|
||||
<CdtrSchmeId><Id><PrvtId><Othr>
|
||||
<Id>${x(kopf.glaeubigerid)}</Id>
|
||||
<SchmeNm><Prtry>SEPA</Prtry></SchmeNm>
|
||||
</Othr></PrvtId></Id></CdtrSchmeId>
|
||||
</DrctDbtTx>
|
||||
<DbtrAgt><FinInstnId>${p.debitorBic ? `<BIC>${x(p.debitorBic)}</BIC>` : '<Othr><Id>NOTPROVIDED</Id></Othr>'}</FinInstnId></DbtrAgt>
|
||||
<Dbtr><Nm>${x(p.debitorName)}</Nm></Dbtr>
|
||||
<DbtrAcct><Id><IBAN>${iban(p.debitorIban)}</IBAN></Id></DbtrAcct>
|
||||
<RmtInf><Ustrd>${x(p.verwendungszweck.slice(0, 140))}</Ustrd></RmtInf>
|
||||
</DrctDbtTxInf>`).join('');
|
||||
|
||||
return `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Document xmlns="urn:iso:std:iso:20022:tech:xsd:pain.008.003.02"
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">
|
||||
<CstmrDrctDbtInitn>
|
||||
<GrpHdr>
|
||||
<MsgId>${msgId}</MsgId>
|
||||
<CreDtTm>${now}</CreDtTm>
|
||||
<NbOfTxs>${nbOfTxs}</NbOfTxs>
|
||||
<CtrlSum>${ctrlSum}</CtrlSum>
|
||||
<InitgPty><Nm>${x(kopf.vereinName)}</Nm></InitgPty>
|
||||
</GrpHdr>
|
||||
<PmtInf>
|
||||
<PmtInfId>${msgId}-PI</PmtInfId>
|
||||
<PmtMtd>DD</PmtMtd>
|
||||
<NbOfTxs>${nbOfTxs}</NbOfTxs>
|
||||
<CtrlSum>${ctrlSum}</CtrlSum>
|
||||
<PmtTpInf>
|
||||
<SvcLvl><Cd>SEPA</Cd></SvcLvl>
|
||||
<LclInstrm><Cd>CORE</Cd></LclInstrm>
|
||||
<SeqTp>RCUR</SeqTp>
|
||||
</PmtTpInf>
|
||||
<ReqdColltnDt>${kopf.einzugsdatum}</ReqdColltnDt>
|
||||
<Cdtr><Nm>${x(kopf.vereinName)}</Nm></Cdtr>
|
||||
<CdtrAcct><Id><IBAN>${iban(kopf.vereinIban)}</IBAN></Id></CdtrAcct>
|
||||
<CdtrAgt><FinInstnId><BIC>${x(kopf.vereinBic)}</BIC></FinInstnId></CdtrAgt>
|
||||
<CdtrSchmeId><Id><PrvtId><Othr>
|
||||
<Id>${x(kopf.glaeubigerid)}</Id>
|
||||
<SchmeNm><Prtry>SEPA</Prtry></SchmeNm>
|
||||
</Othr></PrvtId></Id></CdtrSchmeId>${txXml}
|
||||
</PmtInf>
|
||||
</CstmrDrctDbtInitn>
|
||||
</Document>`;
|
||||
}
|
||||
|
||||
export function downloadXml(xml: string, dateiname: string) {
|
||||
const blob = new Blob([xml], { type: 'application/xml;charset=utf-8' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = dateiname;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
export function minEinzugsdatum(): string {
|
||||
// SEPA CORE RCUR: mindestens 2 Bankarbeitstage Vorlaufzeit (vereinfacht: +3 Tage)
|
||||
const d = new Date();
|
||||
d.setDate(d.getDate() + 3);
|
||||
return d.toISOString().slice(0, 10);
|
||||
}
|
||||
52
app/src/lib/server/auth.ts
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
import { SignJWT, jwtVerify } from 'jose';
|
||||
import bcrypt from 'bcryptjs';
|
||||
import { error } from '@sveltejs/kit';
|
||||
|
||||
const JWT_SECRET = new TextEncoder().encode(
|
||||
process.env.JWT_SECRET || 'vereinshaus-dev-secret-change-in-production'
|
||||
);
|
||||
|
||||
export interface JwtPayload {
|
||||
sub: string;
|
||||
verein_id: string;
|
||||
rolle: string | null;
|
||||
name: string;
|
||||
email: string;
|
||||
}
|
||||
|
||||
export async function signJwt(payload: JwtPayload): Promise<string> {
|
||||
return new SignJWT({ ...payload })
|
||||
.setProtectedHeader({ alg: 'HS256' })
|
||||
.setExpirationTime('30d')
|
||||
.sign(JWT_SECRET);
|
||||
}
|
||||
|
||||
export async function verifyJwt(token: string): Promise<JwtPayload | null> {
|
||||
try {
|
||||
const { payload } = await jwtVerify(token, JWT_SECRET);
|
||||
return payload as unknown as JwtPayload;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function hashPassword(password: string): Promise<string> {
|
||||
return bcrypt.hash(password, 12);
|
||||
}
|
||||
|
||||
export async function checkPassword(password: string, hash: string): Promise<boolean> {
|
||||
return bcrypt.compare(password, hash);
|
||||
}
|
||||
|
||||
export function bearerToken(request: Request): string | null {
|
||||
const h = request.headers.get('Authorization');
|
||||
return h?.startsWith('Bearer ') ? h.slice(7) : null;
|
||||
}
|
||||
|
||||
export async function requireAuth(request: Request): Promise<JwtPayload> {
|
||||
const token = bearerToken(request);
|
||||
if (!token) throw error(401, 'Nicht authentifiziert');
|
||||
const user = await verifyJwt(token);
|
||||
if (!user) throw error(401, 'Ungültiger Token');
|
||||
return user;
|
||||
}
|
||||
219
app/src/lib/server/db.ts
Normal file
|
|
@ -0,0 +1,219 @@
|
|||
import Database from 'better-sqlite3';
|
||||
import { mkdirSync, existsSync } from 'fs';
|
||||
import { dirname } from 'path';
|
||||
|
||||
const DB_PATH = process.env.DB_PATH || './data/vereinshaus.db';
|
||||
|
||||
const SCHEMA = `
|
||||
PRAGMA journal_mode = WAL;
|
||||
PRAGMA foreign_keys = ON;
|
||||
PRAGMA busy_timeout = 5000;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS vereine (
|
||||
id TEXT PRIMARY KEY NOT NULL DEFAULT (lower(hex(randomblob(8)))),
|
||||
name TEXT NOT NULL,
|
||||
adresse TEXT, plz TEXT, ort TEXT, bundesland TEXT,
|
||||
plan TEXT NOT NULL DEFAULT 'free',
|
||||
dosb_mitglied INTEGER NOT NULL DEFAULT 0,
|
||||
email TEXT, telefon TEXT, website TEXT,
|
||||
glaeubigerid TEXT, iban TEXT, bic TEXT,
|
||||
created TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now')),
|
||||
updated TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now'))
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id TEXT PRIMARY KEY NOT NULL DEFAULT (lower(hex(randomblob(8)))),
|
||||
verein_id TEXT NOT NULL REFERENCES vereine(id) ON DELETE CASCADE,
|
||||
email TEXT NOT NULL UNIQUE,
|
||||
password_hash TEXT NOT NULL,
|
||||
name TEXT NOT NULL DEFAULT '',
|
||||
rolle TEXT DEFAULT NULL,
|
||||
created TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now')),
|
||||
updated TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now'))
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS gruppen (
|
||||
id TEXT PRIMARY KEY NOT NULL DEFAULT (lower(hex(randomblob(8)))),
|
||||
verein_id TEXT NOT NULL REFERENCES vereine(id) ON DELETE CASCADE,
|
||||
name TEXT NOT NULL,
|
||||
beschreibung TEXT,
|
||||
trainer_ids TEXT NOT NULL DEFAULT '[]',
|
||||
created TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now')),
|
||||
updated TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now'))
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS mitglieder (
|
||||
id TEXT PRIMARY KEY NOT NULL DEFAULT (lower(hex(randomblob(8)))),
|
||||
verein_id TEXT NOT NULL REFERENCES vereine(id) ON DELETE CASCADE,
|
||||
vorname TEXT NOT NULL,
|
||||
nachname TEXT NOT NULL,
|
||||
email TEXT, telefon TEXT,
|
||||
geburtsdatum TEXT, eintrittsdatum TEXT, austrittsdatum TEXT,
|
||||
strasse TEXT, plz TEXT, ort TEXT,
|
||||
iban TEXT, bic TEXT,
|
||||
gruppe_ids TEXT NOT NULL DEFAULT '[]',
|
||||
status TEXT NOT NULL DEFAULT 'aktiv',
|
||||
notizen TEXT,
|
||||
mandatsreferenz TEXT, mandatsdatum TEXT,
|
||||
created TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now')),
|
||||
updated TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now'))
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS beitraege (
|
||||
id TEXT PRIMARY KEY NOT NULL DEFAULT (lower(hex(randomblob(8)))),
|
||||
verein_id TEXT NOT NULL REFERENCES vereine(id) ON DELETE CASCADE,
|
||||
name TEXT NOT NULL,
|
||||
betrag REAL NOT NULL,
|
||||
rhythmus TEXT NOT NULL DEFAULT 'jaehrlich',
|
||||
beschreibung TEXT,
|
||||
created TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now')),
|
||||
updated TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now'))
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS einzuege (
|
||||
id TEXT PRIMARY KEY NOT NULL DEFAULT (lower(hex(randomblob(8)))),
|
||||
verein_id TEXT NOT NULL REFERENCES vereine(id) ON DELETE CASCADE,
|
||||
mitglied_id TEXT NOT NULL REFERENCES mitglieder(id) ON DELETE CASCADE,
|
||||
beitrag_id TEXT NOT NULL REFERENCES beitraege(id),
|
||||
betrag REAL NOT NULL,
|
||||
faellig_am TEXT,
|
||||
status TEXT NOT NULL DEFAULT 'ausstehend',
|
||||
created TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now')),
|
||||
updated TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now'))
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS veranstaltungsorte (
|
||||
id TEXT PRIMARY KEY NOT NULL DEFAULT (lower(hex(randomblob(8)))),
|
||||
verein_id TEXT NOT NULL REFERENCES vereine(id) ON DELETE CASCADE,
|
||||
name TEXT NOT NULL,
|
||||
adresse TEXT,
|
||||
typ TEXT DEFAULT 'sonstiges',
|
||||
aktiv INTEGER NOT NULL DEFAULT 1,
|
||||
created TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now')),
|
||||
updated TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now'))
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ort_ausfaelle (
|
||||
id TEXT PRIMARY KEY NOT NULL DEFAULT (lower(hex(randomblob(8)))),
|
||||
ort_id TEXT NOT NULL REFERENCES veranstaltungsorte(id) ON DELETE CASCADE,
|
||||
von TEXT NOT NULL, bis TEXT NOT NULL, grund TEXT,
|
||||
created TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now')),
|
||||
updated TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now'))
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS termine (
|
||||
id TEXT PRIMARY KEY NOT NULL DEFAULT (lower(hex(randomblob(8)))),
|
||||
verein_id TEXT NOT NULL REFERENCES vereine(id) ON DELETE CASCADE,
|
||||
titel TEXT NOT NULL,
|
||||
beschreibung TEXT,
|
||||
beginn TEXT NOT NULL,
|
||||
ende TEXT,
|
||||
ort TEXT,
|
||||
ort_id TEXT REFERENCES veranstaltungsorte(id) ON DELETE SET NULL,
|
||||
gruppe_ids TEXT NOT NULL DEFAULT '[]',
|
||||
durchfuehrender_id TEXT,
|
||||
verfuegbarkeit TEXT DEFAULT 'offen',
|
||||
rrule TEXT,
|
||||
serie_id TEXT,
|
||||
created TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now')),
|
||||
updated TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now'))
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS nachrichten (
|
||||
id TEXT PRIMARY KEY NOT NULL DEFAULT (lower(hex(randomblob(8)))),
|
||||
verein_id TEXT NOT NULL REFERENCES vereine(id) ON DELETE CASCADE,
|
||||
autor_id TEXT NOT NULL,
|
||||
betreff TEXT NOT NULL,
|
||||
text TEXT NOT NULL DEFAULT '',
|
||||
gruppe_ids TEXT NOT NULL DEFAULT '[]',
|
||||
gesendet_am TEXT,
|
||||
created TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now')),
|
||||
updated TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now'))
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS neuigkeiten (
|
||||
id TEXT PRIMARY KEY NOT NULL DEFAULT (lower(hex(randomblob(8)))),
|
||||
verein_id TEXT NOT NULL REFERENCES vereine(id) ON DELETE CASCADE,
|
||||
autor_id TEXT NOT NULL,
|
||||
autor_name TEXT NOT NULL DEFAULT '',
|
||||
text TEXT,
|
||||
medien TEXT NOT NULL DEFAULT '[]',
|
||||
gruppe_ids TEXT NOT NULL DEFAULT '[]',
|
||||
termin_id TEXT REFERENCES termine(id) ON DELETE SET NULL,
|
||||
created TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now')),
|
||||
updated TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now'))
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS reaktionen (
|
||||
id TEXT PRIMARY KEY NOT NULL DEFAULT (lower(hex(randomblob(8)))),
|
||||
beitrag_id TEXT NOT NULL REFERENCES neuigkeiten(id) ON DELETE CASCADE,
|
||||
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
created TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now')),
|
||||
UNIQUE(beitrag_id, user_id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS einladungen (
|
||||
id TEXT PRIMARY KEY NOT NULL DEFAULT (lower(hex(randomblob(8)))),
|
||||
verein_id TEXT NOT NULL REFERENCES vereine(id) ON DELETE CASCADE,
|
||||
token TEXT NOT NULL UNIQUE DEFAULT (lower(hex(randomblob(16)))),
|
||||
rolle TEXT NOT NULL DEFAULT 'trainer',
|
||||
genutzt INTEGER NOT NULL DEFAULT 0,
|
||||
created TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now')),
|
||||
updated TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now'))
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS push_subscriptions (
|
||||
id TEXT PRIMARY KEY NOT NULL DEFAULT (lower(hex(randomblob(8)))),
|
||||
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
verein_id TEXT NOT NULL REFERENCES vereine(id) ON DELETE CASCADE,
|
||||
endpoint TEXT NOT NULL UNIQUE,
|
||||
p256dh TEXT NOT NULL,
|
||||
auth TEXT NOT NULL,
|
||||
created TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now'))
|
||||
);
|
||||
`;
|
||||
|
||||
let _db: Database.Database | null = null;
|
||||
|
||||
export function getDb(): Database.Database {
|
||||
if (_db) return _db;
|
||||
const dir = dirname(DB_PATH);
|
||||
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
||||
_db = new Database(DB_PATH);
|
||||
_db.exec(SCHEMA);
|
||||
return _db;
|
||||
}
|
||||
|
||||
export function newId(): string {
|
||||
const bytes = new Uint8Array(8);
|
||||
crypto.getRandomValues(bytes);
|
||||
return Array.from(bytes).map(b => b.toString(16).padStart(2, '0')).join('');
|
||||
}
|
||||
|
||||
export function parseArr(val: unknown): string[] {
|
||||
if (Array.isArray(val)) return val;
|
||||
if (typeof val === 'string') { try { return JSON.parse(val); } catch { return []; } }
|
||||
return [];
|
||||
}
|
||||
|
||||
export function toArr(val: unknown): string {
|
||||
return JSON.stringify(Array.isArray(val) ? val : []);
|
||||
}
|
||||
|
||||
export function row<T extends Record<string, unknown>>(r: T): T {
|
||||
const out: Record<string, unknown> = {};
|
||||
for (const [k, v] of Object.entries(r)) {
|
||||
if (typeof v === 'string' && (k.endsWith('_ids') || k === 'medien' || k === 'trainer_ids')) {
|
||||
out[k] = parseArr(v);
|
||||
} else if (typeof v === 'number' && (k === 'aktiv' || k === 'dosb_mitglied' || k === 'genutzt')) {
|
||||
out[k] = Boolean(v);
|
||||
} else {
|
||||
out[k] = v;
|
||||
}
|
||||
}
|
||||
return out as T;
|
||||
}
|
||||
|
||||
export function rows<T extends Record<string, unknown>>(rs: T[]): T[] {
|
||||
return rs.map(r => row(r));
|
||||
}
|
||||
59
app/src/lib/styles/theme.css
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
:root {
|
||||
/* Primärfarbe */
|
||||
--c-primary: #1e40af;
|
||||
--c-primary-dark: #1d3a9e;
|
||||
--c-primary-light: #e0e7ff;
|
||||
--c-primary-subtle: #eff6ff;
|
||||
|
||||
/* Text */
|
||||
--c-text: #1e293b;
|
||||
--c-text-secondary: #475569;
|
||||
--c-text-muted: #64748b;
|
||||
--c-text-hint: #94a3b8;
|
||||
|
||||
/* Hintergrund & Rahmen */
|
||||
--c-border: #e2e8f0;
|
||||
--c-bg: #f1f5f9;
|
||||
--c-bg-card: #ffffff;
|
||||
--c-bg-subtle: #f8fafc;
|
||||
|
||||
/* Dunkel (Theme-Farbe, Header, PWA) */
|
||||
--c-dark: #0f172a;
|
||||
|
||||
/* Fehler / Rot */
|
||||
--c-error: #dc2626;
|
||||
--c-error-dark: #b91c1c;
|
||||
--c-error-light: #fca5a5;
|
||||
--c-error-bg: #fee2e2;
|
||||
--c-error-subtle: #fef2f2;
|
||||
|
||||
/* Erfolg / Grün */
|
||||
--c-success: #16a34a;
|
||||
--c-success-light: #86efac;
|
||||
--c-success-bg: #dcfce7;
|
||||
|
||||
/* Warnung / Gelb-Amber */
|
||||
--c-warning: #f59e0b;
|
||||
--c-warning-light: #fde047;
|
||||
--c-warning-bg: #fef9c3;
|
||||
--c-warning-subtle: #fffbeb;
|
||||
--c-warning-dark: #92400e;
|
||||
--c-warning-darker: #713f12;
|
||||
|
||||
/* Akzent / Lila (Plan-Badges) */
|
||||
--c-accent: #7c3aed;
|
||||
--c-accent-subtle: #ede9fe;
|
||||
|
||||
/* Primärfarbe – weitere Töne */
|
||||
--c-primary-100: #bfdbfe;
|
||||
--c-primary-200: #c7d2fe;
|
||||
--c-primary-bg: #f0f9ff;
|
||||
|
||||
/* Erfolg – weitere Töne */
|
||||
--c-success-dark: #166534;
|
||||
--c-success-subtle: #f0fdf4;
|
||||
|
||||
/* Warnung – weitere Töne */
|
||||
--c-warning-amber: #854d0e;
|
||||
--c-warning-pale: #fefce8;
|
||||
}
|
||||
|
|
@ -1,18 +1,23 @@
|
|||
export type Plan = 'free' | 'starter' | 'wachstum' | 'verband';
|
||||
export type MitgliedStatus = 'aktiv' | 'passiv' | 'ausgetreten';
|
||||
export type EinzugStatus = 'ausstehend' | 'eingezogen' | 'fehlgeschlagen';
|
||||
export type Rhythmus = 'monatlich' | 'quartalsweise' | 'halbjaehrlich' | 'jaehrlich';
|
||||
export type EinzugStatus = 'ausstehend' | 'eingezogen' | 'fehlgeschlagen' | 'storniert';
|
||||
export type Rhythmus = 'monatlich' | 'quartalsweise' | 'halbjaehrlich' | 'jaehrlich' | 'einmalig';
|
||||
|
||||
export interface Verein {
|
||||
id: string;
|
||||
name: string;
|
||||
adresse: string;
|
||||
plz: string;
|
||||
ort: string;
|
||||
bundesland: string;
|
||||
adresse?: string;
|
||||
plz?: string;
|
||||
ort?: string;
|
||||
bundesland?: string;
|
||||
plan: Plan;
|
||||
stripe_customer_id?: string;
|
||||
dosb_mitglied: boolean;
|
||||
email?: string;
|
||||
telefon?: string;
|
||||
website?: string;
|
||||
glaeubigerid?: string;
|
||||
iban?: string;
|
||||
bic?: string;
|
||||
}
|
||||
|
||||
export interface Mitglied {
|
||||
|
|
@ -20,26 +25,77 @@ export interface Mitglied {
|
|||
verein_id: string;
|
||||
vorname: string;
|
||||
nachname: string;
|
||||
email: string;
|
||||
email?: string;
|
||||
telefon?: string;
|
||||
geburtsdatum?: string;
|
||||
eintrittsdatum: string;
|
||||
eintrittsdatum?: string;
|
||||
austrittsdatum?: string;
|
||||
strasse: string;
|
||||
plz: string;
|
||||
ort: string;
|
||||
strasse?: string;
|
||||
plz?: string;
|
||||
ort?: string;
|
||||
iban?: string;
|
||||
bic?: string;
|
||||
gruppe_ids: string[];
|
||||
status: MitgliedStatus;
|
||||
notizen?: string;
|
||||
mandatsreferenz?: string;
|
||||
mandatsdatum?: string;
|
||||
}
|
||||
|
||||
export type Rolle = 'admin' | 'trainer';
|
||||
|
||||
export interface Neuigkeit {
|
||||
id: string;
|
||||
verein_id: string;
|
||||
autor_id: string;
|
||||
autor_name?: string;
|
||||
text?: string;
|
||||
medien: string[];
|
||||
gruppe_ids: string[];
|
||||
termin_id?: string;
|
||||
created: string;
|
||||
}
|
||||
|
||||
export interface Reaktion {
|
||||
id: string;
|
||||
beitrag_id: string;
|
||||
user_id: string;
|
||||
}
|
||||
export type Verfuegbarkeit = 'offen' | 'bestaetigt' | 'abgesagt' | 'vertretung_gesucht';
|
||||
|
||||
export interface Gruppe {
|
||||
id: string;
|
||||
verein_id: string;
|
||||
name: string;
|
||||
beschreibung?: 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 {
|
||||
id: string;
|
||||
verein_id: string;
|
||||
rolle: Rolle;
|
||||
token: string;
|
||||
genutzt: boolean;
|
||||
}
|
||||
|
||||
export interface Beitrag {
|
||||
|
|
@ -53,13 +109,11 @@ export interface Beitrag {
|
|||
|
||||
export interface Einzug {
|
||||
id: string;
|
||||
verein_id: string;
|
||||
mitglied_id: string;
|
||||
beitrag_id: string;
|
||||
betrag: number;
|
||||
faelligkeitsdatum: string;
|
||||
faellig_am?: string;
|
||||
status: EinzugStatus;
|
||||
stripe_payment_intent_id?: string;
|
||||
}
|
||||
|
||||
export interface Termin {
|
||||
|
|
@ -71,6 +125,11 @@ export interface Termin {
|
|||
ende?: string;
|
||||
ort?: string;
|
||||
gruppe_ids: string[];
|
||||
durchfuehrender_id?: string;
|
||||
verfuegbarkeit?: Verfuegbarkeit;
|
||||
rrule?: string;
|
||||
serie_id?: string;
|
||||
ort_id?: string;
|
||||
}
|
||||
|
||||
export interface Nachricht {
|
||||
|
|
@ -80,5 +139,5 @@ export interface Nachricht {
|
|||
betreff: string;
|
||||
text: string;
|
||||
gruppe_ids: string[];
|
||||
gesendet_am: string;
|
||||
gesendet_am?: string;
|
||||
}
|
||||
|
|
|
|||
34
app/src/lib/user.ts
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
import { writable } from 'svelte/store';
|
||||
import { browser } from '$app/environment';
|
||||
|
||||
export interface AppUser {
|
||||
id: string;
|
||||
verein_id: string;
|
||||
rolle: string | null;
|
||||
name: string;
|
||||
email: string;
|
||||
token: string;
|
||||
}
|
||||
|
||||
function createUserStore() {
|
||||
let initial: AppUser | null = null;
|
||||
if (browser) {
|
||||
try { initial = JSON.parse(localStorage.getItem('vh_user') || 'null'); } catch { /* */ }
|
||||
}
|
||||
|
||||
const { subscribe, set } = writable<AppUser | null>(initial);
|
||||
|
||||
return {
|
||||
subscribe,
|
||||
set(u: AppUser | null) {
|
||||
if (browser) {
|
||||
if (u) localStorage.setItem('vh_user', JSON.stringify(u));
|
||||
else localStorage.removeItem('vh_user');
|
||||
}
|
||||
set(u);
|
||||
},
|
||||
clear() { this.set(null); }
|
||||
};
|
||||
}
|
||||
|
||||
export const user = createUserStore();
|
||||
|
|
@ -2,34 +2,82 @@
|
|||
import { goto } from '$app/navigation';
|
||||
import { page } from '$app/stores';
|
||||
import { onMount } from 'svelte';
|
||||
import { pb } from '$lib/pb';
|
||||
import { user } from '$lib/user';
|
||||
import Icon from '$lib/components/Icon.svelte';
|
||||
import type { IconName } from '$lib/icons';
|
||||
|
||||
let { children } = $props();
|
||||
|
||||
let zeigInstallBanner = $state(false);
|
||||
|
||||
onMount(() => {
|
||||
if (!pb.authStore.isValid) {
|
||||
goto('/login');
|
||||
}
|
||||
if (!$user) { goto('/login'); return; }
|
||||
if (!$user.verein_id) { goto('/onboarding'); return; }
|
||||
registerPush();
|
||||
|
||||
// iOS Safari: Banner anzeigen wenn App noch nicht installiert
|
||||
const isIos = /iphone|ipad|ipod/i.test(navigator.userAgent);
|
||||
const isStandalone = window.matchMedia('(display-mode: standalone)').matches
|
||||
|| (navigator as any).standalone === true;
|
||||
const dismissed = sessionStorage.getItem('install-banner-dismissed');
|
||||
if (isIos && !isStandalone && !dismissed) zeigInstallBanner = true;
|
||||
});
|
||||
|
||||
function logout() {
|
||||
pb.authStore.clear();
|
||||
goto('/login');
|
||||
async function registerPush() {
|
||||
if (!('serviceWorker' in navigator) || !('PushManager' in window)) return;
|
||||
const permission = await Notification.requestPermission();
|
||||
if (permission !== 'granted') return;
|
||||
try {
|
||||
const reg = await navigator.serviceWorker.ready;
|
||||
const keyRes = await fetch('/api/push/key');
|
||||
const { publicKey } = await keyRes.json();
|
||||
if (!publicKey) return;
|
||||
let sub = await reg.pushManager.getSubscription();
|
||||
if (!sub) {
|
||||
sub = await reg.pushManager.subscribe({
|
||||
userVisibleOnly: true,
|
||||
applicationServerKey: urlBase64ToUint8Array(publicKey) as BufferSource,
|
||||
});
|
||||
}
|
||||
await fetch('/api/push/subscribe', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${$user?.token}` },
|
||||
body: JSON.stringify({ subscription: sub.toJSON(), userId: $user?.id }),
|
||||
});
|
||||
} catch (e) {
|
||||
console.warn('[push] Registrierung fehlgeschlagen:', e);
|
||||
}
|
||||
}
|
||||
|
||||
const navItems = [
|
||||
{ href: '/', label: 'Übersicht', icon: '⊞' },
|
||||
{ href: '/mitglieder', label: 'Mitglieder', icon: '👥' },
|
||||
{ href: '/termine', label: 'Termine', icon: '📅' },
|
||||
{ href: '/beitraege', label: 'Beiträge', icon: '💶' },
|
||||
{ href: '/nachrichten', label: 'Nachrichten', icon: '✉️' },
|
||||
function urlBase64ToUint8Array(base64String: string): Uint8Array {
|
||||
const padding = '='.repeat((4 - (base64String.length % 4)) % 4);
|
||||
const base64 = (base64String + padding).replace(/-/g, '+').replace(/_/g, '/');
|
||||
const raw = atob(base64);
|
||||
return Uint8Array.from([...raw].map((c) => c.charCodeAt(0)));
|
||||
}
|
||||
|
||||
const isAdmin = () => !$user?.rolle || $user?.rolle === 'admin';
|
||||
|
||||
const allNavItems: { href: string; label: string; icon: IconName; adminOnly?: boolean }[] = [
|
||||
{ href: '/neuigkeiten', label: 'Neuigkeiten', icon: 'images' },
|
||||
{ href: '/mitglieder', label: 'Mitglieder', icon: 'users' },
|
||||
{ href: '/termine', label: 'Termine', icon: 'calendar' },
|
||||
{ href: '/beitraege', label: 'Beiträge', icon: 'currency-eur', adminOnly: true },
|
||||
{ href: '/nachrichten', label: 'Nachrichten', icon: 'envelope' },
|
||||
];
|
||||
|
||||
const navItems = $derived(allNavItems.filter(i => !i.adminOnly || isAdmin()));
|
||||
</script>
|
||||
|
||||
<div class="shell">
|
||||
<header>
|
||||
<span class="logo">vereins.haus</span>
|
||||
<button class="logout-btn" onclick={logout}>Abmelden</button>
|
||||
<a href="/" class="logo">
|
||||
<img src="/favicon.svg" alt="" width="28" height="28" />
|
||||
vereins.haus
|
||||
</a>
|
||||
<a href="/einstellungen" class="header-icon" aria-label="Einstellungen">
|
||||
<Icon name="gear" size={22} />
|
||||
</a>
|
||||
</header>
|
||||
|
||||
<main>
|
||||
|
|
@ -42,13 +90,23 @@
|
|||
href={item.href}
|
||||
class:active={$page.url.pathname === item.href}
|
||||
>
|
||||
<span class="nav-icon">{item.icon}</span>
|
||||
<Icon name={item.icon} size={22} />
|
||||
<span class="nav-label">{item.label}</span>
|
||||
</a>
|
||||
{/each}
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
{#if zeigInstallBanner}
|
||||
<div class="install-banner">
|
||||
<div class="install-text">
|
||||
<strong>Zum Homescreen hinzufügen</strong>
|
||||
<span>Tippe auf <strong>Teilen</strong> → <strong>Zum Home-Bildschirm</strong> für die Vollbild-App</span>
|
||||
</div>
|
||||
<button class="install-close" onclick={() => { zeigInstallBanner = false; sessionStorage.setItem('install-banner-dismissed', '1'); }}>✕</button>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.shell {
|
||||
display: flex;
|
||||
|
|
@ -56,6 +114,11 @@
|
|||
min-height: 100dvh;
|
||||
}
|
||||
|
||||
/* iOS PWA: Statusleiste oben nicht überlagern */
|
||||
@supports (padding-top: env(safe-area-inset-top)) {
|
||||
header { padding-top: calc(0.75rem + env(safe-area-inset-top)); }
|
||||
}
|
||||
|
||||
header {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
|
|
@ -64,28 +127,29 @@
|
|||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0.75rem 1rem;
|
||||
background: #fff;
|
||||
border-bottom: 1px solid #e2e8f0;
|
||||
background: var(--c-bg-card);
|
||||
border-bottom: 1px solid var(--c-border);
|
||||
}
|
||||
|
||||
.logo {
|
||||
font-size: 1.1rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.45rem;
|
||||
font-size: 1.05rem;
|
||||
font-weight: 700;
|
||||
color: #1e40af;
|
||||
color: var(--c-dark);
|
||||
letter-spacing: -0.02em;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.logout-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
font-size: 0.85rem;
|
||||
color: #64748b;
|
||||
padding: 0.25rem 0.5rem;
|
||||
}
|
||||
|
||||
.logout-btn:hover {
|
||||
color: #1e293b;
|
||||
.header-icon {
|
||||
color: var(--c-text-muted);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0.25rem;
|
||||
transition: color 0.15s;
|
||||
}
|
||||
.header-icon:hover { color: var(--c-text); }
|
||||
|
||||
main {
|
||||
flex: 1;
|
||||
|
|
@ -103,8 +167,8 @@
|
|||
right: 0;
|
||||
height: calc(60px + env(safe-area-inset-bottom));
|
||||
padding-bottom: env(safe-area-inset-bottom);
|
||||
background: #fff;
|
||||
border-top: 1px solid #e2e8f0;
|
||||
background: var(--c-bg-card);
|
||||
border-top: 1px solid var(--c-border);
|
||||
display: flex;
|
||||
z-index: 10;
|
||||
}
|
||||
|
|
@ -116,23 +180,56 @@
|
|||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.15rem;
|
||||
color: #94a3b8;
|
||||
color: var(--c-text-hint);
|
||||
transition: color .15s;
|
||||
font-size: 0.7rem;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.bottom-nav a.active {
|
||||
color: #1e40af;
|
||||
}
|
||||
|
||||
.nav-icon {
|
||||
font-size: 1.3rem;
|
||||
line-height: 1;
|
||||
color: var(--c-primary);
|
||||
}
|
||||
|
||||
.nav-label {
|
||||
font-size: 0.65rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* iOS Installations-Banner */
|
||||
.install-banner {
|
||||
position: fixed;
|
||||
bottom: calc(60px + env(safe-area-inset-bottom) + 0.5rem);
|
||||
left: 1rem;
|
||||
right: 1rem;
|
||||
background: var(--c-dark);
|
||||
color: #fff;
|
||||
border-radius: 12px;
|
||||
padding: 0.85rem 1rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
z-index: 50;
|
||||
box-shadow: 0 4px 20px rgba(0,0,0,0.25);
|
||||
animation: slide-up 0.25s ease;
|
||||
}
|
||||
@keyframes slide-up {
|
||||
from { transform: translateY(1rem); opacity: 0; }
|
||||
to { transform: translateY(0); opacity: 1; }
|
||||
}
|
||||
.install-text {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.2rem;
|
||||
font-size: 0.82rem;
|
||||
line-height: 1.4;
|
||||
}
|
||||
.install-close {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--c-text-hint);
|
||||
font-size: 1rem;
|
||||
padding: 0.25rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -1,72 +1,141 @@
|
|||
<script lang="ts">
|
||||
import { pb } from '$lib/pb';
|
||||
import { api } from '$lib/api';
|
||||
import { onMount } from 'svelte';
|
||||
import type { Verein, Termin } from '$lib/types';
|
||||
|
||||
const vereinsname = pb.authStore.record?.name ?? 'Dein Verein';
|
||||
let verein = $state<Verein | null>(null);
|
||||
let termine = $state<Termin[]>([]);
|
||||
let loading = $state(true);
|
||||
|
||||
onMount(async () => {
|
||||
const now = new Date().toISOString();
|
||||
[verein, termine] = await Promise.all([
|
||||
api.get<Verein>('/vereine'),
|
||||
api.get<Termin[]>('/termine', { sort: 'beginn', page: '1', perPage: '3' }),
|
||||
]);
|
||||
termine = (termine as any)?.items ?? termine;
|
||||
loading = false;
|
||||
});
|
||||
|
||||
function formatTermin(t: Termin): string {
|
||||
const d = new Date(t.beginn);
|
||||
return d.toLocaleDateString('de-DE', { weekday: 'short', day: '2-digit', month: 'long' })
|
||||
+ ' · ' + d.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' }) + ' Uhr'
|
||||
+ (t.ort ? ' · ' + t.ort : '');
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Übersicht — vereins.haus</title>
|
||||
</svelte:head>
|
||||
|
||||
<h1>Willkommen, {vereinsname}</h1>
|
||||
|
||||
<div class="cards">
|
||||
<a href="/mitglieder" class="card">
|
||||
<span class="card-icon">👥</span>
|
||||
<span class="card-label">Mitglieder</span>
|
||||
</a>
|
||||
<a href="/termine" class="card">
|
||||
<span class="card-icon">📅</span>
|
||||
<span class="card-label">Termine</span>
|
||||
</a>
|
||||
<a href="/beitraege" class="card">
|
||||
<span class="card-icon">💶</span>
|
||||
<span class="card-label">Beiträge</span>
|
||||
</a>
|
||||
<a href="/nachrichten" class="card">
|
||||
<span class="card-icon">✉️</span>
|
||||
<span class="card-label">Nachrichten</span>
|
||||
</a>
|
||||
{#if !loading && verein}
|
||||
<div class="verein-header">
|
||||
<img src="/favicon.svg" alt="" class="verein-icon" />
|
||||
<div>
|
||||
<h1>{verein.name}</h1>
|
||||
{#if verein.ort}
|
||||
<span class="verein-ort">{verein.ort}</span>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if termine.length > 0}
|
||||
<section>
|
||||
<h2>Nächste Termine</h2>
|
||||
<ul>
|
||||
{#each termine as t (t.id)}
|
||||
<li>
|
||||
<span class="termin-titel">{t.titel}</span>
|
||||
<span class="termin-wann">{formatTermin(t)}</span>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
<a href="/termine" class="alle-link">Alle Termine →</a>
|
||||
</section>
|
||||
{:else}
|
||||
<p class="hint">Noch keine Termine geplant.</p>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
h1 {
|
||||
font-size: 1.4rem;
|
||||
font-weight: 700;
|
||||
color: #1e293b;
|
||||
.verein-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
padding: 1.25rem;
|
||||
background: var(--c-dark);
|
||||
border-radius: 14px;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.cards {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 0.75rem;
|
||||
.verein-icon {
|
||||
width: 52px;
|
||||
height: 52px;
|
||||
border-radius: 12px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.card {
|
||||
background: #fff;
|
||||
border: 1.5px solid #e2e8f0;
|
||||
h1 {
|
||||
font-size: 1.15rem;
|
||||
font-weight: 700;
|
||||
color: var(--c-bg-card);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.verein-ort {
|
||||
font-size: 0.82rem;
|
||||
color: var(--c-text-hint);
|
||||
}
|
||||
|
||||
section {
|
||||
background: var(--c-bg-card);
|
||||
border: 1px solid var(--c-border);
|
||||
border-radius: 12px;
|
||||
padding: 1.5rem 1rem;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-size: 0.72rem;
|
||||
font-weight: 700;
|
||||
color: var(--c-text-hint);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
padding: 0.85rem 1rem 0.5rem;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
ul {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
li {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
transition: border-color .15s, box-shadow .15s;
|
||||
gap: 0.15rem;
|
||||
padding: 0.7rem 1rem;
|
||||
border-top: 1px solid var(--c-bg);
|
||||
}
|
||||
|
||||
.card:hover {
|
||||
border-color: #1e40af;
|
||||
box-shadow: 0 2px 8px rgba(30,64,175,.1);
|
||||
.termin-titel { font-size: 0.92rem; font-weight: 600; color: var(--c-text); }
|
||||
.termin-wann { font-size: 0.78rem; color: var(--c-text-muted); }
|
||||
|
||||
.alle-link {
|
||||
display: block;
|
||||
padding: 0.7rem 1rem;
|
||||
border-top: 1px solid var(--c-bg);
|
||||
font-size: 0.85rem;
|
||||
color: var(--c-primary);
|
||||
text-decoration: none;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.card-icon {
|
||||
font-size: 2rem;
|
||||
}
|
||||
|
||||
.card-label {
|
||||
.hint {
|
||||
color: var(--c-text-hint);
|
||||
font-size: 0.9rem;
|
||||
font-weight: 600;
|
||||
color: #1e293b;
|
||||
text-align: center;
|
||||
margin-top: 2rem;
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -1,27 +1,531 @@
|
|||
<script lang="ts">
|
||||
import { api } from '$lib/api';
|
||||
import { user } from '$lib/user';
|
||||
import { get } from 'svelte/store';
|
||||
import { goto } from '$app/navigation';
|
||||
import { onMount } from 'svelte';
|
||||
import { generatePain008, downloadXml, minEinzugsdatum, type SepaPosition } from '$lib/sepa';
|
||||
import type { Beitrag, Mitglied, Verein } from '$lib/types';
|
||||
|
||||
// --- Daten ---
|
||||
let beitraege = $state<Beitrag[]>([]);
|
||||
let verein = $state<Verein | null>(null);
|
||||
let loading = $state(true);
|
||||
|
||||
// --- Beitragsart-Formular ---
|
||||
let showForm = $state(false);
|
||||
let editId = $state<string | null>(null);
|
||||
let fName = $state('');
|
||||
let fBetrag = $state('');
|
||||
let fRhythmus = $state<Beitrag['rhythmus']>('jaehrlich');
|
||||
let fBeschr = $state('');
|
||||
let saving = $state(false);
|
||||
let formError = $state('');
|
||||
|
||||
// Plan-Check
|
||||
const hatSepa = $derived(
|
||||
verein?.plan && ['starter', 'wachstum', 'verband'].includes(verein.plan)
|
||||
);
|
||||
|
||||
// --- SEPA-Export ---
|
||||
let sepaFor = $state<Beitrag | null>(null);
|
||||
let einzugsdatum = $state(minEinzugsdatum());
|
||||
let sepaLoading = $state(false);
|
||||
let sepaError = $state('');
|
||||
let sepaPreview = $state<{ mitglieder: Mitglied[]; ohne: number } | null>(null);
|
||||
|
||||
const rhythmusLabel: Record<Beitrag['rhythmus'], string> = {
|
||||
monatlich: 'monatlich',
|
||||
quartalsweise: 'quartalsweise',
|
||||
halbjaehrlich: 'halbjährlich',
|
||||
jaehrlich: 'jährlich',
|
||||
einmalig: 'einmalig',
|
||||
};
|
||||
|
||||
onMount(async () => {
|
||||
if (get(user)?.rolle === 'trainer') { goto('/'); return; }
|
||||
[beitraege, verein] = await Promise.all([
|
||||
api.get<Beitrag[]>('/beitraege', { sort: 'name' }),
|
||||
api.get<Verein>('/vereine').catch(() => null),
|
||||
]);
|
||||
loading = false;
|
||||
});
|
||||
|
||||
// --- Beitragsart speichern ---
|
||||
function neuerBeitrag() {
|
||||
editId = null; fName = ''; fBetrag = ''; fRhythmus = 'jaehrlich'; fBeschr = '';
|
||||
formError = ''; showForm = true;
|
||||
}
|
||||
|
||||
function bearbeiten(b: Beitrag) {
|
||||
editId = b.id; fName = b.name; fBetrag = String(b.betrag);
|
||||
fRhythmus = b.rhythmus; fBeschr = b.beschreibung ?? '';
|
||||
formError = ''; showForm = true;
|
||||
}
|
||||
|
||||
async function speichern() {
|
||||
formError = '';
|
||||
const betrag = parseFloat(fBetrag.replace(',', '.'));
|
||||
if (!fName.trim() || isNaN(betrag) || betrag <= 0) {
|
||||
formError = 'Name und gültiger Betrag sind Pflichtfelder.';
|
||||
return;
|
||||
}
|
||||
saving = true;
|
||||
try {
|
||||
const data = { name: fName.trim(), betrag, rhythmus: fRhythmus, beschreibung: fBeschr.trim() };
|
||||
if (editId) {
|
||||
await api.put('/beitraege/' + editId, data);
|
||||
beitraege = beitraege.map(b => b.id === editId ? { ...b, ...data } as Beitrag : b);
|
||||
} else {
|
||||
const neu = await api.post<Beitrag>('/beitraege', data);
|
||||
beitraege = [...beitraege, neu].sort((a, b) => a.name.localeCompare(b.name));
|
||||
}
|
||||
showForm = false;
|
||||
} catch (e: unknown) {
|
||||
formError = e instanceof Error ? e.message : 'Fehler beim Speichern.';
|
||||
} finally {
|
||||
saving = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function loeschen(b: Beitrag) {
|
||||
if (!confirm(`"${b.name}" wirklich löschen?`)) return;
|
||||
await api.del('/beitraege/' + b.id);
|
||||
beitraege = beitraege.filter(x => x.id !== b.id);
|
||||
}
|
||||
|
||||
// --- SEPA-Export ---
|
||||
async function sepaOeffnen(b: Beitrag) {
|
||||
sepaFor = b;
|
||||
sepaError = '';
|
||||
sepaPreview = null;
|
||||
einzugsdatum = minEinzugsdatum();
|
||||
sepaLoading = true;
|
||||
try {
|
||||
const alle = await api.get<Mitglied[]>('/mitglieder', { status: 'aktiv', sort: 'nachname,vorname' });
|
||||
const mit = alle.filter(m => m.iban?.trim());
|
||||
const ohne = alle.length - mit.length;
|
||||
sepaPreview = { mitglieder: mit, ohne };
|
||||
} catch (e: unknown) {
|
||||
sepaError = e instanceof Error ? e.message : 'Fehler beim Laden der Mitglieder.';
|
||||
} finally {
|
||||
sepaLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
function sepaSchliessen() {
|
||||
sepaFor = null; sepaPreview = null; sepaError = '';
|
||||
}
|
||||
|
||||
async function sepaExportieren() {
|
||||
if (!sepaFor || !sepaPreview || !verein) return;
|
||||
|
||||
if (!verein.glaeubigerid || !verein.iban || !verein.bic) {
|
||||
sepaError = 'Bitte zuerst Gläubiger-ID, IBAN und BIC des Vereins in den Einstellungen hinterlegen.';
|
||||
return;
|
||||
}
|
||||
|
||||
sepaError = '';
|
||||
sepaLoading = true;
|
||||
|
||||
try {
|
||||
const today = new Date().toISOString().slice(0, 10);
|
||||
const positionen: SepaPosition[] = sepaPreview.mitglieder.map((m, i) => ({
|
||||
endToEndId: `VH-${sepaFor!.id.slice(0, 6)}-${String(i + 1).padStart(4, '0')}`,
|
||||
betrag: sepaFor!.betrag,
|
||||
mandatsreferenz: m.mandatsreferenz || `MANDAT-${m.id.slice(0, 8).toUpperCase()}`,
|
||||
mandatsdatum: m.mandatsdatum || today,
|
||||
debitorName: `${m.vorname} ${m.nachname}`,
|
||||
debitorIban: m.iban!,
|
||||
debitorBic: m.bic ?? '',
|
||||
verwendungszweck: `${sepaFor!.name} (${rhythmusLabel[sepaFor!.rhythmus]})`,
|
||||
}));
|
||||
|
||||
const xml = generatePain008(
|
||||
{
|
||||
glaeubigerid: verein.glaeubigerid,
|
||||
vereinIban: verein.iban,
|
||||
vereinBic: verein.bic,
|
||||
vereinName: verein.name,
|
||||
einzugsdatum,
|
||||
},
|
||||
positionen,
|
||||
);
|
||||
|
||||
downloadXml(xml, `sepa-einzug-${einzugsdatum}.xml`);
|
||||
|
||||
// Einzüge als "ausstehend" anlegen
|
||||
await Promise.all(
|
||||
sepaPreview.mitglieder.map((m) =>
|
||||
api.post('/einzuege', {
|
||||
mitglied_id: m.id,
|
||||
beitrag_id: sepaFor!.id,
|
||||
betrag: sepaFor!.betrag,
|
||||
faellig_am: einzugsdatum,
|
||||
status: 'ausstehend',
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
sepaSchliessen();
|
||||
} catch (e: unknown) {
|
||||
sepaError = e instanceof Error ? e.message : 'Fehler beim Export.';
|
||||
} finally {
|
||||
sepaLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
const gesamtbetrag = $derived(
|
||||
sepaPreview ? sepaPreview.mitglieder.length * (sepaFor?.betrag ?? 0) : 0,
|
||||
);
|
||||
|
||||
const sepaFehlt = $derived(
|
||||
verein ? !verein.glaeubigerid || !verein.iban || !verein.bic : true,
|
||||
);
|
||||
</script>
|
||||
|
||||
<svelte:head><title>Beiträge — vereins.haus</title></svelte:head>
|
||||
|
||||
<div class="page-header">
|
||||
<div class="top">
|
||||
<h1>Beiträge</h1>
|
||||
<button class="btn-primary">+ Beitragsart</button>
|
||||
<button class="btn-primary" onclick={neuerBeitrag}>+ Beitragsart</button>
|
||||
</div>
|
||||
|
||||
<p class="placeholder">SEPA-Beitragseinzug — in Entwicklung</p>
|
||||
{#if !loading && !hatSepa}
|
||||
<div class="plan-hinweis">
|
||||
<strong>SEPA-Export</strong> ist im <strong>Starter-Plan</strong> verfügbar (7 €/Monat).
|
||||
<a href="/einstellungen">Jetzt upgraden →</a>
|
||||
</div>
|
||||
{:else if sepaFehlt && !loading}
|
||||
<div class="hinweis">
|
||||
SEPA-Einzug nicht möglich: Gläubiger-ID, IBAN und BIC des Vereins fehlen noch in den <a href="/einstellungen">Einstellungen</a>.
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if loading}
|
||||
<p class="hint">Laden…</p>
|
||||
{:else if beitraege.length === 0}
|
||||
<p class="hint">Noch keine Beitragsarten — lege die erste an!</p>
|
||||
{:else}
|
||||
<ul class="liste">
|
||||
{#each beitraege as b (b.id)}
|
||||
<li class="karte">
|
||||
<div class="karte-info">
|
||||
<span class="karte-name">{b.name}</span>
|
||||
{#if b.beschreibung}
|
||||
<span class="karte-beschr">{b.beschreibung}</span>
|
||||
{/if}
|
||||
<span class="karte-meta">
|
||||
{b.betrag.toLocaleString('de-DE', { style: 'currency', currency: 'EUR' })}
|
||||
· {rhythmusLabel[b.rhythmus]}
|
||||
</span>
|
||||
</div>
|
||||
<div class="karte-aktionen">
|
||||
<button class="btn-sepa" onclick={() => sepaOeffnen(b)} disabled={sepaFehlt || !hatSepa}>
|
||||
SEPA
|
||||
</button>
|
||||
<button class="btn-icon" onclick={() => bearbeiten(b)} title="Bearbeiten">✎</button>
|
||||
<button class="btn-icon btn-icon-red" onclick={() => loeschen(b)} title="Löschen">✕</button>
|
||||
</div>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
|
||||
<!-- Beitragsart-Formular -->
|
||||
{#if showForm}
|
||||
<div class="overlay" role="dialog" aria-modal="true">
|
||||
<div class="sheet">
|
||||
<h2>{editId ? 'Beitragsart bearbeiten' : 'Neue Beitragsart'}</h2>
|
||||
|
||||
<form onsubmit={(e) => { e.preventDefault(); speichern(); }}>
|
||||
<div class="field">
|
||||
<label for="fname">Name *</label>
|
||||
<input id="fname" type="text" bind:value={fName} placeholder="z. B. Jahresbeitrag" required />
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="field">
|
||||
<label for="fbetrag">Betrag (€) *</label>
|
||||
<input id="fbetrag" type="text" inputmode="decimal" bind:value={fBetrag} placeholder="48,00" required />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="frhythmus">Rhythmus</label>
|
||||
<select id="frhythmus" bind:value={fRhythmus}>
|
||||
<option value="monatlich">Monatlich</option>
|
||||
<option value="quartalsweise">Quartalsweise</option>
|
||||
<option value="halbjaehrlich">Halbjährlich</option>
|
||||
<option value="jaehrlich">Jährlich</option>
|
||||
<option value="einmalig">Einmalig</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label for="fbeschr">Beschreibung</label>
|
||||
<input id="fbeschr" type="text" bind:value={fBeschr} placeholder="Optional" />
|
||||
</div>
|
||||
|
||||
{#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}
|
||||
|
||||
<!-- SEPA-Export -->
|
||||
{#if sepaFor}
|
||||
<div class="overlay" role="dialog" aria-modal="true">
|
||||
<div class="sheet">
|
||||
<h2>SEPA-Einzug</h2>
|
||||
<p class="sepa-sub">{sepaFor.name} · {sepaFor.betrag.toLocaleString('de-DE', { style: 'currency', currency: 'EUR' })} · {rhythmusLabel[sepaFor.rhythmus]}</p>
|
||||
|
||||
<div class="field">
|
||||
<label for="einzugsdatum">Einzugsdatum *</label>
|
||||
<input
|
||||
id="einzugsdatum"
|
||||
type="date"
|
||||
bind:value={einzugsdatum}
|
||||
min={minEinzugsdatum()}
|
||||
/>
|
||||
<span class="field-hint">SEPA CORE: mind. 2 Bankarbeitstage Vorlauf</span>
|
||||
</div>
|
||||
|
||||
{#if sepaLoading}
|
||||
<p class="hint">Laden…</p>
|
||||
{:else if sepaPreview}
|
||||
<div class="sepa-summary">
|
||||
<div class="sepa-row">
|
||||
<span>Mitglieder mit IBAN</span>
|
||||
<strong>{sepaPreview.mitglieder.length}</strong>
|
||||
</div>
|
||||
{#if sepaPreview.ohne > 0}
|
||||
<div class="sepa-row sepa-warn">
|
||||
<span>Ohne IBAN (übersprungen)</span>
|
||||
<strong>{sepaPreview.ohne}</strong>
|
||||
</div>
|
||||
{/if}
|
||||
<div class="sepa-row sepa-total">
|
||||
<span>Gesamtsumme</span>
|
||||
<strong>{gesamtbetrag.toLocaleString('de-DE', { style: 'currency', currency: 'EUR' })}</strong>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if sepaPreview.mitglieder.length === 0}
|
||||
<p class="error">Keine aktiven Mitglieder mit IBAN vorhanden.</p>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
{#if sepaError}
|
||||
<p class="error">{sepaError}</p>
|
||||
{/if}
|
||||
|
||||
<div class="actions">
|
||||
<button type="button" class="btn-ghost" onclick={sepaSchliessen}>Abbrechen</button>
|
||||
<button
|
||||
class="btn-primary"
|
||||
disabled={sepaLoading || !sepaPreview || sepaPreview.mitglieder.length === 0}
|
||||
onclick={sepaExportieren}
|
||||
>
|
||||
XML herunterladen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.page-header {
|
||||
.top {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 1.25rem;
|
||||
}
|
||||
h1 { font-size: 1.4rem; font-weight: 700; color: #1e293b; }
|
||||
.btn-primary {
|
||||
background: #1e40af; color: #fff; border: none;
|
||||
border-radius: 8px; padding: 0.5rem 1rem;
|
||||
font-size: 0.9rem; font-weight: 600;
|
||||
.top .btn-primary { flex: none; padding: 0.45rem 0.9rem; font-size: 0.875rem; }
|
||||
h1 { font-size: 1.4rem; font-weight: 700; color: var(--c-text); }
|
||||
|
||||
.plan-hinweis {
|
||||
background: var(--c-primary-subtle); border: 1px solid var(--c-primary-100); border-radius: 8px;
|
||||
padding: 0.75rem 1rem; font-size: 0.875rem; color: var(--c-primary); margin-bottom: 1rem;
|
||||
}
|
||||
.plan-hinweis a { color: var(--c-primary); font-weight: 700; }
|
||||
|
||||
.hinweis {
|
||||
background: var(--c-warning-bg);
|
||||
border: 1px solid var(--c-warning-light);
|
||||
border-radius: 8px;
|
||||
padding: 0.75rem 1rem;
|
||||
font-size: 0.875rem;
|
||||
color: var(--c-warning-darker);
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
.hinweis a { color: var(--c-warning-darker); }
|
||||
|
||||
.hint { color: var(--c-text-hint); font-size: 0.95rem; text-align: center; margin-top: 3rem; }
|
||||
|
||||
.liste {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.karte {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
padding: 0.9rem 1rem;
|
||||
background: var(--c-bg-card);
|
||||
border: 1px solid var(--c-border);
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.karte-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.1rem;
|
||||
}
|
||||
.karte-name { font-weight: 600; font-size: 0.95rem; color: var(--c-text); }
|
||||
.karte-beschr { font-size: 0.78rem; color: var(--c-text-hint); }
|
||||
.karte-meta { font-size: 0.82rem; color: var(--c-text-secondary); }
|
||||
|
||||
.karte-aktionen {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.btn-sepa {
|
||||
padding: 0.35rem 0.7rem;
|
||||
background: var(--c-primary-light);
|
||||
color: var(--c-primary);
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
font-size: 0.78rem;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
.btn-sepa:hover:not(:disabled) { background: var(--c-primary-200); }
|
||||
.btn-sepa:disabled { opacity: 0.4; cursor: not-allowed; }
|
||||
|
||||
.btn-icon {
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
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.9rem;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.15s, color 0.15s;
|
||||
}
|
||||
.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); }
|
||||
|
||||
/* Overlay & 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: 90dvh;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
h2 { font-size: 1.1rem; font-weight: 700; color: var(--c-text); margin-bottom: 0.25rem; }
|
||||
.sepa-sub { font-size: 0.85rem; color: var(--c-text-muted); margin-bottom: 1.25rem; }
|
||||
|
||||
.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 { font-size: 0.875rem; font-weight: 500; color: var(--c-text-secondary); }
|
||||
.field-hint { font-size: 0.75rem; color: var(--c-text-hint); }
|
||||
|
||||
input, 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;
|
||||
transition: border-color 0.15s;
|
||||
}
|
||||
input:focus, select:focus { outline: none; border-color: var(--c-primary); }
|
||||
|
||||
.sepa-summary {
|
||||
background: var(--c-bg-subtle);
|
||||
border: 1px solid var(--c-border);
|
||||
border-radius: 8px;
|
||||
margin-bottom: 1rem;
|
||||
overflow: hidden;
|
||||
}
|
||||
.sepa-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
padding: 0.65rem 1rem;
|
||||
font-size: 0.9rem;
|
||||
color: var(--c-text);
|
||||
border-bottom: 1px solid var(--c-border);
|
||||
}
|
||||
.sepa-row:last-child { border-bottom: none; }
|
||||
.sepa-warn { color: var(--c-warning-dark); background: var(--c-warning-subtle); }
|
||||
.sepa-total { font-weight: 700; background: var(--c-primary-bg); }
|
||||
|
||||
.error { color: var(--c-error); font-size: 0.875rem; margin-bottom: 0.75rem; }
|
||||
|
||||
.actions { display: flex; gap: 0.75rem; margin-top: 1.25rem; }
|
||||
|
||||
.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;
|
||||
}
|
||||
.placeholder { color: #94a3b8; font-size: 0.95rem; }
|
||||
</style>
|
||||
|
|
|
|||
570
app/src/routes/(app)/einstellungen/+page.svelte
Normal file
|
|
@ -0,0 +1,570 @@
|
|||
<script lang="ts">
|
||||
import { api } from '$lib/api';
|
||||
import { user } from '$lib/user';
|
||||
import { get } from 'svelte/store';
|
||||
import { goto } from '$app/navigation';
|
||||
import { onMount } from 'svelte';
|
||||
import type { Verein, Gruppe } from '$lib/types';
|
||||
|
||||
const isAdmin = () => { const u = get(user); return !u?.rolle || u.rolle === 'admin'; };
|
||||
|
||||
let loading = $state(true);
|
||||
let saving = $state(false);
|
||||
let error = $state('');
|
||||
let success = $state('');
|
||||
|
||||
// Vereinsprofil
|
||||
let name = $state('');
|
||||
let adresse = $state('');
|
||||
let plz = $state('');
|
||||
let ort = $state('');
|
||||
let bundesland = $state('');
|
||||
let email = $state('');
|
||||
let telefon = $state('');
|
||||
let website = $state('');
|
||||
|
||||
// SEPA
|
||||
let glaeubigerid = $state('');
|
||||
let iban = $state('');
|
||||
let bic = $state('');
|
||||
|
||||
// Plan
|
||||
let plan = $state<Verein['plan']>('free');
|
||||
let mitgliederAnz = $state(0);
|
||||
|
||||
// Trainer
|
||||
let trainer = $state<any[]>([]);
|
||||
let gruppen = $state<Gruppe[]>([]);
|
||||
let einladungUrl = $state('');
|
||||
let einladungKopiert = $state(false);
|
||||
|
||||
let vereinId = $state('');
|
||||
|
||||
const planInfo: Record<string, { label: string; farbe: string; features: string[]; limit: number | null }> = {
|
||||
free: { label: 'Kostenlos', farbe: 'var(--c-text-muted)', limit: 50, features: ['Bis 50 Mitglieder', 'Termine & Wiederholungen', 'Nachrichten & Push', 'Veranstaltungsorte', 'Durchführende einladen'] },
|
||||
starter: { label: 'Starter', farbe: 'var(--c-primary)', limit: 150, features: ['Bis 150 Mitglieder', 'SEPA pain.008-Export', 'iCal-Kalender-Abo', 'Alle Free-Features'] },
|
||||
wachstum:{ label: 'Verband', farbe: 'var(--c-accent)', limit: null, features: ['Unbegrenzte Mitglieder', 'Mehrere Admins', 'Prioritäts-Support', 'Alle Starter-Features'] },
|
||||
verband: { label: 'Verband', farbe: 'var(--c-accent)', limit: null, features: ['Unbegrenzte Mitglieder', 'Mehrere Admins', 'Prioritäts-Support', 'Alle Starter-Features'] },
|
||||
};
|
||||
|
||||
const istFree = $derived(plan === 'free');
|
||||
const istStarter = $derived(plan === 'starter' || plan === 'wachstum' || plan === 'verband');
|
||||
const limitErreicht = $derived(istFree && mitgliederAnz > 50);
|
||||
|
||||
const bundeslaender = [
|
||||
['', '—'],
|
||||
['BW', 'Baden-Württemberg'], ['BY', 'Bayern'], ['BE', 'Berlin'],
|
||||
['BB', 'Brandenburg'], ['HB', 'Bremen'], ['HH', 'Hamburg'],
|
||||
['HE', 'Hessen'], ['MV', 'Mecklenburg-Vorpommern'], ['NI', 'Niedersachsen'],
|
||||
['NW', 'Nordrhein-Westfalen'], ['RP', 'Rheinland-Pfalz'], ['SL', 'Saarland'],
|
||||
['SN', 'Sachsen'], ['ST', 'Sachsen-Anhalt'], ['SH', 'Schleswig-Holstein'],
|
||||
['TH', 'Thüringen'],
|
||||
];
|
||||
|
||||
onMount(async () => {
|
||||
vereinId = get(user)?.verein_id ?? '';
|
||||
const [v, alleUser, alleGruppen, mitgliederCount] = await Promise.all([
|
||||
api.get<Verein>('/vereine'),
|
||||
isAdmin()
|
||||
? api.get<any[]>('/users')
|
||||
: Promise.resolve([]),
|
||||
api.get<Gruppe[]>('/gruppen', { sort: 'name' }),
|
||||
api.get<{ total: number }>('/mitglieder/count').catch(() => ({ total: 0 })),
|
||||
]);
|
||||
trainer = alleUser.filter((u: any) => u.rolle === 'trainer');
|
||||
gruppen = alleGruppen;
|
||||
plan = v.plan ?? 'free';
|
||||
mitgliederAnz = (mitgliederCount as any).total ?? 0;
|
||||
name = v.name ?? '';
|
||||
adresse = v.adresse ?? '';
|
||||
plz = v.plz ?? '';
|
||||
ort = v.ort ?? '';
|
||||
bundesland = v.bundesland ?? '';
|
||||
email = v.email ?? '';
|
||||
telefon = v.telefon ?? '';
|
||||
website = v.website ?? '';
|
||||
glaeubigerid = v.glaeubigerid ?? '';
|
||||
iban = v.iban ?? '';
|
||||
bic = v.bic ?? '';
|
||||
loading = false;
|
||||
});
|
||||
|
||||
async function speichern() {
|
||||
if (!name.trim()) { error = 'Vereinsname ist Pflichtfeld.'; return; }
|
||||
error = ''; success = ''; saving = true;
|
||||
try {
|
||||
await api.patch('/vereine', {
|
||||
name: name.trim(),
|
||||
adresse: adresse.trim() || null,
|
||||
plz: plz.trim() || null,
|
||||
ort: ort.trim() || null,
|
||||
bundesland: bundesland || null,
|
||||
email: email.trim() || null,
|
||||
telefon: telefon.trim() || null,
|
||||
website: website.trim() || null,
|
||||
glaeubigerid: glaeubigerid.trim() || null,
|
||||
iban: iban.trim() || null,
|
||||
bic: bic.trim() || null,
|
||||
});
|
||||
success = 'Gespeichert.';
|
||||
} catch (e: unknown) {
|
||||
error = e instanceof Error ? e.message : 'Fehler beim Speichern.';
|
||||
} finally {
|
||||
saving = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function trainerEinladen() {
|
||||
const inv = await api.post<{ token: string }>('/einladungen', { rolle: 'trainer' });
|
||||
einladungUrl = `${window.location.origin}/invite/${inv.token}`;
|
||||
einladungKopiert = false;
|
||||
}
|
||||
|
||||
async function trainerEntfernen(uid: string) {
|
||||
if (!confirm('Trainer wirklich entfernen?')) return;
|
||||
await api.del('/users/' + uid);
|
||||
trainer = trainer.filter(t => t.id !== uid);
|
||||
}
|
||||
|
||||
function trainerGruppen(uid: string): string {
|
||||
return gruppen
|
||||
.filter(g => (g.trainer_ids ?? []).includes(uid))
|
||||
.map(g => g.name)
|
||||
.join(', ') || '—';
|
||||
}
|
||||
|
||||
// Passwort ändern
|
||||
let pwAlt = $state('');
|
||||
let pwNeu = $state('');
|
||||
let pwWdh = $state('');
|
||||
let pwError = $state('');
|
||||
let pwSuccess = $state('');
|
||||
let pwSaving = $state(false);
|
||||
|
||||
async function passwortAendern() {
|
||||
if (!pwAlt || !pwNeu) { pwError = 'Alle Felder ausfüllen.'; return; }
|
||||
if (pwNeu.length < 8) { pwError = 'Neues Passwort mindestens 8 Zeichen.'; return; }
|
||||
if (pwNeu !== pwWdh) { pwError = 'Passwörter stimmen nicht überein.'; return; }
|
||||
pwError = ''; pwSuccess = ''; pwSaving = true;
|
||||
try {
|
||||
const u = get(user);
|
||||
// Altes Passwort prüfen via Login
|
||||
await api.post('/auth/login', { email: u?.email, password: pwAlt });
|
||||
// Neues Passwort setzen
|
||||
await api.put('/users/' + u?.id, { password: pwNeu });
|
||||
pwSuccess = 'Passwort geändert.';
|
||||
pwAlt = ''; pwNeu = ''; pwWdh = '';
|
||||
} catch {
|
||||
pwError = 'Altes Passwort falsch oder Fehler beim Ändern.';
|
||||
} finally {
|
||||
pwSaving = false;
|
||||
}
|
||||
}
|
||||
|
||||
function abmelden() {
|
||||
user.clear();
|
||||
goto('/login');
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head><title>Einstellungen — vereins.haus</title></svelte:head>
|
||||
|
||||
<h1>Einstellungen</h1>
|
||||
|
||||
{#if loading}
|
||||
<p class="hint">Laden…</p>
|
||||
{:else}
|
||||
|
||||
<!-- Plan-Übersicht -->
|
||||
{@const pi = planInfo[plan] ?? planInfo.free}
|
||||
<div class="plan-card">
|
||||
<div class="plan-header">
|
||||
<div>
|
||||
<span class="plan-label" style="color:{pi.farbe}">{pi.label}</span>
|
||||
{#if pi.limit}
|
||||
<span class="plan-limit">bis {pi.limit} Mitglieder</span>
|
||||
{:else}
|
||||
<span class="plan-limit">unbegrenzte Mitglieder</span>
|
||||
{/if}
|
||||
</div>
|
||||
<span class="plan-mitglieder" class:warn={limitErreicht}>
|
||||
{mitgliederAnz} Mitglieder
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{#if limitErreicht}
|
||||
<div class="plan-warn">
|
||||
⚠ Du hast das Limit von 50 Mitgliedern überschritten. Neue Mitglieder können nicht angelegt werden.
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<ul class="plan-features">
|
||||
{#each pi.features as f}
|
||||
<li>✓ {f}</li>
|
||||
{/each}
|
||||
{#if istFree}
|
||||
<li class="gesperrt">✗ SEPA-Export <span class="upgrade-hint">Starter</span></li>
|
||||
<li class="gesperrt">✗ iCal-Kalender-Abo <span class="upgrade-hint">Starter</span></li>
|
||||
{/if}
|
||||
</ul>
|
||||
|
||||
{#if istFree}
|
||||
<a href="mailto:info@vereins.haus?subject=Upgrade%20auf%20Starter&body=Vereins-ID%3A%20{vereinId}" class="btn-upgrade">
|
||||
Auf Starter upgraden – 7 €/Monat →
|
||||
</a>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<form onsubmit={(e) => { e.preventDefault(); speichern(); }}>
|
||||
|
||||
<section>
|
||||
<h2>Vereinsprofil</h2>
|
||||
|
||||
<div class="field">
|
||||
<label for="name">Vereinsname *</label>
|
||||
<input id="name" type="text" bind:value={name} required />
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label for="email">E-Mail</label>
|
||||
<input id="email" type="email" bind:value={email} placeholder="info@meinverein.de" />
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label for="telefon">Telefon</label>
|
||||
<input id="telefon" type="tel" bind:value={telefon} />
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label for="website">Website</label>
|
||||
<input id="website" type="url" bind:value={website} placeholder="https://meinverein.de" />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>Anschrift</h2>
|
||||
|
||||
<div class="field">
|
||||
<label for="adresse">Straße & Hausnummer</label>
|
||||
<input id="adresse" type="text" bind:value={adresse} />
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="field" style="flex: 0 0 5.5rem">
|
||||
<label for="plz">PLZ</label>
|
||||
<input id="plz" type="text" inputmode="numeric" bind:value={plz} />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="ort">Ort</label>
|
||||
<input id="ort" type="text" bind:value={ort} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label for="bundesland">Bundesland</label>
|
||||
<select id="bundesland" bind:value={bundesland}>
|
||||
{#each bundeslaender as [val, label]}
|
||||
<option value={val}>{label}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>SEPA-Lastschrift</h2>
|
||||
<p class="sepa-hint">
|
||||
Für den SEPA-XML-Export. Gläubiger-ID beim Finanzamt oder der Bundesbank beantragen.
|
||||
</p>
|
||||
|
||||
<div class="field">
|
||||
<label for="glaeubigerid">Gläubiger-ID</label>
|
||||
<input id="glaeubigerid" type="text" bind:value={glaeubigerid} placeholder="DE98ZZZ09999999999" />
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label for="iban">IBAN des Vereinskontos</label>
|
||||
<input id="iban" type="text" bind:value={iban} placeholder="DE12 3456 7890 …" />
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label for="bic">BIC</label>
|
||||
<input id="bic" type="text" bind:value={bic} placeholder="COBADEFFXXX" />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>Kalender-Abo</h2>
|
||||
<p class="sepa-hint">
|
||||
Diese URL in Apple Calendar, Google Calendar oder Outlook eintragen – Termine erscheinen automatisch im Handy-Kalender.
|
||||
</p>
|
||||
<div class="ical-url">{typeof window !== 'undefined' ? window.location.origin : ''}/api/kalender/{vereinId}</div>
|
||||
<button type="button" class="btn-kopieren" onclick={async () => {
|
||||
await navigator.clipboard.writeText(`${window.location.origin}/api/kalender/${vereinId}`);
|
||||
}}>URL kopieren</button>
|
||||
<p class="sepa-hint" style="margin-top:0.4rem">Für iOS: <strong>webcal://</strong> statt https:// verwenden – öffnet direkt den Abonnieren-Dialog.</p>
|
||||
</section>
|
||||
|
||||
{#if isAdmin()}
|
||||
<section>
|
||||
<h2>Durchführende</h2>
|
||||
{#if trainer.length === 0}
|
||||
<p class="sepa-hint">Noch keine Trainer eingeladen.</p>
|
||||
{:else}
|
||||
<ul class="trainer-liste">
|
||||
{#each trainer as t (t.id)}
|
||||
<li>
|
||||
<div class="trainer-info">
|
||||
<span class="trainer-name">{t.name}</span>
|
||||
<span class="trainer-gruppen">{trainerGruppen(t.id)}</span>
|
||||
</div>
|
||||
<button type="button" class="btn-remove" onclick={() => trainerEntfernen(t.id)}>Entfernen</button>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
|
||||
<button type="button" class="btn-einladen" onclick={trainerEinladen}>
|
||||
+ Trainer einladen
|
||||
</button>
|
||||
|
||||
{#if einladungUrl}
|
||||
<div class="invite-box">
|
||||
<p class="invite-label">Einladungslink – einmalig verwendbar:</p>
|
||||
<div class="invite-url">{einladungUrl}</div>
|
||||
<button type="button" class="btn-kopieren" onclick={async () => {
|
||||
await navigator.clipboard.writeText(einladungUrl);
|
||||
einladungKopiert = true;
|
||||
}}>
|
||||
{einladungKopiert ? 'Kopiert ✓' : 'Link kopieren'}
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
{#if error}
|
||||
<p class="error">{error}</p>
|
||||
{/if}
|
||||
{#if success}
|
||||
<p class="success">{success}</p>
|
||||
{/if}
|
||||
|
||||
<button type="submit" class="btn-primary" disabled={saving}>
|
||||
{saving ? 'Speichern…' : 'Speichern'}
|
||||
</button>
|
||||
|
||||
</form>
|
||||
|
||||
<div class="divider"></div>
|
||||
|
||||
<!-- Passwort ändern -->
|
||||
<section>
|
||||
<h2>Passwort ändern</h2>
|
||||
<div class="field">
|
||||
<label for="pw-alt">Aktuelles Passwort</label>
|
||||
<input id="pw-alt" type="password" bind:value={pwAlt} autocomplete="current-password" />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="pw-neu">Neues Passwort</label>
|
||||
<input id="pw-neu" type="password" bind:value={pwNeu} autocomplete="new-password" />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="pw-wdh">Neues Passwort wiederholen</label>
|
||||
<input id="pw-wdh" type="password" bind:value={pwWdh} autocomplete="new-password" />
|
||||
</div>
|
||||
{#if pwError}<p class="err">{pwError}</p>{/if}
|
||||
{#if pwSuccess}<p class="ok">{pwSuccess}</p>{/if}
|
||||
<button class="btn-save" onclick={passwortAendern} disabled={pwSaving}>
|
||||
{pwSaving ? 'Wird geändert…' : 'Passwort ändern'}
|
||||
</button>
|
||||
</section>
|
||||
|
||||
<div class="divider"></div>
|
||||
|
||||
<a href="/import-export" class="btn-importexport">Import / Export</a>
|
||||
|
||||
<div class="divider" style="margin-top:1rem"></div>
|
||||
|
||||
<button class="btn-logout" onclick={abmelden}>Abmelden</button>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
h1 { font-size: 1.4rem; font-weight: 700; color: var(--c-text); margin-bottom: 1rem; }
|
||||
|
||||
/* Plan */
|
||||
.plan-card {
|
||||
background: var(--c-bg-card); border: 1.5px solid var(--c-border); border-radius: 12px;
|
||||
padding: 1rem 1.1rem; margin-bottom: 1.5rem;
|
||||
}
|
||||
.plan-header {
|
||||
display: flex; justify-content: space-between; align-items: baseline;
|
||||
margin-bottom: 0.6rem;
|
||||
}
|
||||
.plan-label { font-size: 1rem; font-weight: 700; }
|
||||
.plan-limit { font-size: 0.78rem; color: var(--c-text-hint); margin-left: 0.4rem; }
|
||||
.plan-mitglieder { font-size: 0.82rem; color: var(--c-text-muted); }
|
||||
.plan-mitglieder.warn { color: var(--c-error); font-weight: 700; }
|
||||
.plan-warn {
|
||||
background: var(--c-error-subtle); border: 1px solid var(--c-error-light); border-radius: 8px;
|
||||
padding: 0.6rem 0.8rem; font-size: 0.82rem; color: var(--c-error); margin-bottom: 0.75rem;
|
||||
}
|
||||
.plan-features {
|
||||
list-style: none; padding: 0; margin: 0 0 0.85rem;
|
||||
display: flex; flex-direction: column; gap: 0.25rem;
|
||||
}
|
||||
.plan-features li { font-size: 0.85rem; color: var(--c-text-secondary); }
|
||||
.plan-features li.gesperrt { color: var(--c-text-hint); }
|
||||
.upgrade-hint {
|
||||
display: inline-block; font-size: 0.7rem; font-weight: 700;
|
||||
background: var(--c-primary-light); color: var(--c-primary); border-radius: 4px;
|
||||
padding: 0.05rem 0.35rem; margin-left: 0.3rem;
|
||||
}
|
||||
.btn-upgrade {
|
||||
display: block; width: 100%; padding: 0.65rem;
|
||||
background: var(--c-primary); color: var(--c-bg-card); border: none; border-radius: 8px;
|
||||
font-size: 0.9rem; font-weight: 600; text-align: center;
|
||||
text-decoration: none; transition: background 0.15s;
|
||||
}
|
||||
.btn-upgrade:hover { background: var(--c-primary-dark); }
|
||||
|
||||
section {
|
||||
margin-bottom: 1.5rem;
|
||||
padding-bottom: 1.5rem;
|
||||
border-bottom: 1px solid var(--c-bg);
|
||||
}
|
||||
h2 {
|
||||
font-size: 0.72rem;
|
||||
font-weight: 700;
|
||||
color: var(--c-text-hint);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
margin-bottom: 0.85rem;
|
||||
}
|
||||
|
||||
.sepa-hint {
|
||||
font-size: 0.8rem;
|
||||
color: var(--c-text-hint);
|
||||
margin-bottom: 0.85rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.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: var(--c-text-secondary); }
|
||||
|
||||
input, 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;
|
||||
transition: border-color 0.15s;
|
||||
}
|
||||
input:focus, select:focus { outline: none; border-color: var(--c-primary); }
|
||||
|
||||
.error { color: var(--c-error); font-size: 0.875rem; margin-bottom: 0.75rem; }
|
||||
.success { color: var(--c-success); font-size: 0.875rem; margin-bottom: 0.75rem; }
|
||||
.err { color: var(--c-error); font-size: 0.875rem; margin: 0.5rem 0; }
|
||||
.ok { color: var(--c-success); font-size: 0.875rem; margin: 0.5rem 0; }
|
||||
.btn-save {
|
||||
padding: 0.65rem 1.25rem;
|
||||
background: var(--c-primary); color: var(--c-bg-card);
|
||||
border: none; border-radius: 8px;
|
||||
font-size: 0.95rem; font-weight: 600; cursor: pointer;
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
.btn-save:disabled { opacity: 0.55; cursor: not-allowed; }
|
||||
.hint { color: var(--c-text-hint); font-size: 0.95rem; }
|
||||
|
||||
.btn-primary {
|
||||
width: 100%;
|
||||
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;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
.btn-primary:hover:not(:disabled) { background: var(--c-primary-dark); }
|
||||
.btn-primary:disabled { opacity: 0.55; cursor: not-allowed; }
|
||||
|
||||
.divider {
|
||||
height: 1px;
|
||||
background: var(--c-bg);
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.ical-url {
|
||||
font-family: monospace; font-size: 0.78rem; color: var(--c-text);
|
||||
background: var(--c-bg-subtle); border: 1px solid var(--c-border); border-radius: 6px;
|
||||
padding: 0.5rem 0.75rem; word-break: break-all; margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.trainer-liste {
|
||||
list-style: none; padding: 0; margin: 0 0 0.75rem;
|
||||
border: 1px solid var(--c-border); border-radius: 8px; overflow: hidden;
|
||||
}
|
||||
.trainer-liste li {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
gap: 0.75rem; padding: 0.7rem 0.85rem;
|
||||
border-bottom: 1px solid var(--c-bg);
|
||||
}
|
||||
.trainer-liste li:last-child { border-bottom: none; }
|
||||
.trainer-info { display: flex; flex-direction: column; gap: 0.1rem; }
|
||||
.trainer-name { font-size: 0.9rem; font-weight: 600; color: var(--c-text); }
|
||||
.trainer-gruppen { font-size: 0.75rem; color: var(--c-text-hint); }
|
||||
.btn-remove {
|
||||
padding: 0.3rem 0.65rem; background: none;
|
||||
border: 1px solid var(--c-error-light); border-radius: 6px;
|
||||
font-size: 0.78rem; color: var(--c-error); cursor: pointer; flex-shrink: 0;
|
||||
}
|
||||
.btn-einladen {
|
||||
width: 100%; padding: 0.65rem; background: none;
|
||||
border: 1.5px dashed var(--c-border); border-radius: 8px;
|
||||
font-size: 0.9rem; color: var(--c-primary); cursor: pointer;
|
||||
transition: border-color 0.15s;
|
||||
}
|
||||
.btn-einladen:hover { border-color: var(--c-primary); }
|
||||
.invite-box {
|
||||
margin-top: 0.75rem; padding: 0.85rem;
|
||||
background: var(--c-bg-subtle); border: 1px solid var(--c-border); border-radius: 8px;
|
||||
display: flex; flex-direction: column; gap: 0.5rem;
|
||||
}
|
||||
.invite-label { font-size: 0.78rem; color: var(--c-text-muted); margin: 0; }
|
||||
.invite-url {
|
||||
font-size: 0.78rem; font-family: monospace;
|
||||
color: var(--c-text); word-break: break-all;
|
||||
}
|
||||
.btn-kopieren {
|
||||
padding: 0.4rem 0.75rem; background: var(--c-primary); color: var(--c-bg-card);
|
||||
border: none; border-radius: 6px; font-size: 0.82rem;
|
||||
cursor: pointer; align-self: flex-start;
|
||||
}
|
||||
|
||||
.btn-importexport {
|
||||
display: block; width: 100%; padding: 0.75rem;
|
||||
background: none; border: 1.5px solid var(--c-border); border-radius: 8px;
|
||||
font-size: 0.95rem; color: var(--c-primary); text-align: center;
|
||||
text-decoration: none; margin-bottom: 0;
|
||||
}
|
||||
.btn-importexport:hover { border-color: var(--c-primary); background: var(--c-primary-bg); }
|
||||
|
||||
.btn-logout {
|
||||
width: 100%;
|
||||
padding: 0.75rem;
|
||||
background: none;
|
||||
border: 1.5px solid var(--c-border);
|
||||
border-radius: 8px;
|
||||
font-size: 0.95rem;
|
||||
color: var(--c-text-muted);
|
||||
cursor: pointer;
|
||||
margin-bottom: 5rem;
|
||||
}
|
||||
.btn-logout:hover { border-color: var(--c-text-hint); color: var(--c-text); }
|
||||
</style>
|
||||
429
app/src/routes/(app)/import-export/+page.svelte
Normal file
|
|
@ -0,0 +1,429 @@
|
|||
<script lang="ts">
|
||||
import { api } from '$lib/api';
|
||||
import { onMount } from 'svelte';
|
||||
import Papa from 'papaparse';
|
||||
import type { Mitglied } from '$lib/types';
|
||||
|
||||
let loading = $state(true);
|
||||
let mitglieder = $state<Mitglied[]>([]);
|
||||
let vereinName = $state('');
|
||||
|
||||
// Export
|
||||
let exportStatus = $state('');
|
||||
|
||||
// Import
|
||||
let csvHeaders = $state<string[]>([]);
|
||||
let csvRows = $state<Record<string, string>[]>([]);
|
||||
let feldMapping = $state<Record<string, string>>({});
|
||||
let importPhase = $state<'idle' | 'mapping' | 'preview' | 'done'>('idle');
|
||||
let importResult = $state<{ ok: number; fehler: string[] }>({ ok: 0, fehler: [] });
|
||||
let importLaeuft = $state(false);
|
||||
|
||||
// Mögliche Zielfelder
|
||||
const zielfelder: { key: string; label: string }[] = [
|
||||
{ key: '', label: '— ignorieren —' },
|
||||
{ key: 'vorname', label: 'Vorname' },
|
||||
{ key: 'nachname', label: 'Nachname' },
|
||||
{ key: 'email', label: 'E-Mail' },
|
||||
{ key: 'telefon', label: 'Telefon' },
|
||||
{ key: 'geburtsdatum', label: 'Geburtsdatum (YYYY-MM-DD)' },
|
||||
{ key: 'eintrittsdatum',label: 'Eintrittsdatum (YYYY-MM-DD)' },
|
||||
{ key: 'austrittsdatum',label: 'Austrittsdatum (YYYY-MM-DD)' },
|
||||
{ key: 'strasse', label: 'Straße' },
|
||||
{ key: 'plz', label: 'PLZ' },
|
||||
{ key: 'ort', label: 'Ort' },
|
||||
{ key: 'iban', label: 'IBAN' },
|
||||
{ key: 'bic', label: 'BIC' },
|
||||
{ key: 'status', label: 'Status (aktiv/passiv/ausgetreten)' },
|
||||
{ key: 'notizen', label: 'Notizen' },
|
||||
];
|
||||
|
||||
onMount(async () => {
|
||||
const [m, v] = await Promise.all([
|
||||
api.get<Mitglied[]>('/mitglieder', { sort: 'nachname,vorname' }),
|
||||
api.get<any>('/vereine'),
|
||||
]);
|
||||
mitglieder = m;
|
||||
vereinName = v.name ?? '';
|
||||
loading = false;
|
||||
});
|
||||
|
||||
// ── EXPORT ──────────────────────────────────────────────────────────────
|
||||
|
||||
function toCSV(rows: Record<string, any>[], fields: string[]): string {
|
||||
const escape = (v: any) => {
|
||||
const s = String(v ?? '');
|
||||
return s.includes(',') || s.includes('"') || s.includes('\n')
|
||||
? `"${s.replace(/"/g, '""')}"` : s;
|
||||
};
|
||||
const header = fields.join(',');
|
||||
const body = rows.map(r => fields.map(f => escape(r[f])).join(','));
|
||||
return '' + [header, ...body].join('\r\n'); // BOM für Excel
|
||||
}
|
||||
|
||||
function downloadCSV(csv: string, name: string) {
|
||||
const blob = new Blob([csv], { type: 'text/csv;charset=utf-8' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url; a.download = name; a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
function exportiereAlle() {
|
||||
const felder = ['vorname','nachname','email','telefon','geburtsdatum',
|
||||
'eintrittsdatum','austrittsdatum','strasse','plz','ort',
|
||||
'iban','bic','status','notizen'];
|
||||
downloadCSV(toCSV(mitglieder, felder), `${vereinName}-Mitglieder.csv`);
|
||||
exportStatus = `${mitglieder.length} Mitglieder exportiert.`;
|
||||
}
|
||||
|
||||
function exportiereAktive() {
|
||||
const aktive = mitglieder.filter(m => m.status === 'aktiv');
|
||||
const felder = ['vorname','nachname','email','telefon','strasse','plz','ort','status'];
|
||||
downloadCSV(toCSV(aktive, felder), `${vereinName}-Aktive.csv`);
|
||||
exportStatus = `${aktive.length} aktive Mitglieder exportiert.`;
|
||||
}
|
||||
|
||||
function exportiereMitIBAN() {
|
||||
const sepa = mitglieder.filter(m => m.iban?.trim());
|
||||
const felder = ['vorname','nachname','iban','bic','mandatsreferenz','mandatsdatum','status'];
|
||||
downloadCSV(toCSV(sepa, felder), `${vereinName}-SEPA.csv`);
|
||||
exportStatus = `${sepa.length} Mitglieder mit IBAN exportiert.`;
|
||||
}
|
||||
|
||||
async function exportiereBackup() {
|
||||
const [verein, mitgl, gruppen, termine, beitraege, nachrichten] = await Promise.all([
|
||||
api.get<any>('/vereine'),
|
||||
api.get<any[]>('/mitglieder'),
|
||||
api.get<any[]>('/gruppen'),
|
||||
api.get<any[]>('/termine'),
|
||||
api.get<any[]>('/beitraege'),
|
||||
api.get<any[]>('/nachrichten'),
|
||||
]);
|
||||
const backup = {
|
||||
exportiert_am: new Date().toISOString(),
|
||||
version: '1',
|
||||
verein, mitglieder: mitgl, gruppen, termine, beitraege, nachrichten,
|
||||
};
|
||||
const blob = new Blob([JSON.stringify(backup, null, 2)], { type: 'application/json' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url; a.download = `${vereinName}-Backup-${new Date().toISOString().slice(0,10)}.json`;
|
||||
a.click(); URL.revokeObjectURL(url);
|
||||
exportStatus = 'Datensicherung heruntergeladen.';
|
||||
}
|
||||
|
||||
// ── IMPORT ──────────────────────────────────────────────────────────────
|
||||
|
||||
// Versuche automatisch Spalten zu Feldern zuzuordnen
|
||||
function automap(headers: string[]): Record<string, string> {
|
||||
const normalize = (s: string) => s.toLowerCase().replace(/[^a-z]/g, '');
|
||||
const aliase: Record<string, string> = {
|
||||
vorname: 'vorname', firstname: 'vorname', givenname: 'vorname',
|
||||
nachname: 'nachname', lastname: 'nachname', surname: 'nachname', name: 'nachname',
|
||||
email: 'email', mail: 'email', emailadresse: 'email',
|
||||
telefon: 'telefon', phone: 'telefon', tel: 'telefon', mobil: 'telefon',
|
||||
geburtsdatum: 'geburtsdatum', geburtstag: 'geburtsdatum', birthdate: 'geburtsdatum',
|
||||
eintrittsdatum: 'eintrittsdatum', eintritt: 'eintrittsdatum', mitgliedseit: 'eintrittsdatum',
|
||||
strasse: 'strasse', street: 'strasse', adresse: 'strasse',
|
||||
plz: 'plz', postleitzahl: 'plz', zip: 'plz',
|
||||
ort: 'ort', city: 'ort', stadt: 'ort',
|
||||
iban: 'iban', bic: 'bic', swift: 'bic',
|
||||
status: 'status', mitgliedsstatus: 'status',
|
||||
notizen: 'notizen', notes: 'notizen', bemerkung: 'notizen', bemerkungen: 'notizen',
|
||||
};
|
||||
const mapping: Record<string, string> = {};
|
||||
for (const h of headers) {
|
||||
mapping[h] = aliase[normalize(h)] ?? '';
|
||||
}
|
||||
return mapping;
|
||||
}
|
||||
|
||||
function handleDateiUpload(e: Event) {
|
||||
const file = (e.target as HTMLInputElement).files?.[0];
|
||||
if (!file) return;
|
||||
const reader = new FileReader();
|
||||
reader.onload = (ev) => {
|
||||
const text = ev.target?.result as string;
|
||||
const result = Papa.parse<Record<string, string>>(text, {
|
||||
header: true, skipEmptyLines: true,
|
||||
});
|
||||
csvHeaders = (result as any).meta?.fields ?? [];
|
||||
csvRows = (result as any).data ?? [];
|
||||
feldMapping = automap(csvHeaders);
|
||||
importPhase = 'mapping';
|
||||
};
|
||||
reader.readAsText(file, 'UTF-8');
|
||||
}
|
||||
|
||||
function normalisiereStatus(s: string): Mitglied['status'] {
|
||||
const v = s.toLowerCase().trim();
|
||||
if (v === 'passiv' || v === 'passive') return 'passiv';
|
||||
if (v === 'ausgetreten' || v === 'ex' || v === 'former') return 'ausgetreten';
|
||||
return 'aktiv';
|
||||
}
|
||||
|
||||
function normalisiereDate(s: string): string | null {
|
||||
if (!s?.trim()) return null;
|
||||
// DD.MM.YYYY → YYYY-MM-DD
|
||||
const de = s.match(/^(\d{1,2})\.(\d{1,2})\.(\d{4})$/);
|
||||
if (de) return `${de[3]}-${de[2].padStart(2,'0')}-${de[1].padStart(2,'0')}`;
|
||||
// already YYYY-MM-DD
|
||||
if (/^\d{4}-\d{2}-\d{2}/.test(s)) return s.slice(0, 10);
|
||||
return null;
|
||||
}
|
||||
|
||||
async function importStarten() {
|
||||
importLaeuft = true;
|
||||
let ok = 0;
|
||||
const fehler: string[] = [];
|
||||
|
||||
for (const row of csvRows) {
|
||||
const record: Record<string, any> = { status: 'aktiv' };
|
||||
for (const [csvSpalte, ziel] of Object.entries(feldMapping)) {
|
||||
if (!ziel) continue;
|
||||
const wert = row[csvSpalte]?.trim() ?? '';
|
||||
if (['geburtsdatum','eintrittsdatum','austrittsdatum','mandatsdatum'].includes(ziel)) {
|
||||
const d = normalisiereDate(wert);
|
||||
if (d) record[ziel] = d;
|
||||
} else if (ziel === 'status') {
|
||||
record[ziel] = normalisiereStatus(wert);
|
||||
} else if (wert) {
|
||||
record[ziel] = wert;
|
||||
}
|
||||
}
|
||||
if (!record.vorname || !record.nachname) {
|
||||
fehler.push(`Zeile übersprungen: Vor- oder Nachname fehlt (${row[csvHeaders[0]] ?? '?'})`);
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
await api.post('/mitglieder', record);
|
||||
ok++;
|
||||
} catch (e: unknown) {
|
||||
fehler.push(`${record.vorname} ${record.nachname}: ${e instanceof Error ? e.message : 'Fehler'}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Mitgliederliste aktualisieren
|
||||
mitglieder = await api.get<Mitglied[]>('/mitglieder', { sort: 'nachname,vorname' });
|
||||
importResult = { ok, fehler };
|
||||
importPhase = 'done';
|
||||
importLaeuft = false;
|
||||
}
|
||||
|
||||
function resetImport() {
|
||||
csvHeaders = []; csvRows = []; feldMapping = {};
|
||||
importPhase = 'idle'; importResult = { ok: 0, fehler: [] };
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head><title>Import / Export — vereins.haus</title></svelte:head>
|
||||
|
||||
<div class="top">
|
||||
<a class="back" href="/einstellungen">← Einstellungen</a>
|
||||
<h1>Import / Export</h1>
|
||||
</div>
|
||||
|
||||
{#if loading}
|
||||
<p class="hint">Laden…</p>
|
||||
{:else}
|
||||
|
||||
<!-- ── EXPORT ────────────────────────────────────── -->
|
||||
<section>
|
||||
<h2>Mitglieder exportieren</h2>
|
||||
<div class="export-grid">
|
||||
<button class="export-btn" onclick={exportiereAlle}>
|
||||
<span class="export-icon">📋</span>
|
||||
<span class="export-label">Alle Mitglieder</span>
|
||||
<span class="export-sub">CSV · alle Felder · {mitglieder.length} Einträge</span>
|
||||
</button>
|
||||
<button class="export-btn" onclick={exportiereAktive}>
|
||||
<span class="export-icon">✓</span>
|
||||
<span class="export-label">Aktive Mitglieder</span>
|
||||
<span class="export-sub">CSV · Kontaktfelder · {mitglieder.filter(m => m.status === 'aktiv').length} Einträge</span>
|
||||
</button>
|
||||
<button class="export-btn" onclick={exportiereMitIBAN}>
|
||||
<span class="export-icon">€</span>
|
||||
<span class="export-label">SEPA-Liste</span>
|
||||
<span class="export-sub">CSV · IBAN/Mandat · {mitglieder.filter(m => m.iban?.trim()).length} Einträge</span>
|
||||
</button>
|
||||
</div>
|
||||
{#if exportStatus}
|
||||
<p class="status-ok">✓ {exportStatus}</p>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>Datensicherung</h2>
|
||||
<p class="hinweis-text">Vollständige Sicherungskopie aller Vereinsdaten als JSON – für Archivierung oder Wechsel der Software (DSGVO Art. 20).</p>
|
||||
<button class="export-btn breit" onclick={exportiereBackup}>
|
||||
<span class="export-icon">💾</span>
|
||||
<span class="export-label">Datensicherung herunterladen</span>
|
||||
<span class="export-sub">JSON · Mitglieder, Termine, Beiträge, Nachrichten</span>
|
||||
</button>
|
||||
</section>
|
||||
|
||||
<!-- ── IMPORT ────────────────────────────────────── -->
|
||||
<section>
|
||||
<h2>Mitglieder importieren</h2>
|
||||
<p class="hinweis-text">CSV-Datei hochladen – die Spalten werden automatisch erkannt und können vor dem Import angepasst werden.</p>
|
||||
|
||||
{#if importPhase === 'idle'}
|
||||
<label class="upload-label">
|
||||
<input type="file" accept=".csv,.txt" onchange={handleDateiUpload} class="file-input" />
|
||||
<span class="upload-icon">↑</span>
|
||||
<span>CSV-Datei auswählen</span>
|
||||
<span class="upload-sub">UTF-8 oder Windows-1252, Komma- oder Semikolon-getrennt</span>
|
||||
</label>
|
||||
|
||||
{:else if importPhase === 'mapping'}
|
||||
<div class="mapping-header">
|
||||
<span>{csvRows.length} Zeilen erkannt · Spalten zuordnen:</span>
|
||||
<button class="btn-ghost-sm" onclick={resetImport}>Abbrechen</button>
|
||||
</div>
|
||||
|
||||
<div class="mapping-tabelle">
|
||||
<div class="mapping-row header">
|
||||
<span>CSV-Spalte</span>
|
||||
<span>→ Vereinshaus-Feld</span>
|
||||
<span>Vorschau (1. Zeile)</span>
|
||||
</div>
|
||||
{#each csvHeaders as h}
|
||||
<div class="mapping-row">
|
||||
<span class="col-name">{h}</span>
|
||||
<select bind:value={feldMapping[h]}>
|
||||
{#each zielfelder as f}
|
||||
<option value={f.key}>{f.label}</option>
|
||||
{/each}
|
||||
</select>
|
||||
<span class="col-preview">{csvRows[0]?.[h] ?? ''}</span>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<div class="mapping-actions">
|
||||
<p class="hinweis-text">Vor- und Nachname sind Pflichtfelder.</p>
|
||||
<button class="btn-primary" onclick={importStarten} disabled={importLaeuft}>
|
||||
{importLaeuft ? `Importiere…` : `${csvRows.length} Mitglieder importieren`}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{:else if importPhase === 'done'}
|
||||
<div class="import-result" class:hat-fehler={importResult.fehler.length > 0}>
|
||||
<p class="result-ok">✓ {importResult.ok} Mitglieder importiert</p>
|
||||
{#if importResult.fehler.length > 0}
|
||||
<p class="result-fehler-titel">{importResult.fehler.length} Probleme:</p>
|
||||
<ul class="fehler-liste">
|
||||
{#each importResult.fehler as f}
|
||||
<li>{f}</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
<button class="btn-ghost" onclick={resetImport}>Weiteren Import starten</button>
|
||||
</div>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.top { margin-bottom: 1.25rem; }
|
||||
.back { font-size: 0.85rem; color: var(--c-primary); text-decoration: none; display: block; margin-bottom: 0.25rem; }
|
||||
h1 { font-size: 1.4rem; font-weight: 700; color: var(--c-text); }
|
||||
.hint { color: var(--c-text-hint); text-align: center; margin-top: 3rem; }
|
||||
|
||||
section {
|
||||
margin-bottom: 2rem;
|
||||
padding-bottom: 1.5rem;
|
||||
border-bottom: 1px solid var(--c-bg);
|
||||
}
|
||||
h2 {
|
||||
font-size: 0.72rem; font-weight: 700; color: var(--c-text-hint);
|
||||
text-transform: uppercase; letter-spacing: 0.06em; margin-bottom: 0.75rem;
|
||||
}
|
||||
.hinweis-text { font-size: 0.85rem; color: var(--c-text-muted); margin-bottom: 0.85rem; line-height: 1.5; }
|
||||
|
||||
/* Export */
|
||||
.export-grid { display: flex; flex-direction: column; gap: 0.5rem; margin-bottom: 0.75rem; }
|
||||
|
||||
.export-btn {
|
||||
display: flex; align-items: center; gap: 0.85rem;
|
||||
padding: 0.85rem 1rem; background: var(--c-bg-card);
|
||||
border: 1.5px solid var(--c-border); border-radius: 10px;
|
||||
cursor: pointer; text-align: left; width: 100%;
|
||||
transition: border-color 0.15s;
|
||||
}
|
||||
.export-btn:hover { border-color: var(--c-primary); }
|
||||
.export-btn.breit { width: 100%; }
|
||||
.export-icon { font-size: 1.2rem; flex-shrink: 0; width: 1.5rem; text-align: center; }
|
||||
.export-label { font-weight: 600; font-size: 0.9rem; color: var(--c-text); flex: 1; }
|
||||
.export-sub { font-size: 0.75rem; color: var(--c-text-hint); white-space: nowrap; }
|
||||
|
||||
.status-ok { font-size: 0.85rem; color: var(--c-success); }
|
||||
|
||||
/* Upload */
|
||||
.upload-label {
|
||||
display: flex; flex-direction: column; align-items: center; gap: 0.4rem;
|
||||
padding: 2rem 1rem; border: 2px dashed var(--c-border); border-radius: 10px;
|
||||
cursor: pointer; color: var(--c-text-muted); transition: border-color 0.15s; text-align: center;
|
||||
}
|
||||
.upload-label:hover { border-color: var(--c-primary); color: var(--c-primary); }
|
||||
.file-input { display: none; }
|
||||
.upload-icon { font-size: 1.5rem; }
|
||||
.upload-sub { font-size: 0.75rem; color: var(--c-text-hint); }
|
||||
|
||||
/* Mapping */
|
||||
.mapping-header {
|
||||
display: flex; justify-content: space-between; align-items: center;
|
||||
margin-bottom: 0.75rem; font-size: 0.85rem; color: var(--c-text-muted);
|
||||
}
|
||||
.btn-ghost-sm {
|
||||
padding: 0.3rem 0.65rem; background: none;
|
||||
border: 1px solid var(--c-border); border-radius: 6px;
|
||||
font-size: 0.8rem; color: var(--c-text-muted); cursor: pointer;
|
||||
}
|
||||
|
||||
.mapping-tabelle {
|
||||
border: 1px solid var(--c-border); border-radius: 8px; overflow: hidden;
|
||||
margin-bottom: 1rem; overflow-x: auto;
|
||||
}
|
||||
.mapping-row {
|
||||
display: grid; grid-template-columns: 1fr 1.4fr 1fr;
|
||||
gap: 0.5rem; padding: 0.5rem 0.75rem;
|
||||
border-bottom: 1px solid var(--c-bg); align-items: center;
|
||||
}
|
||||
.mapping-row:last-child { border-bottom: none; }
|
||||
.mapping-row.header { background: var(--c-bg-subtle); font-size: 0.72rem; font-weight: 700; color: var(--c-text-hint); text-transform: uppercase; }
|
||||
.col-name { font-size: 0.82rem; color: var(--c-text); font-weight: 500; }
|
||||
.col-preview { font-size: 0.75rem; color: var(--c-text-hint); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.mapping-row select {
|
||||
padding: 0.3rem 0.5rem; border: 1px solid var(--c-border); border-radius: 6px;
|
||||
font-size: 0.8rem; background: var(--c-bg-card); width: 100%;
|
||||
}
|
||||
|
||||
.mapping-actions { display: flex; flex-direction: column; gap: 0.5rem; }
|
||||
|
||||
/* Ergebnis */
|
||||
.import-result {
|
||||
background: var(--c-success-subtle); border: 1px solid var(--c-success-light); border-radius: 10px;
|
||||
padding: 1rem; display: flex; flex-direction: column; gap: 0.5rem;
|
||||
}
|
||||
.import-result.hat-fehler { background: var(--c-warning-pale); border-color: var(--c-warning-light); }
|
||||
.result-ok { font-weight: 700; color: var(--c-success); font-size: 0.95rem; }
|
||||
.result-fehler-titel { font-size: 0.85rem; color: var(--c-warning-dark); font-weight: 600; }
|
||||
.fehler-liste { margin: 0; padding-left: 1.2rem; }
|
||||
.fehler-liste li { font-size: 0.78rem; color: var(--c-warning-dark); }
|
||||
|
||||
.btn-primary {
|
||||
padding: 0.75rem; background: var(--c-primary); color: var(--c-bg-card);
|
||||
border: none; border-radius: 8px; font-size: 0.95rem; font-weight: 600;
|
||||
cursor: pointer; transition: background 0.15s; width: 100%;
|
||||
}
|
||||
.btn-primary:hover:not(:disabled) { background: var(--c-primary-dark); }
|
||||
.btn-primary:disabled { opacity: 0.55; cursor: not-allowed; }
|
||||
.btn-ghost {
|
||||
padding: 0.6rem 1rem; background: none;
|
||||
border: 1.5px solid var(--c-border); border-radius: 8px;
|
||||
font-size: 0.9rem; color: var(--c-text-muted); cursor: pointer; align-self: flex-start;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -1,27 +1,174 @@
|
|||
<script lang="ts">
|
||||
import { api } from '$lib/api';
|
||||
import { onMount } from 'svelte';
|
||||
|
||||
let mitglieder = $state<any[]>([]);
|
||||
let gruppen = $state<any[]>([]);
|
||||
let search = $state('');
|
||||
let loading = $state(true);
|
||||
|
||||
onMount(async () => {
|
||||
[mitglieder, gruppen] = await Promise.all([
|
||||
api.get<any[]>('/mitglieder', { sort: 'nachname,vorname' }),
|
||||
api.get<any[]>('/gruppen', { sort: 'name' })
|
||||
]);
|
||||
loading = false;
|
||||
});
|
||||
|
||||
let filtered = $derived(
|
||||
search.trim()
|
||||
? mitglieder.filter(m =>
|
||||
`${m.vorname} ${m.nachname} ${m.email}`.toLowerCase().includes(search.toLowerCase())
|
||||
)
|
||||
: mitglieder
|
||||
);
|
||||
|
||||
function gruppenLabel(ids: string[]) {
|
||||
return (ids ?? [])
|
||||
.map(id => gruppen.find(g => g.id === id)?.name)
|
||||
.filter(Boolean)
|
||||
.join(', ');
|
||||
}
|
||||
|
||||
const statusFarbe: Record<string, string> = {
|
||||
aktiv: 'var(--c-success)',
|
||||
passiv: 'var(--c-warning)',
|
||||
ausgetreten: 'var(--c-text-hint)'
|
||||
};
|
||||
</script>
|
||||
|
||||
<svelte:head><title>Mitglieder — vereins.haus</title></svelte:head>
|
||||
|
||||
<div class="page-header">
|
||||
<div class="top">
|
||||
<h1>Mitglieder</h1>
|
||||
<button class="btn-primary">+ Mitglied</button>
|
||||
<span class="count">{mitglieder.length}</span>
|
||||
</div>
|
||||
|
||||
<p class="placeholder">Mitgliederverwaltung — in Entwicklung</p>
|
||||
<input
|
||||
class="search"
|
||||
type="search"
|
||||
bind:value={search}
|
||||
placeholder="Name oder E-Mail suchen…"
|
||||
/>
|
||||
|
||||
{#if loading}
|
||||
<p class="hint">Laden…</p>
|
||||
{:else if filtered.length === 0}
|
||||
<p class="hint">
|
||||
{search ? 'Keine Treffer.' : 'Noch keine Mitglieder — lege das erste an!'}
|
||||
</p>
|
||||
{:else}
|
||||
<ul>
|
||||
{#each filtered as m (m.id)}
|
||||
<li>
|
||||
<a href="/mitglieder/{m.id}">
|
||||
<div class="avatar">{m.vorname[0]}{m.nachname[0]}</div>
|
||||
<div class="info">
|
||||
<span class="name">{m.vorname} {m.nachname}</span>
|
||||
<span class="meta">
|
||||
{[m.email, gruppenLabel(m.gruppe_ids)].filter(Boolean).join(' · ')}
|
||||
</span>
|
||||
</div>
|
||||
<span class="badge" style="color:{statusFarbe[m.status] ?? 'var(--c-text-hint)'}">
|
||||
{m.status}
|
||||
</span>
|
||||
</a>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
|
||||
<a class="fab" href="/mitglieder/neu" aria-label="Neues Mitglied">+</a>
|
||||
|
||||
<style>
|
||||
.page-header {
|
||||
.top {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
h1 { font-size: 1.4rem; font-weight: 700; color: var(--c-text); }
|
||||
.count { font-size: 1rem; color: var(--c-text-hint); }
|
||||
.search {
|
||||
width: 100%;
|
||||
padding: 0.6rem 0.85rem;
|
||||
border: 1.5px solid var(--c-border);
|
||||
border-radius: 8px;
|
||||
font-size: 1rem;
|
||||
margin-bottom: 1rem;
|
||||
background: var(--c-bg-subtle);
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.search:focus { outline: none; border-color: var(--c-primary); background: var(--c-bg-card); }
|
||||
ul {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 5rem;
|
||||
}
|
||||
a {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 1.25rem;
|
||||
gap: 0.75rem;
|
||||
padding: 0.85rem;
|
||||
background: var(--c-bg-card);
|
||||
border: 1px solid var(--c-border);
|
||||
border-radius: 10px;
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
transition: border-color 0.15s;
|
||||
}
|
||||
h1 { font-size: 1.4rem; font-weight: 700; color: #1e293b; }
|
||||
.btn-primary {
|
||||
background: #1e40af; color: #fff; border: none;
|
||||
border-radius: 8px; padding: 0.5rem 1rem;
|
||||
font-size: 0.9rem; font-weight: 600;
|
||||
a:hover { border-color: var(--c-primary); }
|
||||
.avatar {
|
||||
width: 2.4rem;
|
||||
height: 2.4rem;
|
||||
border-radius: 50%;
|
||||
background: var(--c-primary-light);
|
||||
color: var(--c-primary);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-weight: 700;
|
||||
font-size: 0.85rem;
|
||||
flex-shrink: 0;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.placeholder { color: #94a3b8; font-size: 0.95rem; }
|
||||
.info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.1rem;
|
||||
}
|
||||
.name { font-weight: 600; font-size: 0.95rem; color: var(--c-text); }
|
||||
.meta {
|
||||
font-size: 0.78rem;
|
||||
color: var(--c-text-hint);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.badge { font-size: 0.72rem; font-weight: 600; text-transform: capitalize; flex-shrink: 0; }
|
||||
.hint { color: var(--c-text-hint); text-align: center; margin-top: 3rem; font-size: 0.95rem; }
|
||||
.fab {
|
||||
position: fixed;
|
||||
bottom: calc(60px + env(safe-area-inset-bottom) + 1.25rem);
|
||||
right: 1.25rem;
|
||||
width: 3.5rem;
|
||||
height: 3.5rem;
|
||||
background: var(--c-primary);
|
||||
color: var(--c-bg-card);
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 2rem;
|
||||
line-height: 1;
|
||||
text-decoration: none;
|
||||
box-shadow: 0 4px 14px rgba(30, 64, 175, 0.4);
|
||||
transition: background 0.15s;
|
||||
}
|
||||
.fab:hover { background: var(--c-primary-dark); }
|
||||
</style>
|
||||
|
|
|
|||
503
app/src/routes/(app)/mitglieder/[id]/+page.svelte
Normal file
|
|
@ -0,0 +1,503 @@
|
|||
<script lang="ts">
|
||||
import { api } from '$lib/api';
|
||||
import { goto } from '$app/navigation';
|
||||
import { page } from '$app/stores';
|
||||
import { onMount } from 'svelte';
|
||||
import type { Mitglied, Gruppe } from '$lib/types';
|
||||
|
||||
const id = $derived($page.params.id as string);
|
||||
|
||||
let gruppen = $state<Gruppe[]>([]);
|
||||
let loading = $state(true);
|
||||
let saving = $state(false);
|
||||
let error = $state('');
|
||||
let editMode = $state(false);
|
||||
let showDelete = $state(false);
|
||||
|
||||
// Felder
|
||||
let vorname = $state('');
|
||||
let nachname = $state('');
|
||||
let email = $state('');
|
||||
let telefon = $state('');
|
||||
let geburtsdatum = $state('');
|
||||
let status = $state('aktiv');
|
||||
let eintrittsdatum = $state('');
|
||||
let austrittsdatum = $state('');
|
||||
let strasse = $state('');
|
||||
let plz = $state('');
|
||||
let ort = $state('');
|
||||
let iban = $state('');
|
||||
let bic = $state('');
|
||||
let mandatsreferenz = $state('');
|
||||
let mandatsdatum = $state('');
|
||||
let notizen = $state('');
|
||||
let gruppe_ids = $state<string[]>([]);
|
||||
|
||||
function pbDateToInput(val: string | undefined): string {
|
||||
// PocketBase gibt "2026-05-20 00:00:00.000Z" zurück, input[type=date] braucht "2026-05-20"
|
||||
if (!val) return '';
|
||||
return val.slice(0, 10);
|
||||
}
|
||||
|
||||
function loadRecord(m: Mitglied) {
|
||||
vorname = m.vorname;
|
||||
nachname = m.nachname;
|
||||
email = m.email ?? '';
|
||||
telefon = m.telefon ?? '';
|
||||
geburtsdatum = pbDateToInput(m.geburtsdatum);
|
||||
status = m.status;
|
||||
eintrittsdatum = pbDateToInput(m.eintrittsdatum);
|
||||
austrittsdatum = pbDateToInput(m.austrittsdatum);
|
||||
strasse = m.strasse ?? '';
|
||||
plz = m.plz ?? '';
|
||||
ort = m.ort ?? '';
|
||||
iban = m.iban ?? '';
|
||||
bic = m.bic ?? '';
|
||||
mandatsreferenz = m.mandatsreferenz ?? '';
|
||||
mandatsdatum = pbDateToInput(m.mandatsdatum);
|
||||
notizen = m.notizen ?? '';
|
||||
gruppe_ids = m.gruppe_ids ?? [];
|
||||
}
|
||||
|
||||
onMount(async () => {
|
||||
const [m, g] = await Promise.all([
|
||||
api.get<Mitglied>('/mitglieder/' + id),
|
||||
api.get<Gruppe[]>('/gruppen', { sort: 'name' }),
|
||||
]);
|
||||
loadRecord(m);
|
||||
gruppen = g;
|
||||
loading = false;
|
||||
});
|
||||
|
||||
function toggleGruppe(gid: string) {
|
||||
gruppe_ids = gruppe_ids.includes(gid)
|
||||
? gruppe_ids.filter(g => g !== gid)
|
||||
: [...gruppe_ids, gid];
|
||||
}
|
||||
|
||||
async function speichern() {
|
||||
error = ''; saving = true;
|
||||
try {
|
||||
await api.put('/mitglieder/' + id, {
|
||||
vorname: vorname.trim(),
|
||||
nachname: nachname.trim(),
|
||||
email: email.trim() || null,
|
||||
telefon: telefon.trim() || null,
|
||||
geburtsdatum: geburtsdatum || null,
|
||||
status,
|
||||
eintrittsdatum: eintrittsdatum || null,
|
||||
austrittsdatum: austrittsdatum || null,
|
||||
strasse: strasse.trim() || null,
|
||||
plz: plz.trim() || null,
|
||||
ort: ort.trim() || null,
|
||||
iban: iban.trim() || null,
|
||||
bic: bic.trim() || null,
|
||||
mandatsreferenz: mandatsreferenz.trim() || null,
|
||||
mandatsdatum: mandatsdatum || null,
|
||||
notizen: notizen.trim() || null,
|
||||
gruppe_ids,
|
||||
});
|
||||
editMode = false;
|
||||
} catch (e: unknown) {
|
||||
error = e instanceof Error ? e.message : 'Fehler beim Speichern.';
|
||||
} finally {
|
||||
saving = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function loeschen() {
|
||||
try {
|
||||
await api.del('/mitglieder/' + id);
|
||||
goto('/mitglieder');
|
||||
} catch (e: unknown) {
|
||||
error = e instanceof Error ? e.message : 'Fehler beim Löschen.';
|
||||
showDelete = false;
|
||||
}
|
||||
}
|
||||
|
||||
function gruppenName(ids: string[]): string {
|
||||
return (ids ?? [])
|
||||
.map(gid => gruppen.find(g => g.id === gid)?.name)
|
||||
.filter(Boolean)
|
||||
.join(', ');
|
||||
}
|
||||
|
||||
function formatDatum(iso: string): string {
|
||||
if (!iso) return '—';
|
||||
return new Date(iso).toLocaleDateString('de-DE');
|
||||
}
|
||||
|
||||
const statusFarbe: Record<string, string> = {
|
||||
aktiv: 'var(--c-success)', passiv: 'var(--c-warning)', ausgetreten: 'var(--c-text-hint)',
|
||||
};
|
||||
</script>
|
||||
|
||||
<svelte:head><title>{vorname} {nachname} — vereins.haus</title></svelte:head>
|
||||
|
||||
<div class="top">
|
||||
<a class="back" href="/mitglieder">← Mitglieder</a>
|
||||
{#if !loading}
|
||||
<button class="edit-btn" onclick={() => { editMode = !editMode; error = ''; }}>
|
||||
{editMode ? 'Abbrechen' : 'Bearbeiten'}
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if loading}
|
||||
<p class="hint">Laden…</p>
|
||||
|
||||
{:else if !editMode}
|
||||
<!-- Detailansicht -->
|
||||
<div class="hero">
|
||||
<div class="avatar-lg">{vorname[0]}{nachname[0]}</div>
|
||||
<h1>{vorname} {nachname}</h1>
|
||||
<span class="status-badge" style="color:{statusFarbe[status] ?? 'var(--c-text-hint)'}">{status}</span>
|
||||
</div>
|
||||
|
||||
<div class="detail-block">
|
||||
<h2>Kontakt</h2>
|
||||
{#if email}
|
||||
<div class="row-detail">
|
||||
<span class="dl">E-Mail</span>
|
||||
<a href="mailto:{email}" class="dv link">{email}</a>
|
||||
</div>
|
||||
{/if}
|
||||
{#if telefon}
|
||||
<div class="row-detail">
|
||||
<span class="dl">Telefon</span>
|
||||
<a href="tel:{telefon}" class="dv link">{telefon}</a>
|
||||
</div>
|
||||
{/if}
|
||||
{#if strasse || plz || ort}
|
||||
<div class="row-detail">
|
||||
<span class="dl">Adresse</span>
|
||||
<span class="dv">{[strasse, [plz, ort].filter(Boolean).join(' ')].filter(Boolean).join(', ')}</span>
|
||||
</div>
|
||||
{/if}
|
||||
{#if !email && !telefon && !strasse}
|
||||
<p class="leer">Keine Kontaktdaten hinterlegt.</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="detail-block">
|
||||
<h2>Mitgliedschaft</h2>
|
||||
{#if eintrittsdatum}
|
||||
<div class="row-detail">
|
||||
<span class="dl">Eintritt</span>
|
||||
<span class="dv">{formatDatum(eintrittsdatum)}</span>
|
||||
</div>
|
||||
{/if}
|
||||
{#if geburtsdatum}
|
||||
<div class="row-detail">
|
||||
<span class="dl">Geburtsdatum</span>
|
||||
<span class="dv">{formatDatum(geburtsdatum)}</span>
|
||||
</div>
|
||||
{/if}
|
||||
{#if austrittsdatum}
|
||||
<div class="row-detail">
|
||||
<span class="dl">Austritt</span>
|
||||
<span class="dv">{formatDatum(austrittsdatum)}</span>
|
||||
</div>
|
||||
{/if}
|
||||
{#if gruppe_ids?.length}
|
||||
<div class="row-detail">
|
||||
<span class="dl">Gruppen</span>
|
||||
<span class="dv">{gruppenName(gruppe_ids)}</span>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if iban || bic || mandatsreferenz}
|
||||
<div class="detail-block">
|
||||
<h2>SEPA-Lastschrift</h2>
|
||||
{#if iban}
|
||||
<div class="row-detail">
|
||||
<span class="dl">IBAN</span>
|
||||
<span class="dv mono">{iban}</span>
|
||||
</div>
|
||||
{/if}
|
||||
{#if bic}
|
||||
<div class="row-detail">
|
||||
<span class="dl">BIC</span>
|
||||
<span class="dv mono">{bic}</span>
|
||||
</div>
|
||||
{/if}
|
||||
{#if mandatsreferenz}
|
||||
<div class="row-detail">
|
||||
<span class="dl">Mandat</span>
|
||||
<span class="dv mono">{mandatsreferenz}{mandatsdatum ? ' · ' + formatDatum(mandatsdatum) : ''}</span>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if notizen}
|
||||
<div class="detail-block">
|
||||
<h2>Notizen</h2>
|
||||
<p class="notiz-text">{notizen}</p>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<button class="btn-delete" onclick={() => showDelete = true}>Mitglied löschen</button>
|
||||
|
||||
{:else}
|
||||
<!-- Bearbeitungsformular -->
|
||||
<form onsubmit={(e) => { e.preventDefault(); speichern(); }}>
|
||||
|
||||
<section>
|
||||
<h2>Stammdaten</h2>
|
||||
<div class="row">
|
||||
<div class="field">
|
||||
<label for="vorname">Vorname *</label>
|
||||
<input id="vorname" type="text" bind:value={vorname} required />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="nachname">Nachname *</label>
|
||||
<input id="nachname" type="text" bind:value={nachname} required />
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="field">
|
||||
<label for="status">Status</label>
|
||||
<select id="status" bind:value={status}>
|
||||
<option value="aktiv">Aktiv</option>
|
||||
<option value="passiv">Passiv</option>
|
||||
<option value="ausgetreten">Ausgetreten</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="geburtsdatum">Geburtsdatum</label>
|
||||
<input id="geburtsdatum" type="date" bind:value={geburtsdatum} />
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="field">
|
||||
<label for="eintrittsdatum">Eintrittsdatum</label>
|
||||
<input id="eintrittsdatum" type="date" bind:value={eintrittsdatum} />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="austrittsdatum">Austrittsdatum</label>
|
||||
<input id="austrittsdatum" type="date" bind:value={austrittsdatum} />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>Kontakt</h2>
|
||||
<div class="field">
|
||||
<label for="email">E-Mail</label>
|
||||
<input id="email" type="email" bind:value={email} />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="telefon">Telefon</label>
|
||||
<input id="telefon" type="tel" bind:value={telefon} />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="strasse">Straße & Hausnummer</label>
|
||||
<input id="strasse" type="text" bind:value={strasse} />
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="field" style="flex: 0 0 5rem">
|
||||
<label for="plz">PLZ</label>
|
||||
<input id="plz" type="text" inputmode="numeric" bind:value={plz} />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="ort">Ort</label>
|
||||
<input id="ort" type="text" bind:value={ort} />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>SEPA-Lastschrift</h2>
|
||||
<div class="field">
|
||||
<label for="iban">IBAN</label>
|
||||
<input id="iban" type="text" bind:value={iban} placeholder="DE12 3456 7890 …" />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="bic">BIC</label>
|
||||
<input id="bic" type="text" bind:value={bic} placeholder="COBADEFFXXX" />
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="field">
|
||||
<label for="mandatsreferenz">Mandatsreferenz</label>
|
||||
<input id="mandatsreferenz" type="text" bind:value={mandatsreferenz} placeholder="automatisch" />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="mandatsdatum">Mandatsdatum</label>
|
||||
<input id="mandatsdatum" type="date" bind:value={mandatsdatum} />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{#if gruppen.length > 0}
|
||||
<section>
|
||||
<h2>Gruppen</h2>
|
||||
<div class="checkboxes">
|
||||
{#each gruppen as g (g.id)}
|
||||
<label class="check-label" class:active={gruppe_ids.includes(g.id)}>
|
||||
<input type="checkbox" checked={gruppe_ids.includes(g.id)} onchange={() => toggleGruppe(g.id)} />
|
||||
{g.name}
|
||||
</label>
|
||||
{/each}
|
||||
</div>
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
<section>
|
||||
<h2>Notizen</h2>
|
||||
<div class="field">
|
||||
<label for="notizen">Interne Notizen</label>
|
||||
<textarea id="notizen" bind:value={notizen} rows="3"></textarea>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{#if error}
|
||||
<p class="error">{error}</p>
|
||||
{/if}
|
||||
|
||||
<div class="actions" style="margin-bottom:5rem">
|
||||
<button type="submit" class="btn-primary" disabled={saving || !vorname || !nachname}>
|
||||
{saving ? 'Speichern…' : 'Änderungen speichern'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
{/if}
|
||||
|
||||
<!-- Lösch-Dialog -->
|
||||
{#if showDelete}
|
||||
<div class="overlay" role="dialog" aria-modal="true">
|
||||
<div class="dialog">
|
||||
<p><strong>{vorname} {nachname}</strong> wirklich löschen?</p>
|
||||
<p class="dialog-sub">Diese Aktion kann nicht rückgängig gemacht werden.</p>
|
||||
<div class="dialog-actions">
|
||||
<button class="btn-ghost" onclick={() => showDelete = false}>Abbrechen</button>
|
||||
<button class="btn-danger" onclick={loeschen}>Löschen</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.top { display: flex; align-items: center; justify-content: space-between; margin-bottom: 1.5rem; }
|
||||
.back { font-size: 0.9rem; color: var(--c-primary); text-decoration: none; }
|
||||
.edit-btn {
|
||||
background: none; border: 1.5px solid var(--c-border); border-radius: 8px;
|
||||
padding: 0.4rem 0.85rem; font-size: 0.875rem; color: var(--c-text-secondary); cursor: pointer;
|
||||
}
|
||||
h1 { font-size: 1.3rem; font-weight: 700; color: var(--c-text); }
|
||||
.hint { color: var(--c-text-hint); text-align: center; margin-top: 3rem; }
|
||||
|
||||
.hero {
|
||||
display: flex; flex-direction: column; align-items: center; gap: 0.4rem;
|
||||
padding: 1.5rem 1rem; background: var(--c-bg-card);
|
||||
border: 1px solid var(--c-border); border-radius: 12px; margin-bottom: 1rem;
|
||||
}
|
||||
.avatar-lg {
|
||||
width: 4rem; height: 4rem; border-radius: 50%;
|
||||
background: var(--c-primary-light); color: var(--c-primary);
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
font-weight: 700; font-size: 1.3rem; text-transform: uppercase;
|
||||
}
|
||||
.status-badge { font-size: 0.8rem; font-weight: 600; text-transform: capitalize; }
|
||||
|
||||
.detail-block {
|
||||
background: var(--c-bg-card); border: 1px solid var(--c-border); border-radius: 12px;
|
||||
overflow: hidden; margin-bottom: 0.75rem; padding: 0.75rem 1rem;
|
||||
}
|
||||
.detail-block h2 {
|
||||
font-size: 0.72rem; font-weight: 700; color: var(--c-text-hint);
|
||||
text-transform: uppercase; letter-spacing: 0.06em;
|
||||
margin-bottom: 0.6rem;
|
||||
}
|
||||
.row-detail {
|
||||
display: flex; justify-content: space-between; align-items: baseline;
|
||||
gap: 1rem; padding: 0.45rem 0;
|
||||
border-bottom: 1px solid var(--c-bg);
|
||||
}
|
||||
.row-detail:last-child { border-bottom: none; }
|
||||
.dl { font-size: 0.82rem; color: var(--c-text-hint); flex-shrink: 0; }
|
||||
.dv { font-size: 0.88rem; color: var(--c-text); text-align: right; }
|
||||
.dv.link { color: var(--c-primary); text-decoration: none; }
|
||||
.dv.mono { font-family: monospace; font-size: 0.82rem; }
|
||||
.leer { font-size: 0.85rem; color: var(--c-text-hint); }
|
||||
.notiz-text { font-size: 0.875rem; color: var(--c-text-secondary); white-space: pre-wrap; }
|
||||
|
||||
.btn-delete {
|
||||
width: 100%; padding: 0.75rem; background: none;
|
||||
border: 1.5px solid var(--c-error-light); border-radius: 8px;
|
||||
color: var(--c-error); font-size: 0.9rem; cursor: pointer;
|
||||
transition: background 0.15s; margin-bottom: 5rem;
|
||||
}
|
||||
.btn-delete:hover { background: var(--c-error-subtle); }
|
||||
|
||||
/* Formular */
|
||||
section {
|
||||
margin-bottom: 1.5rem; padding-bottom: 1.5rem;
|
||||
border-bottom: 1px solid var(--c-bg);
|
||||
}
|
||||
section:last-of-type { border-bottom: none; }
|
||||
section h2 {
|
||||
font-size: 0.72rem; font-weight: 700; color: var(--c-text-hint);
|
||||
text-transform: uppercase; letter-spacing: 0.06em; margin-bottom: 0.85rem;
|
||||
}
|
||||
.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: var(--c-text-secondary); }
|
||||
|
||||
input, select, textarea {
|
||||
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, select:focus, textarea:focus { outline: none; border-color: var(--c-primary); }
|
||||
|
||||
.checkboxes { display: flex; flex-wrap: wrap; gap: 0.5rem; }
|
||||
.check-label {
|
||||
display: flex; align-items: center; gap: 0.4rem;
|
||||
padding: 0.4rem 0.75rem;
|
||||
border: 1.5px solid var(--c-border); border-radius: 20px;
|
||||
font-size: 0.875rem; cursor: pointer;
|
||||
transition: border-color 0.15s, background 0.15s;
|
||||
}
|
||||
.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: 1rem; }
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
.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));
|
||||
}
|
||||
.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.5rem; }
|
||||
.dialog-sub { font-size: 0.875rem !important; color: var(--c-text-hint); }
|
||||
.dialog-actions { display: flex; gap: 0.75rem; margin-top: 1.25rem; }
|
||||
.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; transition: background 0.15s;
|
||||
}
|
||||
.btn-danger:hover { background: var(--c-error-dark); }
|
||||
</style>
|
||||
257
app/src/routes/(app)/mitglieder/neu/+page.svelte
Normal file
|
|
@ -0,0 +1,257 @@
|
|||
<script lang="ts">
|
||||
import { api } from '$lib/api';
|
||||
import { goto } from '$app/navigation';
|
||||
import { onMount } from 'svelte';
|
||||
|
||||
let gruppen = $state<any[]>([]);
|
||||
|
||||
// Stammdaten
|
||||
let vorname = $state('');
|
||||
let nachname = $state('');
|
||||
let email = $state('');
|
||||
let telefon = $state('');
|
||||
let geburtsdatum = $state('');
|
||||
let status = $state('aktiv');
|
||||
let eintrittsdatum = $state('');
|
||||
let gruppe_ids = $state<string[]>([]);
|
||||
let notizen = $state('');
|
||||
|
||||
// Adresse
|
||||
let strasse = $state('');
|
||||
let plz = $state('');
|
||||
let ort = $state('');
|
||||
|
||||
// SEPA
|
||||
let iban = $state('');
|
||||
let bic = $state('');
|
||||
let mandatsreferenz = $state('');
|
||||
let mandatsdatum = $state('');
|
||||
|
||||
let error = $state('');
|
||||
let loading = $state(false);
|
||||
|
||||
onMount(async () => {
|
||||
gruppen = await api.get<any[]>('/gruppen', { sort: 'name' });
|
||||
});
|
||||
|
||||
function toggleGruppe(id: string) {
|
||||
gruppe_ids = gruppe_ids.includes(id)
|
||||
? gruppe_ids.filter(g => g !== id)
|
||||
: [...gruppe_ids, id];
|
||||
}
|
||||
|
||||
async function speichern() {
|
||||
error = ''; loading = true;
|
||||
try {
|
||||
await api.post('/mitglieder', {
|
||||
vorname: vorname.trim(),
|
||||
nachname: nachname.trim(),
|
||||
email: email.trim() || null,
|
||||
telefon: telefon.trim() || null,
|
||||
geburtsdatum: geburtsdatum || null,
|
||||
status,
|
||||
eintrittsdatum: eintrittsdatum || null,
|
||||
strasse: strasse.trim() || null,
|
||||
plz: plz.trim() || null,
|
||||
ort: ort.trim() || null,
|
||||
iban: iban.trim() || null,
|
||||
bic: bic.trim() || null,
|
||||
mandatsreferenz: mandatsreferenz.trim() || null,
|
||||
mandatsdatum: mandatsdatum || null,
|
||||
notizen: notizen.trim() || null,
|
||||
gruppe_ids,
|
||||
});
|
||||
goto('/mitglieder');
|
||||
} catch (e: unknown) {
|
||||
error = e instanceof Error ? e.message : 'Fehler beim Speichern.';
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head><title>Neues Mitglied — vereins.haus</title></svelte:head>
|
||||
|
||||
<div class="top">
|
||||
<a class="back" href="/mitglieder">← Zurück</a>
|
||||
<h1>Neues Mitglied</h1>
|
||||
</div>
|
||||
|
||||
<form onsubmit={(e) => { e.preventDefault(); speichern(); }}>
|
||||
|
||||
<section>
|
||||
<h2>Stammdaten</h2>
|
||||
<div class="row">
|
||||
<div class="field">
|
||||
<label for="vorname">Vorname *</label>
|
||||
<input id="vorname" type="text" bind:value={vorname} required autocomplete="given-name" />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="nachname">Nachname *</label>
|
||||
<input id="nachname" type="text" bind:value={nachname} required autocomplete="family-name" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="field">
|
||||
<label for="status">Status</label>
|
||||
<select id="status" bind:value={status}>
|
||||
<option value="aktiv">Aktiv</option>
|
||||
<option value="passiv">Passiv</option>
|
||||
<option value="ausgetreten">Ausgetreten</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="geburtsdatum">Geburtsdatum</label>
|
||||
<input id="geburtsdatum" type="date" bind:value={geburtsdatum} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="field">
|
||||
<label for="eintrittsdatum">Eintrittsdatum</label>
|
||||
<input id="eintrittsdatum" type="date" bind:value={eintrittsdatum} />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>Kontakt</h2>
|
||||
<div class="field">
|
||||
<label for="email">E-Mail</label>
|
||||
<input id="email" type="email" bind:value={email} autocomplete="email" />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="telefon">Telefon</label>
|
||||
<input id="telefon" type="tel" bind:value={telefon} autocomplete="tel" />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>Adresse</h2>
|
||||
<div class="field">
|
||||
<label for="strasse">Straße & Hausnummer</label>
|
||||
<input id="strasse" type="text" bind:value={strasse} autocomplete="street-address" />
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="field" style="flex: 0 0 5rem">
|
||||
<label for="plz">PLZ</label>
|
||||
<input id="plz" type="text" inputmode="numeric" bind:value={plz} autocomplete="postal-code" />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="ort">Ort</label>
|
||||
<input id="ort" type="text" bind:value={ort} autocomplete="address-level2" />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>SEPA-Lastschrift</h2>
|
||||
<div class="field">
|
||||
<label for="iban">IBAN</label>
|
||||
<input id="iban" type="text" bind:value={iban} placeholder="DE12 3456 7890 …" autocomplete="off" />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="bic">BIC</label>
|
||||
<input id="bic" type="text" bind:value={bic} placeholder="COBADEFFXXX" autocomplete="off" />
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="field">
|
||||
<label for="mandatsreferenz">Mandatsreferenz</label>
|
||||
<input id="mandatsreferenz" type="text" bind:value={mandatsreferenz} placeholder="wird automatisch vergeben" />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="mandatsdatum">Mandatsdatum</label>
|
||||
<input id="mandatsdatum" type="date" bind:value={mandatsdatum} />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{#if gruppen.length > 0}
|
||||
<section>
|
||||
<h2>Gruppen</h2>
|
||||
<div class="checkboxes">
|
||||
{#each gruppen as g (g.id)}
|
||||
<label class="check-label" class:active={gruppe_ids.includes(g.id)}>
|
||||
<input type="checkbox" checked={gruppe_ids.includes(g.id)} onchange={() => toggleGruppe(g.id)} />
|
||||
{g.name}
|
||||
</label>
|
||||
{/each}
|
||||
</div>
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
<section>
|
||||
<h2>Notizen</h2>
|
||||
<div class="field">
|
||||
<label for="notizen">Interne Notizen</label>
|
||||
<textarea id="notizen" bind:value={notizen} rows="3" placeholder="Nur für Vorstand sichtbar"></textarea>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{#if error}
|
||||
<p class="error">{error}</p>
|
||||
{/if}
|
||||
|
||||
<div class="actions">
|
||||
<a class="btn-ghost" href="/mitglieder">Abbrechen</a>
|
||||
<button type="submit" class="btn-primary" disabled={loading || !vorname || !nachname}>
|
||||
{loading ? 'Speichern…' : 'Mitglied anlegen'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
</form>
|
||||
|
||||
<style>
|
||||
.top { margin-bottom: 1.25rem; }
|
||||
.back { font-size: 0.9rem; color: var(--c-primary); text-decoration: none; display: block; margin-bottom: 0.5rem; }
|
||||
h1 { font-size: 1.4rem; font-weight: 700; color: var(--c-text); }
|
||||
|
||||
section {
|
||||
margin-bottom: 1.5rem;
|
||||
padding-bottom: 1.5rem;
|
||||
border-bottom: 1px solid var(--c-bg);
|
||||
}
|
||||
section:last-of-type { border-bottom: none; }
|
||||
h2 { font-size: 0.8rem; font-weight: 700; color: var(--c-text-hint); text-transform: uppercase; letter-spacing: 0.06em; margin-bottom: 0.85rem; }
|
||||
|
||||
.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: var(--c-text-secondary); }
|
||||
|
||||
input, select, textarea {
|
||||
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, select:focus, textarea:focus { outline: none; border-color: var(--c-primary); }
|
||||
|
||||
.checkboxes { display: flex; flex-wrap: wrap; gap: 0.5rem; }
|
||||
.check-label {
|
||||
display: flex; align-items: center; gap: 0.4rem;
|
||||
padding: 0.4rem 0.75rem;
|
||||
border: 1.5px solid var(--c-border); border-radius: 20px;
|
||||
font-size: 0.875rem; cursor: pointer;
|
||||
transition: border-color 0.15s, background 0.15s;
|
||||
}
|
||||
.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: 1.5rem; margin-bottom: 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); text-decoration: none; text-align: center;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -1,27 +1,330 @@
|
|||
<script lang="ts">
|
||||
import { api } from '$lib/api';
|
||||
import { user } from '$lib/user';
|
||||
import { get } from 'svelte/store';
|
||||
import { onMount } from 'svelte';
|
||||
import type { Nachricht, Gruppe } from '$lib/types';
|
||||
|
||||
let nachrichten = $state<Nachricht[]>([]);
|
||||
let gruppen = $state<Gruppe[]>([]);
|
||||
let loading = $state(true);
|
||||
|
||||
// Formular
|
||||
let showForm = $state(false);
|
||||
let fBetreff = $state('');
|
||||
let fText = $state('');
|
||||
let fGruppeIds = $state<string[]>([]);
|
||||
let sending = $state(false);
|
||||
let sendError = $state('');
|
||||
let sendSuccess = $state('');
|
||||
|
||||
onMount(async () => {
|
||||
[nachrichten, gruppen] = await Promise.all([
|
||||
api.get<Nachricht[]>('/nachrichten', { sort: '-gesendet_am' }),
|
||||
api.get<Gruppe[]>('/gruppen', { sort: 'name' }),
|
||||
]);
|
||||
loading = false;
|
||||
});
|
||||
|
||||
function stripHtml(html: string): string {
|
||||
return html.replace(/<[^>]*>/g, ' ').replace(/\s+/g, ' ').trim();
|
||||
}
|
||||
|
||||
function toggleGruppe(id: string) {
|
||||
fGruppeIds = fGruppeIds.includes(id)
|
||||
? fGruppeIds.filter((g) => g !== id)
|
||||
: [...fGruppeIds, id];
|
||||
}
|
||||
|
||||
function neueNachricht() {
|
||||
fBetreff = ''; fText = ''; fGruppeIds = [];
|
||||
sendError = ''; sendSuccess = ''; showForm = true;
|
||||
}
|
||||
|
||||
async function senden() {
|
||||
if (!fBetreff.trim() || !fText.trim()) {
|
||||
sendError = 'Betreff und Nachricht sind Pflichtfelder.';
|
||||
return;
|
||||
}
|
||||
sendError = ''; sending = true;
|
||||
|
||||
try {
|
||||
const record = await api.post<Nachricht>('/nachrichten', {
|
||||
betreff: fBetreff.trim(),
|
||||
text: fText.trim(),
|
||||
gruppe_ids: fGruppeIds,
|
||||
gesendet_am: new Date().toISOString(),
|
||||
});
|
||||
|
||||
nachrichten = [record, ...nachrichten];
|
||||
showForm = false;
|
||||
|
||||
// Push-Benachrichtigung an alle abonnierten Geräte im Verein
|
||||
fetch('/api/push/senden', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${get(user)?.token ?? ''}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
titel: fBetreff.trim(),
|
||||
body: fText.trim().slice(0, 120),
|
||||
url: '/nachrichten',
|
||||
}),
|
||||
}).catch(() => {});
|
||||
|
||||
sendSuccess = 'Nachricht wurde gespeichert und per E-Mail versendet.';
|
||||
} catch (e: unknown) {
|
||||
sendError = e instanceof Error ? e.message : 'Fehler beim Senden.';
|
||||
} finally {
|
||||
sending = false;
|
||||
}
|
||||
}
|
||||
|
||||
function formatDatum(iso: string | undefined): string {
|
||||
if (!iso) return '';
|
||||
return new Date(iso).toLocaleDateString('de-DE', {
|
||||
day: '2-digit', month: '2-digit', year: 'numeric',
|
||||
hour: '2-digit', minute: '2-digit',
|
||||
});
|
||||
}
|
||||
|
||||
const gruppenLabel = (ids: string[]) =>
|
||||
ids.length === 0
|
||||
? 'Alle Mitglieder'
|
||||
: ids.map((id) => gruppen.find((g) => g.id === id)?.name ?? id).join(', ');
|
||||
</script>
|
||||
|
||||
<svelte:head><title>Nachrichten — vereins.haus</title></svelte:head>
|
||||
|
||||
<div class="page-header">
|
||||
<div class="top">
|
||||
<h1>Nachrichten</h1>
|
||||
<button class="btn-primary">+ Nachricht</button>
|
||||
<button class="btn-primary" onclick={neueNachricht}>+ Nachricht</button>
|
||||
</div>
|
||||
|
||||
<p class="placeholder">Nachrichten & Push-Benachrichtigungen — in Entwicklung</p>
|
||||
{#if sendSuccess}
|
||||
<div class="success">{sendSuccess}</div>
|
||||
{/if}
|
||||
|
||||
{#if loading}
|
||||
<p class="hint">Laden…</p>
|
||||
{:else if nachrichten.length === 0}
|
||||
<p class="hint">Noch keine Nachrichten versendet.</p>
|
||||
{:else}
|
||||
<ul class="liste">
|
||||
{#each nachrichten as n (n.id)}
|
||||
<li class="karte">
|
||||
<div class="karte-header">
|
||||
<span class="karte-betreff">{n.betreff}</span>
|
||||
<span class="karte-datum">{formatDatum(n.gesendet_am)}</span>
|
||||
</div>
|
||||
<span class="karte-meta">{gruppenLabel(n.gruppe_ids ?? [])}</span>
|
||||
{#if n.text}
|
||||
<p class="karte-vorschau">{stripHtml(n.text).slice(0, 120)}{stripHtml(n.text).length > 120 ? '…' : ''}</p>
|
||||
{/if}
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
|
||||
<!-- Nachricht verfassen -->
|
||||
{#if showForm}
|
||||
<div class="overlay" role="dialog" aria-modal="true">
|
||||
<div class="sheet">
|
||||
<h2>Neue Nachricht</h2>
|
||||
|
||||
<form onsubmit={(e) => { e.preventDefault(); senden(); }}>
|
||||
<div class="field">
|
||||
<label for="fbetreff">Betreff *</label>
|
||||
<input id="fbetreff" type="text" bind:value={fBetreff} placeholder="Betreff" required />
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label for="ftext">Nachricht *</label>
|
||||
<textarea id="ftext" bind:value={fText} rows="6" placeholder="Text der Nachricht…" required></textarea>
|
||||
</div>
|
||||
|
||||
{#if gruppen.length > 0}
|
||||
<div class="field">
|
||||
<span class="field-label">Empfänger</span>
|
||||
<div class="checkboxes">
|
||||
<label class="check-label alle" class:active={fGruppeIds.length === 0}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={fGruppeIds.length === 0}
|
||||
onchange={() => (fGruppeIds = [])}
|
||||
/>
|
||||
Alle Mitglieder
|
||||
</label>
|
||||
{#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}
|
||||
|
||||
<p class="versand-info">
|
||||
Die Nachricht wird an alle aktiven Mitglieder
|
||||
{fGruppeIds.length > 0 ? 'der gewählten Gruppen' : ''}
|
||||
mit hinterlegter E-Mail-Adresse gesendet.
|
||||
</p>
|
||||
|
||||
{#if sendError}
|
||||
<p class="error">{sendError}</p>
|
||||
{/if}
|
||||
|
||||
<div class="actions">
|
||||
<button type="button" class="btn-ghost" onclick={() => (showForm = false)}>
|
||||
Abbrechen
|
||||
</button>
|
||||
<button type="submit" class="btn-primary" disabled={sending}>
|
||||
{sending ? 'Senden…' : 'Senden'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.page-header {
|
||||
.top {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 1.25rem;
|
||||
}
|
||||
h1 { font-size: 1.4rem; font-weight: 700; color: #1e293b; }
|
||||
.btn-primary {
|
||||
background: #1e40af; color: #fff; border: none;
|
||||
border-radius: 8px; padding: 0.5rem 1rem;
|
||||
font-size: 0.9rem; font-weight: 600;
|
||||
.top .btn-primary { flex: none; padding: 0.45rem 0.9rem; font-size: 0.875rem; }
|
||||
h1 { font-size: 1.4rem; font-weight: 700; color: var(--c-text); }
|
||||
|
||||
.success {
|
||||
background: var(--c-success-bg);
|
||||
border: 1px solid var(--c-success-light);
|
||||
border-radius: 8px;
|
||||
padding: 0.75rem 1rem;
|
||||
font-size: 0.875rem;
|
||||
color: var(--c-success-dark);
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.hint { color: var(--c-text-hint); font-size: 0.95rem; text-align: center; margin-top: 3rem; }
|
||||
|
||||
.liste { list-style: none; padding: 0; display: flex; flex-direction: column; gap: 0.5rem; }
|
||||
|
||||
.karte {
|
||||
background: var(--c-bg-card);
|
||||
border: 1px solid var(--c-border);
|
||||
border-radius: 10px;
|
||||
padding: 0.9rem 1rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.karte-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: baseline;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.karte-betreff { font-weight: 600; font-size: 0.95rem; color: var(--c-text); }
|
||||
.karte-datum { font-size: 0.75rem; color: var(--c-text-hint); flex-shrink: 0; }
|
||||
.karte-meta { font-size: 0.78rem; color: var(--c-text-muted); }
|
||||
.karte-vorschau { font-size: 0.85rem; color: var(--c-text-secondary); margin: 0; }
|
||||
|
||||
/* 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; }
|
||||
|
||||
.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 {
|
||||
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;
|
||||
transition: border-color 0.15s;
|
||||
font-family: inherit;
|
||||
resize: vertical;
|
||||
}
|
||||
input:focus, textarea:focus { outline: none; border-color: var(--c-primary); }
|
||||
|
||||
.checkboxes { display: flex; flex-wrap: wrap; gap: 0.4rem; margin-top: 0.25rem; }
|
||||
.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;
|
||||
transition: border-color 0.15s, background 0.15s;
|
||||
}
|
||||
.check-label.active { border-color: var(--c-primary); background: var(--c-primary-light); color: var(--c-primary); }
|
||||
.check-label input { display: none; }
|
||||
|
||||
.versand-info {
|
||||
font-size: 0.8rem;
|
||||
color: var(--c-text-hint);
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
.placeholder { color: #94a3b8; font-size: 0.95rem; }
|
||||
</style>
|
||||
|
|
|
|||
476
app/src/routes/(app)/neuigkeiten/+page.svelte
Normal file
|
|
@ -0,0 +1,476 @@
|
|||
<script lang="ts">
|
||||
import { api } from '$lib/api';
|
||||
import { user } from '$lib/user';
|
||||
import { get } from 'svelte/store';
|
||||
import { onMount } from 'svelte';
|
||||
import type { Neuigkeit, Gruppe, Termin } from '$lib/types';
|
||||
|
||||
const canPost = () => true; // alle eingeloggten User dürfen posten
|
||||
const userId = () => get(user)?.id as string;
|
||||
|
||||
let neuigkeiten = $state<Neuigkeit[]>([]);
|
||||
let gruppen = $state<Gruppe[]>([]);
|
||||
let termine = $state<Termin[]>([]);
|
||||
let reaktionen = $state<Record<string, number>>({}); // beitrag_id → count
|
||||
let meineReaktion = $state<Record<string, string>>({}); // beitrag_id → reaktion_id
|
||||
let loading = $state(true);
|
||||
|
||||
// Formular
|
||||
let showForm = $state(false);
|
||||
let fText = $state('');
|
||||
let fGruppeIds = $state<string[]>([]);
|
||||
let fTerminId = $state('');
|
||||
let fDateien = $state<FileList | null>(null);
|
||||
let previews = $state<string[]>([]);
|
||||
let saving = $state(false);
|
||||
let formError = $state('');
|
||||
|
||||
// Lightbox
|
||||
let lightboxUrl = $state('');
|
||||
let ladeError = $state('');
|
||||
|
||||
onMount(async () => {
|
||||
try {
|
||||
// Queries einzeln damit ein Fehler sichtbar wird
|
||||
const [nList, gList] = await Promise.all([
|
||||
api.get<Neuigkeit[]>('/neuigkeiten', { sort: '-created' }),
|
||||
api.get<Gruppe[]>('/gruppen', { sort: 'name' }),
|
||||
]);
|
||||
neuigkeiten = nList;
|
||||
gruppen = gList;
|
||||
|
||||
// Termine der letzten 30 Tage + zukünftige
|
||||
try {
|
||||
termine = await api.get<Termin[]>('/termine', { sort: '-beginn' });
|
||||
} catch { termine = []; }
|
||||
|
||||
// Reaktionen – separat damit Fehler nicht alles blockiert
|
||||
try {
|
||||
const [rList, meineList] = await Promise.all([
|
||||
api.get<any[]>('/reaktionen'),
|
||||
api.get<any[]>('/reaktionen', { meine: 'true' }),
|
||||
]);
|
||||
const counts: Record<string, number> = {};
|
||||
for (const r of rList) counts[r.beitrag_id] = (counts[r.beitrag_id] ?? 0) + 1;
|
||||
reaktionen = counts;
|
||||
const mine: Record<string, string> = {};
|
||||
for (const r of meineList) mine[r.beitrag_id] = r.id;
|
||||
meineReaktion = mine;
|
||||
} catch { /* keine Reaktionen = kein Problem */ }
|
||||
|
||||
} catch (e: unknown) {
|
||||
ladeError = e instanceof Error ? e.message : 'Ladefehler';
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
});
|
||||
|
||||
function handleDateiAuswahl(e: Event) {
|
||||
const files = (e.target as HTMLInputElement).files;
|
||||
fDateien = files;
|
||||
previews = [];
|
||||
if (!files) return;
|
||||
for (const file of Array.from(files)) {
|
||||
if (file.type.startsWith('image/')) {
|
||||
previews.push(URL.createObjectURL(file));
|
||||
} else {
|
||||
previews.push(''); // Video – kein Preview
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function toggleGruppe(id: string) {
|
||||
fGruppeIds = fGruppeIds.includes(id)
|
||||
? fGruppeIds.filter(g => g !== id)
|
||||
: [...fGruppeIds, id];
|
||||
}
|
||||
|
||||
async function posten() {
|
||||
if (!fText.trim() && (!fDateien || fDateien.length === 0)) {
|
||||
formError = 'Text oder Foto ist Pflicht.';
|
||||
return;
|
||||
}
|
||||
formError = ''; saving = true;
|
||||
try {
|
||||
const u = get(user);
|
||||
const form = new FormData();
|
||||
form.append('autor_name', u?.name ?? '');
|
||||
if (fText.trim()) form.append('text', fText.trim());
|
||||
if (fTerminId) form.append('termin_id', fTerminId);
|
||||
for (const id of fGruppeIds) form.append('gruppe_ids', id);
|
||||
if (fDateien) {
|
||||
for (const file of Array.from(fDateien)) form.append('medien', file);
|
||||
}
|
||||
|
||||
const neu = await api.postForm<Neuigkeit>('/neuigkeiten', form);
|
||||
neuigkeiten = [neu, ...neuigkeiten];
|
||||
showForm = false;
|
||||
fText = ''; fGruppeIds = []; fTerminId = ''; fDateien = null; previews = [];
|
||||
} catch (e: unknown) {
|
||||
formError = e instanceof Error ? e.message : 'Fehler beim Posten.';
|
||||
} finally {
|
||||
saving = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function loeschen(n: Neuigkeit) {
|
||||
if (!confirm('Beitrag löschen?')) return;
|
||||
await api.del('/neuigkeiten/' + n.id);
|
||||
neuigkeiten = neuigkeiten.filter(x => x.id !== n.id);
|
||||
}
|
||||
|
||||
async function toggleReaktion(n: Neuigkeit) {
|
||||
if (meineReaktion[n.id]) {
|
||||
await api.del('/reaktionen/' + meineReaktion[n.id]);
|
||||
meineReaktion = { ...meineReaktion, [n.id]: '' };
|
||||
reaktionen = { ...reaktionen, [n.id]: Math.max(0, (reaktionen[n.id] ?? 1) - 1) };
|
||||
} else {
|
||||
const r = await api.post<{ id: string }>('/reaktionen', { beitrag_id: n.id });
|
||||
meineReaktion = { ...meineReaktion, [n.id]: r.id };
|
||||
reaktionen = { ...reaktionen, [n.id]: (reaktionen[n.id] ?? 0) + 1 };
|
||||
}
|
||||
}
|
||||
|
||||
function mediaUrl(n: Neuigkeit, datei: string, thumb = false): string {
|
||||
return api.fileUrl(n.verein_id, n.id, datei, thumb);
|
||||
}
|
||||
|
||||
function isVideo(datei: string): boolean {
|
||||
return /\.(mp4|mov|m4v)$/i.test(datei);
|
||||
}
|
||||
|
||||
function zeitAgo(iso: string): string {
|
||||
const diff = Date.now() - new Date(iso).getTime();
|
||||
const min = Math.floor(diff / 60000);
|
||||
if (min < 1) return 'gerade eben';
|
||||
if (min < 60) return `vor ${min} Min.`;
|
||||
const h = Math.floor(min / 60);
|
||||
if (h < 24) return `vor ${h} Std.`;
|
||||
const d = Math.floor(h / 24);
|
||||
if (d < 7) return `vor ${d} Tag${d > 1 ? 'en' : ''}`;
|
||||
return new Date(iso).toLocaleDateString('de-DE', { day: '2-digit', month: 'short' });
|
||||
}
|
||||
|
||||
function autorName(n: Neuigkeit): string {
|
||||
return n.autor_name || 'Unbekannt';
|
||||
}
|
||||
|
||||
function terminName(id: string): string {
|
||||
return termine.find(t => t.id === id)?.titel ?? '';
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head><title>Neuigkeiten — vereins.haus</title></svelte:head>
|
||||
|
||||
<div class="top">
|
||||
<h1>Neuigkeiten</h1>
|
||||
{#if canPost()}
|
||||
<button class="btn-primary" onclick={() => showForm = true}>+ Beitrag</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if loading}
|
||||
<p class="hint">Laden…</p>
|
||||
{:else if ladeError}
|
||||
<p class="hint" style="color:#dc2626">{ladeError}</p>
|
||||
{:else if neuigkeiten.length === 0}
|
||||
<p class="hint">Noch keine Beiträge – schreib den ersten!</p>
|
||||
{:else}
|
||||
<div class="feed">
|
||||
{#each neuigkeiten as n (n.id)}
|
||||
<article class="beitrag">
|
||||
<div class="beitrag-kopf">
|
||||
<div class="avatar">{autorName(n)[0]?.toUpperCase()}</div>
|
||||
<div class="beitrag-meta">
|
||||
<span class="autor">{autorName(n)}</span>
|
||||
<span class="zeit">{zeitAgo(n.created)}</span>
|
||||
</div>
|
||||
{#if n.autor_id === userId()}
|
||||
<button class="btn-del" onclick={() => loeschen(n)}>✕</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if n.termin_id}
|
||||
<div class="termin-tag">📅 {terminName(n.termin_id)}</div>
|
||||
{/if}
|
||||
|
||||
{#if n.text}
|
||||
<p class="beitrag-text">{n.text}</p>
|
||||
{/if}
|
||||
|
||||
{#if n.medien?.length > 0}
|
||||
<div class="medien-grid" class:single={n.medien.length === 1}>
|
||||
{#each n.medien as datei (datei)}
|
||||
{#if isVideo(datei)}
|
||||
<video controls class="media-item" preload="metadata">
|
||||
<source src={mediaUrl(n, datei)} />
|
||||
</video>
|
||||
{:else}
|
||||
<button
|
||||
class="media-btn"
|
||||
onclick={() => lightboxUrl = mediaUrl(n, datei)}
|
||||
>
|
||||
<img
|
||||
src={mediaUrl(n, datei, true)}
|
||||
alt=""
|
||||
class="media-item"
|
||||
loading="lazy"
|
||||
/>
|
||||
</button>
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="beitrag-fuss">
|
||||
<button
|
||||
class="btn-like"
|
||||
class:aktiv={!!meineReaktion[n.id]}
|
||||
onclick={() => toggleReaktion(n)}
|
||||
>
|
||||
👍 {reaktionen[n.id] ?? 0}
|
||||
</button>
|
||||
{#if n.gruppe_ids?.length > 0}
|
||||
<span class="gruppe-tag">
|
||||
{n.gruppe_ids.length === 1
|
||||
? (gruppen.find(g => g.id === n.gruppe_ids[0])?.name ?? '')
|
||||
: `${n.gruppe_ids.length} Gruppen`}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
</article>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Beitrag verfassen -->
|
||||
{#if showForm}
|
||||
<div class="overlay" role="dialog" aria-modal="true">
|
||||
<div class="sheet">
|
||||
<h2>Neuer Beitrag</h2>
|
||||
|
||||
<form onsubmit={(e) => { e.preventDefault(); posten(); }}>
|
||||
<div class="field">
|
||||
<textarea bind:value={fText} rows="4" placeholder="Was gibt's Neues?"></textarea>
|
||||
</div>
|
||||
|
||||
<!-- Foto/Video Upload -->
|
||||
<div class="upload-area">
|
||||
<label class="upload-label">
|
||||
<input
|
||||
type="file"
|
||||
accept="image/*,video/mp4,video/quicktime"
|
||||
multiple
|
||||
class="file-input"
|
||||
onchange={handleDateiAuswahl}
|
||||
/>
|
||||
📷 Fotos / Videos hinzufügen
|
||||
</label>
|
||||
{#if previews.length > 0}
|
||||
<div class="preview-grid">
|
||||
{#each previews as p, i}
|
||||
{#if p}
|
||||
<img src={p} alt="" class="preview-img" />
|
||||
{:else}
|
||||
<div class="preview-video">🎬 Video {i + 1}</div>
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Termin verknüpfen -->
|
||||
{#if termine.length > 0}
|
||||
<div class="field">
|
||||
<label for="ftermin">Zum Termin</label>
|
||||
<select id="ftermin" bind:value={fTerminId}>
|
||||
<option value="">— keinen verknüpfen —</option>
|
||||
{#each termine as t (t.id)}
|
||||
<option value={t.id}>{t.titel} · {new Date(t.beginn).toLocaleDateString('de-DE')}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Gruppen -->
|
||||
{#if gruppen.length > 0}
|
||||
<div class="field">
|
||||
<span class="field-label">Für Gruppen (leer = alle)</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-full" disabled={saving}>
|
||||
{saving ? 'Posten…' : 'Posten'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Lightbox -->
|
||||
{#if lightboxUrl}
|
||||
<div class="lightbox" role="button" tabindex="0"
|
||||
onclick={() => lightboxUrl = ''}
|
||||
onkeydown={(e) => e.key === 'Escape' && (lightboxUrl = '')}>
|
||||
<img src={lightboxUrl} alt="" />
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.top { display: flex; align-items: center; justify-content: space-between; margin-bottom: 1.25rem; }
|
||||
.top .btn-primary { flex: none; padding: 0.45rem 0.9rem; font-size: 0.875rem; }
|
||||
h1 { font-size: 1.4rem; font-weight: 700; color: var(--c-text); }
|
||||
.hint { color: var(--c-text-hint); text-align: center; margin-top: 3rem; }
|
||||
|
||||
.feed { display: flex; flex-direction: column; gap: 0.75rem; margin-bottom: 5rem; }
|
||||
|
||||
.beitrag {
|
||||
background: var(--c-bg-card); border: 1px solid var(--c-border); border-radius: 12px;
|
||||
overflow: hidden; padding: 0.9rem 1rem;
|
||||
}
|
||||
|
||||
.beitrag-kopf {
|
||||
display: flex; align-items: center; gap: 0.65rem; margin-bottom: 0.6rem;
|
||||
}
|
||||
.avatar {
|
||||
width: 2.2rem; height: 2.2rem; border-radius: 50%;
|
||||
background: var(--c-primary-light); color: var(--c-primary);
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
font-weight: 700; font-size: 0.85rem; flex-shrink: 0;
|
||||
}
|
||||
.beitrag-meta { flex: 1; display: flex; flex-direction: column; gap: 0.05rem; }
|
||||
.autor { font-weight: 600; font-size: 0.875rem; color: var(--c-text); }
|
||||
.zeit { font-size: 0.72rem; color: var(--c-text-hint); }
|
||||
.btn-del {
|
||||
background: none; border: none; color: var(--c-text-hint);
|
||||
font-size: 0.85rem; cursor: pointer; padding: 0.2rem;
|
||||
}
|
||||
.btn-del:hover { color: var(--c-error); }
|
||||
|
||||
.termin-tag {
|
||||
font-size: 0.75rem; color: var(--c-primary); background: var(--c-primary-subtle);
|
||||
border-radius: 20px; padding: 0.2rem 0.65rem;
|
||||
display: inline-block; margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.beitrag-text {
|
||||
font-size: 0.92rem; color: var(--c-text); line-height: 1.55;
|
||||
white-space: pre-wrap; margin: 0 0 0.6rem;
|
||||
}
|
||||
|
||||
/* Medien-Grid */
|
||||
.medien-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 0.25rem;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
margin-bottom: 0.6rem;
|
||||
}
|
||||
.medien-grid.single { grid-template-columns: 1fr; }
|
||||
.media-btn {
|
||||
display: block; padding: 0; background: none; border: none;
|
||||
cursor: pointer; overflow: hidden;
|
||||
}
|
||||
.media-item {
|
||||
width: 100%; aspect-ratio: 4/3; object-fit: cover; display: block;
|
||||
transition: opacity 0.15s;
|
||||
}
|
||||
.media-btn:hover .media-item { opacity: 0.9; }
|
||||
|
||||
.beitrag-fuss {
|
||||
display: flex; align-items: center; gap: 0.75rem; padding-top: 0.5rem;
|
||||
border-top: 1px solid var(--c-bg); margin-top: 0.25rem;
|
||||
}
|
||||
.btn-like {
|
||||
background: none; border: 1px solid var(--c-border); border-radius: 20px;
|
||||
padding: 0.25rem 0.75rem; font-size: 0.82rem; cursor: pointer;
|
||||
transition: all 0.15s; color: var(--c-text-muted);
|
||||
}
|
||||
.btn-like.aktiv { background: var(--c-warning-bg); border-color: var(--c-warning); color: var(--c-warning-dark); }
|
||||
.gruppe-tag { font-size: 0.72rem; color: var(--c-text-hint); }
|
||||
|
||||
/* Formular */
|
||||
.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; }
|
||||
.field { display: flex; flex-direction: column; gap: 0.3rem; margin-bottom: 0.85rem; }
|
||||
label, .field-label { font-size: 0.875rem; font-weight: 500; color: var(--c-text-secondary); }
|
||||
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;
|
||||
}
|
||||
textarea:focus, select:focus { outline: none; border-color: var(--c-primary); }
|
||||
|
||||
.upload-area { margin-bottom: 0.85rem; }
|
||||
.upload-label {
|
||||
display: inline-flex; align-items: center; gap: 0.4rem;
|
||||
padding: 0.5rem 0.85rem; border: 1.5px dashed var(--c-border); border-radius: 8px;
|
||||
font-size: 0.875rem; color: var(--c-text-muted); cursor: pointer;
|
||||
transition: border-color 0.15s;
|
||||
}
|
||||
.upload-label:hover { border-color: var(--c-primary); color: var(--c-primary); }
|
||||
.file-input { display: none; }
|
||||
.preview-grid {
|
||||
display: flex; flex-wrap: wrap; gap: 0.4rem; margin-top: 0.5rem;
|
||||
}
|
||||
.preview-img { width: 72px; height: 72px; object-fit: cover; border-radius: 6px; }
|
||||
.preview-video {
|
||||
width: 72px; height: 72px; background: var(--c-bg); border-radius: 6px;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
font-size: 0.75rem; color: var(--c-text-muted);
|
||||
}
|
||||
|
||||
.checkboxes { display: flex; flex-wrap: wrap; gap: 0.4rem; margin-top: 0.25rem; }
|
||||
.check-label {
|
||||
display: flex; align-items: center; gap: 0.35rem;
|
||||
padding: 0.3rem 0.65rem; 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.5rem; }
|
||||
.actions { display: flex; gap: 0.75rem; margin-top: 0.5rem; }
|
||||
.btn-primary-full {
|
||||
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;
|
||||
}
|
||||
.btn-primary-full: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;
|
||||
}
|
||||
.btn-primary { background: var(--c-primary); color: var(--c-bg-card); border: none; border-radius: 8px; font-weight: 600; cursor: pointer; }
|
||||
|
||||
/* Lightbox */
|
||||
.lightbox {
|
||||
position: fixed; inset: 0; background: rgba(0,0,0,0.9);
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
z-index: 200; cursor: zoom-out;
|
||||
}
|
||||
.lightbox img { max-width: 95vw; max-height: 90dvh; border-radius: 8px; }
|
||||
</style>
|
||||
345
app/src/routes/(app)/orte/+page.svelte
Normal file
|
|
@ -0,0 +1,345 @@
|
|||
<script lang="ts">
|
||||
import { api } from '$lib/api';
|
||||
import { onMount } from 'svelte';
|
||||
import type { Veranstaltungsort, OrtAusfall } from '$lib/types';
|
||||
|
||||
let orte = $state<Veranstaltungsort[]>([]);
|
||||
let ausfaelle = $state<OrtAusfall[]>([]);
|
||||
let loading = $state(true);
|
||||
|
||||
// Ort-Formular
|
||||
let showOrtForm = $state(false);
|
||||
let editOrtId = $state<string | null>(null);
|
||||
let fName = $state('');
|
||||
let fAdresse = $state('');
|
||||
let fTyp = $state<Veranstaltungsort['typ']>('halle');
|
||||
let fAktiv = $state(true);
|
||||
let ortSaving = $state(false);
|
||||
let ortError = $state('');
|
||||
|
||||
// Ausfall-Formular
|
||||
let showAusfallForm = $state(false);
|
||||
let aOrtId = $state('');
|
||||
let aVon = $state('');
|
||||
let aBis = $state('');
|
||||
let aGrund = $state('');
|
||||
let ausfallSaving = $state(false);
|
||||
let ausfallError = $state('');
|
||||
|
||||
const typLabel: Record<string, string> = {
|
||||
halle: 'Halle', platz: 'Platz', gebaeude: 'Gebäude', sonstiges: 'Sonstiges',
|
||||
};
|
||||
|
||||
onMount(async () => {
|
||||
[orte, ausfaelle] = await Promise.all([
|
||||
api.get<Veranstaltungsort[]>('/orte', { sort: 'name' }),
|
||||
api.get<OrtAusfall[]>('/ort-ausfaelle', { sort: 'von' }),
|
||||
]);
|
||||
loading = false;
|
||||
});
|
||||
|
||||
function neuerOrt() {
|
||||
editOrtId = null; fName = ''; fAdresse = ''; fTyp = 'halle'; fAktiv = true;
|
||||
ortError = ''; showOrtForm = true;
|
||||
}
|
||||
|
||||
function bearbeitenOrt(o: Veranstaltungsort) {
|
||||
editOrtId = o.id; fName = o.name; fAdresse = o.adresse ?? '';
|
||||
fTyp = o.typ ?? 'halle'; fAktiv = o.aktiv;
|
||||
ortError = ''; showOrtForm = true;
|
||||
}
|
||||
|
||||
async function ortSpeichern() {
|
||||
if (!fName.trim()) { ortError = 'Name ist Pflichtfeld.'; return; }
|
||||
ortError = ''; ortSaving = true;
|
||||
try {
|
||||
const data = { name: fName.trim(), adresse: fAdresse.trim() || null, typ: fTyp, aktiv: fAktiv };
|
||||
if (editOrtId) {
|
||||
const u = await api.put<Veranstaltungsort>('/orte/' + editOrtId, data);
|
||||
orte = orte.map(o => o.id === editOrtId ? u : o);
|
||||
} else {
|
||||
const n = await api.post<Veranstaltungsort>('/orte', data);
|
||||
orte = [...orte, n].sort((a, b) => a.name.localeCompare(b.name));
|
||||
}
|
||||
showOrtForm = false;
|
||||
} catch (e: unknown) {
|
||||
ortError = e instanceof Error ? e.message : 'Fehler.';
|
||||
} finally {
|
||||
ortSaving = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function ortLoeschen(id: string) {
|
||||
if (!confirm('Ort wirklich löschen? Alle verknüpften Termine verlieren die Ortzuordnung.')) return;
|
||||
await api.del('/orte/' + id);
|
||||
orte = orte.filter(o => o.id !== id);
|
||||
ausfaelle = ausfaelle.filter(a => a.ort_id !== id);
|
||||
}
|
||||
|
||||
function ausfallErstellen(ortId: string) {
|
||||
aOrtId = ortId; aVon = ''; aBis = ''; aGrund = '';
|
||||
ausfallError = ''; showAusfallForm = true;
|
||||
}
|
||||
|
||||
async function ausfallSpeichern() {
|
||||
if (!aVon || !aBis) { ausfallError = 'Von und Bis sind Pflichtfelder.'; return; }
|
||||
if (aVon > aBis) { ausfallError = 'Bis muss nach Von liegen.'; return; }
|
||||
ausfallError = ''; ausfallSaving = true;
|
||||
try {
|
||||
const n = await api.post<OrtAusfall>('/ort-ausfaelle', {
|
||||
ort_id: aOrtId, von: aVon, bis: aBis, grund: aGrund.trim() || null,
|
||||
});
|
||||
ausfaelle = [...ausfaelle, n].sort((a, b) => a.von.localeCompare(b.von));
|
||||
showAusfallForm = false;
|
||||
} catch (e: unknown) {
|
||||
ausfallError = e instanceof Error ? e.message : 'Fehler.';
|
||||
} finally {
|
||||
ausfallSaving = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function ausfallLoeschen(id: string) {
|
||||
await api.del('/ort-ausfaelle/' + id);
|
||||
ausfaelle = ausfaelle.filter(a => a.id !== id);
|
||||
}
|
||||
|
||||
function ortName(id: string) {
|
||||
return orte.find(o => o.id === id)?.name ?? '—';
|
||||
}
|
||||
|
||||
function formatDatum(iso: string) {
|
||||
return new Date(iso).toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric' });
|
||||
}
|
||||
|
||||
// Ort ist aktuell gesperrt?
|
||||
function istGesperrt(o: Veranstaltungsort): OrtAusfall | undefined {
|
||||
const heute = new Date().toISOString().slice(0, 10);
|
||||
return ausfaelle.find(a => a.ort_id === o.id && a.von <= heute && a.bis >= heute);
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head><title>Veranstaltungsorte — vereins.haus</title></svelte:head>
|
||||
|
||||
<div class="top">
|
||||
<div>
|
||||
<a class="back" href="/termine">← Termine</a>
|
||||
<h1>Veranstaltungsorte</h1>
|
||||
</div>
|
||||
<button class="btn-primary" onclick={neuerOrt}>+ Ort</button>
|
||||
</div>
|
||||
|
||||
{#if loading}
|
||||
<p class="hint">Laden…</p>
|
||||
{:else if orte.length === 0}
|
||||
<p class="hint">Noch keine Orte angelegt.</p>
|
||||
{:else}
|
||||
<ul class="liste">
|
||||
{#each orte as o (o.id)}
|
||||
{@const gesperrt = istGesperrt(o)}
|
||||
<li class="karte" class:inaktiv={!o.aktiv}>
|
||||
<div class="ort-info">
|
||||
<div class="ort-name-zeile">
|
||||
<span class="ort-name">{o.name}</span>
|
||||
{#if !o.aktiv}
|
||||
<span class="badge grau">Inaktiv</span>
|
||||
{:else if gesperrt}
|
||||
<span class="badge rot">Gesperrt</span>
|
||||
{:else}
|
||||
<span class="badge gruen">Verfügbar</span>
|
||||
{/if}
|
||||
</div>
|
||||
{#if o.adresse}<span class="ort-meta">{o.adresse}</span>{/if}
|
||||
{#if o.typ}<span class="ort-meta">{typLabel[o.typ]}</span>{/if}
|
||||
{#if gesperrt}
|
||||
<span class="gesperrt-hinweis">
|
||||
Gesperrt bis {formatDatum(gesperrt.bis)}{gesperrt.grund ? ' · ' + gesperrt.grund : ''}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="ort-aktionen">
|
||||
<button class="btn-ausfall" onclick={() => ausfallErstellen(o.id)} title="Ausfall eintragen">Ausfall</button>
|
||||
<button class="btn-icon" onclick={() => bearbeitenOrt(o)}>✎</button>
|
||||
<button class="btn-icon btn-icon-red" onclick={() => ortLoeschen(o.id)}>✕</button>
|
||||
</div>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
|
||||
<!-- Ausfälle -->
|
||||
{#if ausfaelle.length > 0}
|
||||
<h2 class="section-title">Geplante Ausfälle</h2>
|
||||
<ul class="liste">
|
||||
{#each ausfaelle as a (a.id)}
|
||||
<li class="ausfall-karte">
|
||||
<div class="ausfall-info">
|
||||
<span class="ausfall-ort">{ortName(a.ort_id)}</span>
|
||||
<span class="ausfall-zeitraum">{formatDatum(a.von)} – {formatDatum(a.bis)}</span>
|
||||
{#if a.grund}<span class="ausfall-grund">{a.grund}</span>{/if}
|
||||
</div>
|
||||
<button class="btn-icon btn-icon-red" onclick={() => ausfallLoeschen(a.id)}>✕</button>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
|
||||
<!-- Ort-Formular -->
|
||||
{#if showOrtForm}
|
||||
<div class="overlay" role="dialog" aria-modal="true">
|
||||
<div class="sheet">
|
||||
<h2>{editOrtId ? 'Ort bearbeiten' : 'Neuer Ort'}</h2>
|
||||
<form onsubmit={(e) => { e.preventDefault(); ortSpeichern(); }}>
|
||||
<div class="field">
|
||||
<label for="fname">Name *</label>
|
||||
<input id="fname" type="text" bind:value={fName} placeholder="z. B. Turnhalle Schillerschule" required />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="fadresse">Adresse</label>
|
||||
<input id="fadresse" type="text" bind:value={fAdresse} placeholder="Musterstraße 1, 12345 Stadt" />
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="field">
|
||||
<label for="ftyp">Typ</label>
|
||||
<select id="ftyp" bind:value={fTyp}>
|
||||
<option value="halle">Halle</option>
|
||||
<option value="platz">Platz</option>
|
||||
<option value="gebaeude">Gebäude</option>
|
||||
<option value="sonstiges">Sonstiges</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="field" style="justify-content:flex-end;padding-bottom:0.85rem">
|
||||
<label class="toggle-label">
|
||||
<input type="checkbox" bind:checked={fAktiv} />
|
||||
Aktiv
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
{#if ortError}<p class="error">{ortError}</p>{/if}
|
||||
<div class="actions">
|
||||
<button type="button" class="btn-ghost" onclick={() => showOrtForm = false}>Abbrechen</button>
|
||||
<button type="submit" class="btn-primary" disabled={ortSaving}>{ortSaving ? 'Speichern…' : 'Speichern'}</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Ausfall-Formular -->
|
||||
{#if showAusfallForm}
|
||||
<div class="overlay" role="dialog" aria-modal="true">
|
||||
<div class="sheet">
|
||||
<h2>Ausfall eintragen</h2>
|
||||
<p class="sheet-sub">{ortName(aOrtId)}</p>
|
||||
<form onsubmit={(e) => { e.preventDefault(); ausfallSpeichern(); }}>
|
||||
<div class="row">
|
||||
<div class="field">
|
||||
<label for="avon">Von *</label>
|
||||
<input id="avon" type="date" bind:value={aVon} required />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="abis">Bis *</label>
|
||||
<input id="abis" type="date" bind:value={aBis} required />
|
||||
</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="agrund">Grund</label>
|
||||
<input id="agrund" type="text" bind:value={aGrund} placeholder="z. B. Schulveranstaltung, Wartung" />
|
||||
</div>
|
||||
{#if ausfallError}<p class="error">{ausfallError}</p>{/if}
|
||||
<div class="actions">
|
||||
<button type="button" class="btn-ghost" onclick={() => showAusfallForm = false}>Abbrechen</button>
|
||||
<button type="submit" class="btn-primary" disabled={ausfallSaving}>{ausfallSaving ? 'Speichern…' : 'Speichern'}</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.top { display: flex; align-items: flex-start; justify-content: space-between; margin-bottom: 1.25rem; }
|
||||
.top .btn-primary { flex: none; padding: 0.45rem 0.9rem; font-size: 0.875rem; }
|
||||
.back { font-size: 0.85rem; color: var(--c-primary); text-decoration: none; display: block; margin-bottom: 0.25rem; }
|
||||
h1 { font-size: 1.4rem; font-weight: 700; color: var(--c-text); }
|
||||
.hint { color: var(--c-text-hint); font-size: 0.95rem; text-align: center; margin-top: 3rem; }
|
||||
.section-title { font-size: 0.72rem; font-weight: 700; color: var(--c-text-hint); 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: var(--c-bg-card);
|
||||
border: 1px solid var(--c-border); 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: var(--c-text); }
|
||||
.ort-meta { font-size: 0.78rem; color: var(--c-text-muted); }
|
||||
.gesperrt-hinweis { font-size: 0.75rem; color: var(--c-error); font-weight: 500; }
|
||||
|
||||
.badge { font-size: 0.7rem; font-weight: 700; padding: 0.1rem 0.45rem; border-radius: 20px; }
|
||||
.badge.gruen { background: var(--c-success-bg); color: var(--c-success); }
|
||||
.badge.rot { background: var(--c-error-bg); color: var(--c-error); }
|
||||
.badge.grau { background: var(--c-bg); color: var(--c-text-muted); }
|
||||
|
||||
.ort-aktionen { display: flex; gap: 0.35rem; flex-shrink: 0; align-items: flex-start; }
|
||||
|
||||
.btn-ausfall {
|
||||
padding: 0.3rem 0.6rem; background: var(--c-warning-bg); border: 1px solid var(--c-warning-light);
|
||||
border-radius: 6px; font-size: 0.75rem; font-weight: 600; color: var(--c-warning-amber); cursor: pointer;
|
||||
}
|
||||
|
||||
.ausfall-karte {
|
||||
display: flex; align-items: center; gap: 0.75rem;
|
||||
padding: 0.7rem 1rem; background: var(--c-bg-card);
|
||||
border: 1px solid var(--c-error-light); 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: var(--c-text); }
|
||||
.ausfall-zeitraum { font-size: 0.78rem; color: var(--c-error); }
|
||||
.ausfall-grund { font-size: 0.75rem; color: var(--c-text-hint); }
|
||||
|
||||
.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); }
|
||||
|
||||
.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: 90dvh; overflow-y: auto;
|
||||
}
|
||||
h2 { font-size: 1.1rem; font-weight: 700; color: var(--c-text); margin-bottom: 0.25rem; }
|
||||
.sheet-sub { font-size: 0.85rem; color: var(--c-text-muted); 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: var(--c-text-secondary); }
|
||||
.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 var(--c-border); border-radius: 8px;
|
||||
font-size: 1rem; background: var(--c-bg-card); width: 100%; box-sizing: border-box;
|
||||
transition: border-color 0.15s;
|
||||
}
|
||||
input:focus, select:focus { outline: none; border-color: var(--c-primary); }
|
||||
.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;
|
||||
}
|
||||
.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;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -1,27 +1,716 @@
|
|||
<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="page-header">
|
||||
<div class="top">
|
||||
<h1>Termine</h1>
|
||||
<button class="btn-primary">+ Termin</button>
|
||||
<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>
|
||||
|
||||
<p class="placeholder">Terminkalender — in Entwicklung</p>
|
||||
{#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>
|
||||
.page-header {
|
||||
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; }
|
||||
.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;
|
||||
}
|
||||
h1 { font-size: 1.4rem; font-weight: 700; color: #1e293b; }
|
||||
|
||||
.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 {
|
||||
background: #1e40af; color: #fff; border: none;
|
||||
border-radius: 8px; padding: 0.5rem 1rem;
|
||||
font-size: 0.9rem; font-weight: 600;
|
||||
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;
|
||||
}
|
||||
.placeholder { color: #94a3b8; font-size: 0.95rem; }
|
||||
.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>
|
||||
|
|
|
|||
|
|
@ -1,12 +1,13 @@
|
|||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { onMount } from 'svelte';
|
||||
import { pb } from '$lib/pb';
|
||||
import { get } from 'svelte/store';
|
||||
import { user } from '$lib/user';
|
||||
|
||||
let { children } = $props();
|
||||
|
||||
onMount(() => {
|
||||
if (pb.authStore.isValid) {
|
||||
if (!!get(user)) {
|
||||
goto('/');
|
||||
}
|
||||
});
|
||||
|
|
@ -26,13 +27,13 @@
|
|||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 1.5rem;
|
||||
background: #f8fafc;
|
||||
background: var(--c-bg-subtle);
|
||||
}
|
||||
|
||||
.auth-card {
|
||||
width: 100%;
|
||||
max-width: 400px;
|
||||
background: #fff;
|
||||
background: var(--c-bg-card);
|
||||
border-radius: 12px;
|
||||
padding: 2rem;
|
||||
box-shadow: 0 1px 3px rgba(0,0,0,.08), 0 4px 16px rgba(0,0,0,.06);
|
||||
|
|
@ -42,7 +43,7 @@
|
|||
display: block;
|
||||
font-size: 1.4rem;
|
||||
font-weight: 700;
|
||||
color: #1e40af;
|
||||
color: var(--c-primary);
|
||||
margin-bottom: 1.5rem;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,8 @@
|
|||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { pb } from '$lib/pb';
|
||||
import { api } from '$lib/api';
|
||||
import { user } from '$lib/user';
|
||||
import type { AppUser } from '$lib/user';
|
||||
|
||||
let email = $state('');
|
||||
let password = $state('');
|
||||
|
|
@ -11,7 +13,8 @@
|
|||
error = '';
|
||||
loading = true;
|
||||
try {
|
||||
await pb.collection('users').authWithPassword(email, password);
|
||||
const u = await api.post<AppUser & { token: string }>('/auth/login', { email, password });
|
||||
user.set(u);
|
||||
goto('/');
|
||||
} catch {
|
||||
error = 'E-Mail oder Passwort falsch.';
|
||||
|
|
@ -68,7 +71,7 @@
|
|||
font-size: 1.4rem;
|
||||
font-weight: 700;
|
||||
margin-bottom: 1.5rem;
|
||||
color: #1e293b;
|
||||
color: var(--c-text);
|
||||
}
|
||||
|
||||
.field {
|
||||
|
|
@ -81,25 +84,25 @@
|
|||
label {
|
||||
font-size: 0.9rem;
|
||||
font-weight: 500;
|
||||
color: #475569;
|
||||
color: var(--c-text-secondary);
|
||||
}
|
||||
|
||||
input {
|
||||
padding: 0.65rem 0.85rem;
|
||||
border: 1.5px solid #e2e8f0;
|
||||
border: 1.5px solid var(--c-border);
|
||||
border-radius: 8px;
|
||||
font-size: 1rem;
|
||||
transition: border-color .15s;
|
||||
background: #fff;
|
||||
background: var(--c-bg-card);
|
||||
}
|
||||
|
||||
input:focus {
|
||||
outline: none;
|
||||
border-color: #1e40af;
|
||||
border-color: var(--c-primary);
|
||||
}
|
||||
|
||||
.error {
|
||||
color: #dc2626;
|
||||
color: var(--c-error);
|
||||
font-size: 0.9rem;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
|
@ -107,8 +110,8 @@
|
|||
button {
|
||||
width: 100%;
|
||||
padding: 0.75rem;
|
||||
background: #1e40af;
|
||||
color: #fff;
|
||||
background: var(--c-primary);
|
||||
color: var(--c-bg-card);
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
font-size: 1rem;
|
||||
|
|
@ -118,7 +121,7 @@
|
|||
}
|
||||
|
||||
button:hover:not(:disabled) {
|
||||
background: #1d3a9e;
|
||||
background: var(--c-primary-dark);
|
||||
}
|
||||
|
||||
button:disabled {
|
||||
|
|
@ -129,12 +132,12 @@
|
|||
.switch {
|
||||
margin-top: 1.25rem;
|
||||
font-size: 0.9rem;
|
||||
color: #64748b;
|
||||
color: var(--c-text-muted);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.switch a {
|
||||
color: #1e40af;
|
||||
color: var(--c-primary);
|
||||
font-weight: 500;
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { pb } from '$lib/pb';
|
||||
import { api } from '$lib/api';
|
||||
import { user } from '$lib/user';
|
||||
|
||||
let vereinsname = $state('');
|
||||
let email = $state('');
|
||||
|
|
@ -17,20 +18,9 @@
|
|||
}
|
||||
loading = true;
|
||||
try {
|
||||
const user = await pb.collection('users').create({
|
||||
email,
|
||||
password,
|
||||
passwordConfirm,
|
||||
name: vereinsname
|
||||
});
|
||||
await pb.collection('vereine').create({
|
||||
name: vereinsname,
|
||||
plan: 'free',
|
||||
dosb_mitglied: false,
|
||||
user_id: user.id
|
||||
});
|
||||
await pb.collection('users').authWithPassword(email, password);
|
||||
goto('/');
|
||||
const u = await api.post('/auth/register', { vereinName: vereinsname, email, password, name: vereinsname });
|
||||
user.set(u as any);
|
||||
goto('/onboarding');
|
||||
} catch (e: unknown) {
|
||||
error = e instanceof Error ? e.message : 'Registrierung fehlgeschlagen.';
|
||||
} finally {
|
||||
|
|
@ -109,13 +99,13 @@
|
|||
h1 {
|
||||
font-size: 1.4rem;
|
||||
font-weight: 700;
|
||||
color: #1e293b;
|
||||
color: var(--c-text);
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
font-size: 0.9rem;
|
||||
color: #64748b;
|
||||
color: var(--c-text-muted);
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
|
|
@ -129,25 +119,25 @@
|
|||
label {
|
||||
font-size: 0.9rem;
|
||||
font-weight: 500;
|
||||
color: #475569;
|
||||
color: var(--c-text-secondary);
|
||||
}
|
||||
|
||||
input {
|
||||
padding: 0.65rem 0.85rem;
|
||||
border: 1.5px solid #e2e8f0;
|
||||
border: 1.5px solid var(--c-border);
|
||||
border-radius: 8px;
|
||||
font-size: 1rem;
|
||||
transition: border-color .15s;
|
||||
background: #fff;
|
||||
background: var(--c-bg-card);
|
||||
}
|
||||
|
||||
input:focus {
|
||||
outline: none;
|
||||
border-color: #1e40af;
|
||||
border-color: var(--c-primary);
|
||||
}
|
||||
|
||||
.error {
|
||||
color: #dc2626;
|
||||
color: var(--c-error);
|
||||
font-size: 0.9rem;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
|
@ -155,8 +145,8 @@
|
|||
button {
|
||||
width: 100%;
|
||||
padding: 0.75rem;
|
||||
background: #1e40af;
|
||||
color: #fff;
|
||||
background: var(--c-primary);
|
||||
color: var(--c-bg-card);
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
font-size: 1rem;
|
||||
|
|
@ -166,7 +156,7 @@
|
|||
}
|
||||
|
||||
button:hover:not(:disabled) {
|
||||
background: #1d3a9e;
|
||||
background: var(--c-primary-dark);
|
||||
}
|
||||
|
||||
button:disabled {
|
||||
|
|
@ -177,12 +167,12 @@
|
|||
.switch {
|
||||
margin-top: 1.25rem;
|
||||
font-size: 0.9rem;
|
||||
color: #64748b;
|
||||
color: var(--c-text-muted);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.switch a {
|
||||
color: #1e40af;
|
||||
color: var(--c-primary);
|
||||
font-weight: 500;
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
<script lang="ts">
|
||||
import '$lib/styles/theme.css';
|
||||
let { children } = $props();
|
||||
</script>
|
||||
|
||||
|
|
@ -31,8 +32,8 @@
|
|||
|
||||
:global(body) {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||
color: #1e293b;
|
||||
background: #f8fafc;
|
||||
color: var(--c-text);
|
||||
background: var(--c-bg-subtle);
|
||||
line-height: 1.6;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
|
|
|||
18
app/src/routes/api/auth/login/+server.ts
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
import { json, error } from '@sveltejs/kit';
|
||||
import { getDb } from '$lib/server/db';
|
||||
import { checkPassword, signJwt } from '$lib/server/auth';
|
||||
|
||||
export async function POST({ request }) {
|
||||
const { email, password } = await request.json();
|
||||
if (!email || !password) throw error(400, 'E-Mail und Passwort erforderlich');
|
||||
|
||||
const db = getDb();
|
||||
const u = db.prepare('SELECT * FROM users WHERE email = ?').get(email.toLowerCase()) as any;
|
||||
if (!u || !(await checkPassword(password, u.password_hash))) throw error(401, 'Ungültige Zugangsdaten');
|
||||
|
||||
const token = await signJwt({
|
||||
sub: u.id, verein_id: u.verein_id, rolle: u.rolle, name: u.name, email: u.email
|
||||
});
|
||||
|
||||
return json({ token, id: u.id, verein_id: u.verein_id, rolle: u.rolle, name: u.name, email: u.email });
|
||||
}
|
||||
11
app/src/routes/api/auth/me/+server.ts
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
import { json } from '@sveltejs/kit';
|
||||
import { requireAuth } from '$lib/server/auth';
|
||||
import { getDb } from '$lib/server/db';
|
||||
|
||||
export async function GET({ request }) {
|
||||
const u = await requireAuth(request);
|
||||
const db = getDb();
|
||||
const row = db.prepare('SELECT id, verein_id, email, name, rolle FROM users WHERE id = ?').get(u.sub) as any;
|
||||
if (!row) return new Response(null, { status: 401 });
|
||||
return json(row);
|
||||
}
|
||||
24
app/src/routes/api/auth/register/+server.ts
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
import { json, error } from '@sveltejs/kit';
|
||||
import { getDb, newId } from '$lib/server/db';
|
||||
import { hashPassword, signJwt } from '$lib/server/auth';
|
||||
|
||||
export async function POST({ request }) {
|
||||
const { vereinName, email, password, name } = await request.json();
|
||||
if (!vereinName || !email || !password) throw error(400, 'Pflichtfelder fehlen');
|
||||
if (password.length < 8) throw error(400, 'Passwort mindestens 8 Zeichen');
|
||||
|
||||
const db = getDb();
|
||||
const existing = db.prepare('SELECT id FROM users WHERE email = ?').get(email.toLowerCase());
|
||||
if (existing) throw error(409, 'E-Mail bereits registriert');
|
||||
|
||||
const vereinId = newId();
|
||||
const userId = newId();
|
||||
const hash = await hashPassword(password);
|
||||
|
||||
db.prepare('INSERT INTO vereine (id, name) VALUES (?, ?)').run(vereinId, vereinName);
|
||||
db.prepare('INSERT INTO users (id, verein_id, email, password_hash, name, rolle) VALUES (?, ?, ?, ?, ?, NULL)')
|
||||
.run(userId, vereinId, email.toLowerCase(), hash, name || email.split('@')[0]);
|
||||
|
||||
const token = await signJwt({ sub: userId, verein_id: vereinId, rolle: null, name: name || email.split('@')[0], email: email.toLowerCase() });
|
||||
return json({ token, id: userId, verein_id: vereinId, rolle: null, name: name || email.split('@')[0], email: email.toLowerCase() }, { status: 201 });
|
||||
}
|
||||
37
app/src/routes/api/beitraege/+server.ts
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
import { json, error } from '@sveltejs/kit';
|
||||
import { getDb, newId, rows, row } from '$lib/server/db';
|
||||
import { requireAuth } from '$lib/server/auth';
|
||||
|
||||
export async function GET({ request }) {
|
||||
const u = await requireAuth(request);
|
||||
const db = getDb();
|
||||
const items = db.prepare(
|
||||
'SELECT * FROM beitraege WHERE verein_id = ? ORDER BY name'
|
||||
).all(u.verein_id);
|
||||
return json(rows(items as Record<string, unknown>[]));
|
||||
}
|
||||
|
||||
export async function POST({ request }) {
|
||||
const u = await requireAuth(request);
|
||||
const db = getDb();
|
||||
const body = await request.json();
|
||||
|
||||
if (!body.name || body.betrag == null) throw error(400, 'Name und Betrag sind Pflichtfelder');
|
||||
|
||||
const id = newId();
|
||||
|
||||
db.prepare(`
|
||||
INSERT INTO beitraege (id, verein_id, name, betrag, rhythmus, beschreibung)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
`).run(
|
||||
id,
|
||||
u.verein_id,
|
||||
body.name,
|
||||
body.betrag,
|
||||
body.rhythmus ?? 'jaehrlich',
|
||||
body.beschreibung ?? null
|
||||
);
|
||||
|
||||
const beitrag = db.prepare('SELECT * FROM beitraege WHERE id = ?').get(id);
|
||||
return json(row(beitrag as Record<string, unknown>), { status: 201 });
|
||||
}
|
||||
52
app/src/routes/api/beitraege/[id]/+server.ts
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
import { json, error } from '@sveltejs/kit';
|
||||
import { getDb, row } from '$lib/server/db';
|
||||
import { requireAuth } from '$lib/server/auth';
|
||||
|
||||
export async function GET({ request, params }) {
|
||||
const u = await requireAuth(request);
|
||||
const db = getDb();
|
||||
const beitrag = db.prepare(
|
||||
'SELECT * FROM beitraege WHERE id = ? AND verein_id = ?'
|
||||
).get(params.id, u.verein_id);
|
||||
if (!beitrag) throw error(404, 'Beitrag nicht gefunden');
|
||||
return json(row(beitrag as Record<string, unknown>));
|
||||
}
|
||||
|
||||
export async function PUT({ request, params }) {
|
||||
const u = await requireAuth(request);
|
||||
const db = getDb();
|
||||
|
||||
const existing = db.prepare(
|
||||
'SELECT id FROM beitraege WHERE id = ? AND verein_id = ?'
|
||||
).get(params.id, u.verein_id);
|
||||
if (!existing) throw error(404, 'Beitrag nicht gefunden');
|
||||
|
||||
const body = await request.json();
|
||||
|
||||
db.prepare(`
|
||||
UPDATE beitraege SET
|
||||
name = ?, betrag = ?, rhythmus = ?, beschreibung = ?,
|
||||
updated = strftime('%Y-%m-%dT%H:%M:%SZ','now')
|
||||
WHERE id = ? AND verein_id = ?
|
||||
`).run(
|
||||
body.name,
|
||||
body.betrag,
|
||||
body.rhythmus ?? 'jaehrlich',
|
||||
body.beschreibung ?? null,
|
||||
params.id,
|
||||
u.verein_id
|
||||
);
|
||||
|
||||
const beitrag = db.prepare('SELECT * FROM beitraege WHERE id = ?').get(params.id);
|
||||
return json(row(beitrag as Record<string, unknown>));
|
||||
}
|
||||
|
||||
export async function DELETE({ request, params }) {
|
||||
const u = await requireAuth(request);
|
||||
const db = getDb();
|
||||
const result = db.prepare(
|
||||
'DELETE FROM beitraege WHERE id = ? AND verein_id = ?'
|
||||
).run(params.id, u.verein_id);
|
||||
if (result.changes === 0) throw error(404, 'Beitrag nicht gefunden');
|
||||
return new Response(null, { status: 204 });
|
||||
}
|
||||
23
app/src/routes/api/einladungen/+server.ts
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
import { json, error } from '@sveltejs/kit';
|
||||
import { getDb, newId, row } from '$lib/server/db';
|
||||
import { requireAuth } from '$lib/server/auth';
|
||||
|
||||
export async function POST({ request }) {
|
||||
const u = await requireAuth(request);
|
||||
const db = getDb();
|
||||
const body = await request.json();
|
||||
|
||||
const id = newId();
|
||||
|
||||
db.prepare(`
|
||||
INSERT INTO einladungen (id, verein_id, rolle)
|
||||
VALUES (?, ?, ?)
|
||||
`).run(
|
||||
id,
|
||||
u.verein_id,
|
||||
body.rolle ?? 'trainer'
|
||||
);
|
||||
|
||||
const einladung = db.prepare('SELECT * FROM einladungen WHERE id = ?').get(id);
|
||||
return json(row(einladung as Record<string, unknown>), { status: 201 });
|
||||
}
|
||||
62
app/src/routes/api/einladungen/[token]/+server.ts
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
import { json, error } from '@sveltejs/kit';
|
||||
import { getDb, newId, row } from '$lib/server/db';
|
||||
import { hashPassword, signJwt } from '$lib/server/auth';
|
||||
|
||||
export async function GET({ params }) {
|
||||
const db = getDb();
|
||||
|
||||
const einladung = db.prepare(`
|
||||
SELECT e.*, v.name as vereinName
|
||||
FROM einladungen e JOIN vereine v ON v.id = e.verein_id
|
||||
WHERE e.token = ? AND e.genutzt = 0
|
||||
`).get(params.token);
|
||||
|
||||
if (!einladung) throw error(404, 'Einladung nicht gefunden oder bereits verwendet');
|
||||
|
||||
return json(row(einladung as Record<string, unknown>));
|
||||
}
|
||||
|
||||
export async function POST({ request, params }) {
|
||||
const db = getDb();
|
||||
const body = await request.json();
|
||||
|
||||
if (!body.email || !body.password || !body.name) throw error(400, 'E-Mail, Passwort und Name sind Pflichtfelder');
|
||||
if (body.password.length < 8) throw error(400, 'Passwort mindestens 8 Zeichen');
|
||||
|
||||
const einladung = db.prepare(
|
||||
'SELECT * FROM einladungen WHERE token = ? AND genutzt = 0'
|
||||
).get(params.token) as Record<string, unknown> | undefined;
|
||||
|
||||
if (!einladung) throw error(404, 'Einladung nicht gefunden oder bereits verwendet');
|
||||
|
||||
const verein_id = einladung.verein_id as string;
|
||||
const rolle = einladung.rolle as string;
|
||||
|
||||
const existing = db.prepare('SELECT id FROM users WHERE email = ?').get(body.email.toLowerCase());
|
||||
if (existing) throw error(409, 'E-Mail bereits registriert');
|
||||
|
||||
const userId = newId();
|
||||
const hash = await hashPassword(body.password);
|
||||
|
||||
db.prepare(`
|
||||
INSERT INTO users (id, verein_id, email, password_hash, name, rolle)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
`).run(userId, verein_id, body.email.toLowerCase(), hash, body.name, rolle);
|
||||
|
||||
db.prepare(
|
||||
`UPDATE einladungen SET genutzt = 1, updated = strftime('%Y-%m-%dT%H:%M:%SZ','now') WHERE token = ?`
|
||||
).run(params.token);
|
||||
|
||||
const token = await signJwt({
|
||||
sub: userId,
|
||||
verein_id,
|
||||
rolle,
|
||||
name: body.name,
|
||||
email: body.email.toLowerCase()
|
||||
});
|
||||
|
||||
return json(
|
||||
{ token, id: userId, verein_id, rolle, name: body.name, email: body.email.toLowerCase() },
|
||||
{ status: 201 }
|
||||
);
|
||||
}
|
||||
58
app/src/routes/api/einzuege/+server.ts
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
import { json, error } from '@sveltejs/kit';
|
||||
import { getDb, newId, rows } from '$lib/server/db';
|
||||
import { requireAuth } from '$lib/server/auth';
|
||||
|
||||
export async function GET({ request }) {
|
||||
const u = await requireAuth(request);
|
||||
const db = getDb();
|
||||
const items = db.prepare(`
|
||||
SELECT e.*, m.vorname, m.nachname, b.name as beitrag_name
|
||||
FROM einzuege e
|
||||
JOIN mitglieder m ON m.id = e.mitglied_id
|
||||
JOIN beitraege b ON b.id = e.beitrag_id
|
||||
WHERE e.verein_id = ?
|
||||
ORDER BY e.faellig_am
|
||||
`).all(u.verein_id);
|
||||
return json(rows(items as Record<string, unknown>[]));
|
||||
}
|
||||
|
||||
export async function POST({ request }) {
|
||||
const u = await requireAuth(request);
|
||||
const db = getDb();
|
||||
const body = await request.json();
|
||||
|
||||
const einzuege = Array.isArray(body) ? body : [body];
|
||||
if (einzuege.length === 0) throw error(400, 'Keine Einzüge angegeben');
|
||||
|
||||
const insert = db.prepare(`
|
||||
INSERT INTO einzuege (id, verein_id, mitglied_id, beitrag_id, betrag, faellig_am, status)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
`);
|
||||
|
||||
const insertMany = db.transaction((items: typeof einzuege) => {
|
||||
for (const e of items) {
|
||||
insert.run(
|
||||
newId(),
|
||||
u.verein_id,
|
||||
e.mitglied_id,
|
||||
e.beitrag_id,
|
||||
e.betrag,
|
||||
e.faellig_am ?? null,
|
||||
e.status ?? 'ausstehend'
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
insertMany(einzuege);
|
||||
|
||||
const created = db.prepare(`
|
||||
SELECT e.*, m.vorname, m.nachname, b.name as beitrag_name
|
||||
FROM einzuege e
|
||||
JOIN mitglieder m ON m.id = e.mitglied_id
|
||||
JOIN beitraege b ON b.id = e.beitrag_id
|
||||
WHERE e.verein_id = ?
|
||||
ORDER BY e.faellig_am
|
||||
`).all(u.verein_id);
|
||||
|
||||
return json(rows(created as Record<string, unknown>[]), { status: 201 });
|
||||
}
|
||||
26
app/src/routes/api/files/[...path]/+server.ts
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
import { readFileSync, existsSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
|
||||
const UPLOAD_DIR = process.env.UPLOAD_DIR || './data/uploads';
|
||||
|
||||
export async function GET({ params }) {
|
||||
const filePath = join(UPLOAD_DIR, params.path);
|
||||
if (!existsSync(filePath)) return new Response(null, { status: 404 });
|
||||
const data = readFileSync(filePath);
|
||||
const ext = filePath.split('.').pop()?.toLowerCase() || '';
|
||||
const mime: Record<string, string> = {
|
||||
jpg: 'image/jpeg',
|
||||
jpeg: 'image/jpeg',
|
||||
png: 'image/png',
|
||||
gif: 'image/gif',
|
||||
webp: 'image/webp',
|
||||
mp4: 'video/mp4',
|
||||
mov: 'video/quicktime'
|
||||
};
|
||||
return new Response(data, {
|
||||
headers: {
|
||||
'Content-Type': mime[ext] || 'application/octet-stream',
|
||||
'Cache-Control': 'public, max-age=31536000'
|
||||
}
|
||||
});
|
||||
}
|
||||
36
app/src/routes/api/gruppen/+server.ts
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
import { json, error } from '@sveltejs/kit';
|
||||
import { getDb, newId, rows, row, toArr } from '$lib/server/db';
|
||||
import { requireAuth } from '$lib/server/auth';
|
||||
|
||||
export async function GET({ request }) {
|
||||
const u = await requireAuth(request);
|
||||
const db = getDb();
|
||||
const items = db.prepare(
|
||||
'SELECT * FROM gruppen WHERE verein_id = ? ORDER BY name'
|
||||
).all(u.verein_id);
|
||||
return json(rows(items as Record<string, unknown>[]));
|
||||
}
|
||||
|
||||
export async function POST({ request }) {
|
||||
const u = await requireAuth(request);
|
||||
const db = getDb();
|
||||
const body = await request.json();
|
||||
|
||||
if (!body.name) throw error(400, 'Name ist ein Pflichtfeld');
|
||||
|
||||
const id = newId();
|
||||
|
||||
db.prepare(`
|
||||
INSERT INTO gruppen (id, verein_id, name, beschreibung, trainer_ids)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
`).run(
|
||||
id,
|
||||
u.verein_id,
|
||||
body.name,
|
||||
body.beschreibung ?? null,
|
||||
toArr(body.trainer_ids)
|
||||
);
|
||||
|
||||
const gruppe = db.prepare('SELECT * FROM gruppen WHERE id = ?').get(id);
|
||||
return json(row(gruppe as Record<string, unknown>), { status: 201 });
|
||||
}
|
||||
51
app/src/routes/api/gruppen/[id]/+server.ts
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
import { json, error } from '@sveltejs/kit';
|
||||
import { getDb, row, toArr } from '$lib/server/db';
|
||||
import { requireAuth } from '$lib/server/auth';
|
||||
|
||||
export async function GET({ request, params }) {
|
||||
const u = await requireAuth(request);
|
||||
const db = getDb();
|
||||
const gruppe = db.prepare(
|
||||
'SELECT * FROM gruppen WHERE id = ? AND verein_id = ?'
|
||||
).get(params.id, u.verein_id);
|
||||
if (!gruppe) throw error(404, 'Gruppe nicht gefunden');
|
||||
return json(row(gruppe as Record<string, unknown>));
|
||||
}
|
||||
|
||||
export async function PUT({ request, params }) {
|
||||
const u = await requireAuth(request);
|
||||
const db = getDb();
|
||||
|
||||
const existing = db.prepare(
|
||||
'SELECT id FROM gruppen WHERE id = ? AND verein_id = ?'
|
||||
).get(params.id, u.verein_id);
|
||||
if (!existing) throw error(404, 'Gruppe nicht gefunden');
|
||||
|
||||
const body = await request.json();
|
||||
|
||||
db.prepare(`
|
||||
UPDATE gruppen SET
|
||||
name = ?, beschreibung = ?, trainer_ids = ?,
|
||||
updated = strftime('%Y-%m-%dT%H:%M:%SZ','now')
|
||||
WHERE id = ? AND verein_id = ?
|
||||
`).run(
|
||||
body.name,
|
||||
body.beschreibung ?? null,
|
||||
toArr(body.trainer_ids),
|
||||
params.id,
|
||||
u.verein_id
|
||||
);
|
||||
|
||||
const gruppe = db.prepare('SELECT * FROM gruppen WHERE id = ?').get(params.id);
|
||||
return json(row(gruppe as Record<string, unknown>));
|
||||
}
|
||||
|
||||
export async function DELETE({ request, params }) {
|
||||
const u = await requireAuth(request);
|
||||
const db = getDb();
|
||||
const result = db.prepare(
|
||||
'DELETE FROM gruppen WHERE id = ? AND verein_id = ?'
|
||||
).run(params.id, u.verein_id);
|
||||
if (result.changes === 0) throw error(404, 'Gruppe nicht gefunden');
|
||||
return new Response(null, { status: 204 });
|
||||
}
|
||||
53
app/src/routes/api/kalender/[vereinId]/+server.ts
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
import ical from 'ical-generator';
|
||||
import { getDb } from '$lib/server/db';
|
||||
|
||||
export async function GET({ params }) {
|
||||
const { vereinId } = params;
|
||||
|
||||
const db = getDb();
|
||||
|
||||
const verein = db.prepare('SELECT * FROM vereine WHERE id = ?').get(vereinId) as { name: string } | undefined;
|
||||
if (!verein) {
|
||||
return new Response('Verein nicht gefunden.', { status: 404 });
|
||||
}
|
||||
|
||||
// Termine der nächsten 365 Tage laden
|
||||
const von = new Date().toISOString();
|
||||
const bis = new Date(Date.now() + 365 * 24 * 60 * 60 * 1000).toISOString();
|
||||
|
||||
const termine = db.prepare(
|
||||
`SELECT * FROM termine WHERE verein_id = ? AND beginn >= ? AND beginn <= ? ORDER BY beginn LIMIT 500`
|
||||
).all(vereinId, von, bis) as { id: string; titel: string; beginn: string; ende: string | null; ort: string | null; beschreibung: string | null }[];
|
||||
|
||||
// iCal-Kalender aufbauen
|
||||
const cal = ical({
|
||||
name: verein.name ?? 'vereins.haus',
|
||||
prodId: '//vereins.haus//Vereinskalender//DE',
|
||||
timezone: 'Europe/Berlin',
|
||||
ttl: 60 * 60, // Clients aktualisieren stündlich
|
||||
});
|
||||
|
||||
for (const t of termine) {
|
||||
const start = new Date(t.beginn);
|
||||
const end = t.ende
|
||||
? new Date(t.ende)
|
||||
: new Date(start.getTime() + 60 * 60 * 1000); // Default: 1 Stunde
|
||||
|
||||
const ev = cal.createEvent({
|
||||
start,
|
||||
end,
|
||||
summary: t.titel,
|
||||
location: t.ort ?? undefined,
|
||||
description: t.beschreibung ?? undefined,
|
||||
});
|
||||
ev.uid(t.id + '@vereins.haus');
|
||||
}
|
||||
|
||||
return new Response(cal.toString(), {
|
||||
headers: {
|
||||
'Content-Type': 'text/calendar; charset=utf-8',
|
||||
'Content-Disposition': `attachment; filename="${encodeURIComponent(verein.name ?? 'kalender')}.ics"`,
|
||||
'Cache-Control': 'no-cache',
|
||||
},
|
||||
});
|
||||
}
|
||||
65
app/src/routes/api/mitglieder/+server.ts
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
import { json, error } from '@sveltejs/kit';
|
||||
import { getDb, newId, rows, row, toArr } from '$lib/server/db';
|
||||
import { requireAuth } from '$lib/server/auth';
|
||||
|
||||
export async function GET({ request, url }) {
|
||||
const u = await requireAuth(request);
|
||||
const db = getDb();
|
||||
const status = url.searchParams.get('status');
|
||||
|
||||
let items;
|
||||
if (status) {
|
||||
items = db.prepare(
|
||||
'SELECT * FROM mitglieder WHERE verein_id = ? AND status = ? ORDER BY nachname, vorname'
|
||||
).all(u.verein_id, status);
|
||||
} else {
|
||||
items = db.prepare(
|
||||
'SELECT * FROM mitglieder WHERE verein_id = ? ORDER BY nachname, vorname'
|
||||
).all(u.verein_id);
|
||||
}
|
||||
|
||||
return json(rows(items as Record<string, unknown>[]));
|
||||
}
|
||||
|
||||
export async function POST({ request }) {
|
||||
const u = await requireAuth(request);
|
||||
const db = getDb();
|
||||
const body = await request.json();
|
||||
|
||||
if (!body.vorname || !body.nachname) throw error(400, 'Vorname und Nachname sind Pflichtfelder');
|
||||
|
||||
const id = newId();
|
||||
|
||||
db.prepare(`
|
||||
INSERT INTO mitglieder (
|
||||
id, verein_id, vorname, nachname, email, telefon,
|
||||
geburtsdatum, eintrittsdatum, austrittsdatum,
|
||||
strasse, plz, ort, iban, bic,
|
||||
gruppe_ids, status, notizen,
|
||||
mandatsreferenz, mandatsdatum
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`).run(
|
||||
id,
|
||||
u.verein_id,
|
||||
body.vorname,
|
||||
body.nachname,
|
||||
body.email ?? null,
|
||||
body.telefon ?? null,
|
||||
body.geburtsdatum ?? null,
|
||||
body.eintrittsdatum ?? null,
|
||||
body.austrittsdatum ?? null,
|
||||
body.strasse ?? null,
|
||||
body.plz ?? null,
|
||||
body.ort ?? null,
|
||||
body.iban ?? null,
|
||||
body.bic ?? null,
|
||||
toArr(body.gruppe_ids),
|
||||
body.status ?? 'aktiv',
|
||||
body.notizen ?? null,
|
||||
body.mandatsreferenz ?? null,
|
||||
body.mandatsdatum ?? null
|
||||
);
|
||||
|
||||
const mitglied = db.prepare('SELECT * FROM mitglieder WHERE id = ?').get(id);
|
||||
return json(row(mitglied as Record<string, unknown>), { status: 201 });
|
||||
}
|
||||
69
app/src/routes/api/mitglieder/[id]/+server.ts
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
import { json, error } from '@sveltejs/kit';
|
||||
import { getDb, row, toArr } from '$lib/server/db';
|
||||
import { requireAuth } from '$lib/server/auth';
|
||||
|
||||
export async function GET({ request, params }) {
|
||||
const u = await requireAuth(request);
|
||||
const db = getDb();
|
||||
const mitglied = db.prepare(
|
||||
'SELECT * FROM mitglieder WHERE id = ? AND verein_id = ?'
|
||||
).get(params.id, u.verein_id);
|
||||
if (!mitglied) throw error(404, 'Mitglied nicht gefunden');
|
||||
return json(row(mitglied as Record<string, unknown>));
|
||||
}
|
||||
|
||||
export async function PUT({ request, params }) {
|
||||
const u = await requireAuth(request);
|
||||
const db = getDb();
|
||||
|
||||
const existing = db.prepare(
|
||||
'SELECT id FROM mitglieder WHERE id = ? AND verein_id = ?'
|
||||
).get(params.id, u.verein_id);
|
||||
if (!existing) throw error(404, 'Mitglied nicht gefunden');
|
||||
|
||||
const body = await request.json();
|
||||
|
||||
db.prepare(`
|
||||
UPDATE mitglieder SET
|
||||
vorname = ?, nachname = ?, email = ?, telefon = ?,
|
||||
geburtsdatum = ?, eintrittsdatum = ?, austrittsdatum = ?,
|
||||
strasse = ?, plz = ?, ort = ?, iban = ?, bic = ?,
|
||||
gruppe_ids = ?, status = ?, notizen = ?,
|
||||
mandatsreferenz = ?, mandatsdatum = ?,
|
||||
updated = strftime('%Y-%m-%dT%H:%M:%SZ','now')
|
||||
WHERE id = ? AND verein_id = ?
|
||||
`).run(
|
||||
body.vorname,
|
||||
body.nachname,
|
||||
body.email ?? null,
|
||||
body.telefon ?? null,
|
||||
body.geburtsdatum ?? null,
|
||||
body.eintrittsdatum ?? null,
|
||||
body.austrittsdatum ?? null,
|
||||
body.strasse ?? null,
|
||||
body.plz ?? null,
|
||||
body.ort ?? null,
|
||||
body.iban ?? null,
|
||||
body.bic ?? null,
|
||||
toArr(body.gruppe_ids),
|
||||
body.status ?? 'aktiv',
|
||||
body.notizen ?? null,
|
||||
body.mandatsreferenz ?? null,
|
||||
body.mandatsdatum ?? null,
|
||||
params.id,
|
||||
u.verein_id
|
||||
);
|
||||
|
||||
const mitglied = db.prepare('SELECT * FROM mitglieder WHERE id = ?').get(params.id);
|
||||
return json(row(mitglied as Record<string, unknown>));
|
||||
}
|
||||
|
||||
export async function DELETE({ request, params }) {
|
||||
const u = await requireAuth(request);
|
||||
const db = getDb();
|
||||
const result = db.prepare(
|
||||
'DELETE FROM mitglieder WHERE id = ? AND verein_id = ?'
|
||||
).run(params.id, u.verein_id);
|
||||
if (result.changes === 0) throw error(404, 'Mitglied nicht gefunden');
|
||||
return new Response(null, { status: 204 });
|
||||
}
|
||||
39
app/src/routes/api/nachrichten/+server.ts
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
import { json, error } from '@sveltejs/kit';
|
||||
import { getDb, newId, rows, row, toArr } from '$lib/server/db';
|
||||
import { requireAuth } from '$lib/server/auth';
|
||||
|
||||
export async function GET({ request }) {
|
||||
const u = await requireAuth(request);
|
||||
const db = getDb();
|
||||
const items = db.prepare(
|
||||
'SELECT * FROM nachrichten WHERE verein_id = ? ORDER BY gesendet_am DESC, created DESC'
|
||||
).all(u.verein_id);
|
||||
return json(rows(items as Record<string, unknown>[]));
|
||||
}
|
||||
|
||||
export async function POST({ request }) {
|
||||
const u = await requireAuth(request);
|
||||
const db = getDb();
|
||||
const body = await request.json();
|
||||
|
||||
if (!body.betreff) throw error(400, 'Betreff ist ein Pflichtfeld');
|
||||
|
||||
const id = newId();
|
||||
const gesendet_am = new Date().toISOString().replace(/\.\d{3}Z$/, 'Z');
|
||||
|
||||
db.prepare(`
|
||||
INSERT INTO nachrichten (id, verein_id, autor_id, betreff, text, gruppe_ids, gesendet_am)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
`).run(
|
||||
id,
|
||||
u.verein_id,
|
||||
u.sub,
|
||||
body.betreff,
|
||||
body.text ?? '',
|
||||
toArr(body.gruppe_ids),
|
||||
gesendet_am
|
||||
);
|
||||
|
||||
const nachricht = db.prepare('SELECT * FROM nachrichten WHERE id = ?').get(id);
|
||||
return json(row(nachricht as Record<string, unknown>), { status: 201 });
|
||||
}
|
||||
13
app/src/routes/api/nachrichten/[id]/+server.ts
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
import { error } from '@sveltejs/kit';
|
||||
import { getDb } from '$lib/server/db';
|
||||
import { requireAuth } from '$lib/server/auth';
|
||||
|
||||
export async function DELETE({ request, params }) {
|
||||
const u = await requireAuth(request);
|
||||
const db = getDb();
|
||||
const result = db.prepare(
|
||||
'DELETE FROM nachrichten WHERE id = ? AND verein_id = ?'
|
||||
).run(params.id, u.verein_id);
|
||||
if (result.changes === 0) throw error(404, 'Nachricht nicht gefunden');
|
||||
return new Response(null, { status: 204 });
|
||||
}
|
||||
68
app/src/routes/api/neuigkeiten/+server.ts
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
import { json, error } from '@sveltejs/kit';
|
||||
import { getDb, newId, rows, row } from '$lib/server/db';
|
||||
import { requireAuth } from '$lib/server/auth';
|
||||
import { writeFileSync, mkdirSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
|
||||
const UPLOAD_DIR = process.env.UPLOAD_DIR || './data/uploads';
|
||||
|
||||
export async function GET({ request }) {
|
||||
const u = await requireAuth(request);
|
||||
const db = getDb();
|
||||
const items = db.prepare(
|
||||
'SELECT * FROM neuigkeiten WHERE verein_id = ? ORDER BY created DESC'
|
||||
).all(u.verein_id);
|
||||
return json(rows(items as Record<string, unknown>[]));
|
||||
}
|
||||
|
||||
export async function POST({ request }) {
|
||||
const u = await requireAuth(request);
|
||||
const db = getDb();
|
||||
|
||||
const formData = await request.formData();
|
||||
|
||||
const text = formData.get('text') as string | null;
|
||||
const gruppeIdsRaw = formData.get('gruppe_ids') as string | null;
|
||||
const terminId = formData.get('termin_id') as string | null;
|
||||
|
||||
let gruppe_ids: string[] = [];
|
||||
if (gruppeIdsRaw) {
|
||||
try { gruppe_ids = JSON.parse(gruppeIdsRaw); } catch { gruppe_ids = []; }
|
||||
}
|
||||
|
||||
const id = newId();
|
||||
const uploadPath = join(UPLOAD_DIR, u.verein_id, id);
|
||||
const medien: string[] = [];
|
||||
|
||||
const files = formData.getAll('medien') as File[];
|
||||
if (files.length > 0) {
|
||||
mkdirSync(uploadPath, { recursive: true });
|
||||
for (const file of files) {
|
||||
if (!(file instanceof File)) continue;
|
||||
const ext = file.name.split('.').pop() || 'bin';
|
||||
const filename = `${newId()}.${ext}`;
|
||||
const buffer = Buffer.from(await file.arrayBuffer());
|
||||
writeFileSync(join(uploadPath, filename), buffer);
|
||||
medien.push(filename);
|
||||
}
|
||||
}
|
||||
|
||||
if (!text && medien.length === 0) throw error(400, 'Text oder Medien sind erforderlich');
|
||||
|
||||
db.prepare(`
|
||||
INSERT INTO neuigkeiten (id, verein_id, autor_id, autor_name, text, medien, gruppe_ids, termin_id)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`).run(
|
||||
id,
|
||||
u.verein_id,
|
||||
u.sub,
|
||||
u.name,
|
||||
text ?? null,
|
||||
JSON.stringify(medien),
|
||||
JSON.stringify(gruppe_ids),
|
||||
terminId ?? null
|
||||
);
|
||||
|
||||
const neuigkeit = db.prepare('SELECT * FROM neuigkeiten WHERE id = ?').get(id);
|
||||
return json(row(neuigkeit as Record<string, unknown>), { status: 201 });
|
||||
}
|
||||
27
app/src/routes/api/neuigkeiten/[id]/+server.ts
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
import { error } from '@sveltejs/kit';
|
||||
import { getDb, parseArr } from '$lib/server/db';
|
||||
import { requireAuth } from '$lib/server/auth';
|
||||
import { rmSync, existsSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
|
||||
const UPLOAD_DIR = process.env.UPLOAD_DIR || './data/uploads';
|
||||
|
||||
export async function DELETE({ request, params }) {
|
||||
const u = await requireAuth(request);
|
||||
const db = getDb();
|
||||
|
||||
const neuigkeit = db.prepare(
|
||||
'SELECT * FROM neuigkeiten WHERE id = ? AND verein_id = ?'
|
||||
).get(params.id, u.verein_id) as Record<string, unknown> | undefined;
|
||||
|
||||
if (!neuigkeit) throw error(404, 'Neuigkeit nicht gefunden');
|
||||
|
||||
const uploadPath = join(UPLOAD_DIR, u.verein_id, params.id);
|
||||
if (existsSync(uploadPath)) {
|
||||
rmSync(uploadPath, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
db.prepare('DELETE FROM neuigkeiten WHERE id = ? AND verein_id = ?').run(params.id, u.verein_id);
|
||||
|
||||
return new Response(null, { status: 204 });
|
||||
}
|
||||
58
app/src/routes/api/ort-ausfaelle/+server.ts
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
import { json, error } from '@sveltejs/kit';
|
||||
import { getDb, newId, rows, row } from '$lib/server/db';
|
||||
import { requireAuth } from '$lib/server/auth';
|
||||
|
||||
export async function GET({ request, url }) {
|
||||
const u = await requireAuth(request);
|
||||
const db = getDb();
|
||||
|
||||
const ort_id = url.searchParams.get('ort_id');
|
||||
|
||||
let items;
|
||||
if (ort_id) {
|
||||
items = db.prepare(`
|
||||
SELECT a.* FROM ort_ausfaelle a
|
||||
JOIN veranstaltungsorte o ON o.id = a.ort_id
|
||||
WHERE a.ort_id = ? AND o.verein_id = ?
|
||||
ORDER BY a.von
|
||||
`).all(ort_id, u.verein_id);
|
||||
} else {
|
||||
items = db.prepare(`
|
||||
SELECT a.* FROM ort_ausfaelle a
|
||||
JOIN veranstaltungsorte o ON o.id = a.ort_id
|
||||
WHERE o.verein_id = ?
|
||||
ORDER BY a.von
|
||||
`).all(u.verein_id);
|
||||
}
|
||||
|
||||
return json(rows(items as Record<string, unknown>[]));
|
||||
}
|
||||
|
||||
export async function POST({ request }) {
|
||||
const u = await requireAuth(request);
|
||||
const db = getDb();
|
||||
const body = await request.json();
|
||||
|
||||
if (!body.ort_id || !body.von || !body.bis) throw error(400, 'ort_id, von und bis sind Pflichtfelder');
|
||||
|
||||
const ort = db.prepare(
|
||||
'SELECT id FROM veranstaltungsorte WHERE id = ? AND verein_id = ?'
|
||||
).get(body.ort_id, u.verein_id);
|
||||
if (!ort) throw error(404, 'Ort nicht gefunden');
|
||||
|
||||
const id = newId();
|
||||
|
||||
db.prepare(`
|
||||
INSERT INTO ort_ausfaelle (id, ort_id, von, bis, grund)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
`).run(
|
||||
id,
|
||||
body.ort_id,
|
||||
body.von,
|
||||
body.bis,
|
||||
body.grund ?? null
|
||||
);
|
||||
|
||||
const ausfall = db.prepare('SELECT * FROM ort_ausfaelle WHERE id = ?').get(id);
|
||||
return json(row(ausfall as Record<string, unknown>), { status: 201 });
|
||||
}
|
||||
21
app/src/routes/api/ort-ausfaelle/[id]/+server.ts
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
import { error } from '@sveltejs/kit';
|
||||
import { getDb } from '$lib/server/db';
|
||||
import { requireAuth } from '$lib/server/auth';
|
||||
|
||||
export async function DELETE({ request, params }) {
|
||||
const u = await requireAuth(request);
|
||||
const db = getDb();
|
||||
|
||||
// Verify the ausfall belongs to an ort in the user's Verein
|
||||
const ausfall = db.prepare(`
|
||||
SELECT a.id FROM ort_ausfaelle a
|
||||
JOIN veranstaltungsorte o ON o.id = a.ort_id
|
||||
WHERE a.id = ? AND o.verein_id = ?
|
||||
`).get(params.id, u.verein_id);
|
||||
|
||||
if (!ausfall) throw error(404, 'Ausfall nicht gefunden');
|
||||
|
||||
db.prepare('DELETE FROM ort_ausfaelle WHERE id = ?').run(params.id);
|
||||
|
||||
return new Response(null, { status: 204 });
|
||||
}
|
||||
37
app/src/routes/api/orte/+server.ts
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
import { json, error } from '@sveltejs/kit';
|
||||
import { getDb, newId, rows, row } from '$lib/server/db';
|
||||
import { requireAuth } from '$lib/server/auth';
|
||||
|
||||
export async function GET({ request }) {
|
||||
const u = await requireAuth(request);
|
||||
const db = getDb();
|
||||
const items = db.prepare(
|
||||
'SELECT * FROM veranstaltungsorte WHERE verein_id = ? ORDER BY name'
|
||||
).all(u.verein_id);
|
||||
return json(rows(items as Record<string, unknown>[]));
|
||||
}
|
||||
|
||||
export async function POST({ request }) {
|
||||
const u = await requireAuth(request);
|
||||
const db = getDb();
|
||||
const body = await request.json();
|
||||
|
||||
if (!body.name) throw error(400, 'Name ist ein Pflichtfeld');
|
||||
|
||||
const id = newId();
|
||||
|
||||
db.prepare(`
|
||||
INSERT INTO veranstaltungsorte (id, verein_id, name, adresse, typ, aktiv)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
`).run(
|
||||
id,
|
||||
u.verein_id,
|
||||
body.name,
|
||||
body.adresse ?? null,
|
||||
body.typ ?? 'sonstiges',
|
||||
body.aktiv !== false ? 1 : 0
|
||||
);
|
||||
|
||||
const ort = db.prepare('SELECT * FROM veranstaltungsorte WHERE id = ?').get(id);
|
||||
return json(row(ort as Record<string, unknown>), { status: 201 });
|
||||
}
|
||||
52
app/src/routes/api/orte/[id]/+server.ts
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
import { json, error } from '@sveltejs/kit';
|
||||
import { getDb, row } from '$lib/server/db';
|
||||
import { requireAuth } from '$lib/server/auth';
|
||||
|
||||
export async function GET({ request, params }) {
|
||||
const u = await requireAuth(request);
|
||||
const db = getDb();
|
||||
const ort = db.prepare(
|
||||
'SELECT * FROM veranstaltungsorte WHERE id = ? AND verein_id = ?'
|
||||
).get(params.id, u.verein_id);
|
||||
if (!ort) throw error(404, 'Ort nicht gefunden');
|
||||
return json(row(ort as Record<string, unknown>));
|
||||
}
|
||||
|
||||
export async function PUT({ request, params }) {
|
||||
const u = await requireAuth(request);
|
||||
const db = getDb();
|
||||
|
||||
const existing = db.prepare(
|
||||
'SELECT id FROM veranstaltungsorte WHERE id = ? AND verein_id = ?'
|
||||
).get(params.id, u.verein_id);
|
||||
if (!existing) throw error(404, 'Ort nicht gefunden');
|
||||
|
||||
const body = await request.json();
|
||||
|
||||
db.prepare(`
|
||||
UPDATE veranstaltungsorte SET
|
||||
name = ?, adresse = ?, typ = ?, aktiv = ?,
|
||||
updated = strftime('%Y-%m-%dT%H:%M:%SZ','now')
|
||||
WHERE id = ? AND verein_id = ?
|
||||
`).run(
|
||||
body.name,
|
||||
body.adresse ?? null,
|
||||
body.typ ?? 'sonstiges',
|
||||
body.aktiv !== false ? 1 : 0,
|
||||
params.id,
|
||||
u.verein_id
|
||||
);
|
||||
|
||||
const ort = db.prepare('SELECT * FROM veranstaltungsorte WHERE id = ?').get(params.id);
|
||||
return json(row(ort as Record<string, unknown>));
|
||||
}
|
||||
|
||||
export async function DELETE({ request, params }) {
|
||||
const u = await requireAuth(request);
|
||||
const db = getDb();
|
||||
const result = db.prepare(
|
||||
'DELETE FROM veranstaltungsorte WHERE id = ? AND verein_id = ?'
|
||||
).run(params.id, u.verein_id);
|
||||
if (result.changes === 0) throw error(404, 'Ort nicht gefunden');
|
||||
return new Response(null, { status: 204 });
|
||||
}
|
||||
6
app/src/routes/api/push/key/+server.ts
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
import { json } from '@sveltejs/kit';
|
||||
import { env } from '$env/dynamic/private';
|
||||
|
||||
export async function GET() {
|
||||
return json({ publicKey: env.PUBLIC_VAPID_KEY ?? '' });
|
||||
}
|
||||
53
app/src/routes/api/push/senden/+server.ts
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
import { json } from '@sveltejs/kit';
|
||||
import { env } from '$env/dynamic/private';
|
||||
import webpush from 'web-push';
|
||||
import { requireAuth } from '$lib/server/auth';
|
||||
import { getDb } from '$lib/server/db';
|
||||
|
||||
export async function POST({ request }) {
|
||||
const authUser = await requireAuth(request).catch(() => null);
|
||||
if (!authUser) return json({ error: 'Nicht authentifiziert.' }, { status: 401 });
|
||||
|
||||
const { titel, body, url = '/nachrichten' } = await request.json();
|
||||
|
||||
if (!titel) return json({ error: 'Titel fehlt.' }, { status: 400 });
|
||||
|
||||
const vapidPublic = env.PUBLIC_VAPID_KEY ?? '';
|
||||
const vapidPrivate = env.VAPID_PRIVATE_KEY ?? '';
|
||||
const vapidSubject = env.VAPID_SUBJECT ?? 'mailto:info@vereins.haus';
|
||||
|
||||
if (!vapidPublic || !vapidPrivate) {
|
||||
return json({ error: 'VAPID-Keys nicht konfiguriert.' }, { status: 500 });
|
||||
}
|
||||
|
||||
webpush.setVapidDetails(vapidSubject, vapidPublic, vapidPrivate);
|
||||
|
||||
const db = getDb();
|
||||
const items = db.prepare(
|
||||
'SELECT * FROM push_subscriptions WHERE verein_id = ?'
|
||||
).all(authUser.verein_id) as { endpoint: string; p256dh: string; auth: string; id: string }[];
|
||||
|
||||
if (!items.length) return json({ sent: 0 });
|
||||
|
||||
const payload = JSON.stringify({ title: titel, body, url });
|
||||
let sent = 0;
|
||||
|
||||
await Promise.allSettled(
|
||||
items.map(async (sub) => {
|
||||
try {
|
||||
await webpush.sendNotification(
|
||||
{ endpoint: sub.endpoint, keys: { p256dh: sub.p256dh, auth: sub.auth } },
|
||||
payload,
|
||||
);
|
||||
sent++;
|
||||
} catch (err: unknown) {
|
||||
// 410 Gone = Subscription abgelaufen → löschen
|
||||
if ((err as { statusCode?: number }).statusCode === 410) {
|
||||
db.prepare('DELETE FROM push_subscriptions WHERE id = ?').run(sub.id);
|
||||
}
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
return json({ sent });
|
||||
}
|
||||
44
app/src/routes/api/push/subscribe/+server.ts
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
import { json } from '@sveltejs/kit';
|
||||
import { requireAuth } from '$lib/server/auth';
|
||||
import { getDb, newId } from '$lib/server/db';
|
||||
|
||||
export async function POST({ request }) {
|
||||
const authUser = await requireAuth(request).catch(() => null);
|
||||
if (!authUser) return json({ error: 'Nicht authentifiziert.' }, { status: 401 });
|
||||
|
||||
const { subscription } = await request.json();
|
||||
|
||||
if (!subscription?.endpoint) {
|
||||
return json({ error: 'Ungültige Subscription.' }, { status: 400 });
|
||||
}
|
||||
|
||||
const db = getDb();
|
||||
|
||||
// Alte Subscriptions dieses Users löschen (Gerätewechsel)
|
||||
db.prepare('DELETE FROM push_subscriptions WHERE user_id = ?').run(authUser.sub);
|
||||
|
||||
// Neue Subscription speichern
|
||||
db.prepare(`
|
||||
INSERT INTO push_subscriptions (id, user_id, verein_id, endpoint, p256dh, auth)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
`).run(
|
||||
newId(),
|
||||
authUser.sub,
|
||||
authUser.verein_id,
|
||||
subscription.endpoint,
|
||||
subscription.keys.p256dh,
|
||||
subscription.keys.auth,
|
||||
);
|
||||
|
||||
return json({ success: true });
|
||||
}
|
||||
|
||||
export async function DELETE({ request }) {
|
||||
const authUser = await requireAuth(request).catch(() => null);
|
||||
if (!authUser) return json({ success: true });
|
||||
|
||||
const db = getDb();
|
||||
db.prepare('DELETE FROM push_subscriptions WHERE user_id = ?').run(authUser.sub);
|
||||
|
||||
return json({ success: true });
|
||||
}
|
||||
58
app/src/routes/api/reaktionen/+server.ts
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
import { json, error } from '@sveltejs/kit';
|
||||
import { getDb, newId, rows, row } from '$lib/server/db';
|
||||
import { requireAuth } from '$lib/server/auth';
|
||||
|
||||
export async function GET({ request, url }) {
|
||||
const u = await requireAuth(request);
|
||||
const db = getDb();
|
||||
|
||||
const beitrag_id = url.searchParams.get('beitrag_id');
|
||||
|
||||
let items;
|
||||
if (beitrag_id) {
|
||||
items = db.prepare(`
|
||||
SELECT r.* FROM reaktionen r
|
||||
JOIN neuigkeiten n ON n.id = r.beitrag_id
|
||||
WHERE r.beitrag_id = ? AND n.verein_id = ?
|
||||
ORDER BY r.created
|
||||
`).all(beitrag_id, u.verein_id);
|
||||
} else {
|
||||
items = db.prepare(`
|
||||
SELECT r.* FROM reaktionen r
|
||||
JOIN neuigkeiten n ON n.id = r.beitrag_id
|
||||
WHERE n.verein_id = ?
|
||||
ORDER BY r.created
|
||||
`).all(u.verein_id);
|
||||
}
|
||||
|
||||
return json(rows(items as Record<string, unknown>[]));
|
||||
}
|
||||
|
||||
export async function POST({ request }) {
|
||||
const u = await requireAuth(request);
|
||||
const db = getDb();
|
||||
const body = await request.json();
|
||||
|
||||
if (!body.beitrag_id) throw error(400, 'beitrag_id ist erforderlich');
|
||||
|
||||
const beitrag = db.prepare(
|
||||
'SELECT id FROM neuigkeiten WHERE id = ? AND verein_id = ?'
|
||||
).get(body.beitrag_id, u.verein_id);
|
||||
if (!beitrag) throw error(404, 'Beitrag nicht gefunden');
|
||||
|
||||
const id = newId();
|
||||
|
||||
try {
|
||||
db.prepare(`
|
||||
INSERT INTO reaktionen (id, beitrag_id, user_id)
|
||||
VALUES (?, ?, ?)
|
||||
`).run(id, body.beitrag_id, u.sub);
|
||||
} catch (e: unknown) {
|
||||
const msg = e instanceof Error ? e.message : String(e);
|
||||
if (msg.includes('UNIQUE')) throw error(409, 'Reaktion bereits vorhanden');
|
||||
throw e;
|
||||
}
|
||||
|
||||
const reaktion = db.prepare('SELECT * FROM reaktionen WHERE id = ?').get(id);
|
||||
return json(row(reaktion as Record<string, unknown>), { status: 201 });
|
||||
}
|
||||
13
app/src/routes/api/reaktionen/[id]/+server.ts
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
import { error } from '@sveltejs/kit';
|
||||
import { getDb } from '$lib/server/db';
|
||||
import { requireAuth } from '$lib/server/auth';
|
||||
|
||||
export async function DELETE({ request, params }) {
|
||||
const u = await requireAuth(request);
|
||||
const db = getDb();
|
||||
const result = db.prepare(
|
||||
'DELETE FROM reaktionen WHERE id = ? AND user_id = ?'
|
||||
).run(params.id, u.sub);
|
||||
if (result.changes === 0) throw error(404, 'Reaktion nicht gefunden oder keine Berechtigung');
|
||||
return new Response(null, { status: 204 });
|
||||
}
|
||||
63
app/src/routes/api/termine/+server.ts
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
import { json, error } from '@sveltejs/kit';
|
||||
import { getDb, newId, rows, row, toArr } from '$lib/server/db';
|
||||
import { requireAuth } from '$lib/server/auth';
|
||||
|
||||
export async function GET({ request, url }) {
|
||||
const u = await requireAuth(request);
|
||||
const db = getDb();
|
||||
|
||||
const von = url.searchParams.get('von');
|
||||
const bis = url.searchParams.get('bis');
|
||||
|
||||
let query = 'SELECT * FROM termine WHERE verein_id = ?';
|
||||
const params: unknown[] = [u.verein_id];
|
||||
|
||||
if (von) {
|
||||
query += ' AND beginn >= ?';
|
||||
params.push(von);
|
||||
}
|
||||
if (bis) {
|
||||
query += ' AND beginn <= ?';
|
||||
params.push(bis);
|
||||
}
|
||||
|
||||
query += ' ORDER BY beginn';
|
||||
|
||||
const items = db.prepare(query).all(...params);
|
||||
return json(rows(items as Record<string, unknown>[]));
|
||||
}
|
||||
|
||||
export async function POST({ request }) {
|
||||
const u = await requireAuth(request);
|
||||
const db = getDb();
|
||||
const body = await request.json();
|
||||
|
||||
if (!body.titel || !body.beginn) throw error(400, 'Titel und Beginn sind Pflichtfelder');
|
||||
|
||||
const id = newId();
|
||||
|
||||
db.prepare(`
|
||||
INSERT INTO termine (
|
||||
id, verein_id, titel, beschreibung, beginn, ende,
|
||||
ort, ort_id, gruppe_ids, durchfuehrender_id,
|
||||
verfuegbarkeit, rrule, serie_id
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`).run(
|
||||
id,
|
||||
u.verein_id,
|
||||
body.titel,
|
||||
body.beschreibung ?? null,
|
||||
body.beginn,
|
||||
body.ende ?? null,
|
||||
body.ort ?? null,
|
||||
body.ort_id ?? null,
|
||||
toArr(body.gruppe_ids),
|
||||
body.durchfuehrender_id ?? null,
|
||||
body.verfuegbarkeit ?? 'offen',
|
||||
body.rrule ?? null,
|
||||
body.serie_id ?? null
|
||||
);
|
||||
|
||||
const termin = db.prepare('SELECT * FROM termine WHERE id = ?').get(id);
|
||||
return json(row(termin as Record<string, unknown>), { status: 201 });
|
||||
}
|
||||
83
app/src/routes/api/termine/[id]/+server.ts
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
import { json, error } from '@sveltejs/kit';
|
||||
import { getDb, row, toArr } from '$lib/server/db';
|
||||
import { requireAuth } from '$lib/server/auth';
|
||||
|
||||
export async function GET({ request, params }) {
|
||||
const u = await requireAuth(request);
|
||||
const db = getDb();
|
||||
const termin = db.prepare(
|
||||
'SELECT * FROM termine WHERE id = ? AND verein_id = ?'
|
||||
).get(params.id, u.verein_id);
|
||||
if (!termin) throw error(404, 'Termin nicht gefunden');
|
||||
return json(row(termin as Record<string, unknown>));
|
||||
}
|
||||
|
||||
export async function PUT({ request, params }) {
|
||||
const u = await requireAuth(request);
|
||||
const db = getDb();
|
||||
|
||||
const existing = db.prepare(
|
||||
'SELECT id FROM termine WHERE id = ? AND verein_id = ?'
|
||||
).get(params.id, u.verein_id);
|
||||
if (!existing) throw error(404, 'Termin nicht gefunden');
|
||||
|
||||
const body = await request.json();
|
||||
|
||||
db.prepare(`
|
||||
UPDATE termine SET
|
||||
titel = ?, beschreibung = ?, beginn = ?, ende = ?,
|
||||
ort = ?, ort_id = ?, gruppe_ids = ?, durchfuehrender_id = ?,
|
||||
verfuegbarkeit = ?, rrule = ?, serie_id = ?,
|
||||
updated = strftime('%Y-%m-%dT%H:%M:%SZ','now')
|
||||
WHERE id = ? AND verein_id = ?
|
||||
`).run(
|
||||
body.titel,
|
||||
body.beschreibung ?? null,
|
||||
body.beginn,
|
||||
body.ende ?? null,
|
||||
body.ort ?? null,
|
||||
body.ort_id ?? null,
|
||||
toArr(body.gruppe_ids),
|
||||
body.durchfuehrender_id ?? null,
|
||||
body.verfuegbarkeit ?? 'offen',
|
||||
body.rrule ?? null,
|
||||
body.serie_id ?? null,
|
||||
params.id,
|
||||
u.verein_id
|
||||
);
|
||||
|
||||
const termin = db.prepare('SELECT * FROM termine WHERE id = ?').get(params.id);
|
||||
return json(row(termin as Record<string, unknown>));
|
||||
}
|
||||
|
||||
export async function DELETE({ request, params, url }) {
|
||||
const u = await requireAuth(request);
|
||||
const db = getDb();
|
||||
|
||||
const deleteSerie = url.searchParams.get('serie') === 'true';
|
||||
|
||||
if (deleteSerie) {
|
||||
const termin = db.prepare(
|
||||
'SELECT serie_id FROM termine WHERE id = ? AND verein_id = ?'
|
||||
).get(params.id, u.verein_id) as { serie_id: string | null } | undefined;
|
||||
|
||||
if (!termin) throw error(404, 'Termin nicht gefunden');
|
||||
|
||||
if (termin.serie_id) {
|
||||
db.prepare(
|
||||
'DELETE FROM termine WHERE serie_id = ? AND verein_id = ?'
|
||||
).run(termin.serie_id, u.verein_id);
|
||||
} else {
|
||||
db.prepare(
|
||||
'DELETE FROM termine WHERE id = ? AND verein_id = ?'
|
||||
).run(params.id, u.verein_id);
|
||||
}
|
||||
} else {
|
||||
const result = db.prepare(
|
||||
'DELETE FROM termine WHERE id = ? AND verein_id = ?'
|
||||
).run(params.id, u.verein_id);
|
||||
if (result.changes === 0) throw error(404, 'Termin nicht gefunden');
|
||||
}
|
||||
|
||||
return new Response(null, { status: 204 });
|
||||
}
|
||||
18
app/src/routes/api/users/+server.ts
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
import { json } from '@sveltejs/kit';
|
||||
import { getDb, rows } from '$lib/server/db';
|
||||
import { requireAuth } from '$lib/server/auth';
|
||||
|
||||
export async function GET({ request, url }) {
|
||||
const u = await requireAuth(request);
|
||||
const db = getDb();
|
||||
const rolle = url.searchParams.get('rolle');
|
||||
|
||||
let query = 'SELECT id, verein_id, email, name, rolle, created FROM users WHERE verein_id = ?';
|
||||
const params: unknown[] = [u.verein_id];
|
||||
|
||||
if (rolle) { query += ' AND rolle = ?'; params.push(rolle); }
|
||||
query += ' ORDER BY name';
|
||||
|
||||
const users = db.prepare(query).all(...params);
|
||||
return json(users);
|
||||
}
|
||||
47
app/src/routes/api/users/[id]/+server.ts
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
import { json, error } from '@sveltejs/kit';
|
||||
import { getDb } from '$lib/server/db';
|
||||
import { requireAuth, hashPassword } from '$lib/server/auth';
|
||||
|
||||
export async function GET({ request, params }) {
|
||||
const u = await requireAuth(request);
|
||||
const db = getDb();
|
||||
const row = db.prepare(
|
||||
'SELECT id, verein_id, email, name, rolle, created FROM users WHERE id = ? AND verein_id = ?'
|
||||
).get(params.id, u.verein_id);
|
||||
if (!row) throw error(404, 'User nicht gefunden');
|
||||
return json(row);
|
||||
}
|
||||
|
||||
export async function PUT({ request, params }) {
|
||||
const u = await requireAuth(request);
|
||||
const db = getDb();
|
||||
const body = await request.json();
|
||||
|
||||
const existing = db.prepare('SELECT id FROM users WHERE id = ? AND verein_id = ?').get(params.id, u.verein_id);
|
||||
if (!existing) throw error(404, 'User nicht gefunden');
|
||||
|
||||
const fields: string[] = [];
|
||||
const vals: unknown[] = [];
|
||||
|
||||
if (body.name !== undefined) { fields.push('name = ?'); vals.push(body.name); }
|
||||
if (body.email !== undefined) { fields.push('email = ?'); vals.push(body.email.toLowerCase()); }
|
||||
if (body.rolle !== undefined) { fields.push('rolle = ?'); vals.push(body.rolle || null); }
|
||||
if (body.password) { fields.push('password_hash = ?'); vals.push(await hashPassword(body.password)); }
|
||||
if (!fields.length) throw error(400, 'Keine Felder zum Aktualisieren');
|
||||
|
||||
fields.push("updated = strftime('%Y-%m-%dT%H:%M:%SZ','now')");
|
||||
vals.push(params.id, u.verein_id);
|
||||
|
||||
db.prepare(`UPDATE users SET ${fields.join(', ')} WHERE id = ? AND verein_id = ?`).run(...vals);
|
||||
const row = db.prepare('SELECT id, verein_id, email, name, rolle, created FROM users WHERE id = ?').get(params.id);
|
||||
return json(row);
|
||||
}
|
||||
|
||||
export async function DELETE({ request, params }) {
|
||||
const u = await requireAuth(request);
|
||||
if (u.sub === params.id) throw error(400, 'Eigenen Account nicht löschbar');
|
||||
const db = getDb();
|
||||
const result = db.prepare('DELETE FROM users WHERE id = ? AND verein_id = ?').run(params.id, u.verein_id);
|
||||
if (result.changes === 0) throw error(404, 'User nicht gefunden');
|
||||
return new Response(null, { status: 204 });
|
||||
}
|
||||
39
app/src/routes/api/vereine/+server.ts
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
import { json, error } from '@sveltejs/kit';
|
||||
import { getDb, row } from '$lib/server/db';
|
||||
import { requireAuth } from '$lib/server/auth';
|
||||
|
||||
export async function GET({ request }) {
|
||||
const u = await requireAuth(request);
|
||||
const db = getDb();
|
||||
const verein = db.prepare('SELECT * FROM vereine WHERE id = ?').get(u.verein_id);
|
||||
if (!verein) throw error(404, 'Verein nicht gefunden');
|
||||
return json(row(verein as Record<string, unknown>));
|
||||
}
|
||||
|
||||
export async function PATCH({ request }) {
|
||||
const u = await requireAuth(request);
|
||||
const db = getDb();
|
||||
const body = await request.json();
|
||||
|
||||
const allowed = [
|
||||
'name', 'adresse', 'plz', 'ort', 'bundesland',
|
||||
'email', 'telefon', 'website',
|
||||
'glaeubigerid', 'iban', 'bic',
|
||||
'dosb_mitglied'
|
||||
];
|
||||
|
||||
const fields = Object.keys(body).filter(k => allowed.includes(k));
|
||||
if (fields.length === 0) throw error(400, 'Keine gültigen Felder');
|
||||
|
||||
const sets = fields.map(k => `${k} = ?`).join(', ');
|
||||
const vals = fields.map(k => {
|
||||
if (k === 'dosb_mitglied') return body[k] ? 1 : 0;
|
||||
return body[k];
|
||||
});
|
||||
|
||||
db.prepare(`UPDATE vereine SET ${sets}, updated = strftime('%Y-%m-%dT%H:%M:%SZ','now') WHERE id = ?`)
|
||||
.run(...vals, u.verein_id);
|
||||
|
||||
const verein = db.prepare('SELECT * FROM vereine WHERE id = ?').get(u.verein_id);
|
||||
return json(row(verein as Record<string, unknown>));
|
||||
}
|
||||
5
app/src/routes/favicon.ico/+server.ts
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
import { redirect } from '@sveltejs/kit';
|
||||
|
||||
export function GET() {
|
||||
throw redirect(301, '/favicon.svg');
|
||||
}
|
||||
173
app/src/routes/invite/[token]/+page.svelte
Normal file
|
|
@ -0,0 +1,173 @@
|
|||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { page } from '$app/stores';
|
||||
import { onMount } from 'svelte';
|
||||
import { get } from 'svelte/store';
|
||||
import { api } from '$lib/api';
|
||||
import { user } from '$lib/user';
|
||||
import type { Einladung } from '$lib/types';
|
||||
|
||||
const token = $derived($page.params.token as string);
|
||||
|
||||
let einladung = $state<Einladung | null>(null);
|
||||
let vereinName = $state('');
|
||||
let fehler = $state('');
|
||||
let loading = $state(true);
|
||||
|
||||
let name = $state('');
|
||||
let email = $state('');
|
||||
let password = $state('');
|
||||
let passwordConfirm = $state('');
|
||||
let saving = $state(false);
|
||||
let formError = $state('');
|
||||
|
||||
onMount(async () => {
|
||||
if (!!get(user)) {
|
||||
goto('/');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const inv = await api.get<Einladung & { vereinName: string }>('/einladungen/' + token);
|
||||
einladung = inv;
|
||||
vereinName = (inv as any).vereinName ?? '';
|
||||
} catch {
|
||||
fehler = 'Dieser Einladungslink ist ungültig oder wurde bereits verwendet.';
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
});
|
||||
|
||||
async function registrieren() {
|
||||
if (!einladung) return;
|
||||
if (password !== passwordConfirm) { formError = 'Passwörter stimmen nicht überein.'; return; }
|
||||
formError = ''; saving = true;
|
||||
try {
|
||||
const u = await api.post<any>(`/einladungen/${token}`, {
|
||||
email: email.trim(),
|
||||
password,
|
||||
name: name.trim(),
|
||||
});
|
||||
user.set(u);
|
||||
goto('/');
|
||||
} catch (e: unknown) {
|
||||
formError = e instanceof Error ? e.message : 'Registrierung fehlgeschlagen.';
|
||||
} finally {
|
||||
saving = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head><title>Einladung — vereins.haus</title></svelte:head>
|
||||
|
||||
<div class="shell">
|
||||
<div class="logo">
|
||||
<img src="/favicon.svg" alt="" width="36" height="36" />
|
||||
vereins.haus
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
{#if loading}
|
||||
<p class="hint">Prüfe Einladung…</p>
|
||||
|
||||
{:else if fehler}
|
||||
<div class="icon-ring error">✕</div>
|
||||
<h1>Ungültige Einladung</h1>
|
||||
<p class="beschr">{fehler}</p>
|
||||
|
||||
{:else}
|
||||
<div class="icon-ring">🎉</div>
|
||||
<h1>Du wurdest eingeladen!</h1>
|
||||
<p class="beschr">
|
||||
Du wirst als <strong>{einladung?.rolle === 'trainer' ? 'Trainer' : 'Admin'}</strong>
|
||||
zum Verein <strong>„{vereinName}"</strong> hinzugefügt.
|
||||
</p>
|
||||
|
||||
<form onsubmit={(e) => { e.preventDefault(); registrieren(); }}>
|
||||
<div class="field">
|
||||
<label for="name">Dein Name *</label>
|
||||
<input id="name" type="text" bind:value={name} required autocomplete="name" />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="email">E-Mail *</label>
|
||||
<input id="email" type="email" bind:value={email} required autocomplete="email" />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="pw">Passwort *</label>
|
||||
<input id="pw" type="password" bind:value={password} required minlength="8" autocomplete="new-password" />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="pw2">Passwort bestätigen *</label>
|
||||
<input id="pw2" type="password" bind:value={passwordConfirm} required autocomplete="new-password" />
|
||||
</div>
|
||||
|
||||
{#if formError}
|
||||
<p class="error">{formError}</p>
|
||||
{/if}
|
||||
|
||||
<button type="submit" class="btn-primary" disabled={saving || !name || !email || !password}>
|
||||
{saving ? 'Registrieren…' : 'Konto erstellen & beitreten'}
|
||||
</button>
|
||||
</form>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.shell {
|
||||
min-height: 100dvh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 1.5rem 1rem;
|
||||
background: var(--c-bg-subtle);
|
||||
}
|
||||
.logo {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
font-size: 1.2rem;
|
||||
font-weight: 700;
|
||||
color: var(--c-dark);
|
||||
letter-spacing: -0.02em;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
.card {
|
||||
background: var(--c-bg-card);
|
||||
border: 1px solid var(--c-border);
|
||||
border-radius: 16px;
|
||||
padding: 2rem 1.5rem;
|
||||
width: 100%;
|
||||
max-width: 420px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
.icon-ring {
|
||||
width: 3.5rem; height: 3.5rem; border-radius: 50%;
|
||||
background: var(--c-primary-light); display: flex;
|
||||
align-items: center; justify-content: center;
|
||||
font-size: 1.5rem; margin: 0 auto;
|
||||
}
|
||||
.icon-ring.error { background: var(--c-error-bg); }
|
||||
h1 { font-size: 1.2rem; font-weight: 700; color: var(--c-text); text-align: center; margin: 0; }
|
||||
.beschr { font-size: 0.9rem; color: var(--c-text-muted); text-align: center; line-height: 1.55; margin: 0; }
|
||||
.hint { color: var(--c-text-hint); text-align: center; }
|
||||
.field { display: flex; flex-direction: column; gap: 0.3rem; margin-bottom: 0.5rem; }
|
||||
label { font-size: 0.875rem; font-weight: 500; color: var(--c-text-secondary); }
|
||||
input {
|
||||
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;
|
||||
transition: border-color 0.15s;
|
||||
}
|
||||
input:focus { outline: none; border-color: var(--c-primary); }
|
||||
.error { color: var(--c-error); font-size: 0.875rem; margin: 0; }
|
||||
.btn-primary {
|
||||
width: 100%; 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; margin-top: 0.25rem; transition: background 0.15s;
|
||||
}
|
||||
.btn-primary:hover:not(:disabled) { background: var(--c-primary-dark); }
|
||||
.btn-primary:disabled { opacity: 0.55; cursor: not-allowed; }
|
||||
</style>
|
||||
334
app/src/routes/onboarding/+page.svelte
Normal file
|
|
@ -0,0 +1,334 @@
|
|||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { onMount } from 'svelte';
|
||||
import { get } from 'svelte/store';
|
||||
import { api } from '$lib/api';
|
||||
import { user } from '$lib/user';
|
||||
|
||||
let schritt = $state(1);
|
||||
let loading = $state(false);
|
||||
let error = $state('');
|
||||
|
||||
// Formular
|
||||
let vereinsname = $state('');
|
||||
let ort = $state('');
|
||||
let fertigName = $state('');
|
||||
|
||||
onMount(() => {
|
||||
const u = get(user);
|
||||
if (!u) {
|
||||
goto('/login');
|
||||
return;
|
||||
}
|
||||
if (u.verein_id) {
|
||||
goto('/');
|
||||
return;
|
||||
}
|
||||
// Vereinsname aus Registration vorbelegen
|
||||
vereinsname = u.name ?? '';
|
||||
});
|
||||
|
||||
async function vereinAnlegen() {
|
||||
if (!vereinsname.trim()) return;
|
||||
error = ''; loading = true;
|
||||
try {
|
||||
const updated = await api.post<any>('/onboarding/verein', {
|
||||
name: vereinsname.trim(),
|
||||
ort: ort.trim() || null,
|
||||
});
|
||||
// user store mit aktualisiertem verein_id updaten
|
||||
const u = get(user);
|
||||
if (u) user.set({ ...u, verein_id: updated.verein_id });
|
||||
fertigName = vereinsname.trim();
|
||||
schritt = 3;
|
||||
} catch (e: unknown) {
|
||||
error = e instanceof Error ? e.message : 'Fehler beim Anlegen.';
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head><title>Einrichtung — vereins.haus</title></svelte:head>
|
||||
|
||||
<div class="shell">
|
||||
<div class="logo">
|
||||
<img src="/favicon.svg" alt="" width="36" height="36" />
|
||||
vereins.haus
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<!-- Fortschritt -->
|
||||
<div class="dots">
|
||||
{#each [1, 2, 3] as s}
|
||||
<span class="dot" class:active={schritt >= s}></span>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
{#if schritt === 1}
|
||||
<!-- Willkommen -->
|
||||
<div class="step">
|
||||
<div class="icon-ring">👋</div>
|
||||
<h1>Schön, dass du dabei bist!</h1>
|
||||
<p class="beschr">
|
||||
vereins.haus hilft deinem Vorstand, Mitglieder, Termine und
|
||||
Beiträge zu verwalten – übersichtlich, mobil und ohne Excel.
|
||||
</p>
|
||||
<p class="beschr">
|
||||
Die Einrichtung dauert weniger als eine Minute.
|
||||
</p>
|
||||
<button class="btn-primary" onclick={() => (schritt = 2)}>
|
||||
Los geht's →
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{:else if schritt === 2}
|
||||
<!-- Verein anlegen -->
|
||||
<div class="step">
|
||||
<h1>Dein Verein</h1>
|
||||
<p class="beschr">Wie heißt dein Verein? Du kannst alle Angaben später noch ändern.</p>
|
||||
|
||||
<form onsubmit={(e) => { e.preventDefault(); vereinAnlegen(); }}>
|
||||
<div class="field">
|
||||
<label for="vname">Name des Vereins *</label>
|
||||
<input
|
||||
id="vname"
|
||||
type="text"
|
||||
bind:value={vereinsname}
|
||||
placeholder="z. B. TSV Musterstadt 1923"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label for="vort">Ort</label>
|
||||
<input
|
||||
id="vort"
|
||||
type="text"
|
||||
bind:value={ort}
|
||||
placeholder="z. B. Musterstadt"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{#if error}
|
||||
<p class="error">{error}</p>
|
||||
{/if}
|
||||
|
||||
<div class="btn-row">
|
||||
<button type="button" class="btn-ghost" onclick={() => (schritt = 1)}>
|
||||
← Zurück
|
||||
</button>
|
||||
<button type="submit" class="btn-primary" disabled={loading || !vereinsname.trim()}>
|
||||
{loading ? 'Anlegen…' : 'Weiter →'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{:else}
|
||||
<!-- Fertig -->
|
||||
<div class="step">
|
||||
<div class="icon-ring success">✓</div>
|
||||
<h1>Alles bereit!</h1>
|
||||
<p class="beschr">
|
||||
<strong>„{fertigName}"</strong> wurde eingerichtet. Du kannst jetzt loslegen.
|
||||
</p>
|
||||
|
||||
<ul class="naechstes">
|
||||
<li>
|
||||
<span class="naechstes-icon">👤</span>
|
||||
<div>
|
||||
<strong>Mitglieder anlegen</strong>
|
||||
<span>Namen, E-Mail und IBAN für den SEPA-Einzug</span>
|
||||
</div>
|
||||
</li>
|
||||
<li>
|
||||
<span class="naechstes-icon">💶</span>
|
||||
<div>
|
||||
<strong>Beitragsarten definieren</strong>
|
||||
<span>Jahresbeitrag, Sonderumlage – mit SEPA-Export</span>
|
||||
</div>
|
||||
</li>
|
||||
<li>
|
||||
<span class="naechstes-icon">📅</span>
|
||||
<div>
|
||||
<strong>Termine eintragen</strong>
|
||||
<span>Versammlung, Training, Ausflug</span>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<button class="btn-primary" onclick={() => goto('/')}>
|
||||
Zum Dashboard →
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.shell {
|
||||
min-height: 100dvh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 1.5rem 1rem;
|
||||
background: var(--c-bg-subtle);
|
||||
}
|
||||
|
||||
.logo {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
font-size: 1.2rem;
|
||||
font-weight: 700;
|
||||
color: var(--c-dark);
|
||||
letter-spacing: -0.02em;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.card {
|
||||
background: var(--c-bg-card);
|
||||
border: 1px solid var(--c-border);
|
||||
border-radius: 16px;
|
||||
padding: 2rem 1.5rem;
|
||||
width: 100%;
|
||||
max-width: 420px;
|
||||
}
|
||||
|
||||
.dots {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
justify-content: center;
|
||||
margin-bottom: 1.75rem;
|
||||
}
|
||||
.dot {
|
||||
width: 0.5rem;
|
||||
height: 0.5rem;
|
||||
border-radius: 50%;
|
||||
background: var(--c-border);
|
||||
transition: background 0.3s;
|
||||
}
|
||||
.dot.active { background: var(--c-primary); }
|
||||
|
||||
.step {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.icon-ring {
|
||||
width: 3.5rem;
|
||||
height: 3.5rem;
|
||||
border-radius: 50%;
|
||||
background: var(--c-primary-light);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 1.5rem;
|
||||
margin: 0 auto 0.25rem;
|
||||
}
|
||||
.icon-ring.success {
|
||||
background: var(--c-success-bg);
|
||||
color: var(--c-success);
|
||||
font-size: 1.6rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 1.3rem;
|
||||
font-weight: 700;
|
||||
color: var(--c-text);
|
||||
text-align: center;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.beschr {
|
||||
font-size: 0.9rem;
|
||||
color: var(--c-text-muted);
|
||||
text-align: center;
|
||||
line-height: 1.55;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.3rem;
|
||||
margin-bottom: 0.85rem;
|
||||
}
|
||||
label { font-size: 0.875rem; font-weight: 500; color: var(--c-text-secondary); }
|
||||
input {
|
||||
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;
|
||||
transition: border-color 0.15s;
|
||||
}
|
||||
input:focus { outline: none; border-color: var(--c-primary); }
|
||||
|
||||
.error { color: var(--c-error); font-size: 0.875rem; margin: 0; }
|
||||
|
||||
.btn-row {
|
||||
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;
|
||||
}
|
||||
|
||||
.naechstes {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0;
|
||||
border: 1px solid var(--c-border);
|
||||
border-radius: 10px;
|
||||
overflow: hidden;
|
||||
margin: 0.25rem 0;
|
||||
}
|
||||
.naechstes li {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
padding: 0.75rem 1rem;
|
||||
border-bottom: 1px solid var(--c-bg);
|
||||
}
|
||||
.naechstes li:last-child { border-bottom: none; }
|
||||
.naechstes-icon { font-size: 1.1rem; flex-shrink: 0; }
|
||||
.naechstes div {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.1rem;
|
||||
}
|
||||
.naechstes strong { font-size: 0.875rem; color: var(--c-text); }
|
||||
.naechstes span { font-size: 0.78rem; color: var(--c-text-hint); }
|
||||
</style>
|
||||
45
app/src/sw.ts
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
/// <reference lib="webworker" />
|
||||
import { cleanupOutdatedCaches, precacheAndRoute } from 'workbox-precaching';
|
||||
import { registerRoute } from 'workbox-routing';
|
||||
import { NetworkFirst } from 'workbox-strategies';
|
||||
|
||||
declare const self: ServiceWorkerGlobalScope;
|
||||
|
||||
cleanupOutdatedCaches();
|
||||
precacheAndRoute(self.__WB_MANIFEST);
|
||||
|
||||
// PocketBase API: NetworkFirst (offline-fähig aus Cache)
|
||||
registerRoute(
|
||||
({ url }) => url.hostname === 'api.vereins.haus',
|
||||
new NetworkFirst({
|
||||
cacheName: 'pocketbase-api',
|
||||
networkTimeoutSeconds: 5,
|
||||
}),
|
||||
);
|
||||
|
||||
self.addEventListener('push', (event) => {
|
||||
const data = event.data?.json() ?? {};
|
||||
const title = data.title ?? 'vereins.haus';
|
||||
const options: NotificationOptions = {
|
||||
body: data.body ?? '',
|
||||
icon: '/icons/icon-192.png',
|
||||
badge: '/icons/icon-192.png',
|
||||
tag: data.tag ?? 'vereinshaus',
|
||||
data: { url: data.url ?? '/nachrichten' },
|
||||
};
|
||||
event.waitUntil(self.registration.showNotification(title, options));
|
||||
});
|
||||
|
||||
self.addEventListener('notificationclick', (event) => {
|
||||
event.notification.close();
|
||||
const url = (event.notification.data as { url: string })?.url ?? '/';
|
||||
event.waitUntil(
|
||||
self.clients
|
||||
.matchAll({ type: 'window', includeUncontrolled: true })
|
||||
.then((list) => {
|
||||
const existing = list.find((c) => c.url.includes(url));
|
||||
if (existing) return existing.focus();
|
||||
return self.clients.openWindow(url);
|
||||
}),
|
||||
);
|
||||
});
|
||||
BIN
app/static/apple-touch-icon.png
Normal file
|
After Width: | Height: | Size: 4.8 KiB |
8
app/static/favicon.svg
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
<svg width="64" height="64" viewBox="0 0 64 64" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="64" height="64" rx="16" fill="#0F172A"/>
|
||||
<path d="M18 30L32 18L46 30V44C46 45.1046 45.1046 46 44 46H20C18.8954 46 18 45.1046 18 44V30Z" fill="white"/>
|
||||
<circle cx="25" cy="33" r="3" fill="#22C55E"/>
|
||||
<circle cx="39" cy="33" r="3" fill="#22C55E"/>
|
||||
<circle cx="32" cy="41" r="3" fill="#22C55E"/>
|
||||
<path d="M25 33L32 41L39 33" stroke="#22C55E" stroke-width="2" stroke-linecap="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 508 B |
BIN
app/static/icons/icon-192.png
Normal file
|
After Width: | Height: | Size: 4.7 KiB |
BIN
app/static/icons/icon-512.png
Normal file
|
After Width: | Height: | Size: 14 KiB |
8
app/static/icons/icon-512.svg
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
<svg width="512" height="512" viewBox="0 0 512 512" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="512" height="512" rx="120" fill="#0F172A"/>
|
||||
<path d="M140 236L256 140L372 236V340C372 353.255 361.255 364 348 364H164C150.745 364 140 353.255 140 340V236Z" fill="white"/>
|
||||
<circle cx="196" cy="256" r="20" fill="#22C55E"/>
|
||||
<circle cx="316" cy="256" r="20" fill="#22C55E"/>
|
||||
<circle cx="256" cy="324" r="20" fill="#22C55E"/>
|
||||
<path d="M196 256L256 324L316 256" stroke="#22C55E" stroke-width="12" stroke-linecap="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 548 B |
11
app/static/logo.svg
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
<svg width="720" height="220" viewBox="0 0 720 220" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="720" height="220" rx="28" fill="white"/>
|
||||
<rect x="28" y="28" width="164" height="164" rx="36" fill="#0F172A"/>
|
||||
<path d="M72 108L110 74L148 108V146C148 150.418 144.418 154 140 154H80C75.582 154 72 150.418 72 146V108Z" fill="white"/>
|
||||
<circle cx="92" cy="116" r="8" fill="#22C55E"/>
|
||||
<circle cx="128" cy="116" r="8" fill="#22C55E"/>
|
||||
<circle cx="110" cy="136" r="8" fill="#22C55E"/>
|
||||
<path d="M92 116L110 136L128 116" stroke="#22C55E" stroke-width="4" stroke-linecap="round"/>
|
||||
<text x="230" y="108" fill="#0F172A" font-size="52" font-family="Inter, Arial, sans-serif" font-weight="700">vereins.haus</text>
|
||||
<text x="232" y="146" fill="#475569" font-size="22" font-family="Inter, Arial, sans-serif" font-weight="500">Die einfache Vereins-App</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 879 B |
|
|
@ -6,34 +6,28 @@ export default defineConfig({
|
|||
plugins: [
|
||||
sveltekit(),
|
||||
VitePWA({
|
||||
strategies: 'injectManifest',
|
||||
srcDir: 'src',
|
||||
filename: 'sw.ts',
|
||||
registerType: 'autoUpdate',
|
||||
manifest: {
|
||||
name: 'vereins.haus',
|
||||
short_name: 'vereins.haus',
|
||||
description: 'Vereinsverwaltung die einfach funktioniert',
|
||||
theme_color: '#1e40af',
|
||||
background_color: '#f8fafc',
|
||||
theme_color: '#0F172A',
|
||||
background_color: '#0F172A',
|
||||
display: 'standalone',
|
||||
start_url: '/',
|
||||
icons: [
|
||||
{ src: '/icons/icon-192.png', sizes: '192x192', type: 'image/png', purpose: 'any' },
|
||||
{ src: '/icons/icon-512.png', sizes: '512x512', type: 'image/png', purpose: 'any maskable' },
|
||||
{ src: '/icons/apple-touch-icon.png', sizes: '180x180', type: 'image/png' }
|
||||
{ src: '/icons/icon-192.png', sizes: '192x192', type: 'image/png', purpose: 'any maskable' },
|
||||
{ src: '/apple-touch-icon.png', sizes: '180x180', type: 'image/png', purpose: 'any' },
|
||||
{ src: '/favicon.svg', sizes: 'any', type: 'image/svg+xml', purpose: 'any' }
|
||||
]
|
||||
},
|
||||
workbox: {
|
||||
injectManifest: {
|
||||
globPatterns: ['**/*.{js,css,html,svg,png,ico}'],
|
||||
runtimeCaching: [
|
||||
{
|
||||
urlPattern: /^https:\/\/api\.vereins\.haus\/.*/i,
|
||||
handler: 'NetworkFirst',
|
||||
options: {
|
||||
cacheName: 'pocketbase-api',
|
||||
expiration: { maxEntries: 100, maxAgeSeconds: 60 * 60 * 24 }
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
})
|
||||
]
|
||||
},
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
|
|
|||
33
docker-compose.staging.yml
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
version: "3.8"
|
||||
|
||||
services:
|
||||
app-staging:
|
||||
build:
|
||||
context: ./app
|
||||
dockerfile: Dockerfile
|
||||
image: vereinshaus-staging-app
|
||||
container_name: vereinshaus-staging-app
|
||||
restart: unless-stopped
|
||||
volumes:
|
||||
- /volume1/docker/vereinshaus-staging/data:/data
|
||||
environment:
|
||||
- TZ=Europe/Berlin
|
||||
- HOST=0.0.0.0
|
||||
- PORT=3000
|
||||
- DB_PATH=/data/vereinshaus.db
|
||||
- UPLOAD_DIR=/data/uploads
|
||||
- JWT_SECRET=${JWT_SECRET:-staging-secret-change-me}
|
||||
- PUBLIC_VAPID_KEY=${PUBLIC_VAPID_KEY}
|
||||
- VAPID_PRIVATE_KEY=${VAPID_PRIVATE_KEY}
|
||||
- VAPID_SUBJECT=${VAPID_SUBJECT:-mailto:info@vereins.haus}
|
||||
- BREVO_KEY=${BREVO_KEY}
|
||||
- BREVO_SENDER=${BREVO_SENDER:-noreply@vereins.haus}
|
||||
networks:
|
||||
default: {}
|
||||
npm_bridge:
|
||||
ipv4_address: 172.25.0.13
|
||||
|
||||
networks:
|
||||
npm_bridge:
|
||||
external: true
|
||||
name: nginx-proxy-manager_bridge_net
|
||||
|
|
@ -1,21 +1,6 @@
|
|||
version: "3.8"
|
||||
|
||||
services:
|
||||
pocketbase:
|
||||
image: ghcr.io/muchobien/pocketbase:latest
|
||||
container_name: vereinshaus-pocketbase
|
||||
restart: unless-stopped
|
||||
volumes:
|
||||
- /volume1/docker/vereinshaus/pocketbase/data:/pb_data
|
||||
- /volume1/docker/vereinshaus/pocketbase/storage:/pb_public
|
||||
- /volume1/docker/vereinshaus/pocketbase/data/pb_hooks:/pb_hooks
|
||||
environment:
|
||||
- TZ=Europe/Berlin
|
||||
- BREVO_KEY=${BREVO_KEY}
|
||||
networks:
|
||||
- default
|
||||
- npm_bridge
|
||||
|
||||
app:
|
||||
build:
|
||||
context: ./app
|
||||
|
|
@ -23,13 +8,24 @@ services:
|
|||
image: vereinshaus-app
|
||||
container_name: vereinshaus-app
|
||||
restart: unless-stopped
|
||||
volumes:
|
||||
- /volume1/docker/vereinshaus/data:/data
|
||||
environment:
|
||||
- TZ=Europe/Berlin
|
||||
- HOST=0.0.0.0
|
||||
- PORT=3000
|
||||
- DB_PATH=/data/vereinshaus.db
|
||||
- UPLOAD_DIR=/data/uploads
|
||||
- JWT_SECRET=${JWT_SECRET}
|
||||
- PUBLIC_VAPID_KEY=${PUBLIC_VAPID_KEY}
|
||||
- VAPID_PRIVATE_KEY=${VAPID_PRIVATE_KEY}
|
||||
- VAPID_SUBJECT=${VAPID_SUBJECT:-mailto:info@vereins.haus}
|
||||
- BREVO_KEY=${BREVO_KEY}
|
||||
- BREVO_SENDER=${BREVO_SENDER:-noreply@vereins.haus}
|
||||
networks:
|
||||
- default
|
||||
- npm_bridge
|
||||
default: {}
|
||||
npm_bridge:
|
||||
ipv4_address: 172.25.0.11
|
||||
|
||||
networks:
|
||||
npm_bridge:
|
||||
|
|
|
|||
86
pocketbase/pb_hooks/nachrichten.pb.js
Normal file
|
|
@ -0,0 +1,86 @@
|
|||
onRecordAfterCreateSuccess(function(e) {
|
||||
if (!e.record) return;
|
||||
|
||||
var key = $os.getenv("BREVO_KEY");
|
||||
if (!key) {
|
||||
console.log("[nachrichten] BREVO_KEY nicht gesetzt – E-Mail übersprungen.");
|
||||
return;
|
||||
}
|
||||
|
||||
var vereinId = e.record.getString("verein_id");
|
||||
var betreff = e.record.getString("betreff");
|
||||
var text = e.record.getString("text");
|
||||
var gruppeIds = e.record.getStringSlice("gruppe_ids");
|
||||
|
||||
// Vereinsname für Absender
|
||||
var vereinName = "Ihr Verein";
|
||||
try {
|
||||
var verein = $app.findRecordById("vereine", vereinId);
|
||||
vereinName = verein.getString("name");
|
||||
} catch(err) {}
|
||||
|
||||
// Mitglieder-Filter
|
||||
var filter = 'verein_id = "' + vereinId + '" && status = "aktiv" && email != ""';
|
||||
if (gruppeIds && gruppeIds.length > 0) {
|
||||
var gruppenParts = [];
|
||||
for (var i = 0; i < gruppeIds.length; i++) {
|
||||
gruppenParts.push('gruppe_ids ~ "' + gruppeIds[i] + '"');
|
||||
}
|
||||
filter += " && (" + gruppenParts.join(" || ") + ")";
|
||||
}
|
||||
|
||||
var mitglieder;
|
||||
try {
|
||||
mitglieder = $app.findRecordsByFilter("mitglieder", filter, "nachname", 500, 0);
|
||||
} catch(err) {
|
||||
console.error("[nachrichten] Mitglieder laden fehlgeschlagen: " + String(err));
|
||||
return;
|
||||
}
|
||||
|
||||
var empfaenger = [];
|
||||
for (var j = 0; j < mitglieder.length; j++) {
|
||||
var m = mitglieder[j];
|
||||
var email = m.getString("email");
|
||||
if (email) {
|
||||
empfaenger.push({
|
||||
email: email,
|
||||
name: m.getString("vorname") + " " + m.getString("nachname")
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (empfaenger.length === 0) {
|
||||
console.log("[nachrichten] Keine Empfänger mit E-Mail gefunden.");
|
||||
return;
|
||||
}
|
||||
|
||||
var sender = $os.getenv("BREVO_SENDER") || "noreply@vereins.haus";
|
||||
var htmlContent = text
|
||||
? text.replace(/\n/g, "<br>")
|
||||
: "<p>(Kein Inhalt)</p>";
|
||||
|
||||
// In 50er-Batches senden (Brevo-Limit)
|
||||
var BATCH = 50;
|
||||
for (var b = 0; b < empfaenger.length; b += BATCH) {
|
||||
var batch = empfaenger.slice(b, b + BATCH);
|
||||
var body = JSON.stringify({
|
||||
sender: { name: vereinName, email: sender },
|
||||
to: [batch[0]],
|
||||
bcc: batch.slice(1),
|
||||
subject: betreff,
|
||||
htmlContent: htmlContent
|
||||
});
|
||||
|
||||
try {
|
||||
$http.send({
|
||||
url: "https://api.brevo.com/v3/smtp/email",
|
||||
method: "POST",
|
||||
headers: { "api-key": key, "Content-Type": "application/json" },
|
||||
body: body
|
||||
});
|
||||
console.log("[nachrichten] Batch " + (b / BATCH + 1) + " gesendet (" + batch.length + " Empfänger).");
|
||||
} catch(err) {
|
||||
console.error("[nachrichten] Brevo Fehler Batch " + (b / BATCH + 1) + ": " + String(err));
|
||||
}
|
||||
}
|
||||
}, "nachrichten");
|
||||
144
pocketbase/pb_migrations/1779215839_created_vereine.js
Normal file
|
|
@ -0,0 +1,144 @@
|
|||
/// <reference path="../pb_data/types.d.ts" />
|
||||
migrate((app) => {
|
||||
const collection = new Collection({
|
||||
"createRule": null,
|
||||
"deleteRule": null,
|
||||
"fields": [
|
||||
{
|
||||
"autogeneratePattern": "[a-z0-9]{15}",
|
||||
"help": "",
|
||||
"hidden": false,
|
||||
"id": "text3208210256",
|
||||
"max": 15,
|
||||
"min": 15,
|
||||
"name": "id",
|
||||
"pattern": "^[a-z0-9]+$",
|
||||
"presentable": false,
|
||||
"primaryKey": true,
|
||||
"required": true,
|
||||
"system": true,
|
||||
"type": "text"
|
||||
},
|
||||
{
|
||||
"autogeneratePattern": "",
|
||||
"help": "",
|
||||
"hidden": false,
|
||||
"id": "text1579384326",
|
||||
"max": 0,
|
||||
"min": 0,
|
||||
"name": "name",
|
||||
"pattern": "",
|
||||
"presentable": false,
|
||||
"primaryKey": false,
|
||||
"required": true,
|
||||
"system": false,
|
||||
"type": "text"
|
||||
},
|
||||
{
|
||||
"autogeneratePattern": "",
|
||||
"help": "",
|
||||
"hidden": false,
|
||||
"id": "text3525840331",
|
||||
"max": 0,
|
||||
"min": 0,
|
||||
"name": "plz",
|
||||
"pattern": "",
|
||||
"presentable": false,
|
||||
"primaryKey": false,
|
||||
"required": false,
|
||||
"system": false,
|
||||
"type": "text"
|
||||
},
|
||||
{
|
||||
"autogeneratePattern": "",
|
||||
"help": "",
|
||||
"hidden": false,
|
||||
"id": "text4138466142",
|
||||
"max": 0,
|
||||
"min": 0,
|
||||
"name": "ort",
|
||||
"pattern": "",
|
||||
"presentable": false,
|
||||
"primaryKey": false,
|
||||
"required": false,
|
||||
"system": false,
|
||||
"type": "text"
|
||||
},
|
||||
{
|
||||
"help": "",
|
||||
"hidden": false,
|
||||
"id": "select1497100012",
|
||||
"maxSelect": 1,
|
||||
"name": "bundesland",
|
||||
"presentable": false,
|
||||
"required": false,
|
||||
"system": false,
|
||||
"type": "select",
|
||||
"values": [
|
||||
"BW",
|
||||
"BY",
|
||||
"BE",
|
||||
"BB",
|
||||
"HB",
|
||||
"HH",
|
||||
"HE",
|
||||
"MV",
|
||||
"NI",
|
||||
"NW",
|
||||
"RP",
|
||||
"SL",
|
||||
"SN",
|
||||
"ST",
|
||||
"SH",
|
||||
"TH"
|
||||
]
|
||||
},
|
||||
{
|
||||
"help": "",
|
||||
"hidden": false,
|
||||
"id": "select3713686397",
|
||||
"maxSelect": 1,
|
||||
"name": "plan",
|
||||
"presentable": false,
|
||||
"required": false,
|
||||
"system": false,
|
||||
"type": "select",
|
||||
"values": [
|
||||
"free",
|
||||
"starter",
|
||||
"wachstum",
|
||||
"verband"
|
||||
]
|
||||
},
|
||||
{
|
||||
"autogeneratePattern": "",
|
||||
"help": "",
|
||||
"hidden": false,
|
||||
"id": "text1888339527",
|
||||
"max": 0,
|
||||
"min": 0,
|
||||
"name": "stripe_customer_id",
|
||||
"pattern": "",
|
||||
"presentable": false,
|
||||
"primaryKey": false,
|
||||
"required": false,
|
||||
"system": false,
|
||||
"type": "text"
|
||||
}
|
||||
],
|
||||
"id": "pbc_3589557411",
|
||||
"indexes": [],
|
||||
"listRule": "@request.auth.verein_id = id",
|
||||
"name": "vereine",
|
||||
"system": false,
|
||||
"type": "base",
|
||||
"updateRule": "@request.auth.verein_id = id",
|
||||
"viewRule": "@request.auth.verein_id = id"
|
||||
});
|
||||
|
||||
return app.save(collection);
|
||||
}, (app) => {
|
||||
const collection = app.findCollectionByNameOrId("pbc_3589557411");
|
||||
|
||||
return app.delete(collection);
|
||||
})
|
||||
67
pocketbase/pb_migrations/1779215883_created_gruppen.js
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
/// <reference path="../pb_data/types.d.ts" />
|
||||
migrate((app) => {
|
||||
const collection = new Collection({
|
||||
"createRule": "@request.auth.verein_id = verein_id",
|
||||
"deleteRule": "@request.auth.verein_id = verein_id",
|
||||
"fields": [
|
||||
{
|
||||
"autogeneratePattern": "[a-z0-9]{15}",
|
||||
"help": "",
|
||||
"hidden": false,
|
||||
"id": "text3208210256",
|
||||
"max": 15,
|
||||
"min": 15,
|
||||
"name": "id",
|
||||
"pattern": "^[a-z0-9]+$",
|
||||
"presentable": false,
|
||||
"primaryKey": true,
|
||||
"required": true,
|
||||
"system": true,
|
||||
"type": "text"
|
||||
},
|
||||
{
|
||||
"cascadeDelete": true,
|
||||
"collectionId": "pbc_3589557411",
|
||||
"help": "",
|
||||
"hidden": false,
|
||||
"id": "relation145676011",
|
||||
"maxSelect": 1,
|
||||
"minSelect": 0,
|
||||
"name": "verein_id",
|
||||
"presentable": false,
|
||||
"required": true,
|
||||
"system": false,
|
||||
"type": "relation"
|
||||
},
|
||||
{
|
||||
"autogeneratePattern": "",
|
||||
"help": "",
|
||||
"hidden": false,
|
||||
"id": "text1579384326",
|
||||
"max": 0,
|
||||
"min": 0,
|
||||
"name": "name",
|
||||
"pattern": "",
|
||||
"presentable": false,
|
||||
"primaryKey": false,
|
||||
"required": true,
|
||||
"system": false,
|
||||
"type": "text"
|
||||
}
|
||||
],
|
||||
"id": "pbc_3099069179",
|
||||
"indexes": [],
|
||||
"listRule": "@request.auth.verein_id = verein_id",
|
||||
"name": "gruppen",
|
||||
"system": false,
|
||||
"type": "base",
|
||||
"updateRule": "@request.auth.verein_id = verein_id",
|
||||
"viewRule": "@request.auth.verein_id = verein_id"
|
||||
});
|
||||
|
||||
return app.save(collection);
|
||||
}, (app) => {
|
||||
const collection = app.findCollectionByNameOrId("pbc_3099069179");
|
||||
|
||||
return app.delete(collection);
|
||||
})
|
||||
29
pocketbase/pb_migrations/1779215883_updated_users.js
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
/// <reference path="../pb_data/types.d.ts" />
|
||||
migrate((app) => {
|
||||
const collection = app.findCollectionByNameOrId("_pb_users_auth_")
|
||||
|
||||
// add field
|
||||
collection.fields.addAt(10, new Field({
|
||||
"cascadeDelete": false,
|
||||
"collectionId": "pbc_3589557411",
|
||||
"help": "",
|
||||
"hidden": false,
|
||||
"id": "relation145676011",
|
||||
"maxSelect": 1,
|
||||
"minSelect": 0,
|
||||
"name": "verein_id",
|
||||
"presentable": false,
|
||||
"required": false,
|
||||
"system": false,
|
||||
"type": "relation"
|
||||
}))
|
||||
|
||||
return app.save(collection)
|
||||
}, (app) => {
|
||||
const collection = app.findCollectionByNameOrId("_pb_users_auth_")
|
||||
|
||||
// remove field
|
||||
collection.fields.removeById("relation145676011")
|
||||
|
||||
return app.save(collection)
|
||||
})
|
||||
97
pocketbase/pb_migrations/1779215901_created_beitraege.js
Normal file
|
|
@ -0,0 +1,97 @@
|
|||
/// <reference path="../pb_data/types.d.ts" />
|
||||
migrate((app) => {
|
||||
const collection = new Collection({
|
||||
"createRule": "@request.auth.verein_id = verein_id",
|
||||
"deleteRule": "@request.auth.verein_id = verein_id",
|
||||
"fields": [
|
||||
{
|
||||
"autogeneratePattern": "[a-z0-9]{15}",
|
||||
"help": "",
|
||||
"hidden": false,
|
||||
"id": "text3208210256",
|
||||
"max": 15,
|
||||
"min": 15,
|
||||
"name": "id",
|
||||
"pattern": "^[a-z0-9]+$",
|
||||
"presentable": false,
|
||||
"primaryKey": true,
|
||||
"required": true,
|
||||
"system": true,
|
||||
"type": "text"
|
||||
},
|
||||
{
|
||||
"cascadeDelete": true,
|
||||
"collectionId": "pbc_3589557411",
|
||||
"help": "",
|
||||
"hidden": false,
|
||||
"id": "relation145676011",
|
||||
"maxSelect": 1,
|
||||
"minSelect": 0,
|
||||
"name": "verein_id",
|
||||
"presentable": false,
|
||||
"required": true,
|
||||
"system": false,
|
||||
"type": "relation"
|
||||
},
|
||||
{
|
||||
"autogeneratePattern": "",
|
||||
"help": "",
|
||||
"hidden": false,
|
||||
"id": "text1579384326",
|
||||
"max": 0,
|
||||
"min": 0,
|
||||
"name": "name",
|
||||
"pattern": "",
|
||||
"presentable": false,
|
||||
"primaryKey": false,
|
||||
"required": true,
|
||||
"system": false,
|
||||
"type": "text"
|
||||
},
|
||||
{
|
||||
"help": "",
|
||||
"hidden": false,
|
||||
"id": "number3107246631",
|
||||
"max": null,
|
||||
"min": null,
|
||||
"name": "betrag",
|
||||
"onlyInt": false,
|
||||
"presentable": false,
|
||||
"required": true,
|
||||
"system": false,
|
||||
"type": "number"
|
||||
},
|
||||
{
|
||||
"help": "",
|
||||
"hidden": false,
|
||||
"id": "select917011370",
|
||||
"maxSelect": 1,
|
||||
"name": "rhythmus",
|
||||
"presentable": false,
|
||||
"required": true,
|
||||
"system": false,
|
||||
"type": "select",
|
||||
"values": [
|
||||
"monatlich",
|
||||
"quartalsweise",
|
||||
"jaehrlich",
|
||||
"einmalig"
|
||||
]
|
||||
}
|
||||
],
|
||||
"id": "pbc_3218207135",
|
||||
"indexes": [],
|
||||
"listRule": "@request.auth.verein_id = verein_id",
|
||||
"name": "beitraege",
|
||||
"system": false,
|
||||
"type": "base",
|
||||
"updateRule": "@request.auth.verein_id = verein_id",
|
||||
"viewRule": "@request.auth.verein_id = verein_id"
|
||||
});
|
||||
|
||||
return app.save(collection);
|
||||
}, (app) => {
|
||||
const collection = app.findCollectionByNameOrId("pbc_3218207135");
|
||||
|
||||
return app.delete(collection);
|
||||
})
|
||||
139
pocketbase/pb_migrations/1779215901_created_mitglieder.js
Normal file
|
|
@ -0,0 +1,139 @@
|
|||
/// <reference path="../pb_data/types.d.ts" />
|
||||
migrate((app) => {
|
||||
const collection = new Collection({
|
||||
"createRule": "@request.auth.verein_id = verein_id",
|
||||
"deleteRule": "@request.auth.verein_id = verein_id",
|
||||
"fields": [
|
||||
{
|
||||
"autogeneratePattern": "[a-z0-9]{15}",
|
||||
"help": "",
|
||||
"hidden": false,
|
||||
"id": "text3208210256",
|
||||
"max": 15,
|
||||
"min": 15,
|
||||
"name": "id",
|
||||
"pattern": "^[a-z0-9]+$",
|
||||
"presentable": false,
|
||||
"primaryKey": true,
|
||||
"required": true,
|
||||
"system": true,
|
||||
"type": "text"
|
||||
},
|
||||
{
|
||||
"cascadeDelete": true,
|
||||
"collectionId": "pbc_3589557411",
|
||||
"help": "",
|
||||
"hidden": false,
|
||||
"id": "relation145676011",
|
||||
"maxSelect": 1,
|
||||
"minSelect": 0,
|
||||
"name": "verein_id",
|
||||
"presentable": false,
|
||||
"required": true,
|
||||
"system": false,
|
||||
"type": "relation"
|
||||
},
|
||||
{
|
||||
"autogeneratePattern": "",
|
||||
"help": "",
|
||||
"hidden": false,
|
||||
"id": "text386110805",
|
||||
"max": 0,
|
||||
"min": 0,
|
||||
"name": "vorname",
|
||||
"pattern": "",
|
||||
"presentable": false,
|
||||
"primaryKey": false,
|
||||
"required": true,
|
||||
"system": false,
|
||||
"type": "text"
|
||||
},
|
||||
{
|
||||
"autogeneratePattern": "",
|
||||
"help": "",
|
||||
"hidden": false,
|
||||
"id": "text3586640595",
|
||||
"max": 0,
|
||||
"min": 0,
|
||||
"name": "nachname",
|
||||
"pattern": "",
|
||||
"presentable": false,
|
||||
"primaryKey": false,
|
||||
"required": true,
|
||||
"system": false,
|
||||
"type": "text"
|
||||
},
|
||||
{
|
||||
"exceptDomains": null,
|
||||
"help": "",
|
||||
"hidden": false,
|
||||
"id": "email3885137012",
|
||||
"name": "email",
|
||||
"onlyDomains": null,
|
||||
"presentable": false,
|
||||
"required": false,
|
||||
"system": false,
|
||||
"type": "email"
|
||||
},
|
||||
{
|
||||
"autogeneratePattern": "",
|
||||
"help": "",
|
||||
"hidden": false,
|
||||
"id": "text4208291426",
|
||||
"max": 0,
|
||||
"min": 0,
|
||||
"name": "iban",
|
||||
"pattern": "",
|
||||
"presentable": false,
|
||||
"primaryKey": false,
|
||||
"required": false,
|
||||
"system": false,
|
||||
"type": "text"
|
||||
},
|
||||
{
|
||||
"cascadeDelete": false,
|
||||
"collectionId": "pbc_3099069179",
|
||||
"help": "",
|
||||
"hidden": false,
|
||||
"id": "relation1077495665",
|
||||
"maxSelect": 99,
|
||||
"minSelect": 0,
|
||||
"name": "gruppe_ids",
|
||||
"presentable": false,
|
||||
"required": false,
|
||||
"system": false,
|
||||
"type": "relation"
|
||||
},
|
||||
{
|
||||
"help": "",
|
||||
"hidden": false,
|
||||
"id": "select2063623452",
|
||||
"maxSelect": 1,
|
||||
"name": "status",
|
||||
"presentable": false,
|
||||
"required": false,
|
||||
"system": false,
|
||||
"type": "select",
|
||||
"values": [
|
||||
"aktiv",
|
||||
"passiv",
|
||||
"ausgetreten"
|
||||
]
|
||||
}
|
||||
],
|
||||
"id": "pbc_2707111162",
|
||||
"indexes": [],
|
||||
"listRule": "@request.auth.verein_id = verein_id",
|
||||
"name": "mitglieder",
|
||||
"system": false,
|
||||
"type": "base",
|
||||
"updateRule": "@request.auth.verein_id = verein_id",
|
||||
"viewRule": "@request.auth.verein_id = verein_id"
|
||||
});
|
||||
|
||||
return app.save(collection);
|
||||
}, (app) => {
|
||||
const collection = app.findCollectionByNameOrId("pbc_2707111162");
|
||||
|
||||
return app.delete(collection);
|
||||
})
|
||||
123
pocketbase/pb_migrations/1779215948_created_einzuege.js
Normal file
|
|
@ -0,0 +1,123 @@
|
|||
/// <reference path="../pb_data/types.d.ts" />
|
||||
migrate((app) => {
|
||||
const collection = new Collection({
|
||||
"createRule": "@request.auth.verein_id = mitglied_id.verein_id",
|
||||
"deleteRule": "@request.auth.verein_id = mitglied_id.verein_id",
|
||||
"fields": [
|
||||
{
|
||||
"autogeneratePattern": "[a-z0-9]{15}",
|
||||
"help": "",
|
||||
"hidden": false,
|
||||
"id": "text3208210256",
|
||||
"max": 15,
|
||||
"min": 15,
|
||||
"name": "id",
|
||||
"pattern": "^[a-z0-9]+$",
|
||||
"presentable": false,
|
||||
"primaryKey": true,
|
||||
"required": true,
|
||||
"system": true,
|
||||
"type": "text"
|
||||
},
|
||||
{
|
||||
"cascadeDelete": true,
|
||||
"collectionId": "pbc_2707111162",
|
||||
"help": "",
|
||||
"hidden": false,
|
||||
"id": "relation3039789658",
|
||||
"maxSelect": 1,
|
||||
"minSelect": 0,
|
||||
"name": "mitglied_id",
|
||||
"presentable": false,
|
||||
"required": true,
|
||||
"system": false,
|
||||
"type": "relation"
|
||||
},
|
||||
{
|
||||
"cascadeDelete": false,
|
||||
"collectionId": "pbc_3218207135",
|
||||
"help": "",
|
||||
"hidden": false,
|
||||
"id": "relation715527895",
|
||||
"maxSelect": 1,
|
||||
"minSelect": 0,
|
||||
"name": "beitrag_id",
|
||||
"presentable": false,
|
||||
"required": true,
|
||||
"system": false,
|
||||
"type": "relation"
|
||||
},
|
||||
{
|
||||
"help": "",
|
||||
"hidden": false,
|
||||
"id": "number3107246631",
|
||||
"max": null,
|
||||
"min": null,
|
||||
"name": "betrag",
|
||||
"onlyInt": false,
|
||||
"presentable": false,
|
||||
"required": true,
|
||||
"system": false,
|
||||
"type": "number"
|
||||
},
|
||||
{
|
||||
"help": "",
|
||||
"hidden": false,
|
||||
"id": "date3314956993",
|
||||
"max": "",
|
||||
"min": "",
|
||||
"name": "faellig_am",
|
||||
"presentable": false,
|
||||
"required": false,
|
||||
"system": false,
|
||||
"type": "date"
|
||||
},
|
||||
{
|
||||
"help": "",
|
||||
"hidden": false,
|
||||
"id": "select2063623452",
|
||||
"maxSelect": 1,
|
||||
"name": "status",
|
||||
"presentable": false,
|
||||
"required": false,
|
||||
"system": false,
|
||||
"type": "select",
|
||||
"values": [
|
||||
"ausstehend",
|
||||
"bezahlt",
|
||||
"fehlgeschlagen",
|
||||
"storniert"
|
||||
]
|
||||
},
|
||||
{
|
||||
"autogeneratePattern": "",
|
||||
"help": "",
|
||||
"hidden": false,
|
||||
"id": "text4235393406",
|
||||
"max": 0,
|
||||
"min": 0,
|
||||
"name": "stripe_payment_intent_id",
|
||||
"pattern": "",
|
||||
"presentable": false,
|
||||
"primaryKey": false,
|
||||
"required": false,
|
||||
"system": false,
|
||||
"type": "text"
|
||||
}
|
||||
],
|
||||
"id": "pbc_659326735",
|
||||
"indexes": [],
|
||||
"listRule": "@request.auth.verein_id = mitglied_id.verein_id",
|
||||
"name": "einzuege",
|
||||
"system": false,
|
||||
"type": "base",
|
||||
"updateRule": "@request.auth.verein_id = mitglied_id.verein_id",
|
||||
"viewRule": "@request.auth.verein_id = mitglied_id.verein_id"
|
||||
});
|
||||
|
||||
return app.save(collection);
|
||||
}, (app) => {
|
||||
const collection = app.findCollectionByNameOrId("pbc_659326735");
|
||||
|
||||
return app.delete(collection);
|
||||
})
|
||||
105
pocketbase/pb_migrations/1779215948_created_nachrichten.js
Normal file
|
|
@ -0,0 +1,105 @@
|
|||
/// <reference path="../pb_data/types.d.ts" />
|
||||
migrate((app) => {
|
||||
const collection = new Collection({
|
||||
"createRule": "@request.auth.verein_id = verein_id",
|
||||
"deleteRule": "@request.auth.verein_id = verein_id",
|
||||
"fields": [
|
||||
{
|
||||
"autogeneratePattern": "[a-z0-9]{15}",
|
||||
"help": "",
|
||||
"hidden": false,
|
||||
"id": "text3208210256",
|
||||
"max": 15,
|
||||
"min": 15,
|
||||
"name": "id",
|
||||
"pattern": "^[a-z0-9]+$",
|
||||
"presentable": false,
|
||||
"primaryKey": true,
|
||||
"required": true,
|
||||
"system": true,
|
||||
"type": "text"
|
||||
},
|
||||
{
|
||||
"cascadeDelete": true,
|
||||
"collectionId": "pbc_3589557411",
|
||||
"help": "",
|
||||
"hidden": false,
|
||||
"id": "relation145676011",
|
||||
"maxSelect": 1,
|
||||
"minSelect": 0,
|
||||
"name": "verein_id",
|
||||
"presentable": false,
|
||||
"required": true,
|
||||
"system": false,
|
||||
"type": "relation"
|
||||
},
|
||||
{
|
||||
"autogeneratePattern": "",
|
||||
"help": "",
|
||||
"hidden": false,
|
||||
"id": "text3446813636",
|
||||
"max": 0,
|
||||
"min": 0,
|
||||
"name": "betreff",
|
||||
"pattern": "",
|
||||
"presentable": false,
|
||||
"primaryKey": false,
|
||||
"required": true,
|
||||
"system": false,
|
||||
"type": "text"
|
||||
},
|
||||
{
|
||||
"convertURLs": false,
|
||||
"help": "",
|
||||
"hidden": false,
|
||||
"id": "editor999008199",
|
||||
"maxSize": 0,
|
||||
"name": "text",
|
||||
"presentable": false,
|
||||
"required": false,
|
||||
"system": false,
|
||||
"type": "editor"
|
||||
},
|
||||
{
|
||||
"cascadeDelete": false,
|
||||
"collectionId": "pbc_3099069179",
|
||||
"help": "",
|
||||
"hidden": false,
|
||||
"id": "relation1077495665",
|
||||
"maxSelect": 99,
|
||||
"minSelect": 0,
|
||||
"name": "gruppe_ids",
|
||||
"presentable": false,
|
||||
"required": false,
|
||||
"system": false,
|
||||
"type": "relation"
|
||||
},
|
||||
{
|
||||
"help": "",
|
||||
"hidden": false,
|
||||
"id": "date2716153632",
|
||||
"max": "",
|
||||
"min": "",
|
||||
"name": "gesendet_am",
|
||||
"presentable": false,
|
||||
"required": false,
|
||||
"system": false,
|
||||
"type": "date"
|
||||
}
|
||||
],
|
||||
"id": "pbc_1415911511",
|
||||
"indexes": [],
|
||||
"listRule": "@request.auth.verein_id = verein_id",
|
||||
"name": "nachrichten",
|
||||
"system": false,
|
||||
"type": "base",
|
||||
"updateRule": "@request.auth.verein_id = verein_id",
|
||||
"viewRule": "@request.auth.verein_id = verein_id"
|
||||
});
|
||||
|
||||
return app.save(collection);
|
||||
}, (app) => {
|
||||
const collection = app.findCollectionByNameOrId("pbc_1415911511");
|
||||
|
||||
return app.delete(collection);
|
||||
})
|
||||
|
|
@ -0,0 +1,94 @@
|
|||
/// <reference path="../pb_data/types.d.ts" />
|
||||
migrate((app) => {
|
||||
const collection = new Collection({
|
||||
"createRule": "@request.auth.id != ''",
|
||||
"deleteRule": "@request.auth.id = user_id",
|
||||
"fields": [
|
||||
{
|
||||
"autogeneratePattern": "[a-z0-9]{15}",
|
||||
"help": "",
|
||||
"hidden": false,
|
||||
"id": "text3208210256",
|
||||
"max": 15,
|
||||
"min": 15,
|
||||
"name": "id",
|
||||
"pattern": "^[a-z0-9]+$",
|
||||
"presentable": false,
|
||||
"primaryKey": true,
|
||||
"required": true,
|
||||
"system": true,
|
||||
"type": "text"
|
||||
},
|
||||
{
|
||||
"cascadeDelete": true,
|
||||
"collectionId": "_pb_users_auth_",
|
||||
"help": "",
|
||||
"hidden": false,
|
||||
"id": "relation2809058197",
|
||||
"maxSelect": 1,
|
||||
"minSelect": 0,
|
||||
"name": "user_id",
|
||||
"presentable": false,
|
||||
"required": true,
|
||||
"system": false,
|
||||
"type": "relation"
|
||||
},
|
||||
{
|
||||
"exceptDomains": null,
|
||||
"help": "",
|
||||
"hidden": false,
|
||||
"id": "url3292663675",
|
||||
"name": "endpoint",
|
||||
"onlyDomains": null,
|
||||
"presentable": false,
|
||||
"required": true,
|
||||
"system": false,
|
||||
"type": "url"
|
||||
},
|
||||
{
|
||||
"autogeneratePattern": "",
|
||||
"help": "",
|
||||
"hidden": false,
|
||||
"id": "text3303707132",
|
||||
"max": 0,
|
||||
"min": 0,
|
||||
"name": "p256dh",
|
||||
"pattern": "",
|
||||
"presentable": false,
|
||||
"primaryKey": false,
|
||||
"required": true,
|
||||
"system": false,
|
||||
"type": "text"
|
||||
},
|
||||
{
|
||||
"autogeneratePattern": "",
|
||||
"help": "",
|
||||
"hidden": false,
|
||||
"id": "text4175343705",
|
||||
"max": 0,
|
||||
"min": 0,
|
||||
"name": "auth",
|
||||
"pattern": "",
|
||||
"presentable": false,
|
||||
"primaryKey": false,
|
||||
"required": true,
|
||||
"system": false,
|
||||
"type": "text"
|
||||
}
|
||||
],
|
||||
"id": "pbc_1438754935",
|
||||
"indexes": [],
|
||||
"listRule": "@request.auth.id = user_id",
|
||||
"name": "push_subscriptions",
|
||||
"system": false,
|
||||
"type": "base",
|
||||
"updateRule": "@request.auth.id = user_id",
|
||||
"viewRule": "@request.auth.id = user_id"
|
||||
});
|
||||
|
||||
return app.save(collection);
|
||||
}, (app) => {
|
||||
const collection = app.findCollectionByNameOrId("pbc_1438754935");
|
||||
|
||||
return app.delete(collection);
|
||||
})
|
||||
120
pocketbase/pb_migrations/1779215948_created_termine.js
Normal file
|
|
@ -0,0 +1,120 @@
|
|||
/// <reference path="../pb_data/types.d.ts" />
|
||||
migrate((app) => {
|
||||
const collection = new Collection({
|
||||
"createRule": "@request.auth.verein_id = verein_id",
|
||||
"deleteRule": "@request.auth.verein_id = verein_id",
|
||||
"fields": [
|
||||
{
|
||||
"autogeneratePattern": "[a-z0-9]{15}",
|
||||
"help": "",
|
||||
"hidden": false,
|
||||
"id": "text3208210256",
|
||||
"max": 15,
|
||||
"min": 15,
|
||||
"name": "id",
|
||||
"pattern": "^[a-z0-9]+$",
|
||||
"presentable": false,
|
||||
"primaryKey": true,
|
||||
"required": true,
|
||||
"system": true,
|
||||
"type": "text"
|
||||
},
|
||||
{
|
||||
"cascadeDelete": true,
|
||||
"collectionId": "pbc_3589557411",
|
||||
"help": "",
|
||||
"hidden": false,
|
||||
"id": "relation145676011",
|
||||
"maxSelect": 1,
|
||||
"minSelect": 0,
|
||||
"name": "verein_id",
|
||||
"presentable": false,
|
||||
"required": true,
|
||||
"system": false,
|
||||
"type": "relation"
|
||||
},
|
||||
{
|
||||
"autogeneratePattern": "",
|
||||
"help": "",
|
||||
"hidden": false,
|
||||
"id": "text2200468358",
|
||||
"max": 0,
|
||||
"min": 0,
|
||||
"name": "titel",
|
||||
"pattern": "",
|
||||
"presentable": false,
|
||||
"primaryKey": false,
|
||||
"required": true,
|
||||
"system": false,
|
||||
"type": "text"
|
||||
},
|
||||
{
|
||||
"help": "",
|
||||
"hidden": false,
|
||||
"id": "date363840172",
|
||||
"max": "",
|
||||
"min": "",
|
||||
"name": "beginn",
|
||||
"presentable": false,
|
||||
"required": true,
|
||||
"system": false,
|
||||
"type": "date"
|
||||
},
|
||||
{
|
||||
"help": "",
|
||||
"hidden": false,
|
||||
"id": "date1404831091",
|
||||
"max": "",
|
||||
"min": "",
|
||||
"name": "ende",
|
||||
"presentable": false,
|
||||
"required": false,
|
||||
"system": false,
|
||||
"type": "date"
|
||||
},
|
||||
{
|
||||
"autogeneratePattern": "",
|
||||
"help": "",
|
||||
"hidden": false,
|
||||
"id": "text4138466142",
|
||||
"max": 0,
|
||||
"min": 0,
|
||||
"name": "ort",
|
||||
"pattern": "",
|
||||
"presentable": false,
|
||||
"primaryKey": false,
|
||||
"required": false,
|
||||
"system": false,
|
||||
"type": "text"
|
||||
},
|
||||
{
|
||||
"cascadeDelete": false,
|
||||
"collectionId": "pbc_3099069179",
|
||||
"help": "",
|
||||
"hidden": false,
|
||||
"id": "relation1077495665",
|
||||
"maxSelect": 99,
|
||||
"minSelect": 0,
|
||||
"name": "gruppe_ids",
|
||||
"presentable": false,
|
||||
"required": false,
|
||||
"system": false,
|
||||
"type": "relation"
|
||||
}
|
||||
],
|
||||
"id": "pbc_2279568741",
|
||||
"indexes": [],
|
||||
"listRule": "@request.auth.verein_id = verein_id",
|
||||
"name": "termine",
|
||||
"system": false,
|
||||
"type": "base",
|
||||
"updateRule": "@request.auth.verein_id = verein_id",
|
||||
"viewRule": "@request.auth.verein_id = verein_id"
|
||||
});
|
||||
|
||||
return app.save(collection);
|
||||
}, (app) => {
|
||||
const collection = app.findCollectionByNameOrId("pbc_2279568741");
|
||||
|
||||
return app.delete(collection);
|
||||
})
|
||||
194
pocketbase/pb_migrations/1779230000_align_schema.js
Normal file
|
|
@ -0,0 +1,194 @@
|
|||
/// <reference path="../pb_data/types.d.ts" />
|
||||
migrate((app) => {
|
||||
|
||||
// vereine: +adresse, +dosb_mitglied; -stripe_customer_id
|
||||
{
|
||||
const c = app.findCollectionByNameOrId("pbc_3589557411")
|
||||
c.fields.removeById("text1888339527") // stripe_customer_id
|
||||
c.fields.addAt(2, new Field({
|
||||
"type": "text", "id": "text2001000001", "name": "adresse",
|
||||
"help": "", "hidden": false, "presentable": false, "required": false, "system": false,
|
||||
"autogeneratePattern": "", "min": 0, "max": 0, "pattern": ""
|
||||
}))
|
||||
c.fields.addAt(99, new Field({
|
||||
"type": "bool", "id": "bool2001000002", "name": "dosb_mitglied",
|
||||
"help": "", "hidden": false, "presentable": false, "required": false, "system": false
|
||||
}))
|
||||
app.save(c)
|
||||
}
|
||||
|
||||
// gruppen: +beschreibung
|
||||
{
|
||||
const c = app.findCollectionByNameOrId("pbc_3099069179")
|
||||
c.fields.addAt(99, new Field({
|
||||
"type": "text", "id": "text2001000003", "name": "beschreibung",
|
||||
"help": "", "hidden": false, "presentable": false, "required": false, "system": false,
|
||||
"autogeneratePattern": "", "min": 0, "max": 0, "pattern": ""
|
||||
}))
|
||||
app.save(c)
|
||||
}
|
||||
|
||||
// mitglieder: +telefon, geburtsdatum, eintrittsdatum, austrittsdatum, strasse, plz, ort, bic, notizen
|
||||
{
|
||||
const c = app.findCollectionByNameOrId("pbc_2707111162")
|
||||
c.fields.addAt(99, new Field({
|
||||
"type": "text", "id": "text2001000010", "name": "telefon",
|
||||
"help": "", "hidden": false, "presentable": false, "required": false, "system": false,
|
||||
"autogeneratePattern": "", "min": 0, "max": 0, "pattern": ""
|
||||
}))
|
||||
c.fields.addAt(99, new Field({
|
||||
"type": "date", "id": "date2001000011", "name": "geburtsdatum",
|
||||
"help": "", "hidden": false, "presentable": false, "required": false, "system": false,
|
||||
"min": "", "max": ""
|
||||
}))
|
||||
c.fields.addAt(99, new Field({
|
||||
"type": "date", "id": "date2001000012", "name": "eintrittsdatum",
|
||||
"help": "", "hidden": false, "presentable": false, "required": false, "system": false,
|
||||
"min": "", "max": ""
|
||||
}))
|
||||
c.fields.addAt(99, new Field({
|
||||
"type": "date", "id": "date2001000013", "name": "austrittsdatum",
|
||||
"help": "", "hidden": false, "presentable": false, "required": false, "system": false,
|
||||
"min": "", "max": ""
|
||||
}))
|
||||
c.fields.addAt(99, new Field({
|
||||
"type": "text", "id": "text2001000014", "name": "strasse",
|
||||
"help": "", "hidden": false, "presentable": false, "required": false, "system": false,
|
||||
"autogeneratePattern": "", "min": 0, "max": 0, "pattern": ""
|
||||
}))
|
||||
c.fields.addAt(99, new Field({
|
||||
"type": "text", "id": "text2001000015", "name": "plz",
|
||||
"help": "", "hidden": false, "presentable": false, "required": false, "system": false,
|
||||
"autogeneratePattern": "", "min": 0, "max": 0, "pattern": ""
|
||||
}))
|
||||
c.fields.addAt(99, new Field({
|
||||
"type": "text", "id": "text2001000016", "name": "ort",
|
||||
"help": "", "hidden": false, "presentable": false, "required": false, "system": false,
|
||||
"autogeneratePattern": "", "min": 0, "max": 0, "pattern": ""
|
||||
}))
|
||||
c.fields.addAt(99, new Field({
|
||||
"type": "text", "id": "text2001000017", "name": "bic",
|
||||
"help": "", "hidden": false, "presentable": false, "required": false, "system": false,
|
||||
"autogeneratePattern": "", "min": 0, "max": 0, "pattern": ""
|
||||
}))
|
||||
c.fields.addAt(99, new Field({
|
||||
"type": "text", "id": "text2001000018", "name": "notizen",
|
||||
"help": "", "hidden": false, "presentable": false, "required": false, "system": false,
|
||||
"autogeneratePattern": "", "min": 0, "max": 0, "pattern": ""
|
||||
}))
|
||||
app.save(c)
|
||||
}
|
||||
|
||||
// beitraege: +beschreibung; rhythmus +halbjaehrlich (einmalig bleibt)
|
||||
{
|
||||
const c = app.findCollectionByNameOrId("pbc_3218207135")
|
||||
const rhythmus = c.fields.getById("select917011370")
|
||||
rhythmus.values = ["monatlich", "quartalsweise", "halbjaehrlich", "jaehrlich", "einmalig"]
|
||||
c.fields.addAt(99, new Field({
|
||||
"type": "text", "id": "text2001000020", "name": "beschreibung",
|
||||
"help": "", "hidden": false, "presentable": false, "required": false, "system": false,
|
||||
"autogeneratePattern": "", "min": 0, "max": 0, "pattern": ""
|
||||
}))
|
||||
app.save(c)
|
||||
}
|
||||
|
||||
// einzuege: -stripe_payment_intent_id; status bezahlt→eingezogen
|
||||
{
|
||||
const c = app.findCollectionByNameOrId("pbc_659326735")
|
||||
c.fields.removeById("text4235393406") // stripe_payment_intent_id
|
||||
const status = c.fields.getById("select2063623452")
|
||||
status.values = ["ausstehend", "eingezogen", "fehlgeschlagen", "storniert"]
|
||||
app.save(c)
|
||||
}
|
||||
|
||||
// termine: +beschreibung
|
||||
{
|
||||
const c = app.findCollectionByNameOrId("pbc_2279568741")
|
||||
c.fields.addAt(99, new Field({
|
||||
"type": "text", "id": "text2001000030", "name": "beschreibung",
|
||||
"help": "", "hidden": false, "presentable": false, "required": false, "system": false,
|
||||
"autogeneratePattern": "", "min": 0, "max": 0, "pattern": ""
|
||||
}))
|
||||
app.save(c)
|
||||
}
|
||||
|
||||
// nachrichten: +autor_id (relation zu users)
|
||||
{
|
||||
const c = app.findCollectionByNameOrId("pbc_1415911511")
|
||||
c.fields.addAt(2, new Field({
|
||||
"type": "relation", "id": "relation2001000040", "name": "autor_id",
|
||||
"help": "", "hidden": false, "presentable": false, "required": false, "system": false,
|
||||
"cascadeDelete": false, "collectionId": "_pb_users_auth_", "maxSelect": 1, "minSelect": 0
|
||||
}))
|
||||
app.save(c)
|
||||
}
|
||||
|
||||
}, (app) => {
|
||||
|
||||
// vereine rollback
|
||||
{
|
||||
const c = app.findCollectionByNameOrId("pbc_3589557411")
|
||||
c.fields.removeById("text2001000001")
|
||||
c.fields.removeById("bool2001000002")
|
||||
c.fields.addAt(99, new Field({
|
||||
"type": "text", "id": "text1888339527", "name": "stripe_customer_id",
|
||||
"help": "", "hidden": false, "presentable": false, "required": false, "system": false,
|
||||
"autogeneratePattern": "", "min": 0, "max": 0, "pattern": ""
|
||||
}))
|
||||
app.save(c)
|
||||
}
|
||||
|
||||
// gruppen rollback
|
||||
{
|
||||
const c = app.findCollectionByNameOrId("pbc_3099069179")
|
||||
c.fields.removeById("text2001000003")
|
||||
app.save(c)
|
||||
}
|
||||
|
||||
// mitglieder rollback
|
||||
{
|
||||
const c = app.findCollectionByNameOrId("pbc_2707111162")
|
||||
for (const id of ["text2001000010","date2001000011","date2001000012","date2001000013",
|
||||
"text2001000014","text2001000015","text2001000016","text2001000017","text2001000018"]) {
|
||||
c.fields.removeById(id)
|
||||
}
|
||||
app.save(c)
|
||||
}
|
||||
|
||||
// beitraege rollback
|
||||
{
|
||||
const c = app.findCollectionByNameOrId("pbc_3218207135")
|
||||
const rhythmus = c.fields.getById("select917011370")
|
||||
rhythmus.values = ["monatlich", "quartalsweise", "jaehrlich", "einmalig"]
|
||||
c.fields.removeById("text2001000020")
|
||||
app.save(c)
|
||||
}
|
||||
|
||||
// einzuege rollback
|
||||
{
|
||||
const c = app.findCollectionByNameOrId("pbc_659326735")
|
||||
const status = c.fields.getById("select2063623452")
|
||||
status.values = ["ausstehend", "bezahlt", "fehlgeschlagen", "storniert"]
|
||||
c.fields.addAt(99, new Field({
|
||||
"type": "text", "id": "text4235393406", "name": "stripe_payment_intent_id",
|
||||
"help": "", "hidden": false, "presentable": false, "required": false, "system": false,
|
||||
"autogeneratePattern": "", "min": 0, "max": 0, "pattern": ""
|
||||
}))
|
||||
app.save(c)
|
||||
}
|
||||
|
||||
// termine rollback
|
||||
{
|
||||
const c = app.findCollectionByNameOrId("pbc_2279568741")
|
||||
c.fields.removeById("text2001000030")
|
||||
app.save(c)
|
||||
}
|
||||
|
||||
// nachrichten rollback
|
||||
{
|
||||
const c = app.findCollectionByNameOrId("pbc_1415911511")
|
||||
c.fields.removeById("relation2001000040")
|
||||
app.save(c)
|
||||
}
|
||||
|
||||
})
|
||||
58
pocketbase/pb_migrations/1779230100_sepa_fields.js
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
/// <reference path="../pb_data/types.d.ts" />
|
||||
migrate((app) => {
|
||||
|
||||
// vereine: +glaeubigerid, +iban, +bic (Vereinskonto für SEPA-Einzug)
|
||||
{
|
||||
const c = app.findCollectionByNameOrId("pbc_3589557411")
|
||||
c.fields.addAt(99, new Field({
|
||||
"type": "text", "id": "text2001000050", "name": "glaeubigerid",
|
||||
"help": "", "hidden": false, "presentable": false, "required": false, "system": false,
|
||||
"autogeneratePattern": "", "min": 0, "max": 0, "pattern": ""
|
||||
}))
|
||||
c.fields.addAt(99, new Field({
|
||||
"type": "text", "id": "text2001000051", "name": "iban",
|
||||
"help": "", "hidden": false, "presentable": false, "required": false, "system": false,
|
||||
"autogeneratePattern": "", "min": 0, "max": 0, "pattern": ""
|
||||
}))
|
||||
c.fields.addAt(99, new Field({
|
||||
"type": "text", "id": "text2001000052", "name": "bic",
|
||||
"help": "", "hidden": false, "presentable": false, "required": false, "system": false,
|
||||
"autogeneratePattern": "", "min": 0, "max": 0, "pattern": ""
|
||||
}))
|
||||
app.save(c)
|
||||
}
|
||||
|
||||
// mitglieder: +mandatsreferenz, +mandatsdatum (SEPA-Mandat des Mitglieds)
|
||||
{
|
||||
const c = app.findCollectionByNameOrId("pbc_2707111162")
|
||||
c.fields.addAt(99, new Field({
|
||||
"type": "text", "id": "text2001000053", "name": "mandatsreferenz",
|
||||
"help": "", "hidden": false, "presentable": false, "required": false, "system": false,
|
||||
"autogeneratePattern": "", "min": 0, "max": 0, "pattern": ""
|
||||
}))
|
||||
c.fields.addAt(99, new Field({
|
||||
"type": "date", "id": "date2001000054", "name": "mandatsdatum",
|
||||
"help": "", "hidden": false, "presentable": false, "required": false, "system": false,
|
||||
"min": "", "max": ""
|
||||
}))
|
||||
app.save(c)
|
||||
}
|
||||
|
||||
}, (app) => {
|
||||
|
||||
{
|
||||
const c = app.findCollectionByNameOrId("pbc_3589557411")
|
||||
c.fields.removeById("text2001000050")
|
||||
c.fields.removeById("text2001000051")
|
||||
c.fields.removeById("text2001000052")
|
||||
app.save(c)
|
||||
}
|
||||
|
||||
{
|
||||
const c = app.findCollectionByNameOrId("pbc_2707111162")
|
||||
c.fields.removeById("text2001000053")
|
||||
c.fields.removeById("date2001000054")
|
||||
app.save(c)
|
||||
}
|
||||
|
||||
})
|
||||
|
|
@ -0,0 +1,14 @@
|
|||
/// <reference path="../pb_data/types.d.ts" />
|
||||
// Erlaubt allen Usern desselben Vereins, die Subscriptions ihrer Vereinsmitglieder zu lesen
|
||||
// (notwendig damit die /api/push/senden Route alle Geräte des Vereins erreicht)
|
||||
migrate((app) => {
|
||||
const c = app.findCollectionByNameOrId("pbc_1438754935") // push_subscriptions
|
||||
c.listRule = '@request.auth.verein_id = user_id.verein_id'
|
||||
c.viewRule = '@request.auth.id = user_id'
|
||||
app.save(c)
|
||||
}, (app) => {
|
||||
const c = app.findCollectionByNameOrId("pbc_1438754935")
|
||||
c.listRule = '@request.auth.id = user_id'
|
||||
c.viewRule = '@request.auth.id = user_id'
|
||||
app.save(c)
|
||||
})
|
||||
11
pocketbase/pb_migrations/1779230300_vereine_create_rule.js
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
/// <reference path="../pb_data/types.d.ts" />
|
||||
// Erlaubt eingeloggten Nutzern, einen Verein anzulegen (Onboarding)
|
||||
migrate((app) => {
|
||||
const c = app.findCollectionByNameOrId("pbc_3589557411") // vereine
|
||||
c.createRule = "@request.auth.id != ''"
|
||||
app.save(c)
|
||||
}, (app) => {
|
||||
const c = app.findCollectionByNameOrId("pbc_3589557411")
|
||||
c.createRule = null
|
||||
app.save(c)
|
||||
})
|
||||
26
pocketbase/pb_migrations/1779230400_verein_kontakt.js
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
/// <reference path="../pb_data/types.d.ts" />
|
||||
migrate((app) => {
|
||||
const c = app.findCollectionByNameOrId("pbc_3589557411") // vereine
|
||||
c.fields.addAt(99, new Field({
|
||||
"type": "email", "id": "email2001000060", "name": "email",
|
||||
"help": "", "hidden": false, "presentable": false, "required": false, "system": false,
|
||||
"exceptDomains": null, "onlyDomains": null
|
||||
}))
|
||||
c.fields.addAt(99, new Field({
|
||||
"type": "text", "id": "text2001000061", "name": "telefon",
|
||||
"help": "", "hidden": false, "presentable": false, "required": false, "system": false,
|
||||
"autogeneratePattern": "", "min": 0, "max": 0, "pattern": ""
|
||||
}))
|
||||
c.fields.addAt(99, new Field({
|
||||
"type": "url", "id": "url2001000062", "name": "website",
|
||||
"help": "", "hidden": false, "presentable": false, "required": false, "system": false,
|
||||
"exceptDomains": null, "onlyDomains": null
|
||||
}))
|
||||
app.save(c)
|
||||
}, (app) => {
|
||||
const c = app.findCollectionByNameOrId("pbc_3589557411")
|
||||
c.fields.removeById("email2001000060")
|
||||
c.fields.removeById("text2001000061")
|
||||
c.fields.removeById("url2001000062")
|
||||
app.save(c)
|
||||
})
|
||||
92
pocketbase/pb_migrations/1779230500_rollen.js
Normal file
|
|
@ -0,0 +1,92 @@
|
|||
/// <reference path="../pb_data/types.d.ts" />
|
||||
migrate((app) => {
|
||||
|
||||
// Users: +rolle, listRule für verein-weite Sichtbarkeit
|
||||
{
|
||||
const c = app.findCollectionByNameOrId("_pb_users_auth_")
|
||||
c.fields.addAt(99, new Field({
|
||||
"type": "select", "id": "select2001000070", "name": "rolle",
|
||||
"help": "", "hidden": false, "presentable": false, "required": false, "system": false,
|
||||
"maxSelect": 1, "values": ["admin", "trainer"]
|
||||
}))
|
||||
c.listRule = "@request.auth.verein_id = verein_id"
|
||||
app.save(c)
|
||||
}
|
||||
|
||||
// Gruppen: +trainer_ids (welche User betreuen diese Gruppe)
|
||||
{
|
||||
const c = app.findCollectionByNameOrId("pbc_3099069179")
|
||||
c.fields.addAt(99, new Field({
|
||||
"type": "relation", "id": "relation2001000071", "name": "trainer_ids",
|
||||
"help": "", "hidden": false, "presentable": false, "required": false, "system": false,
|
||||
"cascadeDelete": false, "collectionId": "_pb_users_auth_",
|
||||
"maxSelect": 99, "minSelect": 0
|
||||
}))
|
||||
app.save(c)
|
||||
}
|
||||
|
||||
// Einladungen-Collection
|
||||
{
|
||||
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": "",
|
||||
"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": "relation2001000072", "name": "verein_id",
|
||||
"help": "", "hidden": false, "presentable": false, "required": true, "system": false,
|
||||
"cascadeDelete": true, "collectionId": "pbc_3589557411", "maxSelect": 1, "minSelect": 0
|
||||
},
|
||||
{
|
||||
"type": "select", "id": "select2001000073", "name": "rolle",
|
||||
"help": "", "hidden": false, "presentable": false, "required": true, "system": false,
|
||||
"maxSelect": 1, "values": ["admin", "trainer"]
|
||||
},
|
||||
{
|
||||
"type": "text", "id": "text2001000074", "name": "token",
|
||||
"help": "", "hidden": false, "presentable": false, "required": true, "system": false,
|
||||
"autogeneratePattern": "", "min": 0, "max": 0, "pattern": ""
|
||||
},
|
||||
{
|
||||
"type": "bool", "id": "bool2001000075", "name": "genutzt",
|
||||
"help": "", "hidden": false, "presentable": false, "required": false, "system": false
|
||||
}
|
||||
],
|
||||
"id": "pbc_einladungen",
|
||||
"indexes": ["CREATE UNIQUE INDEX idx_einladungen_token ON einladungen (token)"],
|
||||
"name": "einladungen",
|
||||
"system": false,
|
||||
"type": "base"
|
||||
})
|
||||
app.save(c)
|
||||
}
|
||||
|
||||
}, (app) => {
|
||||
|
||||
{
|
||||
const c = app.findCollectionByNameOrId("_pb_users_auth_")
|
||||
c.fields.removeById("select2001000070")
|
||||
c.listRule = ""
|
||||
app.save(c)
|
||||
}
|
||||
|
||||
{
|
||||
const c = app.findCollectionByNameOrId("pbc_3099069179")
|
||||
c.fields.removeById("relation2001000071")
|
||||
app.save(c)
|
||||
}
|
||||
|
||||
{
|
||||
const c = app.findCollectionByNameOrId("pbc_einladungen")
|
||||
app.delete(c)
|
||||
}
|
||||
|
||||
})
|
||||