Implement member management enhancements; add bulk import functionality and duplicate checking based on geburtsdatum. Update API to support new fields and improve error handling for member data submissions. Refactor member-related components for better user experience and data validation.

This commit is contained in:
Torsten Schulz (local)
2025-11-05 14:34:31 +01:00
parent dd4691b462
commit 623a63c29f
21 changed files with 1765 additions and 513 deletions

View File

@@ -0,0 +1,549 @@
<template>
<div class="min-h-full py-16 bg-gray-50">
<div class="max-w-6xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="mb-8">
<h1 class="text-4xl sm:text-5xl font-display font-bold text-gray-900 mb-4">
API-Dokumentation
</h1>
<div class="w-24 h-1 bg-primary-600 mb-6" />
<p class="text-xl text-gray-600">
Übersicht über alle verfügbaren API-Endpoints und deren Verwendung
</p>
</div>
<!-- Authentication Info -->
<div class="bg-blue-50 border-l-4 border-blue-500 p-6 rounded-lg mb-8">
<h2 class="text-xl font-semibold text-blue-900 mb-2">Authentifizierung</h2>
<p class="text-blue-800 mb-4">
Alle API-Endpoints erfordern Authentifizierung (außer Login). Es werden zwei Methoden unterstützt:
</p>
<div class="space-y-3">
<div>
<strong class="text-blue-900">1. Cookie-basiert:</strong>
<p class="text-blue-700 text-sm mt-1">Nach dem Login über <code>/api/auth/login</code> wird automatisch ein Cookie gesetzt.</p>
</div>
<div>
<strong class="text-blue-900">2. Authorization Header:</strong>
<p class="text-blue-700 text-sm mt-1">Header: <code>Authorization: Bearer &lt;token&gt;</code></p>
<p class="text-blue-700 text-sm">Der Token wird im Login-Response im Feld <code>token</code> zurückgegeben.</p>
</div>
</div>
</div>
<!-- Endpoints -->
<div class="space-y-8">
<!-- Authentication Endpoints -->
<section class="bg-white rounded-xl shadow-lg p-6">
<h2 class="text-2xl font-display font-bold text-gray-900 mb-6">Authentifizierung</h2>
<div class="space-y-6">
<!-- Login -->
<div class="border-l-4 border-primary-600 pl-4">
<div class="flex items-center justify-between mb-2">
<h3 class="text-lg font-semibold text-gray-900">POST /api/auth/login</h3>
<span class="px-2 py-1 text-xs font-medium bg-green-100 text-green-800 rounded">Öffentlich</span>
</div>
<p class="text-gray-600 mb-3">Benutzer einloggen und Token erhalten</p>
<div class="bg-gray-50 rounded-lg p-4 mb-3">
<p class="text-sm font-medium text-gray-700 mb-2">Request Body:</p>
<pre class="text-xs bg-gray-900 text-gray-100 p-3 rounded overflow-x-auto"><code>{
"email": "benutzer@example.com",
"password": "passwort"
}</code></pre>
</div>
<div class="bg-gray-50 rounded-lg p-4">
<p class="text-sm font-medium text-gray-700 mb-2">Response:</p>
<pre class="text-xs bg-gray-900 text-gray-100 p-3 rounded overflow-x-auto"><code>{
"success": true,
"token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
"user": {
"id": "user-id",
"email": "benutzer@example.com",
"name": "Max Mustermann",
"role": "mitglied"
}
}</code></pre>
</div>
</div>
<!-- Logout -->
<div class="border-l-4 border-primary-600 pl-4">
<div class="flex items-center justify-between mb-2">
<h3 class="text-lg font-semibold text-gray-900">POST /api/auth/logout</h3>
<span class="px-2 py-1 text-xs font-medium bg-yellow-100 text-yellow-800 rounded">Auth erforderlich</span>
</div>
<p class="text-gray-600 mb-3">Benutzer ausloggen</p>
<div class="bg-gray-50 rounded-lg p-4">
<p class="text-sm font-medium text-gray-700 mb-2">Response:</p>
<pre class="text-xs bg-gray-900 text-gray-100 p-3 rounded overflow-x-auto"><code>{
"success": true,
"message": "Erfolgreich ausgeloggt"
}</code></pre>
</div>
</div>
<!-- Auth Status -->
<div class="border-l-4 border-primary-600 pl-4">
<div class="flex items-center justify-between mb-2">
<h3 class="text-lg font-semibold text-gray-900">GET /api/auth/status</h3>
<span class="px-2 py-1 text-xs font-medium bg-yellow-100 text-yellow-800 rounded">Auth erforderlich</span>
</div>
<p class="text-gray-600 mb-3">Aktuellen Authentifizierungsstatus abrufen</p>
<div class="bg-gray-50 rounded-lg p-4">
<p class="text-sm font-medium text-gray-700 mb-2">Response:</p>
<pre class="text-xs bg-gray-900 text-gray-100 p-3 rounded overflow-x-auto"><code>{
"isLoggedIn": true,
"user": {
"id": "user-id",
"email": "benutzer@example.com",
"name": "Max Mustermann",
"role": "mitglied"
},
"role": "mitglied"
}</code></pre>
</div>
</div>
</div>
</section>
<!-- Members Endpoints -->
<section class="bg-white rounded-xl shadow-lg p-6">
<h2 class="text-2xl font-display font-bold text-gray-900 mb-6">Mitglieder</h2>
<div class="space-y-6">
<!-- Get Members -->
<div class="border-l-4 border-primary-600 pl-4">
<div class="flex items-center justify-between mb-2">
<h3 class="text-lg font-semibold text-gray-900">GET /api/members</h3>
<span class="px-2 py-1 text-xs font-medium bg-yellow-100 text-yellow-800 rounded">Auth erforderlich</span>
</div>
<p class="text-gray-600 mb-3">Alle Mitglieder abrufen (mit Merge aus registrierten Benutzern)</p>
<div class="bg-gray-50 rounded-lg p-4">
<p class="text-sm font-medium text-gray-700 mb-2">Response:</p>
<pre class="text-xs bg-gray-900 text-gray-100 p-3 rounded overflow-x-auto"><code>{
"success": true,
"members": [
{
"id": "member-id",
"firstName": "Max",
"lastName": "Mustermann",
"geburtsdatum": "1990-01-15",
"email": "max@example.com",
"phone": "0123456789",
"address": "Musterstraße 1",
"source": "manual",
"editable": true,
"hasLogin": false
}
]
}</code></pre>
</div>
</div>
<!-- Post Members -->
<div class="border-l-4 border-red-500 pl-4">
<div class="flex items-center justify-between mb-2">
<h3 class="text-lg font-semibold text-gray-900">POST /api/members</h3>
<span class="px-2 py-1 text-xs font-medium bg-red-100 text-red-800 rounded">admin/vorstand</span>
</div>
<p class="text-gray-600 mb-3">Neues Mitglied hinzufügen oder bestehendes bearbeiten</p>
<div class="bg-gray-50 rounded-lg p-4 mb-3">
<p class="text-sm font-medium text-gray-700 mb-2">Request Body:</p>
<pre class="text-xs bg-gray-900 text-gray-100 p-3 rounded overflow-x-auto"><code>{
"id": "optional-für-update",
"firstName": "Max",
"lastName": "Mustermann",
"geburtsdatum": "1990-01-15",
"email": "max@example.com",
"phone": "0123456789",
"address": "Musterstraße 1, 12345 Musterstadt",
"notes": "Optional"
}</code></pre>
</div>
<div class="bg-gray-50 rounded-lg p-4 mb-3">
<p class="text-sm font-medium text-gray-700 mb-2">Response:</p>
<pre class="text-xs bg-gray-900 text-gray-100 p-3 rounded overflow-x-auto"><code>{
"success": true,
"message": "Mitglied erfolgreich gespeichert."
}</code></pre>
</div>
<div class="mt-3 bg-blue-50 rounded-lg p-3">
<p class="text-xs text-blue-800">
<strong>Hinweis:</strong> Ohne <code>id</code> wird ein neues Mitglied erstellt. Mit <code>id</code> wird ein bestehendes Mitglied aktualisiert. <code>geburtsdatum</code> ist Pflichtfeld zur Duplikatsprüfung (Format: YYYY-MM-DD).
</p>
</div>
</div>
<!-- Bulk Import Members -->
<div class="border-l-4 border-red-500 pl-4">
<div class="flex items-center justify-between mb-2">
<h3 class="text-lg font-semibold text-gray-900">POST /api/members/bulk</h3>
<span class="px-2 py-1 text-xs font-medium bg-red-100 text-red-800 rounded">admin/vorstand</span>
</div>
<p class="text-gray-600 mb-3">Mehrere Mitglieder auf einmal importieren (Bulk-Import)</p>
<div class="bg-gray-50 rounded-lg p-4 mb-3">
<p class="text-sm font-medium text-gray-700 mb-2">Request Body:</p>
<pre class="text-xs bg-gray-900 text-gray-100 p-3 rounded overflow-x-auto"><code>{
"members": [
{
"firstName": "Max",
"lastName": "Mustermann",
"geburtsdatum": "1990-01-15",
"email": "max@example.com",
"phone": "0123456789",
"address": "Musterstraße 1",
"notes": "Optional"
},
{
"firstName": "Anna",
"lastName": "Schmidt",
"geburtsdatum": "1985-03-20",
"email": "anna@example.com"
}
]
}</code></pre>
</div>
<div class="bg-gray-50 rounded-lg p-4 mb-3">
<p class="text-sm font-medium text-gray-700 mb-2">Response:</p>
<pre class="text-xs bg-gray-900 text-gray-100 p-3 rounded overflow-x-auto"><code>{
"success": true,
"summary": {
"total": 2,
"imported": 2,
"duplicates": 0,
"errors": 0
},
"results": {
"success": [
{
"index": 1,
"member": { ... }
}
],
"duplicates": [],
"errors": []
}
}</code></pre>
</div>
<div class="mt-3 bg-blue-50 rounded-lg p-3">
<p class="text-xs text-blue-800 mb-2">
<strong>Features:</strong>
</p>
<ul class="text-xs text-blue-700 list-disc list-inside space-y-1">
<li>Duplikatsprüfung gegen bestehende Mitglieder</li>
<li>Duplikatsprüfung innerhalb des Imports</li>
<li>Validierung aller Daten vor dem Import</li>
<li>Detaillierte Fehlerberichte für jeden Eintrag</li>
<li>Nur erfolgreiche Einträge werden gespeichert</li>
</ul>
</div>
</div>
<!-- Delete Members -->
<div class="border-l-4 border-red-500 pl-4">
<div class="flex items-center justify-between mb-2">
<h3 class="text-lg font-semibold text-gray-900">DELETE /api/members</h3>
<span class="px-2 py-1 text-xs font-medium bg-red-100 text-red-800 rounded">admin/vorstand</span>
</div>
<p class="text-gray-600 mb-3">Mitglied löschen</p>
<div class="bg-gray-50 rounded-lg p-4 mb-3">
<p class="text-sm font-medium text-gray-700 mb-2">Request Body:</p>
<pre class="text-xs bg-gray-900 text-gray-100 p-3 rounded overflow-x-auto"><code>{
"id": "member-id"
}</code></pre>
</div>
<div class="bg-gray-50 rounded-lg p-4">
<p class="text-sm font-medium text-gray-700 mb-2">Response:</p>
<pre class="text-xs bg-gray-900 text-gray-100 p-3 rounded overflow-x-auto"><code>{
"success": true,
"message": "Mitglied erfolgreich gelöscht."
}</code></pre>
</div>
</div>
</div>
</section>
<!-- News Endpoints -->
<section class="bg-white rounded-xl shadow-lg p-6">
<h2 class="text-2xl font-display font-bold text-gray-900 mb-6">News</h2>
<div class="space-y-6">
<!-- Get News -->
<div class="border-l-4 border-primary-600 pl-4">
<div class="flex items-center justify-between mb-2">
<h3 class="text-lg font-semibold text-gray-900">GET /api/news</h3>
<span class="px-2 py-1 text-xs font-medium bg-yellow-100 text-yellow-800 rounded">Auth erforderlich</span>
</div>
<p class="text-gray-600 mb-3">Alle News abrufen (inkl. interner News)</p>
</div>
<!-- Post News -->
<div class="border-l-4 border-red-500 pl-4">
<div class="flex items-center justify-between mb-2">
<h3 class="text-lg font-semibold text-gray-900">POST /api/news</h3>
<span class="px-2 py-1 text-xs font-medium bg-red-100 text-red-800 rounded">admin/vorstand</span>
</div>
<p class="text-gray-600 mb-3">Neue News erstellen oder bestehende bearbeiten</p>
<div class="bg-gray-50 rounded-lg p-4 mb-3">
<p class="text-sm font-medium text-gray-700 mb-2">Request Body:</p>
<pre class="text-xs bg-gray-900 text-gray-100 p-3 rounded overflow-x-auto"><code>{
"id": "optional-für-update",
"title": "Titel der News",
"content": "Inhalt der News",
"isPublic": true,
"expiresAt": "2025-12-31T23:59:59.000Z",
"isHidden": false
}</code></pre>
</div>
</div>
<!-- Delete News -->
<div class="border-l-4 border-red-500 pl-4">
<div class="flex items-center justify-between mb-2">
<h3 class="text-lg font-semibold text-gray-900">DELETE /api/news</h3>
<span class="px-2 py-1 text-xs font-medium bg-red-100 text-red-800 rounded">admin/vorstand</span>
</div>
<p class="text-gray-600 mb-3">News löschen</p>
<div class="bg-gray-50 rounded-lg p-4 mb-3">
<p class="text-sm font-medium text-gray-700 mb-2">Request Body:</p>
<pre class="text-xs bg-gray-900 text-gray-100 p-3 rounded overflow-x-auto"><code>{
"id": "news-id"
}</code></pre>
</div>
</div>
</div>
</section>
<!-- Termine Endpoints -->
<section class="bg-white rounded-xl shadow-lg p-6">
<h2 class="text-2xl font-display font-bold text-gray-900 mb-6">Termine</h2>
<div class="space-y-6">
<!-- Get Termine -->
<div class="border-l-4 border-red-500 pl-4">
<div class="flex items-center justify-between mb-2">
<h3 class="text-lg font-semibold text-gray-900">GET /api/termine-manage</h3>
<span class="px-2 py-1 text-xs font-medium bg-red-100 text-red-800 rounded">admin/vorstand</span>
</div>
<p class="text-gray-600 mb-3">Alle Termine abrufen (für Verwaltung)</p>
</div>
<!-- Post Termine -->
<div class="border-l-4 border-red-500 pl-4">
<div class="flex items-center justify-between mb-2">
<h3 class="text-lg font-semibold text-gray-900">POST /api/termine-manage</h3>
<span class="px-2 py-1 text-xs font-medium bg-red-100 text-red-800 rounded">admin/vorstand</span>
</div>
<p class="text-gray-600 mb-3">Neuen Termin erstellen</p>
<div class="bg-gray-50 rounded-lg p-4 mb-3">
<p class="text-sm font-medium text-gray-700 mb-2">Request Body:</p>
<pre class="text-xs bg-gray-900 text-gray-100 p-3 rounded overflow-x-auto"><code>{
"datum": "2025-12-25",
"uhrzeit": "19:00",
"titel": "Weihnachtsfeier",
"beschreibung": "Gemeinsame Feier",
"kategorie": "Veranstaltung"
}</code></pre>
</div>
</div>
<!-- Delete Termine -->
<div class="border-l-4 border-red-500 pl-4">
<div class="flex items-center justify-between mb-2">
<h3 class="text-lg font-semibold text-gray-900">DELETE /api/termine-manage</h3>
<span class="px-2 py-1 text-xs font-medium bg-red-100 text-red-800 rounded">admin/vorstand</span>
</div>
<p class="text-gray-600 mb-3">Termin löschen</p>
<div class="bg-gray-50 rounded-lg p-4 mb-3">
<p class="text-sm font-medium text-gray-700 mb-2">Query Parameters:</p>
<pre class="text-xs bg-gray-900 text-gray-100 p-3 rounded overflow-x-auto"><code>?datum=2025-12-25&uhrzeit=19:00&titel=Weihnachtsfeier&beschreibung=...&kategorie=...</code></pre>
</div>
</div>
</div>
</section>
<!-- Config Endpoints -->
<section class="bg-white rounded-xl shadow-lg p-6">
<h2 class="text-2xl font-display font-bold text-gray-900 mb-6">Konfiguration</h2>
<div class="space-y-6">
<!-- Get Config -->
<div class="border-l-4 border-red-500 pl-4">
<div class="flex items-center justify-between mb-2">
<h3 class="text-lg font-semibold text-gray-900">GET /api/config</h3>
<span class="px-2 py-1 text-xs font-medium bg-red-100 text-red-800 rounded">admin/vorstand</span>
</div>
<p class="text-gray-600 mb-3">Vereinskonfiguration abrufen</p>
</div>
<!-- Put Config -->
<div class="border-l-4 border-red-500 pl-4">
<div class="flex items-center justify-between mb-2">
<h3 class="text-lg font-semibold text-gray-900">PUT /api/config</h3>
<span class="px-2 py-1 text-xs font-medium bg-red-100 text-red-800 rounded">admin/vorstand</span>
</div>
<p class="text-gray-600 mb-3">Vereinskonfiguration aktualisieren</p>
<div class="bg-gray-50 rounded-lg p-4 mb-3">
<p class="text-sm font-medium text-gray-700 mb-2">Request Body:</p>
<p class="text-xs text-gray-600">Komplettes Config-Objekt mit allen Einstellungen</p>
</div>
</div>
</div>
</section>
</div>
<!-- Example Usage -->
<div class="mt-12 bg-white rounded-xl shadow-lg p-6">
<h2 class="text-2xl font-display font-bold text-gray-900 mb-6">Beispiel-Usage</h2>
<div class="space-y-4">
<div>
<h3 class="text-lg font-semibold text-gray-900 mb-2">cURL Beispiel:</h3>
<pre class="text-xs bg-gray-900 text-gray-100 p-4 rounded overflow-x-auto"><code># Login und Token erhalten
curl -X POST http://localhost:3100/api/auth/login \
-H "Content-Type: application/json" \
-d '{"email": "admin@example.com", "password": "passwort"}'
# Mitglied hinzufügen mit Token
curl -X POST http://localhost:3100/api/members \
-H "Authorization: Bearer YOUR_TOKEN_HERE" \
-H "Content-Type: application/json" \
-d '{
"firstName": "Max",
"lastName": "Mustermann",
"geburtsdatum": "1990-01-15",
"email": "max@example.com",
"phone": "0123456789"
}'
# Bulk-Import von Mitgliedern
curl -X POST http://localhost:3100/api/members/bulk \
-H "Authorization: Bearer YOUR_TOKEN_HERE" \
-H "Content-Type: application/json" \
-d '{
"members": [
{
"firstName": "Max",
"lastName": "Mustermann",
"geburtsdatum": "1990-01-15",
"email": "max@example.com"
},
{
"firstName": "Anna",
"lastName": "Schmidt",
"geburtsdatum": "1985-03-20",
"email": "anna@example.com"
}
]
}'</code></pre>
</div>
<div>
<h3 class="text-lg font-semibold text-gray-900 mb-2">JavaScript/Fetch Beispiel:</h3>
<pre class="text-xs bg-gray-900 text-gray-100 p-4 rounded overflow-x-auto"><code>// Login
const loginResponse = await fetch('/api/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
email: 'admin@example.com',
password: 'passwort'
})
})
const { token } = await loginResponse.json()
// Mitglied hinzufügen
const memberResponse = await fetch('/api/members', {
method: 'POST',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({
firstName: 'Max',
lastName: 'Mustermann',
geburtsdatum: '1990-01-15',
email: 'max@example.com'
})
})
// Bulk-Import
const bulkResponse = await fetch('/api/members/bulk', {
method: 'POST',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({
members: [
{
firstName: 'Max',
lastName: 'Mustermann',
geburtsdatum: '1990-01-15',
email: 'max@example.com'
},
{
firstName: 'Anna',
lastName: 'Schmidt',
geburtsdatum: '1985-03-20',
email: 'anna@example.com'
}
]
})
})
const result = await bulkResponse.json()
console.log(`Importiert: ${result.summary.imported}, Duplikate: ${result.summary.duplicates}`)</code></pre>
</div>
</div>
</div>
<!-- Role Legend -->
<div class="mt-8 bg-gray-50 rounded-xl p-6">
<h2 class="text-xl font-semibold text-gray-900 mb-4">Legende</h2>
<div class="grid md:grid-cols-3 gap-4">
<div class="flex items-center space-x-2">
<span class="px-2 py-1 text-xs font-medium bg-green-100 text-green-800 rounded">Öffentlich</span>
<span class="text-sm text-gray-600">Keine Authentifizierung erforderlich</span>
</div>
<div class="flex items-center space-x-2">
<span class="px-2 py-1 text-xs font-medium bg-yellow-100 text-yellow-800 rounded">Auth erforderlich</span>
<span class="text-sm text-gray-600">Jeder eingeloggte Benutzer</span>
</div>
<div class="flex items-center space-x-2">
<span class="px-2 py-1 text-xs font-medium bg-red-100 text-red-800 rounded">admin/vorstand</span>
<span class="text-sm text-gray-600">Nur Admin oder Vorstand</span>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup>
definePageMeta({
middleware: 'auth',
layout: 'default'
})
useHead({
title: 'API-Dokumentation - Harheimer TC',
})
</script>

