Update CMS navigation links and remove membership application page

This commit modifies the Navigation component and the CMS index page to replace the "Mitglieder" link with "Mitgliederverwaltung" and updates the corresponding route. Additionally, it removes the outdated "mitgliedschaftsantraege" page, streamlining the CMS structure and improving user navigation.
This commit is contained in:
Torsten Schulz (local)
2026-02-09 09:58:46 +01:00
parent 80c2b0bfeb
commit 905e02debf
7 changed files with 1291 additions and 449 deletions

View File

@@ -365,11 +365,11 @@
Sportbetrieb
</NuxtLink>
<NuxtLink
to="/mitgliederbereich/mitglieder"
to="/cms/mitgliederverwaltung"
class="block px-4 py-2 text-sm text-gray-300 hover:bg-primary-600 hover:text-white transition-colors"
@click="showCmsDropdown = false"
>
Mitglieder
Mitgliederverwaltung
</NuxtLink>
<div class="border-t border-gray-700 my-1" />
<NuxtLink
@@ -379,13 +379,6 @@
>
Einstellungen
</NuxtLink>
<NuxtLink
to="/cms/mitgliedschaftsantraege"
class="block px-4 py-2 text-sm text-gray-300 hover:bg-primary-600 hover:text-white transition-colors"
@click="showCmsDropdown = false"
>
Mitgliedschaftsanträge
</NuxtLink>
<NuxtLink
to="/cms/benutzer"
class="block px-4 py-2 text-sm text-gray-300 hover:bg-primary-600 hover:text-white transition-colors"
@@ -745,11 +738,11 @@
Sportbetrieb
</NuxtLink>
<NuxtLink
to="/mitgliederbereich/mitglieder"
to="/cms/mitgliederverwaltung"
class="block px-4 py-2 text-sm text-yellow-300 hover:text-white hover:bg-primary-700/50 rounded-lg transition-colors"
@click="isMobileMenuOpen = false"
>
Mitglieder
Mitgliederverwaltung
</NuxtLink>
<NuxtLink
to="/cms/inhalte"
@@ -772,13 +765,6 @@
>
Einstellungen
</NuxtLink>
<NuxtLink
to="/cms/mitgliedschaftsantraege"
class="block px-4 py-2 text-sm text-yellow-300 hover:text-white hover:bg-primary-700/50 rounded-lg transition-colors"
@click="isMobileMenuOpen = false"
>
Mitgliedschaftsanträge
</NuxtLink>
<NuxtLink
v-if="getAuthStore()?.hasAnyRole('admin', 'vorstand')"
to="/cms/benutzer"

View File

