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.
937 lines
34 KiB
Vue
937 lines
34 KiB
Vue
<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>
|