Add Club Creation Request Management Features
Some checks failed
Deploy Development / deploy (push) Successful in 44s
Test Suite / pytest-backend (push) Successful in 42s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 13s
Test Suite / k6 /health Baseline (push) Successful in 33s
Test Suite / playwright-tests (push) Failing after 1m14s

- 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.
This commit is contained in:
Lars 2026-06-07 07:09:39 +02:00
parent 8718cf5c70
commit 8ee8f52e0f
11 changed files with 836 additions and 9 deletions

View File

@ -40,6 +40,7 @@ AUTH_INFRA_PREFIXES = (
# Zusätzlich für verified_pending_club (Verein bewerben) # Zusätzlich für verified_pending_club (Verein bewerben)
PENDING_CLUB_PREFIXES = ( PENDING_CLUB_PREFIXES = (
"/api/me/club-join-requests", "/api/me/club-join-requests",
"/api/me/club-creation-requests",
) )
_PROFILE_MUTATION_RE = re.compile(r"^/api/profiles/(\d+)$") _PROFILE_MUTATION_RE = re.compile(r"^/api/profiles/(\d+)$")

View File

@ -221,7 +221,7 @@ def read_root():
return out return out
# Register routers # 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(auth.router)
app.include_router(profiles.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(clubs.router)
app.include_router(club_memberships.router) app.include_router(club_memberships.router)
app.include_router(club_join_requests.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_users.router)
app.include_router(admin_user_content.router) app.include_router(admin_user_content.router)
app.include_router(me_entitlements.router) app.include_router(me_entitlements.router)

View File

@ -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;

View File

@ -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}

View File

@ -33,6 +33,16 @@ def test_join_request_allowed_for_pending():
assert allowed 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(): def test_active_member_domain_ok():
allowed, reason = check_api_onboarding_gate( allowed, reason = check_api_onboarding_gate(
path="/api/exercises", path="/api/exercises",

View File

@ -1,8 +1,8 @@
# Shinkan Jinkendo Version Information # Shinkan Jinkendo Version Information
APP_VERSION = "0.8.190" APP_VERSION = "0.8.191"
BUILD_DATE = "2026-05-23" BUILD_DATE = "2026-06-06"
DB_SCHEMA_VERSION = "20260606079" DB_SCHEMA_VERSION = "20260606080"
MODULE_VERSIONS = { MODULE_VERSIONS = {
"legal_documents": "1.4.0", # Admin: Live-Vorschau pro Abschnitt + modale Vollvorschau (Editor + Dokumentenliste) "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 "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_memberships": "1.0.1", # Depends(get_tenant_context)
"club_join_requests": "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 "admin_users": "1.0.0", # GET /api/admin/users
"club_features": "1.2.0", # M4: club_features_map für /me/entitlements "club_features": "1.2.0", # M4: club_features_map für /me/entitlements
"entitlements": "1.0.0", # GET /api/me/entitlements — capabilities + features "entitlements": "1.0.0", # GET /api/me/entitlements — capabilities + features

View File

@ -55,6 +55,7 @@ const AdminMaturityModelsPage = lazy(() => import('./pages/AdminMaturityModelsPa
const TrainerContextsPage = lazy(() => import('./pages/TrainerContextsPage')) const TrainerContextsPage = lazy(() => import('./pages/TrainerContextsPage'))
const MediaWikiImportPage = lazy(() => import('./pages/MediaWikiImportPage')) const MediaWikiImportPage = lazy(() => import('./pages/MediaWikiImportPage'))
const AdminUsersPage = lazy(() => import('./pages/AdminUsersPage')) const AdminUsersPage = lazy(() => import('./pages/AdminUsersPage'))
const AdminClubCreationRequestsPage = lazy(() => import('./pages/AdminClubCreationRequestsPage'))
const MediaLibraryPage = lazy(() => import('./pages/MediaLibraryPage')) const MediaLibraryPage = lazy(() => import('./pages/MediaLibraryPage'))
const LegalPage = lazy(() => import('./pages/LegalPage')) const LegalPage = lazy(() => import('./pages/LegalPage'))
const AdminLegalDocumentsPage = lazy(() => import('./pages/AdminLegalDocumentsPage')) const AdminLegalDocumentsPage = lazy(() => import('./pages/AdminLegalDocumentsPage'))
@ -282,6 +283,14 @@ const appRouter = createBrowserRouter([
</PlatformAdminRoute> </PlatformAdminRoute>
), ),
}, },
{
path: 'admin/club-creation-requests',
element: (
<PlatformAdminRoute>
<AdminClubCreationRequestsPage />
</PlatformAdminRoute>
),
},
{ {
path: 'admin/hierarchy', path: 'admin/hierarchy',
element: ( element: (

View File

@ -1,5 +1,5 @@
import { NavLink } from 'react-router-dom' 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). * Admin-Seiten-Navigation (horizontal) nur für Super-Admins (globaler Portal-Mandant).
@ -8,6 +8,7 @@ export default function AdminPageNav() {
const pages = [ const pages = [
{ to: '/admin/hierarchy', label: 'Hierarchie', icon: TreePine }, { to: '/admin/hierarchy', label: 'Hierarchie', icon: TreePine },
{ to: '/admin/users', label: 'Nutzer', icon: Users }, { 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/user-content', label: 'Nutzer-Inhalte', icon: Activity },
{ to: '/admin/maturity-models', label: 'Fähigkeitsmatrix', icon: Grid3x3 }, { to: '/admin/maturity-models', label: 'Fähigkeitsmatrix', icon: Grid3x3 },
{ to: '/admin/catalogs', label: 'Kataloge', icon: FolderTree }, { to: '/admin/catalogs', label: 'Kataloge', icon: FolderTree },

View File

@ -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 <Navigate to="/" replace />
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 (
<div className="page-padding app-page">
<AdminPageNav />
<h1 style={{ marginTop: '1rem', fontSize: '1.35rem' }}>Vereinsgründungen</h1>
<p style={{ color: 'var(--text2)', maxWidth: '42rem', lineHeight: 1.5 }}>
Offene Anträge von verifizierten Nutzern ohne Vereinsmitgliedschaft. Bei Freigabe wird ein
neuer Verein mit Free-Abo angelegt; der Antragsteller wird Vereinsadmin und Trainer.
</p>
{error ? (
<p role="alert" style={{ color: 'var(--danger)' }}>
{error}
</p>
) : null}
{loading ? (
<p className="spinner" style={{ marginTop: '1rem' }}>
Laden
</p>
) : requests.length === 0 ? (
<div className="card" style={{ marginTop: '1rem' }}>
<p style={{ margin: 0, color: 'var(--text2)' }}>Keine offenen Gründungsanträge.</p>
</div>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.75rem', marginTop: '1rem' }}>
{requests.map((r) => (
<div key={r.id} className="card">
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.5rem 1rem', marginBottom: '0.5rem' }}>
<strong>{r.proposed_name}</strong>
{r.proposed_abbreviation ? (
<span style={{ color: 'var(--text2)' }}>({r.proposed_abbreviation})</span>
) : null}
</div>
<p style={{ margin: '0 0 0.35rem', fontSize: '0.9rem', color: 'var(--text2)' }}>
Antragsteller: {r.applicant_name || '—'}{' '}
{r.applicant_email ? `· ${r.applicant_email}` : ''}
</p>
<p style={{ margin: '0 0 0.35rem', fontSize: '0.85rem', color: 'var(--text3)' }}>
Eingereicht: {formatDate(r.created_at)}
</p>
{r.proposed_description ? (
<p style={{ margin: '0.5rem 0', fontSize: '0.9rem', whiteSpace: 'pre-wrap' }}>
{r.proposed_description}
</p>
) : null}
{r.message ? (
<p
style={{
margin: '0.5rem 0 0',
fontSize: '0.875rem',
color: 'var(--text2)',
fontStyle: 'italic',
}}
>
Nachricht: {r.message}
</p>
) : null}
<div style={{ display: 'flex', gap: '0.5rem', marginTop: '0.85rem', flexWrap: 'wrap' }}>
<button
type="button"
className="btn btn-primary"
disabled={busyId === r.id}
onClick={() => handleApprove(r.id)}
>
{busyId === r.id ? '…' : 'Freigeben'}
</button>
<button
type="button"
className="btn btn-secondary"
disabled={busyId === r.id}
onClick={() => handleReject(r.id)}
>
Ablehnen
</button>
</div>
</div>
))}
</div>
)}
</div>
)
}

View File

@ -13,6 +13,14 @@ const joinStatusLabel = (s) =>
withdrawn: 'zurückgezogen', withdrawn: 'zurückgezogen',
})[s] || s })[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). * Onboarding für Nutzer ohne aktive Vereinsmitgliedschaft (Phase A).
*/ */
@ -23,6 +31,12 @@ export default function OnboardingPage() {
const [joinClubId, setJoinClubId] = useState('') const [joinClubId, setJoinClubId] = useState('')
const [joinMessage, setJoinMessage] = useState('') const [joinMessage, setJoinMessage] = useState('')
const [joinBusy, setJoinBusy] = useState(false) 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 [error, setError] = useState('')
const [ok, setOk] = useState('') const [ok, setOk] = useState('')
@ -34,9 +48,15 @@ export default function OnboardingPage() {
api.getMyClubJoinRequests().then(setMyJoinRequests).catch(() => {}) api.getMyClubJoinRequests().then(setMyJoinRequests).catch(() => {})
} }
const refreshCreationRequests = () => {
if (!emailOk) return
api.getMyClubCreationRequests().then(setMyCreationRequests).catch(() => {})
}
useEffect(() => { useEffect(() => {
api.listPublicClubsDirectory().then(setPublicClubs).catch(() => {}) api.listPublicClubsDirectory().then(setPublicClubs).catch(() => {})
refreshJoinRequests() refreshJoinRequests()
refreshCreationRequests()
}, [user?.id, emailOk]) }, [user?.id, emailOk])
const memberClubIds = new Set((user?.clubs || []).map((c) => c.id)) 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) (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) => { const handleJoin = async (e) => {
e.preventDefault() e.preventDefault()
setError('') setError('')
@ -191,11 +245,126 @@ export default function OnboardingPage() {
<div className="card"> <div className="card">
<h2 style={{ margin: '0 0 0.75rem', fontSize: '1.1rem' }}>Neuen Verein gründen</h2> <h2 style={{ margin: '0 0 0.75rem', fontSize: '1.1rem' }}>Neuen Verein gründen</h2>
<p style={{ color: 'var(--text2)', fontSize: '0.875rem', margin: 0, lineHeight: 1.5 }}> <p style={{ color: 'var(--text2)', fontSize: '0.875rem', marginBottom: '1rem', lineHeight: 1.5 }}>
Die Beantragung einer neuen Vereinsgründung wird als Nächstes freigeschaltet (Freigabe durch Stelle einen Antrag auf Vereinsgründung. Nach Freigabe durch den Plattform-Administrator
den Plattform-Administrator). Bis dahin wende dich an{' '} wird der Verein mit Free-Abo angelegt und du wirst Hauptverwalter.
<a href="mailto:support@jinkendo.de">support@jinkendo.de</a> oder tritt einem bestehenden Verein bei.
</p> </p>
{myCreationRequests.length > 0 ? (
<div style={{ marginBottom: '1rem' }}>
<strong style={{ fontSize: '0.9rem' }}>Meine Gründungsanträge</strong>
<ul
style={{
margin: '0.5rem 0 0',
paddingLeft: '1.25rem',
color: 'var(--text2)',
fontSize: '0.9rem',
}}
>
{myCreationRequests.map((r) => (
<li key={r.id} style={{ marginBottom: '0.35rem' }}>
{r.proposed_name} {creationStatusLabel(r.status)}
{r.status === 'approved' && r.created_club_name
? ` (${r.created_club_name})`
: null}
{r.status === 'pending' ? (
<>
{' '}
<button
type="button"
className="btn btn-secondary"
style={{ fontSize: '0.75rem', padding: '0.15rem 0.45rem' }}
onClick={async () => {
if (!confirm('Antrag wirklich zurückziehen?')) return
try {
await api.withdrawClubCreationRequest(r.id)
refreshCreationRequests()
} catch (err) {
setError(err.message || 'Zurückziehen fehlgeschlagen.')
}
}}
>
zurückziehen
</button>
</>
) : null}
{r.status === 'approved' ? (
<>
{' '}
<button
type="button"
className="btn btn-primary"
style={{ fontSize: '0.75rem', padding: '0.15rem 0.45rem' }}
onClick={() => checkAuth()}
>
App aktualisieren
</button>
</>
) : null}
</li>
))}
</ul>
</div>
) : null}
{hasPendingCreation ? (
<p style={{ margin: 0, color: 'var(--text2)', fontSize: '0.875rem' }}>
Du hast bereits einen offenen Gründungsantrag. Bitte warte auf die Freigabe oder ziehe
den Antrag zurück.
</p>
) : (
<form onSubmit={handleCreateClub}>
<label className="form-label" htmlFor="onb-create-name">
Vereinsname
</label>
<input
id="onb-create-name"
className="form-input"
value={createName}
onChange={(e) => setCreateName(e.target.value)}
maxLength={200}
required
/>
<label className="form-label" htmlFor="onb-create-abbr" style={{ marginTop: '0.75rem' }}>
Kürzel (optional)
</label>
<input
id="onb-create-abbr"
className="form-input"
value={createAbbr}
onChange={(e) => setCreateAbbr(e.target.value)}
maxLength={50}
/>
<label className="form-label" htmlFor="onb-create-desc" style={{ marginTop: '0.75rem' }}>
Beschreibung (optional)
</label>
<textarea
id="onb-create-desc"
className="form-input"
rows={2}
value={createDesc}
onChange={(e) => setCreateDesc(e.target.value)}
/>
<label className="form-label" htmlFor="onb-create-msg" style={{ marginTop: '0.75rem' }}>
Nachricht an den Administrator (optional)
</label>
<textarea
id="onb-create-msg"
className="form-input"
rows={2}
value={createMessage}
onChange={(e) => setCreateMessage(e.target.value)}
/>
<button
type="submit"
className="btn btn-primary"
disabled={createBusy}
style={{ marginTop: '0.85rem' }}
>
{createBusy ? 'Senden…' : 'Gründung beantragen'}
</button>
</form>
)}
</div> </div>
</> </>
)} )}

View File

@ -232,6 +232,35 @@ export async function withdrawClubJoinRequest(requestId) {
return request(`/api/me/club-join-requests/${requestId}`, { method: 'DELETE' }) return request(`/api/me/club-join-requests/${requestId}`, { method: 'DELETE' })
} }
/** Eigene Anträge auf Vereinsgründung. */
export async function getMyClubCreationRequests() {
return request('/api/me/club-creation-requests')
}
export async function createClubCreationRequest(payload) {
return request('/api/me/club-creation-requests', {
method: 'POST',
body: JSON.stringify(payload),
})
}
export async function withdrawClubCreationRequest(requestId) {
return request(`/api/me/club-creation-requests/${requestId}`, { method: 'DELETE' })
}
/** Offene Gründungsanträge — Plattform-Admin. */
export async function listAdminClubCreationRequests() {
return request('/api/admin/club-creation-requests')
}
export async function approveClubCreationRequest(requestId) {
return request(`/api/admin/club-creation-requests/${requestId}/approve`, { method: 'POST' })
}
export async function rejectClubCreationRequest(requestId) {
return request(`/api/admin/club-creation-requests/${requestId}/reject`, { method: 'POST' })
}
/** Offene Anträge (Vereins-/Plattform-Admin). */ /** Offene Anträge (Vereins-/Plattform-Admin). */
export async function listClubJoinRequests(clubId) { export async function listClubJoinRequests(clubId) {
return request(`/api/clubs/${clubId}/join-requests`) return request(`/api/clubs/${clubId}/join-requests`)
@ -889,6 +918,12 @@ export const api = {
getMyClubJoinRequests, getMyClubJoinRequests,
createClubJoinRequest, createClubJoinRequest,
withdrawClubJoinRequest, withdrawClubJoinRequest,
getMyClubCreationRequests,
createClubCreationRequest,
withdrawClubCreationRequest,
listAdminClubCreationRequests,
approveClubCreationRequest,
rejectClubCreationRequest,
listClubJoinRequests, listClubJoinRequests,
acceptClubJoinRequest, acceptClubJoinRequest,
rejectClubJoinRequest, rejectClubJoinRequest,