View File

@@ -16,6 +16,16 @@
<component :is="viewMode === 'cards' ? Table2 : Grid3x3" :size="20" class="mr-2" />
{{ viewMode === 'cards' ? 'Tabelle' : 'Karten' }}
</button>
<button
v-if="canEdit"
@click="showBulkImportModal = true"
class="flex items-center px-4 py-2 bg-green-600 hover:bg-green-700 text-white font-semibold rounded-lg transition-colors"
>
<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"></path>
</svg>
Bulk-Import
</button>
<button
v-if="canEdit"
@click="openAddModal"
@@ -234,6 +244,18 @@
</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
@@ -300,6 +322,144 @@
</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
@click="triggerBulkFileInput"
@dragover.prevent
@dragenter.prevent="isDragOver = true"
@dragleave.prevent="isDragOver = false"
@drop.prevent="handleBulkFileDrop"
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 }"
>
<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"></path>
</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"
@change="handleBulkFileSelect"
class="hidden"
/>
</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"
@click="closeBulkImportModal"
class="px-6 py-2 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50 transition-colors"
:disabled="isBulkImporting"
>
Schließen
</button>
<button
@click="processBulkImport"
: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"
>
<Loader2 v-if="isBulkImporting" :size="20" class="animate-spin mr-2" />
<span>{{ isBulkImporting ? 'Importiert...' : 'Importieren' }}</span>
</button>
</div>
</div>
</div>
</div>
</div>
</template>
@@ -318,9 +478,19 @@ const editingMember = ref(null)
const errorMessage = ref('')
const viewMode = ref('cards') // 'table' or '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: '',
@@ -355,6 +525,7 @@ const openAddModal = () => {
formData.value = {
firstName: '',
lastName: '',
geburtsdatum: '',
email: '',
phone: '',
address: '',
@@ -369,6 +540,7 @@ const openEditModal = (member) => {
formData.value = {
firstName: member.firstName || '',
lastName: member.lastName || '',
geburtsdatum: member.geburtsdatum || '',
email: member.email || '',
phone: member.phone || '',
address: member.address || '',
@@ -399,8 +571,18 @@ const saveMember = async () => {
closeModal()
await loadMembers()
if (window.showSuccessModal) {
window.showSuccessModal('Erfolg', 'Mitglied erfolgreich gespeichert.')
}
} catch (error) {
errorMessage.value = error.data?.message || 'Fehler beim Speichern des Mitglieds.'
console.error('Fehler beim Speichern:', error)
const errorMsg = error.data?.message || error.message || 'Fehler beim Speichern des Mitglieds.'
errorMessage.value = errorMsg
// If it's a duplicate error (409), show it prominently
if ((error.statusCode === 409 || error.status === 409) && window.showErrorModal) {
window.showErrorModal('Duplikat gefunden', errorMsg)
}
} finally {
isSaving.value = false
}
@@ -435,6 +617,131 @@ const formatDate = (dateString) => {
})
}
// 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
}
// Detect delimiter
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, ''))
}
// Parse header
const headers = parseCSVLine(lines[0]).map(h => h.toLowerCase())
// Find column indices
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
}
// Parse data rows
bulkPreviewData.value = lines.slice(1).map((line, index) => {
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.`
)
}
if (response.summary.duplicates > 0 || response.summary.errors > 0) {
// Results are already displayed in the modal
}
} catch (error) {
console.error('Fehler beim Bulk-Import:', error)
const errorMsg = error.data?.message || error.message || 'Fehler beim Import'
window.showErrorModal('Import-Fehler', errorMsg)
} 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()
})