Update CMS navigation links and remove membership application page
Some checks failed
Code Analysis (JS/Vue) / analyze (push) Failing after 46s
Some checks failed
Code Analysis (JS/Vue) / analyze (push) Failing after 46s
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:
@@ -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"
|
||||
|
||||
936
components/cms/CmsMitglieder.vue
Normal file
936
components/cms/CmsMitglieder.vue
Normal 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>
|
||||
307
components/cms/CmsMitgliedschaftsantraege.vue
Normal file
307
components/cms/CmsMitgliedschaftsantraege.vue
Normal 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>
|
||||
Reference in New Issue
Block a user