@@ -0,0 +1,936 @@
<template>
<div>
<div class="flex justify-between items-center mb-6">
<div>
<h2 class="text-2xl sm:text-3xl font-display font-bold text-gray-900 mb-2">
Mitgliederliste
</h2>
<div class="w-24 h-1 bg-primary-600 mb-4" />
</div>
<div class="flex items-center space-x-3">
<button
class="flex items-center px-4 py-2 bg-gray-100 hover:bg-gray-200 text-gray-700 font-semibold rounded-lg transition-colors"
@click="viewMode = viewMode === 'cards' ? 'table' : 'cards'"
>
<component
:is="viewMode === 'cards' ? Table2 : Grid3x3"
:size="20"
class="mr-2"
/>
{{ viewMode === 'cards' ? 'Tabelle' : 'Karten' }}
</button>
<button
v-if="canEdit"
class="flex items-center px-4 py-2 bg-green-600 hover:bg-green-700 text-white font-semibold rounded-lg transition-colors"
@click="showBulkImportModal = true"
>
<svg
class="w-5 h-5 mr-2"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12"
/>
</svg>
Bulk-Import
</button>
<button
v-if="canEdit"
class="flex items-center px-4 py-2 bg-primary-600 hover:bg-primary-700 text-white font-semibold rounded-lg transition-colors"
@click="openAddModal"
>
<UserPlus
:size="20"
class="mr-2"
/>
Mitglied hinzufügen
</button>
</div>
</div>
<!-- Loading State -->
<div
v-if="isLoading"
class="flex items-center justify-center py-12"
>
<Loader2
:size="40"
class="animate-spin text-primary-600"
/>
</div>
<!-- Table View -->
<div
v-else-if="viewMode === 'table'"
class="bg-white rounded-xl shadow-lg overflow-hidden"
>
<div class="overflow-x-auto">
<table class="min-w-full divide-y divide-gray-200">
<thead class="bg-gray-50">
<tr>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Name
</th>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
E-Mail
</th>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Telefon
</th>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Mannschaft
</th>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Status
</th>
<th
v-if="canEdit"
class="px-4 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider"
>
Aktionen
</th>
</tr>
</thead>
<tbody class="bg-white divide-y divide-gray-200">
<tr
v-for="member in members"
:key="member.id"
class="hover:bg-gray-50"
>
<td class="px-4 py-3 whitespace-nowrap">
<div class="text-sm font-medium text-gray-900">
{{ member.name }}
</div>
<div
v-if="member.notes"
class="text-xs text-gray-500"
>
{{ member.notes }}
</div>
</td>
<td class="px-4 py-3 whitespace-nowrap">
<template v-if="canViewContactData">
<a
v-if="member.email"
:href="`mailto:${member.email}`"
class="text-sm text-primary-600 hover:text-primary-800"
>
{{ member.email }}
</a>
<span
v-else
class="text-sm text-gray-400"
>-</span>
</template>
<span
v-else
class="text-sm text-gray-400"
>Nur für Vorstand</span>
</td>
<td class="px-4 py-3 whitespace-nowrap">
<template v-if="canViewContactData">
<a
v-if="member.phone"
:href="`tel:${member.phone}`"
class="text-sm text-primary-600 hover:text-primary-800"
>
{{ member.phone }}
</a>
<span
v-else
class="text-sm text-gray-400"
>-</span>
</template>
<span
v-else
class="text-sm text-gray-400"
>Nur für Vorstand</span>
</td>
<td class="px-4 py-3 whitespace-nowrap">
<button
v-if="canEdit"
:class="[
'px-2 py-1 text-xs font-medium rounded-full transition-colors',
member.isMannschaftsspieler
? 'bg-blue-100 text-blue-800 hover:bg-blue-200'
: 'bg-gray-100 text-gray-600 hover:bg-gray-200'
]"
title="Klicken zum Umschalten"
@click="toggleMannschaftsspieler(member)"
>
{{ member.isMannschaftsspieler ? 'Ja' : 'Nein' }}
</button>
<span
v-else
:class="[
'px-2 py-1 text-xs font-medium rounded-full',
member.isMannschaftsspieler
? 'bg-blue-100 text-blue-800'
: 'bg-gray-100 text-gray-600'
]"
>
{{ member.isMannschaftsspieler ? 'Ja' : 'Nein' }}
</span>
</td>
<td class="px-4 py-3 whitespace-nowrap">
<div class="flex items-center space-x-2">
<span
v-if="member.hasLogin"
class="px-2 py-1 bg-green-100 text-green-800 text-xs font-medium rounded-full"
>
Login
</span>
<span
:class="member.source === 'manual' ? 'bg-blue-100 text-blue-800' : 'bg-purple-100 text-purple-800'"
class="px-2 py-1 text-xs font-medium rounded-full"
>
{{ member.source === 'manual' ? 'Manuell' : 'System' }}
</span>
</div>
</td>
<td
v-if="canEdit"
class="px-4 py-3 whitespace-nowrap text-right text-sm font-medium"
>
<div
v-if="member.editable"
class="flex justify-end space-x-2"
>
<button
class="text-blue-600 hover:text-blue-900"
title="Bearbeiten"
@click="openEditModal(member)"
>
<Edit :size="18" />
</button>
<button
class="text-red-600 hover:text-red-900"
title="Löschen"
@click="confirmDelete(member)"
>
<Trash2 :size="18" />
</button>
</div>
<span
v-else
class="text-gray-400 text-xs"
>Nicht editierbar</span>
</td>
</tr>
</tbody>
</table>
</div>
<div
v-if="members.length === 0"
class="text-center py-12 text-gray-500"
>
Keine Mitglieder gefunden.
</div>
</div>
<!-- Cards View -->
<div
v-else
class="space-y-4"
>
<div
v-for="member in members"
:key="member.id"
class="bg-white p-6 rounded-xl shadow-lg border border-gray-100"
>
<div class="flex justify-between items-start">
<div class="flex-1">
<div class="flex items-center mb-2">
<h3 class="text-xl font-semibold text-gray-900">
{{ member.name }}
</h3>
<span
v-if="member.hasLogin"
class="ml-3 px-2 py-1 bg-green-100 text-green-800 text-xs font-medium rounded-full"
>
Hat Login
</span>
<span
v-if="member.source === 'manual'"
class="ml-2 px-2 py-1 bg-blue-100 text-blue-800 text-xs font-medium rounded-full"
>
Manuell
</span>
<span
v-else
class="ml-2 px-2 py-1 bg-purple-100 text-purple-800 text-xs font-medium rounded-full"
>
Aus Login-System
</span>
<button
v-if="canEdit"
:class="[
'ml-2 px-2 py-1 text-xs font-medium rounded-full transition-colors',
member.isMannschaftsspieler
? 'bg-blue-100 text-blue-800 hover:bg-blue-200'
: 'bg-gray-100 text-gray-600 hover:bg-gray-200'
]"
title="Klicken zum Umschalten"
@click="toggleMannschaftsspieler(member)"
>
Mannschaftsspieler: {{ member.isMannschaftsspieler ? 'Ja' : 'Nein' }}
</button>
<span
v-else
:class="[
'ml-2 px-2 py-1 text-xs font-medium rounded-full',
member.isMannschaftsspieler
? 'bg-blue-100 text-blue-800'
: 'bg-gray-100 text-gray-600'
]"
>
Mannschaftsspieler: {{ member.isMannschaftsspieler ? 'Ja' : 'Nein' }}
</span>
</div>
<div class="grid sm:grid-cols-2 gap-3 text-gray-600">
<template v-if="canViewContactData">
<div
v-if="member.email"
class="flex items-center"
>
<Mail
:size="16"
class="mr-2 text-primary-600"
/>
<a
:href="`mailto:${member.email}`"
class="hover:text-primary-600"
>{{ member.email }}</a>
</div>
<div
v-if="member.phone"
class="flex items-center"
>
<Phone
:size="16"
class="mr-2 text-primary-600"
/>
<a
:href="`tel:${member.phone}`"
class="hover:text-primary-600"
>{{ member.phone }}</a>
</div>
</template>
<div
v-else
class="col-span-2 flex items-center text-gray-500 text-sm italic"
>
<Mail
:size="16"
class="mr-2"
/>
Kontaktdaten nur für Vorstand sichtbar
</div>
<div
v-if="member.address"
class="flex items-start col-span-2"
>
<MapPin
:size="16"
class="mr-2 text-primary-600 mt-0.5"
/>
<span>{{ member.address }}</span>
</div>
<div
v-if="member.notes"
class="flex items-start col-span-2"
>
<FileText
:size="16"
class="mr-2 text-primary-600 mt-0.5"
/>
<span>{{ member.notes }}</span>
</div>
<div
v-if="member.lastLogin"
class="flex items-center col-span-2 text-sm text-gray-500"
>
<Clock
:size="16"
class="mr-2"
/>
Letzter Login: {{ formatDate(member.lastLogin) }}
</div>
</div>
</div>
<div
v-if="canEdit && member.editable"
class="flex space-x-2 ml-4"
>
<button
class="p-2 text-blue-600 hover:bg-blue-50 rounded-lg transition-colors"
title="Bearbeiten"
@click="openEditModal(member)"
>
<Edit :size="20" />
</button>
<button
class="p-2 text-red-600 hover:bg-red-50 rounded-lg transition-colors"
title="Löschen"
@click="confirmDelete(member)"
>
<Trash2 :size="20" />
</button>
</div>
</div>
</div>
<div
v-if="members.length === 0"
class="text-center py-12 text-gray-500"
>
Keine Mitglieder gefunden.
</div>
</div>
<!-- Edit/Add Modal -->
<div
v-if="showModal"
class="fixed inset-0 z-50 bg-black/50 flex items-center justify-center p-4"
@click.self="closeModal"
>
<div class="bg-white rounded-xl shadow-2xl max-w-2xl w-full p-8">
<h2 class="text-2xl font-display font-bold text-gray-900 mb-6">
{{ editingMember ? 'Mitglied bearbeiten' : 'Mitglied hinzufügen' }}
</h2>
<form
class="space-y-4"
@submit.prevent="saveMember"
>
<div class="grid grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">Vorname *</label>
<input
v-model="formData.firstName"
type="text"
required
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500"
:disabled="isSaving"
>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">Nachname *</label>
<input
v-model="formData.lastName"
type="text"
required
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500"
:disabled="isSaving"
>
</div>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">Geburtsdatum *</label>
<input
v-model="formData.geburtsdatum"
type="date"
required
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500"
:disabled="isSaving"
>
<p class="text-xs text-gray-500 mt-1">
Wird zur eindeutigen Identifizierung benötigt
</p>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">E-Mail</label>
<input
v-model="formData.email"
type="email"
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500"
:disabled="isSaving"
>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">Telefon</label>
<input
v-model="formData.phone"
type="tel"
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500"
:disabled="isSaving"
>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">Adresse</label>
<input
v-model="formData.address"
type="text"
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500"
:disabled="isSaving"
>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">Notizen</label>
<textarea
v-model="formData.notes"
rows="3"
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500"
:disabled="isSaving"
/>
</div>
<div class="flex items-center">
<input
id="isMannschaftsspieler"
v-model="formData.isMannschaftsspieler"
type="checkbox"
class="h-4 w-4 text-primary-600 focus:ring-primary-500 border-gray-300 rounded"
:disabled="isSaving"
>
<label
for="isMannschaftsspieler"
class="ml-2 block text-sm font-medium text-gray-700"
>
Mannschaftsspieler
</label>
</div>
<div
v-if="errorMessage"
class="flex items-center p-3 rounded-md bg-red-50 text-red-700 text-sm"
>
<AlertCircle
:size="20"
class="mr-2"
/>
{{ errorMessage }}
</div>
<div class="flex justify-end space-x-4 pt-4">
<button
type="button"
class="px-6 py-2 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50 transition-colors"
:disabled="isSaving"
@click="closeModal"
>
Abbrechen
</button>
<button
type="submit"
class="px-6 py-2 bg-primary-600 hover:bg-primary-700 text-white font-semibold rounded-lg transition-colors flex items-center"
:disabled="isSaving"
>
<Loader2
v-if="isSaving"
:size="20"
class="animate-spin mr-2"
/>
<span>{{ isSaving ? 'Speichert...' : 'Speichern' }}</span>
</button>
</div>
</form>
</div>
</div>
<!-- Bulk Import Modal -->
<div
v-if="showBulkImportModal"
class="fixed inset-0 z-50 bg-black/50 flex items-center justify-center p-4"
@click.self="closeBulkImportModal"
>
<div class="bg-white rounded-xl shadow-2xl max-w-4xl w-full max-h-[90vh] overflow-y-auto p-8">
<h2 class="text-2xl font-display font-bold text-gray-900 mb-6">
Bulk-Import von Mitgliedern
</h2>
<!-- CSV Upload Section -->
<div class="mb-6">
<label class="block text-sm font-medium text-gray-700 mb-2">CSV-Datei hochladen</label>
<div
class="border-2 border-dashed border-gray-300 rounded-lg p-8 text-center hover:border-primary-400 hover:bg-primary-50 transition-colors cursor-pointer"
:class="{ 'border-primary-400 bg-primary-50': isDragOver }"
@click="triggerBulkFileInput"
@dragover.prevent
@dragenter.prevent="isDragOver = true"
@dragleave.prevent="isDragOver = false"
@drop.prevent="handleBulkFileDrop"
>
<svg
class="w-12 h-12 text-gray-400 mx-auto mb-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12"
/>
</svg>
<p class="text-lg font-medium text-gray-900 mb-2">
CSV-Datei hochladen
</p>
<p class="text-sm text-gray-600 mb-4">
Klicken Sie hier oder ziehen Sie eine CSV-Datei hierher
</p>
<p
v-if="bulkSelectedFile"
class="text-sm text-primary-600 font-medium"
>
{{ bulkSelectedFile.name }}
</p>
</div>
<input
ref="bulkFileInput"
type="file"
accept=".csv"
class="hidden"
@change="handleBulkFileSelect"
>
</div>
<!-- CSV Format Info -->
<div class="bg-blue-50 border-l-4 border-blue-500 p-4 rounded-lg mb-6">
<h4 class="text-sm font-medium text-blue-800 mb-2">
Erwartetes CSV-Format:
</h4>
<div class="text-xs text-blue-700 space-y-1">
<p> Erste Zeile: Spaltenüberschriften (firstName, lastName, geburtsdatum, email, phone, address, notes)</p>
<p> <strong>Pflichtfelder:</strong> firstName, lastName, geburtsdatum</p>
<p> <strong>Geburtsdatum:</strong> Format YYYY-MM-DD (z.B. 1990-01-15)</p>
<p> Trennzeichen: Komma (,) oder Semikolon (;)</p>
</div>
</div>
<!-- Preview Section -->
<div
v-if="bulkPreviewData.length > 0"
class="mb-6"
>
<h3 class="text-lg font-semibold text-gray-900 mb-4">
Vorschau ({{ bulkPreviewData.length }} Einträge)
</h3>
<div class="max-h-64 overflow-y-auto border border-gray-200 rounded-lg">
<table class="min-w-full divide-y divide-gray-200 text-sm">
<thead class="bg-gray-50 sticky top-0">
<tr>
<th class="px-3 py-2 text-left text-xs font-medium text-gray-500 uppercase">Vorname</th>
<th class="px-3 py-2 text-left text-xs font-medium text-gray-500 uppercase">Nachname</th>
<th class="px-3 py-2 text-left text-xs font-medium text-gray-500 uppercase">Geburtsdatum</th>
<th class="px-3 py-2 text-left text-xs font-medium text-gray-500 uppercase">E-Mail</th>
</tr>
</thead>
<tbody class="bg-white divide-y divide-gray-200">
<tr
v-for="(row, index) in bulkPreviewData.slice(0, 10)"
:key="index"
class="hover:bg-gray-50"
>
<td class="px-3 py-2">{{ row.firstName || '-' }}</td>
<td class="px-3 py-2">{{ row.lastName || '-' }}</td>
<td class="px-3 py-2">{{ row.geburtsdatum || '-' }}</td>
<td class="px-3 py-2">{{ row.email || '-' }}</td>
</tr>
</tbody>
</table>
<div
v-if="bulkPreviewData.length > 10"
class="px-3 py-2 text-xs text-gray-500 bg-gray-50 text-center"
>
... und {{ bulkPreviewData.length - 10 }} weitere
</div>
</div>
</div>
<!-- Import Results -->
<div
v-if="bulkImportResults"
class="mb-6"
>
<div class="bg-gray-50 rounded-lg p-4">
<h3 class="text-lg font-semibold text-gray-900 mb-3">Import-Ergebnisse</h3>
<div class="grid grid-cols-3 gap-4 mb-4">
<div class="text-center">
<div class="text-2xl font-bold text-green-600">{{ bulkImportResults.summary.imported }}</div>
<div class="text-sm text-gray-600">Importiert</div>
</div>
<div class="text-center">
<div class="text-2xl font-bold text-yellow-600">{{ bulkImportResults.summary.duplicates }}</div>
<div class="text-sm text-gray-600">Duplikate</div>
</div>
<div class="text-center">
<div class="text-2xl font-bold text-red-600">{{ bulkImportResults.summary.errors }}</div>
<div class="text-sm text-gray-600">Fehler</div>
</div>
</div>
<div v-if="bulkImportResults.results.duplicates.length > 0" class="mt-4">
<h4 class="text-sm font-medium text-gray-700 mb-2">Duplikate:</h4>
<div class="text-xs text-gray-600 space-y-1 max-h-32 overflow-y-auto">
<div v-for="dup in bulkImportResults.results.duplicates" :key="dup.index">
Zeile {{ dup.index }}: {{ dup.member.firstName }} {{ dup.member.lastName }} - {{ dup.reason }}
</div>
</div>
</div>
<div v-if="bulkImportResults.results.errors.length > 0" class="mt-4">
<h4 class="text-sm font-medium text-gray-700 mb-2">Fehler:</h4>
<div class="text-xs text-red-600 space-y-1 max-h-32 overflow-y-auto">
<div v-for="err in bulkImportResults.results.errors" :key="err.index">
Zeile {{ err.index }}: {{ err.error }}
</div>
</div>
</div>
</div>
</div>
<div class="flex justify-end space-x-4 pt-4">
<button
type="button"
class="px-6 py-2 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50 transition-colors"
:disabled="isBulkImporting"
@click="closeBulkImportModal"
>
Schließen
</button>
<button
:disabled="!bulkPreviewData.length || isBulkImporting"
class="px-6 py-2 bg-primary-600 hover:bg-primary-700 text-white font-semibold rounded-lg transition-colors flex items-center disabled:bg-gray-400"
@click="processBulkImport"
>
<Loader2
v-if="isBulkImporting"
:size="20"
class="animate-spin mr-2"
/>
<span>{{ isBulkImporting ? 'Importiert...' : 'Importieren' }}</span>
</button>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
import { UserPlus, Mail, Phone, MapPin, FileText, Clock, Edit, Trash2, Loader2, AlertCircle, Table2, Grid3x3 } from 'lucide-vue-next'
const authStore = useAuthStore()
const isLoading = ref(true)
const isSaving = ref(false)
const members = ref([])
const showModal = ref(false)
const editingMember = ref(null)
const errorMessage = ref('')
const viewMode = ref('cards')
// Bulk import state
const showBulkImportModal = ref(false)
const bulkFileInput = ref(null)
const bulkSelectedFile = ref(null)
const bulkPreviewData = ref([])
const isBulkImporting = ref(false)
const bulkImportResults = ref(null)
const isDragOver = ref(false)
const formData = ref({
firstName: '',
lastName: '',
geburtsdatum: '',
email: '',
phone: '',
address: '',
notes: '',
isMannschaftsspieler: false
})
const canEdit = computed(() => {
return authStore.hasAnyRole('admin', 'vorstand')
})
const canViewContactData = computed(() => {
return authStore.hasRole('vorstand')
})
const loadMembers = async () => {
isLoading.value = true
try {
const response = await $fetch('/api/members')
members.value = response.members
} catch (error) {
console.error('Fehler beim Laden der Mitglieder:', error)
} finally {
isLoading.value = false
}
}
const openAddModal = () => {
editingMember.value = null
formData.value = { firstName: '', lastName: '', geburtsdatum: '', email: '', phone: '', address: '', notes: '', isMannschaftsspieler: false }
showModal.value = true
errorMessage.value = ''
}
const openEditModal = (member) => {
editingMember.value = member
formData.value = {
firstName: member.firstName || '',
lastName: member.lastName || '',
geburtsdatum: member.geburtsdatum || '',
email: member.email || '',
phone: member.phone || '',
address: member.address || '',
notes: member.notes || '',
isMannschaftsspieler: member.isMannschaftsspieler === true
}
showModal.value = true
errorMessage.value = ''
}
const closeModal = () => {
showModal.value = false
editingMember.value = null
errorMessage.value = ''
}
const saveMember = async () => {
isSaving.value = true
errorMessage.value = ''
try {
await $fetch('/api/members', {
method: 'POST',
body: { id: editingMember.value?.id, ...formData.value }
})
closeModal()
await loadMembers()
if (window.showSuccessModal) window.showSuccessModal('Erfolg', 'Mitglied erfolgreich gespeichert.')
} catch (error) {
console.error('Fehler beim Speichern:', error)
const errorMsg = error.data?.message || error.message || 'Fehler beim Speichern des Mitglieds.'
errorMessage.value = errorMsg
if ((error.statusCode === 409 || error.status === 409) && window.showErrorModal) {
window.showErrorModal('Duplikat gefunden', errorMsg)
}
} finally {
isSaving.value = false
}
}
const toggleMannschaftsspieler = async (member) => {
try {
const response = await $fetch('/api/members/toggle-mannschaftsspieler', {
method: 'POST',
body: { memberId: member.id }
})
member.isMannschaftsspieler = response.isMannschaftsspieler
await loadMembers()
} catch (error) {
console.error('Fehler beim Umschalten des Mannschaftsspieler-Status:', error)
if (window.showErrorModal) window.showErrorModal('Fehler', error.data?.message || 'Fehler beim Umschalten des Status.')
}
}
const confirmDelete = async (member) => {
window.showConfirmModal('Mitglied löschen', `Möchten Sie "${member.name}" wirklich löschen?`, async () => {
try {
await $fetch('/api/members', { method: 'DELETE', body: { id: member.id } })
await loadMembers()
window.showSuccessModal('Erfolg', 'Mitglied wurde erfolgreich gelöscht')
} catch (error) {
console.error('Fehler beim Löschen:', error)
window.showErrorModal('Fehler', 'Fehler beim Löschen des Mitglieds')
}
})
}
const formatDate = (dateString) => {
if (!dateString) return ''
return new Date(dateString).toLocaleDateString('de-DE', { year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' })
}
// Bulk import functions
const triggerBulkFileInput = () => { bulkFileInput.value?.click() }
const handleBulkFileSelect = (event) => { const file = event.target.files?.[0]; if (file) processBulkCSV(file) }
const handleBulkFileDrop = (event) => { isDragOver.value = false; const file = event.dataTransfer?.files?.[0]; if (file && file.type === 'text/csv') processBulkCSV(file) }
const processBulkCSV = async (file) => {
bulkSelectedFile.value = file
bulkImportResults.value = null
try {
const text = await file.text()
const lines = text.split('\n').filter(line => line.trim() !== '')
if (lines.length < 2) { window.showErrorModal('Fehler', 'CSV-Datei muss mindestens eine Kopfzeile und eine Datenzeile enthalten'); return }
const parseCSVLine = (line) => {
const tabCount = (line.match(/\t/g) || []).length
const semicolonCount = (line.match(/;/g) || []).length
const delimiter = tabCount > semicolonCount ? '\t' : (semicolonCount > 0 ? ';' : ',')
return line.split(delimiter).map(value => value.trim().replace(/^"|"$/g, ''))
}
const headers = parseCSVLine(lines[0]).map(h => h.toLowerCase())
const firstNameIdx = headers.findIndex(h => h.includes('firstname') || h.includes('vorname'))
const lastNameIdx = headers.findIndex(h => h.includes('lastname') || h.includes('nachname'))
const geburtsdatumIdx = headers.findIndex(h => h.includes('geburtsdatum') || h.includes('birthdate') || h.includes('geburt'))
const emailIdx = headers.findIndex(h => h.includes('email') || h.includes('e-mail'))
const phoneIdx = headers.findIndex(h => h.includes('phone') || h.includes('telefon') || h.includes('tel'))
const addressIdx = headers.findIndex(h => h.includes('address') || h.includes('adresse'))
const notesIdx = headers.findIndex(h => h.includes('note') || h.includes('notiz') || h.includes('bemerkung'))
if (firstNameIdx === -1 || lastNameIdx === -1 || geburtsdatumIdx === -1) { window.showErrorModal('Fehler', 'CSV muss Spalten für firstName, lastName und geburtsdatum enthalten'); return }
bulkPreviewData.value = lines.slice(1).map((line) => {
const values = parseCSVLine(line)
return {
firstName: values[firstNameIdx] || '',
lastName: values[lastNameIdx] || '',
geburtsdatum: values[geburtsdatumIdx] || '',
email: emailIdx !== -1 ? (values[emailIdx] || '') : '',
phone: phoneIdx !== -1 ? (values[phoneIdx] || '') : '',
address: addressIdx !== -1 ? (values[addressIdx] || '') : '',
notes: notesIdx !== -1 ? (values[notesIdx] || '') : ''
}
}).filter(row => row.firstName && row.lastName && row.geburtsdatum)
} catch (error) {
console.error('Fehler beim Parsen der CSV:', error)
window.showErrorModal('Fehler', 'Fehler beim Lesen der CSV-Datei: ' + error.message)
}
}
const processBulkImport = async () => {
if (!bulkPreviewData.value.length) return
isBulkImporting.value = true
bulkImportResults.value = null
try {
const response = await $fetch('/api/members/bulk', { method: 'POST', body: { members: bulkPreviewData.value } })
bulkImportResults.value = response
if (response.summary.imported > 0) {
await loadMembers()
window.showSuccessModal('Import erfolgreich', `${response.summary.imported} Mitglieder wurden erfolgreich importiert.`)
}
} catch (error) {
console.error('Fehler beim Bulk-Import:', error)
window.showErrorModal('Import-Fehler', error.data?.message || error.message || 'Fehler beim Import')
} finally {
isBulkImporting.value = false
}
}
const closeBulkImportModal = () => {
showBulkImportModal.value = false
bulkSelectedFile.value = null
bulkPreviewData.value = []
bulkImportResults.value = null
isDragOver.value = false
if (bulkFileInput.value) bulkFileInput.value.value = ''
}
onMounted(() => { loadMembers() })
</script>

View File

@@ -0,0 +1,307 @@
<template>
<div>
<div class="flex items-center justify-between mb-6">
<h2 class="text-2xl sm:text-3xl font-display font-bold text-gray-900">
Mitgliedschaftsanträge
</h2>
<button
:disabled="loading"
class="px-3 py-1.5 sm:px-4 sm:py-2 bg-primary-600 hover:bg-primary-700 disabled:bg-gray-400 text-white text-sm sm:text-base font-medium rounded-lg transition-colors"
@click="refreshApplications"
>
{{ loading ? 'Lädt...' : 'Aktualisieren' }}
</button>
</div>
<!-- Loading State -->
<div
v-if="loading"
class="text-center py-12"
>
<div class="animate-spin rounded-full h-12 w-12 border-b-2 border-primary-600 mx-auto" />
<p class="mt-4 text-gray-600">
Lade Anträge...
</p>
</div>
<!-- Applications List -->
<div
v-else
class="space-y-6"
>
<div
v-for="application in applications"
:key="application.id"
class="bg-white rounded-lg shadow-sm border border-gray-200 overflow-hidden"
>
<!-- Application Header -->
<div class="px-6 py-4 border-b border-gray-200">
<div class="flex items-center justify-between">
<div>
<h3 class="text-lg font-semibold text-gray-900">
{{ application.personalData.vorname }} {{ application.personalData.nachname }}
</h3>
<p class="text-sm text-gray-600">
Eingereicht: {{ formatDate(application.timestamp) }}
</p>
</div>
<div class="flex items-center space-x-3">
<span
:class="[
'px-3 py-1 rounded-full text-sm font-medium',
getStatusClass(application.status)
]"
>
{{ getStatusText(application.status) }}
</span>
<div class="flex space-x-2">
<button
class="px-3 py-1 text-sm bg-gray-100 hover:bg-gray-200 text-gray-700 rounded-lg transition-colors"
@click="viewApplication(application)"
>
Anzeigen
</button>
<button
v-if="application.metadata.pdfGenerated"
class="px-3 py-1 text-sm bg-blue-100 hover:bg-blue-200 text-blue-700 rounded-lg transition-colors flex items-center"
@click="downloadPDF(application.id)"
>
<svg
class="w-4 h-4 mr-1"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"
/>
</svg>
PDF
</button>
<button
v-if="application.status === 'pending'"
class="px-3 py-1 text-sm bg-green-100 hover:bg-green-200 text-green-700 rounded-lg transition-colors"
@click="approveApplication(application.id)"
>
Genehmigen
</button>
<button
v-if="application.status === 'pending'"
class="px-3 py-1 text-sm bg-red-100 hover:bg-red-200 text-red-700 rounded-lg transition-colors"
@click="rejectApplication(application.id)"
>
Ablehnen
</button>
</div>
</div>
</div>
</div>
<!-- Application Details -->
<div class="px-6 py-4">
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<h4 class="text-sm font-medium text-gray-900 mb-2">Kontaktdaten</h4>
<div class="space-y-1 text-sm text-gray-600">
<p><strong>E-Mail:</strong> {{ application.personalData.email }}</p>
<p v-if="application.personalData.telefon_privat"><strong>Telefon:</strong> {{ application.personalData.telefon_privat }}</p>
<p v-if="application.personalData.telefon_mobil"><strong>Mobil:</strong> {{ application.personalData.telefon_mobil }}</p>
</div>
</div>
<div>
<h4 class="text-sm font-medium text-gray-900 mb-2">Antragsdetails</h4>
<div class="space-y-1 text-sm text-gray-600">
<p><strong>Art:</strong> {{ application.metadata.mitgliedschaftsart === 'aktiv' ? 'Aktives Mitglied' : 'Passives Mitglied' }}</p>
<p><strong>Volljährig:</strong> {{ application.metadata.isVolljaehrig ? 'Ja' : 'Nein' }}</p>
<p><strong>PDF:</strong> {{ application.metadata.pdfGenerated ? 'Generiert' : 'Nicht verfügbar' }}</p>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Application Detail Modal -->
<div
v-if="selectedApplication"
class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4"
@click.self="closeModal"
>
<div class="bg-white rounded-lg max-w-4xl w-full max-h-[90vh] overflow-y-auto">
<div class="px-6 py-4 border-b border-gray-200">
<div class="flex items-center justify-between">
<h2 class="text-xl font-semibold text-gray-900">
Antrag: {{ selectedApplication.personalData.vorname }} {{ selectedApplication.personalData.nachname }}
</h2>
<button
class="text-gray-400 hover:text-gray-600"
@click="closeModal"
>
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
</div>
<div class="px-6 py-4">
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<h3 class="text-lg font-medium text-gray-900 mb-4">Persönliche Daten</h3>
<div class="space-y-2 text-sm">
<p><strong>Name:</strong> {{ selectedApplication.personalData.vorname }} {{ selectedApplication.personalData.nachname }}</p>
<p><strong>E-Mail:</strong> {{ selectedApplication.personalData.email }}</p>
<p v-if="selectedApplication.personalData.telefon_privat"><strong>Telefon:</strong> {{ selectedApplication.personalData.telefon_privat }}</p>
<p v-if="selectedApplication.personalData.telefon_mobil"><strong>Mobil:</strong> {{ selectedApplication.personalData.telefon_mobil }}</p>
</div>
</div>
<div>
<h3 class="text-lg font-medium text-gray-900 mb-4">Antragsdetails</h3>
<div class="space-y-2 text-sm">
<p><strong>Status:</strong> {{ getStatusText(selectedApplication.status) }}</p>
<p><strong>Art:</strong> {{ selectedApplication.metadata.mitgliedschaftsart === 'aktiv' ? 'Aktives Mitglied' : 'Passives Mitglied' }}</p>
<p><strong>Volljährig:</strong> {{ selectedApplication.metadata.isVolljaehrig ? 'Ja' : 'Nein' }}</p>
<p><strong>Eingereicht:</strong> {{ formatDate(selectedApplication.timestamp) }}</p>
</div>
</div>
</div>
<div class="mt-6 pt-6 border-t border-gray-200">
<div class="flex justify-end space-x-3">
<button class="px-4 py-2 text-gray-700 bg-gray-100 hover:bg-gray-200 rounded-lg transition-colors" @click="closeModal">Schließen</button>
<button
v-if="selectedApplication.metadata.pdfGenerated"
class="px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg transition-colors flex items-center"
@click="downloadPDF(selectedApplication.id)"
>
<svg class="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
PDF herunterladen
</button>
<button
v-if="selectedApplication.status === 'pending'"
class="px-4 py-2 bg-green-600 hover:bg-green-700 text-white rounded-lg transition-colors"
@click="approveApplication(selectedApplication.id)"
>
Genehmigen
</button>
<button
v-if="selectedApplication.status === 'pending'"
class="px-4 py-2 bg-red-600 hover:bg-red-700 text-white rounded-lg transition-colors"
@click="rejectApplication(selectedApplication.id)"
>
Ablehnen
</button>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
const applications = ref([])
const loading = ref(false)
const selectedApplication = ref(null)
const hasApplications = computed(() => applications.value.length > 0)
const isReady = computed(() => !loading.value)
defineExpose({ hasApplications, isReady })
const loadApplications = async () => {
loading.value = true
try {
const response = await $fetch('/api/membership/applications')
applications.value = response
} catch (error) {
console.error('Fehler beim Laden der Anträge:', error)
window.showErrorModal('Fehler', 'Fehler beim Laden der Anträge')
} finally {
loading.value = false
}
}
const refreshApplications = () => { loadApplications() }
const viewApplication = (application) => { selectedApplication.value = application }
const closeModal = () => { selectedApplication.value = null }
const approveApplication = async (id) => {
window.showConfirmModal('Bestätigung erforderlich', 'Möchten Sie diesen Antrag wirklich genehmigen?', async () => {
try {
await $fetch('/api/membership/update-status', { method: 'PUT', body: { id, status: 'approved' } })
await loadApplications()
window.showSuccessModal('Erfolg', 'Antrag wurde erfolgreich genehmigt')
} catch (error) {
console.error('Fehler beim Genehmigen:', error)
window.showErrorModal('Fehler', 'Fehler beim Genehmigen des Antrags')
}
})
}
const rejectApplication = async (id) => {
window.showConfirmModal('Bestätigung erforderlich', 'Möchten Sie diesen Antrag wirklich ablehnen?', async () => {
try {
await $fetch('/api/membership/update-status', { method: 'PUT', body: { id, status: 'rejected' } })
await loadApplications()
window.showSuccessModal('Erfolg', 'Antrag wurde erfolgreich abgelehnt')
} catch (error) {
console.error('Fehler beim Ablehnen:', error)
window.showErrorModal('Fehler', 'Fehler beim Ablehnen des Antrags')
}
})
}
const downloadPDF = async (id) => {
try {
const filename = `beitrittserklärung_${id}.pdf`
const response = await fetch(`/uploads/${filename}`)
if (!response.ok) throw new Error('PDF nicht gefunden')
const blob = await response.blob()
const url = window.URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = filename
document.body.appendChild(a)
a.click()
window.URL.revokeObjectURL(url)
document.body.removeChild(a)
window.showSuccessModal('Erfolg', 'PDF wurde erfolgreich heruntergeladen')
} catch (error) {
console.error('Fehler beim Herunterladen:', error)
window.showErrorModal('Fehler', 'Fehler beim Herunterladen des PDFs')
}
}
const formatDate = (dateString) => {
return new Date(dateString).toLocaleDateString('de-DE', { year: 'numeric', month: 'long', day: 'numeric', hour: '2-digit', minute: '2-digit' })
}
const getStatusClass = (status) => {
switch (status) {
case 'pending': return 'bg-yellow-100 text-yellow-800'
case 'approved': return 'bg-green-100 text-green-800'
case 'rejected': return 'bg-red-100 text-red-800'
default: return 'bg-gray-100 text-gray-800'
}
}
const getStatusText = (status) => {
switch (status) {
case 'pending': return 'Ausstehend'
case 'approved': return 'Genehmigt'
case 'rejected': return 'Abgelehnt'
default: return 'Unbekannt'
}
}
onMounted(() => { loadApplications() })
</script>