From 8ee8f52e0fed01bc5e4df64dcfd7fe6ebaa24912 Mon Sep 17 00:00:00 2001 From: Lars Date: Sun, 7 Jun 2026 07:09:39 +0200 Subject: [PATCH] Add Club Creation Request Management Features - Introduced endpoints for managing club creation requests, including fetching, creating, and withdrawing requests. - Updated the onboarding page to allow users to submit new club creation requests and view their existing requests. - Enhanced the admin interface with navigation and routing for club creation requests management. - Incremented version to 0.8.191 to reflect these new features and updates in the application. --- backend/account_onboarding_gate.py | 1 + backend/main.py | 3 +- .../migrations/080_club_creation_requests.sql | 41 ++ backend/routers/club_creation_requests.py | 390 ++++++++++++++++++ backend/tests/test_account_onboarding_gate.py | 10 + backend/version.py | 7 +- frontend/src/App.jsx | 9 + frontend/src/components/AdminPageNav.jsx | 3 +- .../pages/AdminClubCreationRequestsPage.jsx | 169 ++++++++ frontend/src/pages/OnboardingPage.jsx | 177 +++++++- frontend/src/utils/api.js | 35 ++ 11 files changed, 836 insertions(+), 9 deletions(-) create mode 100644 backend/migrations/080_club_creation_requests.sql create mode 100644 backend/routers/club_creation_requests.py create mode 100644 frontend/src/pages/AdminClubCreationRequestsPage.jsx diff --git a/backend/account_onboarding_gate.py b/backend/account_onboarding_gate.py index a3207cd..b643b10 100644 --- a/backend/account_onboarding_gate.py +++ b/backend/account_onboarding_gate.py @@ -40,6 +40,7 @@ AUTH_INFRA_PREFIXES = ( # Zusätzlich für verified_pending_club (Verein bewerben) PENDING_CLUB_PREFIXES = ( "/api/me/club-join-requests", + "/api/me/club-creation-requests", ) _PROFILE_MUTATION_RE = re.compile(r"^/api/profiles/(\d+)$") diff --git a/backend/main.py b/backend/main.py index 331c067..5d2ba17 100644 --- a/backend/main.py +++ b/backend/main.py @@ -221,7 +221,7 @@ def read_root(): return out # Register routers -from routers import auth, profiles, exercises, exercise_progression_graphs, clubs, club_memberships, club_join_requests, admin_users, admin_user_content, me_entitlements, platform_media_storage, media_assets, skills, skill_profiles, training_planning, planning_exercise_suggest, dashboard, training_modules, training_framework_programs, catalogs, maturity_models, matrix_stack_bundle, matrix_editor, import_wiki, import_wiki_admin, legal_documents, content_reports, ai_prompts_admin, ai_skill_retrieval_admin, exercise_enrichment_admin +from routers import auth, profiles, exercises, exercise_progression_graphs, clubs, club_memberships, club_join_requests, club_creation_requests, admin_users, admin_user_content, me_entitlements, platform_media_storage, media_assets, skills, skill_profiles, training_planning, planning_exercise_suggest, dashboard, training_modules, training_framework_programs, catalogs, maturity_models, matrix_stack_bundle, matrix_editor, import_wiki, import_wiki_admin, legal_documents, content_reports, ai_prompts_admin, ai_skill_retrieval_admin, exercise_enrichment_admin app.include_router(auth.router) app.include_router(profiles.router) @@ -230,6 +230,7 @@ app.include_router(exercise_progression_graphs.router) app.include_router(clubs.router) app.include_router(club_memberships.router) app.include_router(club_join_requests.router) +app.include_router(club_creation_requests.router) app.include_router(admin_users.router) app.include_router(admin_user_content.router) app.include_router(me_entitlements.router) diff --git a/backend/migrations/080_club_creation_requests.sql b/backend/migrations/080_club_creation_requests.sql new file mode 100644 index 0000000..7b68baf --- /dev/null +++ b/backend/migrations/080_club_creation_requests.sql @@ -0,0 +1,41 @@ +-- Migration 080: Antrag auf Vereinsgründung (M7) +-- Nutzer verified_pending_club stellt Antrag; Plattform-Admin legt Verein + Abo an. + +CREATE TABLE IF NOT EXISTS club_creation_requests ( + id SERIAL PRIMARY KEY, + profile_id INT NOT NULL REFERENCES profiles(id) ON DELETE CASCADE, + proposed_name VARCHAR(200) NOT NULL, + proposed_abbreviation VARCHAR(50), + proposed_description TEXT, + message TEXT, + status VARCHAR(20) NOT NULL DEFAULT 'pending' + CHECK (status IN ('pending', 'approved', 'rejected', 'withdrawn')), + decided_by_profile_id INT REFERENCES profiles(id) ON DELETE SET NULL, + decided_at TIMESTAMP, + created_club_id INT REFERENCES clubs(id) ON DELETE SET NULL, + created_at TIMESTAMP DEFAULT NOW(), + updated_at TIMESTAMP DEFAULT NOW() +); + +CREATE UNIQUE INDEX IF NOT EXISTS uq_club_creation_requests_pending + ON club_creation_requests (profile_id) + WHERE status = 'pending'; + +CREATE INDEX IF NOT EXISTS idx_club_creation_requests_status + ON club_creation_requests (status, created_at); + +CREATE INDEX IF NOT EXISTS idx_club_creation_requests_profile + ON club_creation_requests (profile_id); + +DROP TRIGGER IF EXISTS club_creation_requests_update ON club_creation_requests; +CREATE TRIGGER club_creation_requests_update + BEFORE UPDATE ON club_creation_requests + FOR EACH ROW EXECUTE FUNCTION update_timestamp(); + +-- Capabilities (CAPABILITY_CATALOG.v1.md — club.creation_request.*) +INSERT INTO capabilities (id, name, domain, min_account_state, linked_feature_id) +VALUES + ('club.creation_request.create', 'Vereinsgründung beantragen', 'club', 'verified_pending_club', NULL), + ('club.creation_request.read_own', 'Eigene Gründungsanträge', 'club', 'verified_pending_club', NULL), + ('club.creation_request.withdraw', 'Gründungsantrag zurückziehen', 'club', 'verified_pending_club', NULL) +ON CONFLICT (id) DO NOTHING; diff --git a/backend/routers/club_creation_requests.py b/backend/routers/club_creation_requests.py new file mode 100644 index 0000000..fb09e03 --- /dev/null +++ b/backend/routers/club_creation_requests.py @@ -0,0 +1,390 @@ +""" +Anträge auf Vereinsgründung: Nutzer stellt Antrag, Plattform-Admin legt Verein + Abo an. +""" +from typing import Any, Dict, Optional + +from fastapi import APIRouter, Depends, HTTPException +from pydantic import BaseModel, Field + +from account_lifecycle import assert_min_account_state +from capabilities import probe_capability +from club_tenancy import is_platform_admin +from db import get_db, get_cursor, r2d +from tenant_context import TenantContext, get_tenant_context + +router = APIRouter(prefix="/api", tags=["club_creation_requests"]) + +_FREE_PLAN_ID = "free" + + +def _has_active_membership(cur, profile_id: int) -> bool: + cur.execute( + """ + SELECT 1 FROM club_members + WHERE profile_id = %s AND status = 'active' + LIMIT 1 + """, + (profile_id,), + ) + return cur.fetchone() is not None + + +def _club_name_taken(cur, name: str, *, exclude_club_id: Optional[int] = None) -> bool: + n = (name or "").strip() + if not n: + return False + if exclude_club_id is not None: + cur.execute( + """ + SELECT 1 FROM clubs + WHERE lower(trim(name)) = lower(trim(%s)) AND id <> %s + LIMIT 1 + """, + (n, exclude_club_id), + ) + else: + cur.execute( + """ + SELECT 1 FROM clubs + WHERE lower(trim(name)) = lower(trim(%s)) + LIMIT 1 + """, + (n,), + ) + return cur.fetchone() is not None + + +def _provision_club_for_founder( + cur, + *, + founder_profile_id: int, + name: str, + abbreviation: Optional[str], + description: Optional[str], +) -> int: + """Legt Verein, Mitgliedschaft (club_admin+trainer) und Free-Abo an.""" + cur.execute( + """ + INSERT INTO clubs (name, abbreviation, description, status, primary_admin_profile_id) + VALUES (%s, %s, %s, 'active', %s) + RETURNING id + """, + (name, abbreviation, description, founder_profile_id), + ) + club_id = int(cur.fetchone()["id"]) + + cur.execute( + """ + INSERT INTO club_members (profile_id, club_id, status) + VALUES (%s, %s, 'active') + ON CONFLICT (profile_id, club_id) + DO UPDATE SET status = 'active', updated_at = NOW() + RETURNING id + """, + (founder_profile_id, club_id), + ) + cm_id = cur.fetchone()["id"] + for rc in ("club_admin", "trainer"): + cur.execute( + """ + INSERT INTO club_member_roles (club_member_id, role_code) + VALUES (%s, %s) + ON CONFLICT (club_member_id, role_code) DO NOTHING + """, + (cm_id, rc), + ) + + cur.execute( + """ + INSERT INTO club_subscriptions (club_id, plan_id, status) + VALUES (%s, %s, 'active') + ON CONFLICT (club_id) DO NOTHING + """, + (club_id, _FREE_PLAN_ID), + ) + return club_id + + +class CreationRequestCreate(BaseModel): + proposed_name: str = Field(..., min_length=2, max_length=200) + proposed_abbreviation: Optional[str] = Field(None, max_length=50) + proposed_description: Optional[str] = Field(None, max_length=5000) + message: Optional[str] = Field(None, max_length=2000) + + +def _response_one(cur, req_id: int, viewer_profile_id: int) -> Dict[str, Any]: + cur.execute( + """ + SELECT r.*, c.name AS created_club_name + FROM club_creation_requests r + LEFT JOIN clubs c ON c.id = r.created_club_id + WHERE r.id = %s AND r.profile_id = %s + """, + (req_id, viewer_profile_id), + ) + row = cur.fetchone() + if not row: + raise HTTPException(status_code=404, detail="Antrag nicht gefunden") + return r2d(row) + + +def _assert_platform_admin(tenant: TenantContext) -> None: + if not is_platform_admin(tenant.global_role): + raise HTTPException(status_code=403, detail="Nur Plattform-Administratoren") + + +@router.get("/me/club-creation-requests") +def get_my_creation_requests(tenant: TenantContext = Depends(get_tenant_context)): + assert_min_account_state(tenant, "verified_pending_club", endpoint="GET /me/club-creation-requests") + pid = tenant.profile_id + with get_db() as conn: + probe_capability( + tenant, + "club.creation_request.read_own", + action="read", + endpoint="GET /me/club-creation-requests", + conn=conn, + ) + cur = get_cursor(conn) + cur.execute( + """ + SELECT r.*, c.name AS created_club_name + FROM club_creation_requests r + LEFT JOIN clubs c ON c.id = r.created_club_id + WHERE r.profile_id = %s + ORDER BY r.created_at DESC + LIMIT 50 + """, + (pid,), + ) + return [r2d(r) for r in cur.fetchall()] + + +@router.post("/me/club-creation-requests", status_code=201) +def create_my_creation_request( + body: CreationRequestCreate, + tenant: TenantContext = Depends(get_tenant_context), +): + assert_min_account_state(tenant, "verified_pending_club", endpoint="POST /me/club-creation-requests") + pid = tenant.profile_id + name = body.proposed_name.strip() + abbr = (body.proposed_abbreviation or "").strip() or None + desc = (body.proposed_description or "").strip() or None + msg = (body.message or "").strip() or None + + with get_db() as conn: + probe_capability( + tenant, + "club.creation_request.create", + action="create", + endpoint="POST /me/club-creation-requests", + conn=conn, + ) + cur = get_cursor(conn) + + if _has_active_membership(cur, pid): + raise HTTPException( + status_code=400, + detail="Du bist bereits Vereinsmitglied — Gründungsantrag nicht möglich", + ) + + cur.execute( + """ + SELECT id FROM club_creation_requests + WHERE profile_id = %s AND status = 'pending' + LIMIT 1 + """, + (pid,), + ) + if cur.fetchone(): + raise HTTPException(status_code=409, detail="Es liegt bereits ein offener Gründungsantrag vor") + + if _club_name_taken(cur, name): + raise HTTPException( + status_code=409, + detail="Ein Verein mit diesem Namen existiert bereits — bitte anderen Namen wählen", + ) + + cur.execute( + """ + INSERT INTO club_creation_requests ( + profile_id, proposed_name, proposed_abbreviation, + proposed_description, message, status + ) + VALUES (%s, %s, %s, %s, %s, 'pending') + RETURNING id + """, + (pid, name, abbr, desc, msg), + ) + rid = cur.fetchone()["id"] + conn.commit() + + with get_db() as conn: + cur = get_cursor(conn) + return _response_one(cur, rid, pid) + + +@router.delete("/me/club-creation-requests/{request_id}") +def withdraw_my_creation_request(request_id: int, tenant: TenantContext = Depends(get_tenant_context)): + assert_min_account_state( + tenant, "verified_pending_club", endpoint="DELETE /me/club-creation-requests/{id}" + ) + pid = tenant.profile_id + with get_db() as conn: + probe_capability( + tenant, + "club.creation_request.withdraw", + action="withdraw", + endpoint="DELETE /me/club-creation-requests/{id}", + conn=conn, + ) + cur = get_cursor(conn) + cur.execute( + """ + UPDATE club_creation_requests + SET status = 'withdrawn', updated_at = NOW() + WHERE id = %s AND profile_id = %s AND status = 'pending' + RETURNING id + """, + (request_id, pid), + ) + if not cur.fetchone(): + raise HTTPException(status_code=404, detail="Offener Antrag nicht gefunden") + conn.commit() + return {"ok": True} + + +@router.get("/admin/club-creation-requests") +def list_admin_creation_requests(tenant: TenantContext = Depends(get_tenant_context)): + _assert_platform_admin(tenant) + with get_db() as conn: + probe_capability( + tenant, + "platform.club_creation.approve", + action="list", + endpoint="GET /admin/club-creation-requests", + conn=conn, + ) + cur = get_cursor(conn) + cur.execute( + """ + SELECT r.*, + p.name AS applicant_name, + p.email AS applicant_email, + c.name AS created_club_name + FROM club_creation_requests r + INNER JOIN profiles p ON p.id = r.profile_id + LEFT JOIN clubs c ON c.id = r.created_club_id + WHERE r.status = 'pending' + ORDER BY r.created_at ASC + """ + ) + return [r2d(row) for row in cur.fetchall()] + + +@router.post("/admin/club-creation-requests/{request_id}/approve") +def approve_creation_request(request_id: int, tenant: TenantContext = Depends(get_tenant_context)): + _assert_platform_admin(tenant) + admin_pid = tenant.profile_id + + with get_db() as conn: + probe_capability( + tenant, + "platform.club_creation.approve", + action="approve", + endpoint="POST /admin/club-creation-requests/{id}/approve", + conn=conn, + ) + cur = get_cursor(conn) + + cur.execute( + """ + SELECT id, profile_id, proposed_name, proposed_abbreviation, + proposed_description, status + FROM club_creation_requests + WHERE id = %s + """, + (request_id,), + ) + row = cur.fetchone() + if not row: + raise HTTPException(status_code=404, detail="Antrag nicht gefunden") + if row["status"] != "pending": + raise HTTPException(status_code=400, detail="Antrag ist nicht mehr offen") + + applicant_id = int(row["profile_id"]) + name = (row["proposed_name"] or "").strip() + if not name: + raise HTTPException(status_code=400, detail="Vorgeschlagener Vereinsname fehlt") + + if _has_active_membership(cur, applicant_id): + raise HTTPException( + status_code=409, + detail="Antragsteller ist bereits Vereinsmitglied — Freigabe nicht möglich", + ) + + if _club_name_taken(cur, name): + raise HTTPException( + status_code=409, + detail="Ein Verein mit diesem Namen existiert bereits", + ) + + club_id = _provision_club_for_founder( + cur, + founder_profile_id=applicant_id, + name=name, + abbreviation=row.get("proposed_abbreviation"), + description=row.get("proposed_description"), + ) + + cur.execute( + """ + UPDATE club_creation_requests + SET status = 'approved', + decided_by_profile_id = %s, + decided_at = NOW(), + created_club_id = %s, + updated_at = NOW() + WHERE id = %s AND status = 'pending' + RETURNING id + """, + (admin_pid, club_id, request_id), + ) + if not cur.fetchone(): + raise HTTPException(status_code=409, detail="Antrag konnte nicht freigegeben werden") + + conn.commit() + + return {"ok": True, "club_id": club_id, "profile_id": applicant_id} + + +@router.post("/admin/club-creation-requests/{request_id}/reject") +def reject_creation_request(request_id: int, tenant: TenantContext = Depends(get_tenant_context)): + _assert_platform_admin(tenant) + admin_pid = tenant.profile_id + + with get_db() as conn: + probe_capability( + tenant, + "platform.club_creation.approve", + action="reject", + endpoint="POST /admin/club-creation-requests/{id}/reject", + conn=conn, + ) + cur = get_cursor(conn) + cur.execute( + """ + UPDATE club_creation_requests + SET status = 'rejected', + decided_by_profile_id = %s, + decided_at = NOW(), + updated_at = NOW() + WHERE id = %s AND status = 'pending' + RETURNING id + """, + (admin_pid, request_id), + ) + if not cur.fetchone(): + raise HTTPException(status_code=404, detail="Offener Antrag nicht gefunden") + conn.commit() + return {"ok": True} diff --git a/backend/tests/test_account_onboarding_gate.py b/backend/tests/test_account_onboarding_gate.py index e5c2aa6..0eee794 100644 --- a/backend/tests/test_account_onboarding_gate.py +++ b/backend/tests/test_account_onboarding_gate.py @@ -33,6 +33,16 @@ def test_join_request_allowed_for_pending(): assert allowed +def test_creation_request_allowed_for_pending(): + allowed, _ = check_api_onboarding_gate( + path="/api/me/club-creation-requests", + method="POST", + profile_id=1, + account_state="verified_pending_club", + ) + assert allowed + + def test_active_member_domain_ok(): allowed, reason = check_api_onboarding_gate( path="/api/exercises", diff --git a/backend/version.py b/backend/version.py index e9d351d..d74872c 100644 --- a/backend/version.py +++ b/backend/version.py @@ -1,8 +1,8 @@ # Shinkan Jinkendo Version Information -APP_VERSION = "0.8.190" -BUILD_DATE = "2026-05-23" -DB_SCHEMA_VERSION = "20260606079" +APP_VERSION = "0.8.191" +BUILD_DATE = "2026-06-06" +DB_SCHEMA_VERSION = "20260606080" MODULE_VERSIONS = { "legal_documents": "1.4.0", # Admin: Live-Vorschau pro Abschnitt + modale Vollvorschau (Editor + Dokumentenliste) @@ -14,6 +14,7 @@ MODULE_VERSIONS = { "clubs": "0.4.1", # Alle geschützten Endpoints Depends(get_tenant_context); profile_id/role aus TenantContext "club_memberships": "1.0.1", # Depends(get_tenant_context) "club_join_requests": "1.0.1", # Depends(get_tenant_context) + "club_creation_requests": "1.0.0", # M7: Gründungsanträge + Admin-Freigabe "admin_users": "1.0.0", # GET /api/admin/users "club_features": "1.2.0", # M4: club_features_map für /me/entitlements "entitlements": "1.0.0", # GET /api/me/entitlements — capabilities + features diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index f1feb8c..a0e100d 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -55,6 +55,7 @@ const AdminMaturityModelsPage = lazy(() => import('./pages/AdminMaturityModelsPa const TrainerContextsPage = lazy(() => import('./pages/TrainerContextsPage')) const MediaWikiImportPage = lazy(() => import('./pages/MediaWikiImportPage')) const AdminUsersPage = lazy(() => import('./pages/AdminUsersPage')) +const AdminClubCreationRequestsPage = lazy(() => import('./pages/AdminClubCreationRequestsPage')) const MediaLibraryPage = lazy(() => import('./pages/MediaLibraryPage')) const LegalPage = lazy(() => import('./pages/LegalPage')) const AdminLegalDocumentsPage = lazy(() => import('./pages/AdminLegalDocumentsPage')) @@ -282,6 +283,14 @@ const appRouter = createBrowserRouter([ ), }, + { + path: 'admin/club-creation-requests', + element: ( + + + + ), + }, { path: 'admin/hierarchy', element: ( diff --git a/frontend/src/components/AdminPageNav.jsx b/frontend/src/components/AdminPageNav.jsx index f2afc9c..dafba90 100644 --- a/frontend/src/components/AdminPageNav.jsx +++ b/frontend/src/components/AdminPageNav.jsx @@ -1,5 +1,5 @@ import { NavLink } from 'react-router-dom' -import { TreePine, FolderTree, Download, Grid3x3, Users, Scale, Sparkles, Wand2, Activity } from 'lucide-react' +import { TreePine, FolderTree, Download, Grid3x3, Users, Scale, Sparkles, Wand2, Activity, Building2 } from 'lucide-react' /** * Admin-Seiten-Navigation (horizontal) — nur für Super-Admins (globaler Portal-Mandant). @@ -8,6 +8,7 @@ export default function AdminPageNav() { const pages = [ { to: '/admin/hierarchy', label: 'Hierarchie', icon: TreePine }, { to: '/admin/users', label: 'Nutzer', icon: Users }, + { to: '/admin/club-creation-requests', label: 'Vereinsgründungen', icon: Building2 }, { to: '/admin/user-content', label: 'Nutzer-Inhalte', icon: Activity }, { to: '/admin/maturity-models', label: 'Fähigkeitsmatrix', icon: Grid3x3 }, { to: '/admin/catalogs', label: 'Kataloge', icon: FolderTree }, diff --git a/frontend/src/pages/AdminClubCreationRequestsPage.jsx b/frontend/src/pages/AdminClubCreationRequestsPage.jsx new file mode 100644 index 0000000..652f0be --- /dev/null +++ b/frontend/src/pages/AdminClubCreationRequestsPage.jsx @@ -0,0 +1,169 @@ +import { useCallback, useEffect, useState } from 'react' +import { Navigate } from 'react-router-dom' +import { useAuth } from '../context/AuthContext' +import api from '../utils/api' +import AdminPageNav from '../components/AdminPageNav' + +function formatDate(value) { + if (!value) return '—' + try { + return new Date(value).toLocaleString('de-DE', { + day: '2-digit', + month: '2-digit', + year: 'numeric', + hour: '2-digit', + minute: '2-digit', + }) + } catch { + return String(value) + } +} + +/** + * Superadmin: offene Anträge auf Vereinsgründung freigeben oder ablehnen. + */ +export default function AdminClubCreationRequestsPage() { + const { user } = useAuth() + const isSuperadmin = user?.role === 'superadmin' + + const [requests, setRequests] = useState([]) + const [loading, setLoading] = useState(true) + const [error, setError] = useState('') + const [busyId, setBusyId] = useState(null) + + const load = useCallback(async () => { + const rows = await api.listAdminClubCreationRequests() + setRequests(Array.isArray(rows) ? rows : []) + }, []) + + useEffect(() => { + if (!isSuperadmin) return + let cancelled = false + ;(async () => { + setError('') + setLoading(true) + try { + await load() + } catch (e) { + if (!cancelled) setError(e.message || String(e)) + } finally { + if (!cancelled) setLoading(false) + } + })() + return () => { + cancelled = true + } + }, [isSuperadmin, load]) + + if (!isSuperadmin) return + + const handleApprove = async (id) => { + if (!confirm('Verein anlegen und Antragsteller als Hauptverwalter eintragen?')) return + setBusyId(id) + setError('') + try { + await api.approveClubCreationRequest(id) + await load() + } catch (e) { + setError(e.message || String(e)) + } finally { + setBusyId(null) + } + } + + const handleReject = async (id) => { + if (!confirm('Gründungsantrag wirklich ablehnen?')) return + setBusyId(id) + setError('') + try { + await api.rejectClubCreationRequest(id) + await load() + } catch (e) { + setError(e.message || String(e)) + } finally { + setBusyId(null) + } + } + + return ( +
+ +

Vereinsgründungen

+

+ Offene Anträge von verifizierten Nutzern ohne Vereinsmitgliedschaft. Bei Freigabe wird ein + neuer Verein mit Free-Abo angelegt; der Antragsteller wird Vereinsadmin und Trainer. +

+ + {error ? ( +

+ {error} +

+ ) : null} + + {loading ? ( +

+ Laden… +

+ ) : requests.length === 0 ? ( +
+

Keine offenen Gründungsanträge.

+
+ ) : ( +
+ {requests.map((r) => ( +
+
+ {r.proposed_name} + {r.proposed_abbreviation ? ( + ({r.proposed_abbreviation}) + ) : null} +
+

+ Antragsteller: {r.applicant_name || '—'}{' '} + {r.applicant_email ? `· ${r.applicant_email}` : ''} +

+

+ Eingereicht: {formatDate(r.created_at)} +

+ {r.proposed_description ? ( +

+ {r.proposed_description} +

+ ) : null} + {r.message ? ( +

+ Nachricht: {r.message} +

+ ) : null} +
+ + +
+
+ ))} +
+ )} +
+ ) +} diff --git a/frontend/src/pages/OnboardingPage.jsx b/frontend/src/pages/OnboardingPage.jsx index 7503209..dcb8092 100644 --- a/frontend/src/pages/OnboardingPage.jsx +++ b/frontend/src/pages/OnboardingPage.jsx @@ -13,6 +13,14 @@ const joinStatusLabel = (s) => withdrawn: 'zurückgezogen', })[s] || s +const creationStatusLabel = (s) => + ({ + pending: 'ausstehend', + approved: 'freigegeben', + rejected: 'abgelehnt', + withdrawn: 'zurückgezogen', + })[s] || s + /** * Onboarding für Nutzer ohne aktive Vereinsmitgliedschaft (Phase A). */ @@ -23,6 +31,12 @@ export default function OnboardingPage() { const [joinClubId, setJoinClubId] = useState('') const [joinMessage, setJoinMessage] = useState('') const [joinBusy, setJoinBusy] = useState(false) + const [myCreationRequests, setMyCreationRequests] = useState([]) + const [createName, setCreateName] = useState('') + const [createAbbr, setCreateAbbr] = useState('') + const [createDesc, setCreateDesc] = useState('') + const [createMessage, setCreateMessage] = useState('') + const [createBusy, setCreateBusy] = useState(false) const [error, setError] = useState('') const [ok, setOk] = useState('') @@ -34,9 +48,15 @@ export default function OnboardingPage() { api.getMyClubJoinRequests().then(setMyJoinRequests).catch(() => {}) } + const refreshCreationRequests = () => { + if (!emailOk) return + api.getMyClubCreationRequests().then(setMyCreationRequests).catch(() => {}) + } + useEffect(() => { api.listPublicClubsDirectory().then(setPublicClubs).catch(() => {}) refreshJoinRequests() + refreshCreationRequests() }, [user?.id, emailOk]) const memberClubIds = new Set((user?.clubs || []).map((c) => c.id)) @@ -47,6 +67,40 @@ export default function OnboardingPage() { (c) => !memberClubIds.has(c.id) && !pendingClubIds.has(c.id) ) + const hasPendingCreation = myCreationRequests.some((r) => r.status === 'pending') + + const handleCreateClub = async (e) => { + e.preventDefault() + setError('') + setOk('') + const name = (createName || '').trim() + if (!name) { + setError('Bitte einen Vereinsnamen angeben.') + return + } + setCreateBusy(true) + try { + await api.createClubCreationRequest({ + proposed_name: name, + proposed_abbreviation: (createAbbr || '').trim() || undefined, + proposed_description: (createDesc || '').trim() || undefined, + message: (createMessage || '').trim() || undefined, + }) + setCreateName('') + setCreateAbbr('') + setCreateDesc('') + setCreateMessage('') + refreshCreationRequests() + setOk( + 'Gründungsantrag gesendet. Nach Freigabe durch den Plattform-Administrator wird dein Verein angelegt.' + ) + } catch (err) { + setError(err.message || 'Antrag fehlgeschlagen.') + } finally { + setCreateBusy(false) + } + } + const handleJoin = async (e) => { e.preventDefault() setError('') @@ -191,11 +245,126 @@ export default function OnboardingPage() {

Neuen Verein gründen

-

- Die Beantragung einer neuen Vereinsgründung wird als Nächstes freigeschaltet (Freigabe durch - den Plattform-Administrator). Bis dahin wende dich an{' '} - support@jinkendo.de oder tritt einem bestehenden Verein bei. +

+ Stelle einen Antrag auf Vereinsgründung. Nach Freigabe durch den Plattform-Administrator + wird der Verein mit Free-Abo angelegt und du wirst Hauptverwalter.

+ + {myCreationRequests.length > 0 ? ( +
+ Meine Gründungsanträge +
    + {myCreationRequests.map((r) => ( +
  • + {r.proposed_name} — {creationStatusLabel(r.status)} + {r.status === 'approved' && r.created_club_name + ? ` (${r.created_club_name})` + : null} + {r.status === 'pending' ? ( + <> + {' '} + + + ) : null} + {r.status === 'approved' ? ( + <> + {' '} + + + ) : null} +
  • + ))} +
+
+ ) : null} + + {hasPendingCreation ? ( +

+ Du hast bereits einen offenen Gründungsantrag. Bitte warte auf die Freigabe oder ziehe + den Antrag zurück. +

+ ) : ( +
+ + setCreateName(e.target.value)} + maxLength={200} + required + /> + + setCreateAbbr(e.target.value)} + maxLength={50} + /> + +