Enhance ESLint configuration to include support for .mjs and .cjs file types. Update ignored files patterns to ensure proper linting of project files. Refactor Vue component templates for improved readability and maintainability, including consistent formatting and structure across various components. Update error handling in save functions to prevent silent failures.
Some checks failed
Code Analysis (JS/Vue) / analyze (push) Failing after 52s

This commit is contained in:
Torsten Schulz (local)
2026-04-15 20:37:14 +02:00
parent 1aae808e5f
commit ef2d9353f5
24 changed files with 1238 additions and 285 deletions

View File

@@ -290,10 +290,10 @@ async function save() {
} }
} }
await $fetch('/api/config', { method: 'PUT', body: updated }) await $fetch('/api/config', { method: 'PUT', body: updated })
try { window.showSuccessModal && window.showSuccessModal('Erfolg', 'Links erfolgreich gespeichert.') } catch {} try { window.showSuccessModal && window.showSuccessModal('Erfolg', 'Links erfolgreich gespeichert.') } catch (_e) { /* no-op */ }
} catch (error) { } catch (error) {
const msg = error?.data?.message || 'Fehler beim Speichern der Links' const msg = error?.data?.message || 'Fehler beim Speichern der Links'
try { window.showErrorModal && window.showErrorModal('Fehler', msg) } catch {} try { window.showErrorModal && window.showErrorModal('Fehler', msg) } catch (_e) { /* no-op */ }
} finally { } finally {
saving.value = false saving.value = false
} }

View File

@@ -2,39 +2,98 @@
<div> <div>
<div class="flex justify-between items-center mb-6"> <div class="flex justify-between items-center mb-6">
<div> <div>
<h2 class="text-2xl sm:text-3xl font-display font-bold text-gray-900 mb-2">Mannschaften verwalten</h2> <h2 class="text-2xl sm:text-3xl font-display font-bold text-gray-900 mb-2">
Mannschaften verwalten
</h2>
<div class="w-24 h-1 bg-primary-600" /> <div class="w-24 h-1 bg-primary-600" />
</div> </div>
<button 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"> <button
<Plus :size="20" class="mr-2" /> Mannschaft hinzufügen 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"
>
<Plus
:size="20"
class="mr-2"
/> Mannschaft hinzufügen
</button> </button>
</div> </div>
<div v-if="isLoading" class="flex items-center justify-center py-12"><Loader2 :size="40" class="animate-spin text-primary-600" /></div> <div
v-if="isLoading"
class="flex items-center justify-center py-12"
>
<Loader2
:size="40"
class="animate-spin text-primary-600"
/>
</div>
<div v-else class="bg-white rounded-xl shadow-lg overflow-hidden"> <div
v-else
class="bg-white rounded-xl shadow-lg overflow-hidden"
>
<div class="overflow-x-auto"> <div class="overflow-x-auto">
<table class="min-w-full divide-y divide-gray-200"> <table class="min-w-full divide-y divide-gray-200">
<thead class="bg-gray-50"> <thead class="bg-gray-50">
<tr> <tr>
<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">
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Liga</th> Mannschaft
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Staffelleiter</th> </th>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Mannschaftsführer</th> <th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Spieler</th> Liga
<th class="px-4 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">Aktionen</th> </th>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Staffelleiter
</th>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Mannschaftsführer
</th>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Spieler
</th>
<th class="px-4 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">
Aktionen
</th>
</tr> </tr>
</thead> </thead>
<tbody class="bg-white divide-y divide-gray-200"> <tbody class="bg-white divide-y divide-gray-200">
<tr v-for="(mannschaft, index) in mannschaften" :key="index" class="hover:bg-gray-50"> <tr
<td class="px-4 py-3 text-sm font-medium text-gray-900">{{ mannschaft.mannschaft }}</td> v-for="(mannschaft, index) in mannschaften"
<td class="px-4 py-3 text-sm text-gray-600">{{ mannschaft.liga }}</td> :key="index"
<td class="px-4 py-3 text-sm text-gray-600">{{ mannschaft.staffelleiter }}</td> class="hover:bg-gray-50"
<td class="px-4 py-3 text-sm text-gray-600">{{ mannschaft.mannschaftsfuehrer }}</td> >
<td class="px-4 py-3 text-sm text-gray-600"><div class="max-w-xs truncate">{{ getSpielerListe(mannschaft).join(', ') || '-' }}</div></td> <td class="px-4 py-3 text-sm font-medium text-gray-900">
{{ mannschaft.mannschaft }}
</td>
<td class="px-4 py-3 text-sm text-gray-600">
{{ mannschaft.liga }}
</td>
<td class="px-4 py-3 text-sm text-gray-600">
{{ mannschaft.staffelleiter }}
</td>
<td class="px-4 py-3 text-sm text-gray-600">
{{ mannschaft.mannschaftsfuehrer }}
</td>
<td class="px-4 py-3 text-sm text-gray-600">
<div class="max-w-xs truncate">
{{ getSpielerListe(mannschaft).join(', ') || '-' }}
</div>
</td>
<td class="px-4 py-3 whitespace-nowrap text-right text-sm font-medium space-x-3"> <td class="px-4 py-3 whitespace-nowrap text-right text-sm font-medium space-x-3">
<button class="text-gray-600 hover:text-gray-900" title="Bearbeiten" @click="openEditModal(mannschaft, index)"><Pencil :size="18" /></button> <button
<button class="text-red-600 hover:text-red-900" title="Löschen" @click="confirmDelete(mannschaft, index)"><Trash2 :size="18" /></button> class="text-gray-600 hover:text-gray-900"
title="Bearbeiten"
@click="openEditModal(mannschaft, index)"
>
<Pencil :size="18" />
</button>
<button
class="text-red-600 hover:text-red-900"
title="Löschen"
@click="confirmDelete(mannschaft, index)"
>
<Trash2 :size="18" />
</button>
</td> </td>
</tr> </tr>
</tbody> </tbody>
@@ -42,86 +101,248 @@
</div> </div>
</div> </div>
<div v-if="!isLoading && mannschaften.length === 0" class="bg-white rounded-xl shadow-lg p-12 text-center"> <div
<Users :size="48" class="text-gray-400 mx-auto mb-4" /> v-if="!isLoading && mannschaften.length === 0"
<h3 class="text-lg font-medium text-gray-900 mb-2">Keine Mannschaften vorhanden</h3> class="bg-white rounded-xl shadow-lg p-12 text-center"
<p class="text-gray-600 mb-6">Fügen Sie die erste Mannschaft hinzu.</p> >
<button class="px-4 py-2 bg-primary-600 hover:bg-primary-700 text-white font-semibold rounded-lg transition-colors" @click="openAddModal">Mannschaft hinzufügen</button> <Users
:size="48"
class="text-gray-400 mx-auto mb-4"
/>
<h3 class="text-lg font-medium text-gray-900 mb-2">
Keine Mannschaften vorhanden
</h3>
<p class="text-gray-600 mb-6">
Fügen Sie die erste Mannschaft hinzu.
</p>
<button
class="px-4 py-2 bg-primary-600 hover:bg-primary-700 text-white font-semibold rounded-lg transition-colors"
@click="openAddModal"
>
Mannschaft hinzufügen
</button>
</div> </div>
<!-- Add/Edit Modal --> <!-- Add/Edit 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
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 max-h-[90vh] overflow-y-auto"> <div class="bg-white rounded-xl shadow-2xl max-w-2xl w-full max-h-[90vh] overflow-y-auto">
<div class="p-6 border-b border-gray-200"> <div class="p-6 border-b border-gray-200">
<h2 class="text-2xl font-display font-bold text-gray-900">{{ isEditing ? 'Mannschaft bearbeiten' : 'Neue Mannschaft' }}</h2> <h2 class="text-2xl font-display font-bold text-gray-900">
{{ isEditing ? 'Mannschaft bearbeiten' : 'Neue Mannschaft' }}
</h2>
</div> </div>
<form class="p-6 space-y-4" @submit.prevent="saveMannschaft"> <form
class="p-6 space-y-4"
@submit.prevent="saveMannschaft"
>
<div> <div>
<label class="block text-sm font-medium text-gray-700 mb-2">Mannschaft *</label> <label class="block text-sm font-medium text-gray-700 mb-2">Mannschaft *</label>
<input v-model="formData.mannschaft" 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"> <input
v-model="formData.mannschaft"
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> <div>
<label class="block text-sm font-medium text-gray-700 mb-2">Liga *</label> <label class="block text-sm font-medium text-gray-700 mb-2">Liga *</label>
<input v-model="formData.liga" 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"> <input
v-model="formData.liga"
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 class="grid grid-cols-2 gap-4"> <div class="grid grid-cols-2 gap-4">
<div> <div>
<label class="block text-sm font-medium text-gray-700 mb-2">Staffelleiter</label> <label class="block text-sm font-medium text-gray-700 mb-2">Staffelleiter</label>
<input v-model="formData.staffelleiter" 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"> <input
v-model="formData.staffelleiter"
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>
<div> <div>
<label class="block text-sm font-medium text-gray-700 mb-2">Telefon</label> <label class="block text-sm font-medium text-gray-700 mb-2">Telefon</label>
<input v-model="formData.telefon" 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"> <input
v-model="formData.telefon"
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>
</div> </div>
<div class="grid grid-cols-2 gap-4"> <div class="grid grid-cols-2 gap-4">
<div> <div>
<label class="block text-sm font-medium text-gray-700 mb-2">Heimspieltag</label> <label class="block text-sm font-medium text-gray-700 mb-2">Heimspieltag</label>
<input v-model="formData.heimspieltag" 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"> <input
v-model="formData.heimspieltag"
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>
<div> <div>
<label class="block text-sm font-medium text-gray-700 mb-2">Spielsystem</label> <label class="block text-sm font-medium text-gray-700 mb-2">Spielsystem</label>
<input v-model="formData.spielsystem" 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"> <input
v-model="formData.spielsystem"
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>
</div> </div>
<div> <div>
<label class="block text-sm font-medium text-gray-700 mb-2">Mannschaftsführer</label> <label class="block text-sm font-medium text-gray-700 mb-2">Mannschaftsführer</label>
<input v-model="formData.mannschaftsfuehrer" 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"> <input
v-model="formData.mannschaftsfuehrer"
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>
<div> <div>
<label class="block text-sm font-medium text-gray-700 mb-2">Spieler</label> <label class="block text-sm font-medium text-gray-700 mb-2">Spieler</label>
<div class="space-y-2"> <div class="space-y-2">
<div v-if="formData.spielerListe.length === 0" class="text-sm text-gray-500">Noch keine Spieler eingetragen.</div> <div
<div v-for="(spieler, index) in formData.spielerListe" :key="spieler.id" class="px-3 py-2 border border-gray-200 rounded-lg bg-white"> v-if="formData.spielerListe.length === 0"
class="text-sm text-gray-500"
>
Noch keine Spieler eingetragen.
</div>
<div
v-for="(spieler, index) in formData.spielerListe"
:key="spieler.id"
class="px-3 py-2 border border-gray-200 rounded-lg bg-white"
>
<div class="flex flex-col lg:flex-row lg:items-center gap-2"> <div class="flex flex-col lg:flex-row lg:items-center gap-2">
<input v-model="spieler.name" type="text" class="flex-1 min-w-[14rem] px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500" placeholder="Spielername" :disabled="isSaving"> <input
v-model="spieler.name"
type="text"
class="flex-1 min-w-[14rem] px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500"
placeholder="Spielername"
:disabled="isSaving"
>
<div class="flex items-center gap-1"> <div class="flex items-center gap-1">
<button type="button" class="p-2 border border-gray-300 rounded-lg hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed" title="Nach oben" :disabled="isSaving || index === 0" @click="moveSpielerUp(index)"><ChevronUp :size="18" /></button> <button
<button type="button" class="p-2 border border-gray-300 rounded-lg hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed" title="Nach unten" :disabled="isSaving || index === formData.spielerListe.length - 1" @click="moveSpielerDown(index)"><ChevronDown :size="18" /></button> type="button"
<button type="button" class="p-2 border border-red-300 text-red-700 rounded-lg hover:bg-red-50 disabled:opacity-50 disabled:cursor-not-allowed" title="Spieler entfernen" :disabled="isSaving" @click="removeSpieler(spieler.id)"><Trash2 :size="18" /></button> class="p-2 border border-gray-300 rounded-lg hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
title="Nach oben"
:disabled="isSaving || index === 0"
@click="moveSpielerUp(index)"
>
<ChevronUp :size="18" />
</button>
<button
type="button"
class="p-2 border border-gray-300 rounded-lg hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
title="Nach unten"
:disabled="isSaving || index === formData.spielerListe.length - 1"
@click="moveSpielerDown(index)"
>
<ChevronDown :size="18" />
</button>
<button
type="button"
class="p-2 border border-red-300 text-red-700 rounded-lg hover:bg-red-50 disabled:opacity-50 disabled:cursor-not-allowed"
title="Spieler entfernen"
:disabled="isSaving"
@click="removeSpieler(spieler.id)"
>
<Trash2 :size="18" />
</button>
</div> </div>
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<select v-model="moveTargetBySpielerId[spieler.id]" class="min-w-[14rem] px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500 disabled:opacity-50 disabled:cursor-not-allowed" :disabled="isSaving || !isEditing || mannschaftenSelectOptions.length <= 1" title="Mannschaft auswählen"> <select
<option v-for="t in mannschaftenSelectOptions" :key="t" :value="t">{{ t }}</option> v-model="moveTargetBySpielerId[spieler.id]"
class="min-w-[14rem] px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500 disabled:opacity-50 disabled:cursor-not-allowed"
:disabled="isSaving || !isEditing || mannschaftenSelectOptions.length <= 1"
title="Mannschaft auswählen"
>
<option
v-for="t in mannschaftenSelectOptions"
:key="t"
:value="t"
>
{{ t }}
</option>
</select> </select>
<button type="button" class="inline-flex items-center justify-center px-3 py-2 border border-gray-300 rounded-lg hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed" :disabled="isSaving || !isEditing || mannschaftenSelectOptions.length <= 1 || !canMoveSpieler(spieler.id)" title="In ausgewählte Mannschaft verschieben" @click="moveSpielerToMannschaft(spieler.id)"><ArrowRight :size="18" /></button> <button
type="button"
class="inline-flex items-center justify-center px-3 py-2 border border-gray-300 rounded-lg hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
:disabled="isSaving || !isEditing || mannschaftenSelectOptions.length <= 1 || !canMoveSpieler(spieler.id)"
title="In ausgewählte Mannschaft verschieben"
@click="moveSpielerToMannschaft(spieler.id)"
>
<ArrowRight :size="18" />
</button>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
<div class="mt-3 flex items-center justify-between"> <div class="mt-3 flex items-center justify-between">
<button type="button" class="inline-flex items-center px-4 py-2 bg-gray-100 hover:bg-gray-200 text-gray-800 font-semibold rounded-lg transition-colors" :disabled="isSaving" @click="addSpieler()"><Plus :size="18" class="mr-2" /> Spieler hinzufügen</button> <button
<p class="text-xs text-gray-500">Reihenfolge per / ändern.</p> type="button"
class="inline-flex items-center px-4 py-2 bg-gray-100 hover:bg-gray-200 text-gray-800 font-semibold rounded-lg transition-colors"
:disabled="isSaving"
@click="addSpieler()"
>
<Plus
:size="18"
class="mr-2"
/> Spieler hinzufügen
</button>
<p class="text-xs text-gray-500">
Reihenfolge per / ändern.
</p>
</div> </div>
</div> </div>
<div> <div>
<label class="block text-sm font-medium text-gray-700 mb-2">Weitere Informationen (Link)</label> <label class="block text-sm font-medium text-gray-700 mb-2">Weitere Informationen (Link)</label>
<input v-model="formData.weitere_informationen_link" type="url" class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500" placeholder="https://..." :disabled="isSaving"> <input
v-model="formData.weitere_informationen_link"
type="url"
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500"
placeholder="https://..."
:disabled="isSaving"
>
</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>
<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"> <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
<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> 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> </div>
</form> </form>
</div> </div>

View File

@@ -670,10 +670,18 @@
<table class="min-w-full divide-y divide-gray-200 text-sm"> <table class="min-w-full divide-y divide-gray-200 text-sm">
<thead class="bg-gray-50 sticky top-0"> <thead class="bg-gray-50 sticky top-0">
<tr> <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">
<th class="px-3 py-2 text-left text-xs font-medium text-gray-500 uppercase">Nachname</th> Vorname
<th class="px-3 py-2 text-left text-xs font-medium text-gray-500 uppercase">Geburtsdatum</th> </th>
<th class="px-3 py-2 text-left text-xs font-medium text-gray-500 uppercase">E-Mail</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> </tr>
</thead> </thead>
<tbody class="bg-white divide-y divide-gray-200"> <tbody class="bg-white divide-y divide-gray-200">
@@ -682,10 +690,18 @@
:key="index" :key="index"
class="hover:bg-gray-50" class="hover:bg-gray-50"
> >
<td class="px-3 py-2">{{ row.firstName || '-' }}</td> <td class="px-3 py-2">
<td class="px-3 py-2">{{ row.lastName || '-' }}</td> {{ row.firstName || '-' }}
<td class="px-3 py-2">{{ row.geburtsdatum || '-' }}</td> </td>
<td class="px-3 py-2">{{ row.email || '-' }}</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> </tr>
</tbody> </tbody>
</table> </table>
@@ -704,35 +720,65 @@
class="mb-6" class="mb-6"
> >
<div class="bg-gray-50 rounded-lg p-4"> <div class="bg-gray-50 rounded-lg p-4">
<h3 class="text-lg font-semibold text-gray-900 mb-3">Import-Ergebnisse</h3> <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="grid grid-cols-3 gap-4 mb-4">
<div class="text-center"> <div class="text-center">
<div class="text-2xl font-bold text-green-600">{{ bulkImportResults.summary.imported }}</div> <div class="text-2xl font-bold text-green-600">
<div class="text-sm text-gray-600">Importiert</div> {{ bulkImportResults.summary.imported }}
</div>
<div class="text-sm text-gray-600">
Importiert
</div>
</div> </div>
<div class="text-center"> <div class="text-center">
<div class="text-2xl font-bold text-yellow-600">{{ bulkImportResults.summary.duplicates }}</div> <div class="text-2xl font-bold text-yellow-600">
<div class="text-sm text-gray-600">Duplikate</div> {{ bulkImportResults.summary.duplicates }}
</div>
<div class="text-sm text-gray-600">
Duplikate
</div>
</div> </div>
<div class="text-center"> <div class="text-center">
<div class="text-2xl font-bold text-red-600">{{ bulkImportResults.summary.errors }}</div> <div class="text-2xl font-bold text-red-600">
<div class="text-sm text-gray-600">Fehler</div> {{ bulkImportResults.summary.errors }}
</div>
<div class="text-sm text-gray-600">
Fehler
</div>
</div> </div>
</div> </div>
<div v-if="bulkImportResults.results.duplicates.length > 0" class="mt-4"> <div
<h4 class="text-sm font-medium text-gray-700 mb-2">Duplikate:</h4> 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 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"> <div
v-for="dup in bulkImportResults.results.duplicates"
:key="dup.index"
>
Zeile {{ dup.index }}: {{ dup.member.firstName }} {{ dup.member.lastName }} - {{ dup.reason }} Zeile {{ dup.index }}: {{ dup.member.firstName }} {{ dup.member.lastName }} - {{ dup.reason }}
</div> </div>
</div> </div>
</div> </div>
<div v-if="bulkImportResults.results.errors.length > 0" class="mt-4"> <div
<h4 class="text-sm font-medium text-gray-700 mb-2">Fehler:</h4> 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 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"> <div
v-for="err in bulkImportResults.results.errors"
:key="err.index"
>
Zeile {{ err.index }}: {{ err.error }} Zeile {{ err.index }}: {{ err.error }}
</div> </div>
</div> </div>

View File

@@ -105,15 +105,23 @@
<div class="px-6 py-4"> <div class="px-6 py-4">
<div class="grid grid-cols-1 md:grid-cols-2 gap-4"> <div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div> <div>
<h4 class="text-sm font-medium text-gray-900 mb-2">Kontaktdaten</h4> <h4 class="text-sm font-medium text-gray-900 mb-2">
Kontaktdaten
</h4>
<div class="space-y-1 text-sm text-gray-600"> <div class="space-y-1 text-sm text-gray-600">
<p><strong>E-Mail:</strong> {{ application.personalData.email }}</p> <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_privat">
<p v-if="application.personalData.telefon_mobil"><strong>Mobil:</strong> {{ application.personalData.telefon_mobil }}</p> <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> </div>
<div> <div>
<h4 class="text-sm font-medium text-gray-900 mb-2">Antragsdetails</h4> <h4 class="text-sm font-medium text-gray-900 mb-2">
Antragsdetails
</h4>
<div class="space-y-1 text-sm text-gray-600"> <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>Art:</strong> {{ application.metadata.mitgliedschaftsart === 'aktiv' ? 'Aktives Mitglied' : 'Passives Mitglied' }}</p>
<p><strong>Volljährig:</strong> {{ application.metadata.isVolljaehrig ? 'Ja' : 'Nein' }}</p> <p><strong>Volljährig:</strong> {{ application.metadata.isVolljaehrig ? 'Ja' : 'Nein' }}</p>
@@ -141,8 +149,18 @@
class="text-gray-400 hover:text-gray-600" class="text-gray-400 hover:text-gray-600"
@click="closeModal" @click="closeModal"
> >
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" /> 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> </svg>
</button> </button>
</div> </div>
@@ -151,16 +169,24 @@
<div class="px-6 py-4"> <div class="px-6 py-4">
<div class="grid grid-cols-1 md:grid-cols-2 gap-6"> <div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<div> <div>
<h3 class="text-lg font-medium text-gray-900 mb-4">Persönliche Daten</h3> <h3 class="text-lg font-medium text-gray-900 mb-4">
Persönliche Daten
</h3>
<div class="space-y-2 text-sm"> <div class="space-y-2 text-sm">
<p><strong>Name:</strong> {{ selectedApplication.personalData.vorname }} {{ selectedApplication.personalData.nachname }}</p> <p><strong>Name:</strong> {{ selectedApplication.personalData.vorname }} {{ selectedApplication.personalData.nachname }}</p>
<p><strong>E-Mail:</strong> {{ selectedApplication.personalData.email }}</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_privat">
<p v-if="selectedApplication.personalData.telefon_mobil"><strong>Mobil:</strong> {{ selectedApplication.personalData.telefon_mobil }}</p> <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> </div>
<div> <div>
<h3 class="text-lg font-medium text-gray-900 mb-4">Antragsdetails</h3> <h3 class="text-lg font-medium text-gray-900 mb-4">
Antragsdetails
</h3>
<div class="space-y-2 text-sm"> <div class="space-y-2 text-sm">
<p><strong>Status:</strong> {{ getStatusText(selectedApplication.status) }}</p> <p><strong>Status:</strong> {{ getStatusText(selectedApplication.status) }}</p>
<p><strong>Art:</strong> {{ selectedApplication.metadata.mitgliedschaftsart === 'aktiv' ? 'Aktives Mitglied' : 'Passives Mitglied' }}</p> <p><strong>Art:</strong> {{ selectedApplication.metadata.mitgliedschaftsart === 'aktiv' ? 'Aktives Mitglied' : 'Passives Mitglied' }}</p>
@@ -172,14 +198,29 @@
<div class="mt-6 pt-6 border-t border-gray-200"> <div class="mt-6 pt-6 border-t border-gray-200">
<div class="flex justify-end space-x-3"> <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
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 <button
v-if="selectedApplication.metadata.pdfGenerated" 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" 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)" @click="downloadPDF(selectedApplication.id)"
> >
<svg class="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg
<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" /> 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> </svg>
PDF herunterladen PDF herunterladen
</button> </button>

View File

@@ -2,112 +2,335 @@
<div> <div>
<!-- Header --> <!-- Header -->
<div class="flex items-center justify-between mb-4"> <div class="flex items-center justify-between mb-4">
<h2 class="text-xl sm:text-2xl font-display font-bold text-gray-900">Spielpläne bearbeiten</h2> <h2 class="text-xl sm:text-2xl font-display font-bold text-gray-900">
Spielpläne bearbeiten
</h2>
<div class="space-x-3"> <div class="space-x-3">
<button class="inline-flex items-center px-3 py-1.5 sm:px-4 sm:py-2 rounded-lg bg-green-600 text-white hover:bg-green-700 text-sm sm:text-base" @click="showUploadModal = true"> <button
<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="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> class="inline-flex items-center px-3 py-1.5 sm:px-4 sm:py-2 rounded-lg bg-green-600 text-white hover:bg-green-700 text-sm sm:text-base"
@click="showUploadModal = true"
>
<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="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>
CSV hochladen CSV hochladen
</button> </button>
<button class="inline-flex items-center px-3 py-1.5 sm:px-4 sm:py-2 rounded-lg bg-primary-600 text-white hover:bg-primary-700 text-sm sm:text-base" @click="save">Speichern</button> <button
class="inline-flex items-center px-3 py-1.5 sm:px-4 sm:py-2 rounded-lg bg-primary-600 text-white hover:bg-primary-700 text-sm sm:text-base"
@click="save"
>
Speichern
</button>
</div> </div>
</div> </div>
<!-- CSV Upload Section --> <!-- CSV Upload Section -->
<div class="mb-8 bg-white rounded-xl shadow-lg p-6"> <div class="mb-8 bg-white rounded-xl shadow-lg p-6">
<h3 class="text-xl font-semibold text-gray-900 mb-4">Vereins-Spielplan (CSV)</h3> <h3 class="text-xl font-semibold text-gray-900 mb-4">
<div v-if="currentFile" class="mb-4 p-4 bg-green-50 border border-green-200 rounded-lg"> Vereins-Spielplan (CSV)
</h3>
<div
v-if="currentFile"
class="mb-4 p-4 bg-green-50 border border-green-200 rounded-lg"
>
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<div class="flex items-center"> <div class="flex items-center">
<svg class="w-5 h-5 text-green-600 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" /></svg> <svg
<div><p class="text-sm font-medium text-green-800">{{ currentFile.name }}</p><p class="text-xs text-green-600">{{ currentFile.size }} bytes</p></div> class="w-5 h-5 text-green-600 mr-2"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
><path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"
/></svg>
<div>
<p class="text-sm font-medium text-green-800">
{{ currentFile.name }}
</p><p class="text-xs text-green-600">
{{ currentFile.size }} bytes
</p>
</div>
</div> </div>
<button class="px-3 py-1 text-sm bg-red-100 hover:bg-red-200 text-red-700 rounded-lg transition-colors" @click="removeFile">Entfernen</button> <button
class="px-3 py-1 text-sm bg-red-100 hover:bg-red-200 text-red-700 rounded-lg transition-colors"
@click="removeFile"
>
Entfernen
</button>
</div> </div>
</div> </div>
<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="triggerFileInput" @dragover.prevent @dragenter.prevent @drop.prevent="handleFileDrop"> <div
<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> 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"
<p class="text-lg font-medium text-gray-900 mb-2">CSV-Datei hochladen</p> :class="{ 'border-primary-400 bg-primary-50': isDragOver }"
<p class="text-sm text-gray-600 mb-4">Klicken Sie hier oder ziehen Sie eine CSV-Datei hierher</p> @click="triggerFileInput"
@dragover.prevent
@dragenter.prevent
@drop.prevent="handleFileDrop"
>
<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>
</div> </div>
<input ref="fileInput" type="file" accept=".csv" class="hidden" @change="handleFileSelect"> <input
ref="fileInput"
type="file"
accept=".csv"
class="hidden"
@change="handleFileSelect"
>
</div> </div>
<!-- Column Selection --> <!-- Column Selection -->
<div v-if="csvData.length > 0 && !columnsSelected" class="bg-white rounded-xl shadow-lg p-6 mb-8"> <div
<h3 class="text-xl font-semibold text-gray-900 mb-4">Spalten auswählen</h3> v-if="csvData.length > 0 && !columnsSelected"
<p class="text-sm text-gray-600 mb-6">Wählen Sie die Spalten aus, die für den Spielplan gespeichert werden sollen:</p> class="bg-white rounded-xl shadow-lg p-6 mb-8"
>
<h3 class="text-xl font-semibold text-gray-900 mb-4">
Spalten auswählen
</h3>
<p class="text-sm text-gray-600 mb-6">
Wählen Sie die Spalten aus, die für den Spielplan gespeichert werden sollen:
</p>
<div class="space-y-4"> <div class="space-y-4">
<div v-for="(header, index) in csvHeaders" :key="index" class="flex items-center justify-between p-4 border border-gray-200 rounded-lg hover:bg-gray-50"> <div
v-for="(header, index) in csvHeaders"
:key="index"
class="flex items-center justify-between p-4 border border-gray-200 rounded-lg hover:bg-gray-50"
>
<div class="flex items-center"> <div class="flex items-center">
<input :id="`column-${index}`" v-model="selectedColumns[index]" type="checkbox" class="h-4 w-4 text-primary-600 focus:ring-primary-500 border-gray-300 rounded"> <input
<label :for="`column-${index}`" class="ml-3 text-sm font-medium text-gray-900">{{ header }}</label> :id="`column-${index}`"
v-model="selectedColumns[index]"
type="checkbox"
class="h-4 w-4 text-primary-600 focus:ring-primary-500 border-gray-300 rounded"
>
<label
:for="`column-${index}`"
class="ml-3 text-sm font-medium text-gray-900"
>{{ header }}</label>
</div>
<div class="text-xs text-gray-500">
{{ getColumnPreview(index) }}
</div> </div>
<div class="text-xs text-gray-500">{{ getColumnPreview(index) }}</div>
</div> </div>
</div> </div>
<div class="mt-6 flex justify-between items-center"> <div class="mt-6 flex justify-between items-center">
<div class="text-sm text-gray-600">{{ selectedColumnsCount }} von {{ csvHeaders.length }} Spalten ausgewählt</div> <div class="text-sm text-gray-600">
{{ selectedColumnsCount }} von {{ csvHeaders.length }} Spalten ausgewählt
</div>
<div class="space-x-3"> <div class="space-x-3">
<button class="px-4 py-2 text-sm bg-gray-100 hover:bg-gray-200 text-gray-700 rounded-lg transition-colors" @click="selectAllColumns">Alle auswählen</button> <button
<button class="px-4 py-2 text-sm bg-gray-100 hover:bg-gray-200 text-gray-700 rounded-lg transition-colors" @click="deselectAllColumns">Alle abwählen</button> class="px-4 py-2 text-sm bg-gray-100 hover:bg-gray-200 text-gray-700 rounded-lg transition-colors"
<button class="px-4 py-2 text-sm bg-blue-100 hover:bg-blue-200 text-blue-700 rounded-lg transition-colors" @click="suggestHalleColumns">Halle-Spalten vorschlagen</button> @click="selectAllColumns"
<button :disabled="selectedColumnsCount === 0" class="px-4 py-2 bg-primary-600 text-white rounded-lg hover:bg-primary-700 transition-colors disabled:bg-gray-400" @click="confirmColumnSelection">Auswahl bestätigen</button> >
Alle auswählen
</button>
<button
class="px-4 py-2 text-sm bg-gray-100 hover:bg-gray-200 text-gray-700 rounded-lg transition-colors"
@click="deselectAllColumns"
>
Alle abwählen
</button>
<button
class="px-4 py-2 text-sm bg-blue-100 hover:bg-blue-200 text-blue-700 rounded-lg transition-colors"
@click="suggestHalleColumns"
>
Halle-Spalten vorschlagen
</button>
<button
:disabled="selectedColumnsCount === 0"
class="px-4 py-2 bg-primary-600 text-white rounded-lg hover:bg-primary-700 transition-colors disabled:bg-gray-400"
@click="confirmColumnSelection"
>
Auswahl bestätigen
</button>
</div> </div>
</div> </div>
</div> </div>
<!-- Data Preview --> <!-- Data Preview -->
<div v-if="csvData.length > 0 && columnsSelected" class="bg-white rounded-xl shadow-lg p-6"> <div
v-if="csvData.length > 0 && columnsSelected"
class="bg-white rounded-xl shadow-lg p-6"
>
<div class="flex items-center justify-between mb-4"> <div class="flex items-center justify-between mb-4">
<h3 class="text-xl font-semibold text-gray-900">Datenvorschau</h3> <h3 class="text-xl font-semibold text-gray-900">
Datenvorschau
</h3>
<div class="flex space-x-2"> <div class="flex space-x-2">
<button class="px-3 py-1 text-sm bg-blue-100 hover:bg-blue-200 text-blue-700 rounded-lg transition-colors" @click="exportCSV">CSV exportieren</button> <button
<button class="px-3 py-1 text-sm bg-red-100 hover:bg-red-200 text-red-700 rounded-lg transition-colors" @click="clearData">Daten löschen</button> class="px-3 py-1 text-sm bg-blue-100 hover:bg-blue-200 text-blue-700 rounded-lg transition-colors"
@click="exportCSV"
>
CSV exportieren
</button>
<button
class="px-3 py-1 text-sm bg-red-100 hover:bg-red-200 text-red-700 rounded-lg transition-colors"
@click="clearData"
>
Daten löschen
</button>
</div> </div>
</div> </div>
<div class="overflow-x-auto"> <div class="overflow-x-auto">
<table class="min-w-full divide-y divide-gray-200"> <table class="min-w-full divide-y divide-gray-200">
<thead class="bg-gray-50"><tr><th v-for="(header, index) in (columnsSelected ? filteredCsvHeaders : csvHeaders)" :key="index" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">{{ header }}</th></tr></thead> <thead class="bg-gray-50">
<tr>
<th
v-for="(header, index) in (columnsSelected ? filteredCsvHeaders : csvHeaders)"
:key="index"
class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"
>
{{ header }}
</th>
</tr>
</thead>
<tbody class="bg-white divide-y divide-gray-200"> <tbody class="bg-white divide-y divide-gray-200">
<tr v-for="(row, rowIndex) in (columnsSelected ? filteredCsvData : csvData).slice(0, 10)" :key="rowIndex" :class="rowIndex % 2 === 0 ? 'bg-white' : 'bg-gray-50'"> <tr
<td v-for="(cell, cellIndex) in row" :key="cellIndex" class="px-6 py-4 whitespace-nowrap text-sm text-gray-900">{{ cell }}</td> v-for="(row, rowIndex) in (columnsSelected ? filteredCsvData : csvData).slice(0, 10)"
:key="rowIndex"
:class="rowIndex % 2 === 0 ? 'bg-white' : 'bg-gray-50'"
>
<td
v-for="(cell, cellIndex) in row"
:key="cellIndex"
class="px-6 py-4 whitespace-nowrap text-sm text-gray-900"
>
{{ cell }}
</td>
</tr> </tr>
</tbody> </tbody>
</table> </table>
</div> </div>
<div v-if="(columnsSelected ? filteredCsvData : csvData).length > 10" class="mt-4 text-center text-sm text-gray-600">Zeige erste 10 von {{ (columnsSelected ? filteredCsvData : csvData).length }} Zeilen</div> <div
<div class="mt-4 text-sm text-gray-600"><p><strong>Zeilen:</strong> {{ (columnsSelected ? filteredCsvData : csvData).length }}</p><p><strong>Spalten:</strong> {{ (columnsSelected ? filteredCsvHeaders : csvHeaders).length }}</p></div> v-if="(columnsSelected ? filteredCsvData : csvData).length > 10"
class="mt-4 text-center text-sm text-gray-600"
>
Zeige erste 10 von {{ (columnsSelected ? filteredCsvData : csvData).length }} Zeilen
</div>
<div class="mt-4 text-sm text-gray-600">
<p><strong>Zeilen:</strong> {{ (columnsSelected ? filteredCsvData : csvData).length }}</p><p><strong>Spalten:</strong> {{ (columnsSelected ? filteredCsvHeaders : csvHeaders).length }}</p>
</div>
</div> </div>
<!-- Empty State --> <!-- Empty State -->
<div v-if="csvData.length === 0" class="text-center py-12 bg-white rounded-xl shadow-lg"> <div
<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="M9 12h6m-6 4h6m2 5H7a2 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> v-if="csvData.length === 0"
<p class="text-gray-600">Keine CSV-Daten geladen.</p> class="text-center py-12 bg-white rounded-xl shadow-lg"
<p class="text-sm text-gray-500 mt-2">Laden Sie eine CSV-Datei hoch, um Spielplandaten zu verwalten.</p> >
<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="M9 12h6m-6 4h6m2 5H7a2 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>
<p class="text-gray-600">
Keine CSV-Daten geladen.
</p>
<p class="text-sm text-gray-500 mt-2">
Laden Sie eine CSV-Datei hoch, um Spielplandaten zu verwalten.
</p>
</div> </div>
<!-- Upload Modal --> <!-- Upload Modal -->
<div v-if="showUploadModal" class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4" @click.self="closeUploadModal"> <div
v-if="showUploadModal"
class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4"
@click.self="closeUploadModal"
>
<div class="bg-white rounded-lg max-w-md w-full p-6"> <div class="bg-white rounded-lg max-w-md w-full p-6">
<h3 class="text-lg font-semibold text-gray-900 mb-4">CSV-Datei hochladen</h3> <h3 class="text-lg font-semibold text-gray-900 mb-4">
CSV-Datei hochladen
</h3>
<div class="space-y-4"> <div class="space-y-4">
<div><label class="block text-sm font-medium text-gray-700 mb-2">Datei auswählen</label><input ref="modalFileInput" type="file" accept=".csv" class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500" @change="handleModalFileSelect"></div> <div>
<div v-if="selectedFile" class="p-3 bg-gray-50 rounded-lg"><p class="text-sm text-gray-700"><strong>Ausgewählte Datei:</strong> {{ selectedFile.name }}</p><p class="text-xs text-gray-500">{{ selectedFile.size }} bytes</p></div> <label class="block text-sm font-medium text-gray-700 mb-2">Datei auswählen</label><input
<div class="bg-blue-50 p-4 rounded-lg"><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</p><p> Trennzeichen: Komma (,)</p></div></div> ref="modalFileInput"
type="file"
accept=".csv"
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
@change="handleModalFileSelect"
>
</div>
<div
v-if="selectedFile"
class="p-3 bg-gray-50 rounded-lg"
>
<p class="text-sm text-gray-700">
<strong>Ausgewählte Datei:</strong> {{ selectedFile.name }}
</p><p class="text-xs text-gray-500">
{{ selectedFile.size }} bytes
</p>
</div>
<div class="bg-blue-50 p-4 rounded-lg">
<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</p><p> Trennzeichen: Komma (,)</p>
</div>
</div>
</div> </div>
<div class="flex justify-end space-x-3 pt-4"> <div class="flex justify-end space-x-3 pt-4">
<button class="px-4 py-2 text-gray-700 bg-gray-100 hover:bg-gray-200 rounded-lg transition-colors" @click="closeUploadModal">Abbrechen</button> <button
<button :disabled="!selectedFile" class="px-4 py-2 bg-primary-600 text-white rounded-lg hover:bg-primary-700 transition-colors disabled:bg-gray-400" @click="processSelectedFile">Hochladen</button> class="px-4 py-2 text-gray-700 bg-gray-100 hover:bg-gray-200 rounded-lg transition-colors"
@click="closeUploadModal"
>
Abbrechen
</button>
<button
:disabled="!selectedFile"
class="px-4 py-2 bg-primary-600 text-white rounded-lg hover:bg-primary-700 transition-colors disabled:bg-gray-400"
@click="processSelectedFile"
>
Hochladen
</button>
</div> </div>
</div> </div>
</div> </div>
<!-- Processing Modal --> <!-- Processing Modal -->
<div v-if="isProcessing" class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4"> <div
v-if="isProcessing"
class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4"
>
<div class="bg-white rounded-lg max-w-sm w-full p-6 text-center"> <div class="bg-white rounded-lg max-w-sm w-full p-6 text-center">
<div class="animate-spin rounded-full h-12 w-12 border-b-2 border-primary-600 mx-auto mb-4" /> <div class="animate-spin rounded-full h-12 w-12 border-b-2 border-primary-600 mx-auto mb-4" />
<h3 class="text-lg font-semibold text-gray-900 mb-2">Verarbeitung läuft...</h3> <h3 class="text-lg font-semibold text-gray-900 mb-2">
<p class="text-sm text-gray-600">{{ processingMessage }}</p> Verarbeitung läuft...
</h3>
<p class="text-sm text-gray-600">
{{ processingMessage }}
</p>
</div> </div>
</div> </div>
</div> </div>

View File

@@ -11,36 +11,72 @@
class="flex items-center px-4 py-2 bg-primary-600 hover:bg-primary-700 text-white font-semibold rounded-lg transition-colors" 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" @click="openAddModal"
> >
<Plus :size="20" class="mr-2" /> <Plus
:size="20"
class="mr-2"
/>
Termin hinzufügen Termin hinzufügen
</button> </button>
</div> </div>
<!-- Loading State --> <!-- Loading State -->
<div v-if="isLoading" class="flex items-center justify-center py-12"> <div
<Loader2 :size="40" class="animate-spin text-primary-600" /> v-if="isLoading"
class="flex items-center justify-center py-12"
>
<Loader2
:size="40"
class="animate-spin text-primary-600"
/>
</div> </div>
<!-- Termine Table --> <!-- Termine Table -->
<div v-else class="bg-white rounded-xl shadow-lg overflow-hidden"> <div
v-else
class="bg-white rounded-xl shadow-lg overflow-hidden"
>
<div class="overflow-x-auto"> <div class="overflow-x-auto">
<table class="min-w-full divide-y divide-gray-200"> <table class="min-w-full divide-y divide-gray-200">
<thead class="bg-gray-50"> <thead class="bg-gray-50">
<tr> <tr>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Datum</th> <th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Uhrzeit</th> Datum
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Titel</th> </th>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Beschreibung</th> <th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Kategorie</th> Uhrzeit
<th class="px-4 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">Aktionen</th> </th>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Titel
</th>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Beschreibung
</th>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Kategorie
</th>
<th class="px-4 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">
Aktionen
</th>
</tr> </tr>
</thead> </thead>
<tbody class="bg-white divide-y divide-gray-200"> <tbody class="bg-white divide-y divide-gray-200">
<tr v-for="termin in termine" :key="`${termin.datum}-${termin.uhrzeit || ''}-${termin.titel}`" class="hover:bg-gray-50"> <tr
<td class="px-4 py-3 whitespace-nowrap text-sm text-gray-900">{{ formatDate(termin.datum) }}</td> v-for="termin in termine"
<td class="px-4 py-3 whitespace-nowrap text-sm text-gray-900">{{ termin.uhrzeit || '-' }}</td> :key="`${termin.datum}-${termin.uhrzeit || ''}-${termin.titel}`"
<td class="px-4 py-3 text-sm font-medium text-gray-900">{{ termin.titel }}</td> class="hover:bg-gray-50"
<td class="px-4 py-3 text-sm text-gray-600">{{ termin.beschreibung || '-' }}</td> >
<td class="px-4 py-3 whitespace-nowrap text-sm text-gray-900">
{{ formatDate(termin.datum) }}
</td>
<td class="px-4 py-3 whitespace-nowrap text-sm text-gray-900">
{{ termin.uhrzeit || '-' }}
</td>
<td class="px-4 py-3 text-sm font-medium text-gray-900">
{{ termin.titel }}
</td>
<td class="px-4 py-3 text-sm text-gray-600">
{{ termin.beschreibung || '-' }}
</td>
<td class="px-4 py-3 whitespace-nowrap"> <td class="px-4 py-3 whitespace-nowrap">
<span <span
:class="{ :class="{
@@ -54,56 +90,140 @@
>{{ termin.kategorie }}</span> >{{ termin.kategorie }}</span>
</td> </td>
<td class="px-4 py-3 whitespace-nowrap text-right text-sm font-medium space-x-3"> <td class="px-4 py-3 whitespace-nowrap text-right text-sm font-medium space-x-3">
<button class="text-gray-600 hover:text-gray-900" title="Bearbeiten" @click="openEditModal(termin)"><Pencil :size="18" /></button> <button
<button class="text-red-600 hover:text-red-900" title="Löschen" @click="confirmDelete(termin)"><Trash2 :size="18" /></button> class="text-gray-600 hover:text-gray-900"
title="Bearbeiten"
@click="openEditModal(termin)"
>
<Pencil :size="18" />
</button>
<button
class="text-red-600 hover:text-red-900"
title="Löschen"
@click="confirmDelete(termin)"
>
<Trash2 :size="18" />
</button>
</td> </td>
</tr> </tr>
</tbody> </tbody>
</table> </table>
</div> </div>
<div v-if="termine.length === 0" class="text-center py-12 text-gray-500">Keine Termine vorhanden.</div> <div
v-if="termine.length === 0"
class="text-center py-12 text-gray-500"
>
Keine Termine vorhanden.
</div>
</div> </div>
<!-- Add/Edit Modal --> <!-- Add/Edit 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
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"> <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">{{ isEditing ? 'Termin bearbeiten' : 'Termin hinzufügen' }}</h2> <h2 class="text-2xl font-display font-bold text-gray-900 mb-6">
<form class="space-y-4" @submit.prevent="saveTermin"> {{ isEditing ? 'Termin bearbeiten' : 'Termin hinzufügen' }}
</h2>
<form
class="space-y-4"
@submit.prevent="saveTermin"
>
<div class="grid grid-cols-3 gap-4"> <div class="grid grid-cols-3 gap-4">
<div> <div>
<label class="block text-sm font-medium text-gray-700 mb-2">Datum *</label> <label class="block text-sm font-medium text-gray-700 mb-2">Datum *</label>
<input v-model="formData.datum" 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"> <input
v-model="formData.datum"
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"
>
</div> </div>
<div> <div>
<label class="block text-sm font-medium text-gray-700 mb-2">Uhrzeit</label> <label class="block text-sm font-medium text-gray-700 mb-2">Uhrzeit</label>
<input v-model="formData.uhrzeit" type="time" 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"> <input
v-model="formData.uhrzeit"
type="time"
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> <div>
<label class="block text-sm font-medium text-gray-700 mb-2">Kategorie *</label> <label class="block text-sm font-medium text-gray-700 mb-2">Kategorie *</label>
<select v-model="formData.kategorie" 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"> <select
<option value="Training">Training</option> v-model="formData.kategorie"
<option value="Punktspiel">Punktspiel</option> required
<option value="Turnier">Turnier</option> class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500"
<option value="Veranstaltung">Veranstaltung</option> :disabled="isSaving"
<option value="Sonstiges">Sonstiges</option> >
<option value="Training">
Training
</option>
<option value="Punktspiel">
Punktspiel
</option>
<option value="Turnier">
Turnier
</option>
<option value="Veranstaltung">
Veranstaltung
</option>
<option value="Sonstiges">
Sonstiges
</option>
</select> </select>
</div> </div>
</div> </div>
<div> <div>
<label class="block text-sm font-medium text-gray-700 mb-2">Titel *</label> <label class="block text-sm font-medium text-gray-700 mb-2">Titel *</label>
<input v-model="formData.titel" 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"> <input
v-model="formData.titel"
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> <div>
<label class="block text-sm font-medium text-gray-700 mb-2">Beschreibung</label> <label class="block text-sm font-medium text-gray-700 mb-2">Beschreibung</label>
<textarea v-model="formData.beschreibung" 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" /> <textarea
v-model="formData.beschreibung"
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>
<div v-if="errorMessage" class="flex items-center p-3 rounded-md bg-red-50 text-red-700 text-sm"> <div
<AlertCircle :size="20" class="mr-2" /> {{ errorMessage }} 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>
<div class="flex justify-end space-x-4 pt-4"> <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
<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"> type="button"
<Loader2 v-if="isSaving" :size="20" class="animate-spin mr-2" /> 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> <span>{{ isSaving ? 'Speichert...' : 'Speichern' }}</span>
</button> </button>
</div> </div>

View File

@@ -18,36 +18,108 @@
<div class="flex flex-wrap items-center gap-1 sm:gap-2 py-1.5 sm:py-2 px-3"> <div class="flex flex-wrap items-center gap-1 sm:gap-2 py-1.5 sm:py-2 px-3">
<!-- Formatierung --> <!-- Formatierung -->
<div class="flex items-center gap-1 border-r pr-2 mr-2"> <div class="flex items-center gap-1 border-r pr-2 mr-2">
<button class="px-2 py-1 sm:px-3 sm:py-1 rounded border hover:bg-gray-50 text-xs sm:text-sm" @click="format('bold')"><strong>B</strong></button> <button
<button class="px-2 py-1 sm:px-3 sm:py-1 rounded border hover:bg-gray-50 text-xs sm:text-sm" @click="format('italic')"><em>I</em></button> class="px-2 py-1 sm:px-3 sm:py-1 rounded border hover:bg-gray-50 text-xs sm:text-sm"
<button class="px-2 py-1 sm:px-3 sm:py-1 rounded border hover:bg-gray-50 text-xs sm:text-sm" @click="formatHeader(1)">H1</button> @click="format('bold')"
<button class="px-2 py-1 sm:px-3 sm:py-1 rounded border hover:bg-gray-50 text-xs sm:text-sm" @click="formatHeader(2)">H2</button> >
<button class="px-2 py-1 sm:px-3 sm:py-1 rounded border hover:bg-gray-50 text-xs sm:text-sm" @click="formatHeader(3)">H3</button> <strong>B</strong>
</button>
<button
class="px-2 py-1 sm:px-3 sm:py-1 rounded border hover:bg-gray-50 text-xs sm:text-sm"
@click="format('italic')"
>
<em>I</em>
</button>
<button
class="px-2 py-1 sm:px-3 sm:py-1 rounded border hover:bg-gray-50 text-xs sm:text-sm"
@click="formatHeader(1)"
>
H1
</button>
<button
class="px-2 py-1 sm:px-3 sm:py-1 rounded border hover:bg-gray-50 text-xs sm:text-sm"
@click="formatHeader(2)"
>
H2
</button>
<button
class="px-2 py-1 sm:px-3 sm:py-1 rounded border hover:bg-gray-50 text-xs sm:text-sm"
@click="formatHeader(3)"
>
H3
</button>
</div> </div>
<!-- Listen --> <!-- Listen -->
<div class="flex items-center gap-1 border-r pr-2 mr-2"> <div class="flex items-center gap-1 border-r pr-2 mr-2">
<button class="px-2 py-1 sm:px-3 sm:py-1 rounded border hover:bg-gray-50 text-xs sm:text-sm" @click="format('insertUnorderedList')"></button> <button
<button class="px-2 py-1 sm:px-3 sm:py-1 rounded border hover:bg-gray-50 text-xs sm:text-sm" @click="format('insertOrderedList')">1.</button> class="px-2 py-1 sm:px-3 sm:py-1 rounded border hover:bg-gray-50 text-xs sm:text-sm"
@click="format('insertUnorderedList')"
>
</button>
<button
class="px-2 py-1 sm:px-3 sm:py-1 rounded border hover:bg-gray-50 text-xs sm:text-sm"
@click="format('insertOrderedList')"
>
1.
</button>
</div> </div>
<!-- Schnellzugriff für Regeln --> <!-- Schnellzugriff für Regeln -->
<div class="flex items-center gap-1 border-r pr-2 mr-2"> <div class="flex items-center gap-1 border-r pr-2 mr-2">
<button class="px-2 py-1 sm:px-3 sm:py-1 rounded bg-gray-100 hover:bg-gray-200 text-gray-700 text-xs sm:text-sm" @click="insertRuleTemplate('generic')">Neue Regel</button> <button
<button class="px-2 py-1 sm:px-3 sm:py-1 rounded bg-blue-100 hover:bg-blue-200 text-blue-700 text-xs sm:text-sm" @click="insertRuleTemplate('basic')">Grundregel</button> class="px-2 py-1 sm:px-3 sm:py-1 rounded bg-gray-100 hover:bg-gray-200 text-gray-700 text-xs sm:text-sm"
<button class="px-2 py-1 sm:px-3 sm:py-1 rounded bg-green-100 hover:bg-green-200 text-green-700 text-xs sm:text-sm" @click="insertRuleTemplate('penalty')">Strafregel</button> @click="insertRuleTemplate('generic')"
<button class="px-2 py-1 sm:px-3 sm:py-1 rounded bg-yellow-100 hover:bg-yellow-200 text-yellow-700 text-xs sm:text-sm" @click="insertRuleTemplate('service')">Aufschlag</button> >
<button class="px-2 py-1 sm:px-3 sm:py-1 rounded bg-red-100 hover:bg-red-200 text-red-700 text-xs sm:text-sm" @click="deleteCurrentRule()">Regel löschen</button> Neue Regel
</button>
<button
class="px-2 py-1 sm:px-3 sm:py-1 rounded bg-blue-100 hover:bg-blue-200 text-blue-700 text-xs sm:text-sm"
@click="insertRuleTemplate('basic')"
>
Grundregel
</button>
<button
class="px-2 py-1 sm:px-3 sm:py-1 rounded bg-green-100 hover:bg-green-200 text-green-700 text-xs sm:text-sm"
@click="insertRuleTemplate('penalty')"
>
Strafregel
</button>
<button
class="px-2 py-1 sm:px-3 sm:py-1 rounded bg-yellow-100 hover:bg-yellow-200 text-yellow-700 text-xs sm:text-sm"
@click="insertRuleTemplate('service')"
>
Aufschlag
</button>
<button
class="px-2 py-1 sm:px-3 sm:py-1 rounded bg-red-100 hover:bg-red-200 text-red-700 text-xs sm:text-sm"
@click="deleteCurrentRule()"
>
Regel löschen
</button>
</div> </div>
<!-- Weitere Tools --> <!-- Weitere Tools -->
<div class="flex items-center gap-1"> <div class="flex items-center gap-1">
<button class="px-2 py-1 sm:px-3 sm:py-1 rounded border hover:bg-gray-50 text-xs sm:text-sm" @click="createLink()">Link</button> <button
<button class="px-2 py-1 sm:px-3 sm:py-1 rounded border hover:bg-gray-50 text-xs sm:text-sm" @click="removeFormat()">Clear</button> class="px-2 py-1 sm:px-3 sm:py-1 rounded border hover:bg-gray-50 text-xs sm:text-sm"
@click="createLink()"
>
Link
</button>
<button
class="px-2 py-1 sm:px-3 sm:py-1 rounded border hover:bg-gray-50 text-xs sm:text-sm"
@click="removeFormat()"
>
Clear
</button>
</div> </div>
</div> </div>
</div> </div>
<!-- Hilfe-Sektion --> <!-- Hilfe-Sektion -->
<div class="my-4 bg-blue-50 border border-blue-200 rounded-lg p-4"> <div class="my-4 bg-blue-50 border border-blue-200 rounded-lg p-4">
<h3 class="text-lg font-semibold text-blue-900 mb-2">So arbeiten Sie mit Regel-Kästchen:</h3> <h3 class="text-lg font-semibold text-blue-900 mb-2">
So arbeiten Sie mit Regel-Kästchen:
</h3>
<div class="text-sm text-blue-800 space-y-2"> <div class="text-sm text-blue-800 space-y-2">
<p><strong>1. Neue Kästchen hinzufügen:</strong> Klicken Sie in ein bestehendes Kästchen und verwenden Sie die Buttons:</p> <p><strong>1. Neue Kästchen hinzufügen:</strong> Klicken Sie in ein bestehendes Kästchen und verwenden Sie die Buttons:</p>
<ul class="ml-4 space-y-1"> <ul class="ml-4 space-y-1">

View File

@@ -7,7 +7,7 @@ export default [
js.configs.recommended, js.configs.recommended,
...vue.configs['flat/recommended'], ...vue.configs['flat/recommended'],
{ {
files: ['**/*.vue', '**/*.js'], files: ['**/*.vue', '**/*.js', '**/*.mjs', '**/*.cjs'],
languageOptions: { languageOptions: {
parser: parser, parser: parser,
ecmaVersion: 'latest', ecmaVersion: 'latest',
@@ -81,6 +81,8 @@ export default [
'node_modules/**', 'node_modules/**',
'.output/**', '.output/**',
'.nuxt/**', '.nuxt/**',
'**/.output/**',
'**/.nuxt/**',
'.next/**', '.next/**',
'dist/**', 'dist/**',
'build/**', 'build/**',
@@ -88,6 +90,7 @@ export default [
'*.config.ts', '*.config.ts',
'*.config.cjs', '*.config.cjs',
'*.cjs', '*.cjs',
'**/*.cjs',
'temp/**', 'temp/**',
'backups/**', 'backups/**',
'public/**', 'public/**',

View File

@@ -11,20 +11,48 @@
<div class="bg-white p-6 rounded-xl shadow-lg border border-gray-100"> <div class="bg-white p-6 rounded-xl shadow-lg border border-gray-100">
<div class="flex items-center mb-4"> <div class="flex items-center mb-4">
<div class="w-12 h-12 bg-pink-100 rounded-lg flex items-center justify-center"> <div class="w-12 h-12 bg-pink-100 rounded-lg flex items-center justify-center">
<Calendar :size="20" class="text-pink-600" /> <Calendar
:size="20"
class="text-pink-600"
/>
</div> </div>
<h2 class="ml-4 text-xl font-semibold text-gray-900">Geburtstage (nächste 4 Wochen)</h2> <h2 class="ml-4 text-xl font-semibold text-gray-900">
Geburtstage (nächste 4 Wochen)
</h2>
</div> </div>
<div v-if="loadingBirthdays" class="text-sm text-gray-500">Lade...</div> <div
<ul v-else class="space-y-2"> v-if="loadingBirthdays"
<li v-for="b in birthdays" :key="b.name + b.dayMonth" class="flex items-center justify-between p-3 border border-gray-100 rounded-lg"> class="text-sm text-gray-500"
>
Lade...
</div>
<ul
v-else
class="space-y-2"
>
<li
v-for="b in birthdays"
:key="b.name + b.dayMonth"
class="flex items-center justify-between p-3 border border-gray-100 rounded-lg"
>
<div class="min-w-0"> <div class="min-w-0">
<div class="font-medium text-gray-900 truncate">{{ b.name }}</div> <div class="font-medium text-gray-900 truncate">
<div class="text-xs text-gray-600">{{ b.dayMonth }}</div> {{ b.name }}
</div>
<div class="text-xs text-gray-600">
{{ b.dayMonth }}
</div>
</div>
<div class="text-sm text-gray-500">
{{ b.inDays === 0 ? 'Heute' : (b.inDays === 1 ? 'Morgen' : 'in ' + b.inDays + ' Tagen') }}
</div> </div>
<div class="text-sm text-gray-500">{{ b.inDays === 0 ? 'Heute' : (b.inDays === 1 ? 'Morgen' : 'in ' + b.inDays + ' Tagen') }}</div>
</li> </li>
<li v-if="birthdays.length === 0" class="text-sm text-gray-600">Keine Geburtstage in den nächsten 4 Wochen.</li> <li
v-if="birthdays.length === 0"
class="text-sm text-gray-600"
>
Keine Geburtstage in den nächsten 4 Wochen.
</li>
</ul> </ul>
</div> </div>
<!-- Inhalte (gruppiert) --> <!-- Inhalte (gruppiert) -->

View File

@@ -2,13 +2,20 @@
<div class="min-h-screen bg-gray-50"> <div class="min-h-screen bg-gray-50">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8"> <div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<div class="mb-6"> <div class="mb-6">
<h1 class="text-3xl font-display font-bold text-gray-900">Inhalte verwalten</h1> <h1 class="text-3xl font-display font-bold text-gray-900">
<p class="mt-1 text-sm text-gray-500">Redaktionelle Inhalte der Website bearbeiten</p> Inhalte verwalten
</h1>
<p class="mt-1 text-sm text-gray-500">
Redaktionelle Inhalte der Website bearbeiten
</p>
</div> </div>
<!-- Tabs --> <!-- Tabs -->
<div class="border-b border-gray-200 mb-6"> <div class="border-b border-gray-200 mb-6">
<nav class="-mb-px flex space-x-8 overflow-x-auto" aria-label="Tabs"> <nav
class="-mb-px flex space-x-8 overflow-x-auto"
aria-label="Tabs"
>
<button <button
v-for="tab in tabs" v-for="tab in tabs"
:key="tab.id" :key="tab.id"

View File

@@ -28,15 +28,24 @@
</label> </label>
</div> </div>
<div v-if="isLoading" class="text-center py-12 text-gray-600"> <div
v-if="isLoading"
class="text-center py-12 text-gray-600"
>
Lade Kontaktanfragen... Lade Kontaktanfragen...
</div> </div>
<div v-else-if="filteredRequests.length === 0" class="bg-white rounded-xl shadow p-8 text-center text-gray-600"> <div
v-else-if="filteredRequests.length === 0"
class="bg-white rounded-xl shadow p-8 text-center text-gray-600"
>
{{ showAnswered ? 'Aktuell liegen keine Kontaktanfragen vor.' : 'Aktuell liegen keine offenen Kontaktanfragen vor.' }} {{ showAnswered ? 'Aktuell liegen keine Kontaktanfragen vor.' : 'Aktuell liegen keine offenen Kontaktanfragen vor.' }}
</div> </div>
<div v-else class="space-y-4"> <div
v-else
class="space-y-4"
>
<div <div
v-for="request in filteredRequests" v-for="request in filteredRequests"
:key="request.id" :key="request.id"
@@ -67,7 +76,10 @@
{{ request.message }} {{ request.message }}
</p> </p>
<div v-if="Array.isArray(request.replies) && request.replies.length > 0" class="mt-5 border-t border-gray-100 pt-4"> <div
v-if="Array.isArray(request.replies) && request.replies.length > 0"
class="mt-5 border-t border-gray-100 pt-4"
>
<h3 class="text-sm font-semibold text-gray-700 mb-2"> <h3 class="text-sm font-semibold text-gray-700 mb-2">
Antworten Antworten
</h3> </h3>
@@ -127,7 +139,10 @@
class="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-600" class="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-600"
placeholder="Ihre Antwort..." placeholder="Ihre Antwort..."
/> />
<div v-if="errorMessage" class="mt-3 text-sm text-red-600"> <div
v-if="errorMessage"
class="mt-3 text-sm text-red-600"
>
{{ errorMessage }} {{ errorMessage }}
</div> </div>
<div class="mt-5 flex justify-end gap-3"> <div class="mt-5 flex justify-end gap-3">

View File

@@ -2,15 +2,25 @@
<div class="min-h-screen bg-gray-50"> <div class="min-h-screen bg-gray-50">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8"> <div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<div class="mb-6"> <div class="mb-6">
<h1 class="text-3xl font-display font-bold text-gray-900">Mitgliederverwaltung</h1> <h1 class="text-3xl font-display font-bold text-gray-900">
<p class="mt-1 text-sm text-gray-500">Anträge und Mitgliederliste verwalten</p> Mitgliederverwaltung
</h1>
<p class="mt-1 text-sm text-gray-500">
Anträge und Mitgliederliste verwalten
</p>
</div> </div>
<!-- Mitgliedschaftsanträge oben (nur sichtbar wenn Anträge vorhanden) --> <!-- Mitgliedschaftsanträge oben (nur sichtbar wenn Anträge vorhanden) -->
<div v-show="antraegeRef?.hasApplications" class="mb-10"> <div
v-show="antraegeRef?.hasApplications"
class="mb-10"
>
<CmsMitgliedschaftsantraege ref="antraegeRef" /> <CmsMitgliedschaftsantraege ref="antraegeRef" />
</div> </div>
<div v-if="antraegeRef?.hasApplications" class="border-t border-gray-300 mb-10" /> <div
v-if="antraegeRef?.hasApplications"
class="border-t border-gray-300 mb-10"
/>
<!-- Mitgliederliste darunter --> <!-- Mitgliederliste darunter -->
<CmsMitglieder /> <CmsMitglieder />

View File

@@ -2,13 +2,20 @@
<div class="min-h-screen bg-gray-50"> <div class="min-h-screen bg-gray-50">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8"> <div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<div class="mb-6"> <div class="mb-6">
<h1 class="text-3xl font-display font-bold text-gray-900">Sportbetrieb verwalten</h1> <h1 class="text-3xl font-display font-bold text-gray-900">
<p class="mt-1 text-sm text-gray-500">Termine, Mannschaften und Spielpläne pflegen</p> Sportbetrieb verwalten
</h1>
<p class="mt-1 text-sm text-gray-500">
Termine, Mannschaften und Spielpläne pflegen
</p>
</div> </div>
<!-- Tabs --> <!-- Tabs -->
<div class="border-b border-gray-200 mb-6"> <div class="border-b border-gray-200 mb-6">
<nav class="-mb-px flex space-x-8 overflow-x-auto" aria-label="Tabs"> <nav
class="-mb-px flex space-x-8 overflow-x-auto"
aria-label="Tabs"
>
<button <button
v-for="tab in tabs" v-for="tab in tabs"
:key="tab.id" :key="tab.id"

View File

@@ -58,7 +58,10 @@
> >
<!-- Drag Handle --> <!-- Drag Handle -->
<div class="flex flex-col gap-1 cursor-move"> <div class="flex flex-col gap-1 cursor-move">
<GripVertical :size="16" class="text-gray-400" /> <GripVertical
:size="16"
class="text-gray-400"
/>
</div> </div>
<!-- Section Info --> <!-- Section Info -->

View File

@@ -75,20 +75,48 @@
<div class="bg-white p-6 rounded-xl shadow-lg border border-gray-100"> <div class="bg-white p-6 rounded-xl shadow-lg border border-gray-100">
<div class="flex items-center mb-4"> <div class="flex items-center mb-4">
<div class="w-12 h-12 bg-pink-100 rounded-lg flex items-center justify-center"> <div class="w-12 h-12 bg-pink-100 rounded-lg flex items-center justify-center">
<Calendar :size="20" class="text-pink-600" /> <Calendar
:size="20"
class="text-pink-600"
/>
</div> </div>
<h2 class="ml-4 text-xl font-semibold text-gray-900">Geburtstage (nächste 4 Wochen)</h2> <h2 class="ml-4 text-xl font-semibold text-gray-900">
Geburtstage (nächste 4 Wochen)
</h2>
</div> </div>
<div v-if="loadingBirthdays" class="text-sm text-gray-500">Lade...</div> <div
<ul v-else class="space-y-2"> v-if="loadingBirthdays"
<li v-for="b in birthdays" :key="b.name + b.dayMonth" class="flex items-center justify-between p-3 border border-gray-100 rounded-lg"> class="text-sm text-gray-500"
>
Lade...
</div>
<ul
v-else
class="space-y-2"
>
<li
v-for="b in birthdays"
:key="b.name + b.dayMonth"
class="flex items-center justify-between p-3 border border-gray-100 rounded-lg"
>
<div class="min-w-0"> <div class="min-w-0">
<div class="font-medium text-gray-900 truncate">{{ b.name }}</div> <div class="font-medium text-gray-900 truncate">
<div class="text-xs text-gray-600">{{ b.dayMonth }}</div> {{ b.name }}
</div>
<div class="text-xs text-gray-600">
{{ b.dayMonth }}
</div>
</div>
<div class="text-sm text-gray-500">
{{ b.inDays === 0 ? 'Heute' : (b.inDays === 1 ? 'Morgen' : 'in ' + b.inDays + ' Tagen') }}
</div> </div>
<div class="text-sm text-gray-500">{{ b.inDays === 0 ? 'Heute' : (b.inDays === 1 ? 'Morgen' : 'in ' + b.inDays + ' Tagen') }}</div>
</li> </li>
<li v-if="birthdays.length === 0" class="text-sm text-gray-600">Keine Geburtstage in den nächsten 4 Wochen.</li> <li
v-if="birthdays.length === 0"
class="text-sm text-gray-600"
>
Keine Geburtstage in den nächsten 4 Wochen.
</li>
</ul> </ul>
</div> </div>
</div> </div>

View File

@@ -57,12 +57,25 @@
<!-- Sortieroptionen --> <!-- Sortieroptionen -->
<div class="mb-4 flex items-center justify-between gap-4 flex-wrap"> <div class="mb-4 flex items-center justify-between gap-4 flex-wrap">
<div class="flex items-center space-x-2"> <div class="flex items-center space-x-2">
<label for="sortMode" class="text-sm text-gray-700">Sortieren nach:</label> <label
<select id="sortMode" v-model="sortMode" class="px-2 py-1 border rounded"> for="sortMode"
<option value="name">Name (Vorname Nachname)</option> class="text-sm text-gray-700"
<option value="lastname">Nachname (Nachname Vorname)</option> >Sortieren nach:</label>
<option value="birthday">Geburtstag</option> <select
</select> id="sortMode"
v-model="sortMode"
class="px-2 py-1 border rounded"
>
<option value="name">
Name (Vorname Nachname)
</option>
<option value="lastname">
Nachname (Nachname Vorname)
</option>
<option value="birthday">
Geburtstag
</option>
</select>
</div> </div>
<label class="inline-flex items-center gap-2 text-sm text-gray-700"> <label class="inline-flex items-center gap-2 text-sm text-gray-700">
<input <input
@@ -135,7 +148,10 @@
{{ member.name }} {{ member.name }}
</template> </template>
</div> </div>
<div v-if="member.birthday" class="text-xs text-gray-500"> <div
v-if="member.birthday"
class="text-xs text-gray-500"
>
🎂 {{ formatBirthday(member.birthday) }} 🎂 {{ formatBirthday(member.birthday) }}
</div> </div>
<div <div
@@ -283,7 +299,10 @@
<template v-else> <template v-else>
{{ member.name }} {{ member.name }}
</template> </template>
<span v-if="member.birthday" class="text-xs text-gray-500 ml-2"> <span
v-if="member.birthday"
class="text-xs text-gray-500 ml-2"
>
🎂 {{ formatBirthday(member.birthday) }} 🎂 {{ formatBirthday(member.birthday) }}
</span> </span>
</h3> </h3>
@@ -341,30 +360,79 @@
<div class="grid sm:grid-cols-2 gap-3 text-gray-600"> <div class="grid sm:grid-cols-2 gap-3 text-gray-600">
<template v-if="!(member.showEmail && member.email) && !(member.showPhone && member.phone)"> <template v-if="!(member.showEmail && member.email) && !(member.showPhone && member.phone)">
<div class="col-span-2 flex items-center text-gray-500 text-sm italic"> <div class="col-span-2 flex items-center text-gray-500 text-sm italic">
<Mail :size="16" class="mr-2" /> <Mail
:size="16"
class="mr-2"
/>
Kontaktdaten nur für Vorstand sichtbar Kontaktdaten nur für Vorstand sichtbar
</div> </div>
</template> </template>
<template v-else> <template v-else>
<div v-if="member.showEmail && member.email" class="flex items-center"> <div
<Mail :size="16" class="mr-2 text-primary-600" /> v-if="member.showEmail && member.email"
<a :href="`mailto:${member.email}`" class="hover:text-primary-600">{{ member.email }}</a> 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>
<div v-if="member.showPhone && member.phone" class="flex items-center"> <div
<Phone :size="16" class="mr-2 text-primary-600" /> v-if="member.showPhone && member.phone"
<a :href="`tel:${member.phone}`" class="hover:text-primary-600">{{ member.phone }}</a> 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> </div>
</template> </template>
<!-- Sichtbarkeits-Flags anzeigen --> <!-- Sichtbarkeits-Flags anzeigen -->
<div class="col-span-2 flex items-center gap-2 mt-2 text-xs text-gray-500"> <div class="col-span-2 flex items-center gap-2 mt-2 text-xs text-gray-500">
<span v-if="member.showEmail" title="E-Mail sichtbar">📧</span> <span
<span v-else title="E-Mail verborgen" class="opacity-40">📧</span> v-if="member.showEmail"
<span v-if="member.showPhone" title="Telefon sichtbar">📞</span> title="E-Mail sichtbar"
<span v-else title="Telefon verborgen" class="opacity-40">📞</span> >📧</span>
<span v-if="member.showAddress" title="Adresse sichtbar">🏠</span> <span
<span v-else title="Adresse verborgen" class="opacity-40">🏠</span> v-else
<span v-if="member.showBirthday" title="Geburtstag sichtbar">🎂</span> title="E-Mail verborgen"
<span v-else title="Geburtstag verborgen" class="opacity-40">🎂</span> class="opacity-40"
>📧</span>
<span
v-if="member.showPhone"
title="Telefon sichtbar"
>📞</span>
<span
v-else
title="Telefon verborgen"
class="opacity-40"
>📞</span>
<span
v-if="member.showAddress"
title="Adresse sichtbar"
>🏠</span>
<span
v-else
title="Adresse verborgen"
class="opacity-40"
>🏠</span>
<span
v-if="member.showBirthday"
title="Geburtstag sichtbar"
>🎂</span>
<span
v-else
title="Geburtstag verborgen"
class="opacity-40"
>🎂</span>
</div> </div>
<div <div
v-if="member.address" v-if="member.address"

View File

@@ -98,22 +98,44 @@
<!-- Sichtbarkeits-Einstellungen --> <!-- Sichtbarkeits-Einstellungen -->
<div class="mt-4 border-t border-gray-100 pt-4"> <div class="mt-4 border-t border-gray-100 pt-4">
<h3 class="text-sm font-medium text-gray-900 mb-2">Sichtbarkeit für andere Mitglieder</h3> <h3 class="text-sm font-medium text-gray-900 mb-2">
Sichtbarkeit für andere Mitglieder
</h3>
<div class="flex flex-col gap-2 text-sm text-gray-700"> <div class="flex flex-col gap-2 text-sm text-gray-700">
<label class="inline-flex items-center"> <label class="inline-flex items-center">
<input type="checkbox" class="mr-2" v-model="visibility.showEmail" :disabled="isSaving" /> <input
v-model="visibility.showEmail"
type="checkbox"
class="mr-2"
:disabled="isSaving"
>
E-Mail für alle eingeloggten Mitglieder sichtbar E-Mail für alle eingeloggten Mitglieder sichtbar
</label> </label>
<label class="inline-flex items-center"> <label class="inline-flex items-center">
<input type="checkbox" class="mr-2" v-model="visibility.showPhone" :disabled="isSaving" /> <input
v-model="visibility.showPhone"
type="checkbox"
class="mr-2"
:disabled="isSaving"
>
Telefonnummer für alle eingeloggten Mitglieder sichtbar Telefonnummer für alle eingeloggten Mitglieder sichtbar
</label> </label>
<label class="inline-flex items-center"> <label class="inline-flex items-center">
<input type="checkbox" class="mr-2" v-model="visibility.showAddress" :disabled="isSaving" /> <input
v-model="visibility.showAddress"
type="checkbox"
class="mr-2"
:disabled="isSaving"
>
Adresse für alle eingeloggten Mitglieder sichtbar Adresse für alle eingeloggten Mitglieder sichtbar
</label> </label>
<label class="inline-flex items-center"> <label class="inline-flex items-center">
<input type="checkbox" class="mr-2" v-model="visibility.showBirthday" :disabled="isSaving" /> <input
v-model="visibility.showBirthday"
type="checkbox"
class="mr-2"
:disabled="isSaving"
>
Geburtstag für alle eingeloggten Mitglieder sichtbar Geburtstag für alle eingeloggten Mitglieder sichtbar
</label> </label>
</div> </div>

View File

@@ -173,8 +173,10 @@
</div> </div>
<!-- Optional password toggle for passkey users - vorläufig deaktiviert --> <!-- Optional password toggle for passkey users - vorläufig deaktiviert -->
<!-- <div
<div v-if="usePasskey" class="flex items-center gap-2 text-sm text-gray-700"> v-if="false"
class="flex items-center gap-2 text-sm text-gray-700"
>
<input <input
v-model="setPasswordForPasskey" v-model="setPasswordForPasskey"
type="checkbox" type="checkbox"
@@ -217,17 +219,23 @@
v-if="false" v-if="false"
class="bg-blue-50 border border-blue-200 rounded-lg p-4 text-xs space-y-3" class="bg-blue-50 border border-blue-200 rounded-lg p-4 text-xs space-y-3"
> >
<div class="font-semibold text-blue-900 mb-2">🔍 Debug-Informationen (QR-Code):</div> <div class="font-semibold text-blue-900 mb-2">
🔍 Debug-Informationen (QR-Code):
</div>
<div class="space-y-1 text-blue-800"> <div class="space-y-1 text-blue-800">
<div><strong>Challenge:</strong> <code class="bg-blue-100 px-1 rounded break-all">{{ debugChallenge }}</code></div> <div><strong>Challenge:</strong> <code class="bg-blue-100 px-1 rounded break-all">{{ debugChallenge }}</code></div>
<div><strong>RP-ID:</strong> <code class="bg-blue-100 px-1 rounded">{{ debugRpId }}</code></div> <div><strong>RP-ID:</strong> <code class="bg-blue-100 px-1 rounded">{{ debugRpId }}</code></div>
<div v-if="debugRegistrationId"><strong>Registration-ID:</strong> <code class="bg-blue-100 px-1 rounded break-all">{{ debugRegistrationId }}</code></div> <div v-if="debugRegistrationId">
<strong>Registration-ID:</strong> <code class="bg-blue-100 px-1 rounded break-all">{{ debugRegistrationId }}</code>
</div>
<div><strong>Origin:</strong> <code class="bg-blue-100 px-1 rounded">{{ typeof window !== 'undefined' ? window.location.origin : 'N/A (SSR)' }}</code></div> <div><strong>Origin:</strong> <code class="bg-blue-100 px-1 rounded">{{ typeof window !== 'undefined' ? window.location.origin : 'N/A (SSR)' }}</code></div>
</div> </div>
<!-- FIDO QR-Code Info --> <!-- FIDO QR-Code Info -->
<div class="mt-3 p-3 bg-purple-50 border border-purple-300 rounded"> <div class="mt-3 p-3 bg-purple-50 border border-purple-300 rounded">
<div class="font-semibold text-purple-900 mb-2">🔐 FIDO Cross-Device Info:</div> <div class="font-semibold text-purple-900 mb-2">
🔐 FIDO Cross-Device Info:
</div>
<div class="text-xs text-purple-800 space-y-2"> <div class="text-xs text-purple-800 space-y-2">
<div><strong>QR-Code-Format:</strong> FIDO-URI (enthält öffentlichen Schlüssel + Secret)</div> <div><strong>QR-Code-Format:</strong> FIDO-URI (enthält öffentlichen Schlüssel + Secret)</div>
<div><strong>Hinweis:</strong> Der QR-Code enthält einen FIDO-URI, der vom Smartphone gescannt werden muss.</div> <div><strong>Hinweis:</strong> Der QR-Code enthält einen FIDO-URI, der vom Smartphone gescannt werden muss.</div>
@@ -270,10 +278,19 @@
</div> </div>
<!-- Smartphone URL --> <!-- Smartphone URL -->
<div v-if="debugSmartphoneUrl" class="mt-3 p-3 bg-green-50 border border-green-300 rounded"> <div
<div class="font-semibold text-green-900 mb-2">📱 Alternative: Smartphone-URL (manuell öffnen):</div> v-if="debugSmartphoneUrl"
class="mt-3 p-3 bg-green-50 border border-green-300 rounded"
>
<div class="font-semibold text-green-900 mb-2">
📱 Alternative: Smartphone-URL (manuell öffnen):
</div>
<div class="break-all text-xs mb-2 p-2 bg-white rounded border"> <div class="break-all text-xs mb-2 p-2 bg-white rounded border">
<a :href="debugSmartphoneUrl" target="_blank" class="text-blue-600 hover:underline"> <a
:href="debugSmartphoneUrl"
target="_blank"
class="text-blue-600 hover:underline"
>
{{ debugSmartphoneUrl }} {{ debugSmartphoneUrl }}
</a> </a>
</div> </div>
@@ -281,23 +298,26 @@
<strong>Anleitung:</strong> Falls der QR-Code nicht funktioniert, öffnen Sie diese URL manuell auf Ihrem Smartphone. <strong>Anleitung:</strong> Falls der QR-Code nicht funktioniert, öffnen Sie diese URL manuell auf Ihrem Smartphone.
</div> </div>
<button <button
@click="copyToClipboard(debugSmartphoneUrl)"
class="px-3 py-1 bg-green-600 text-white text-xs rounded hover:bg-green-700" class="px-3 py-1 bg-green-600 text-white text-xs rounded hover:bg-green-700"
@click="copyToClipboard(debugSmartphoneUrl)"
> >
📋 URL kopieren 📋 URL kopieren
</button> </button>
</div> </div>
<!-- Full Options JSON --> <!-- Full Options JSON -->
<div v-if="debugOptions" class="mt-3"> <div
v-if="debugOptions"
class="mt-3"
>
<details class="text-xs"> <details class="text-xs">
<summary class="cursor-pointer font-semibold text-blue-900 hover:text-blue-700 mb-2"> <summary class="cursor-pointer font-semibold text-blue-900 hover:text-blue-700 mb-2">
📄 Vollständige Options (JSON) - Klicken zum Anzeigen 📄 Vollständige Options (JSON) - Klicken zum Anzeigen
</summary> </summary>
<pre class="mt-2 p-2 bg-gray-100 rounded overflow-auto text-xs max-h-60 border">{{ JSON.stringify(debugOptions, null, 2) }}</pre> <pre class="mt-2 p-2 bg-gray-100 rounded overflow-auto text-xs max-h-60 border">{{ JSON.stringify(debugOptions, null, 2) }}</pre>
<button <button
@click="copyToClipboard(JSON.stringify(debugOptions, null, 2))"
class="mt-2 px-3 py-1 bg-gray-600 text-white text-xs rounded hover:bg-gray-700" class="mt-2 px-3 py-1 bg-gray-600 text-white text-xs rounded hover:bg-gray-700"
@click="copyToClipboard(JSON.stringify(debugOptions, null, 2))"
> >
📋 JSON kopieren 📋 JSON kopieren
</button> </button>

View File

@@ -83,11 +83,11 @@ export default defineEventHandler(async (event) => {
// ggf. inkonsistente Inhalte ausgeliefert (Browser meldet Partial Transfer). // ggf. inkonsistente Inhalte ausgeliefert (Browser meldet Partial Transfer).
// Daher: nach erfolgreichem Schreiben alte Varianten entfernen. // Daher: nach erfolgreichem Schreiben alte Varianten entfernen.
for (const ext of ['.gz', '.br']) { for (const ext of ['.gz', '.br']) {
try { await fs.unlink(`${targetPath}${ext}`) } catch (_e3) {} try { await fs.unlink(`${targetPath}${ext}`) } catch (_e3) { /* no-op */ }
} }
} catch (e) { } catch (e) {
// best-effort cleanup // best-effort cleanup
try { await fs.unlink(tmpPath) } catch (_e2) {} try { await fs.unlink(tmpPath) } catch (_e2) { /* no-op */ }
throw e throw e
} }
} }

View File

@@ -27,7 +27,7 @@ export default defineEventHandler(async (event) => {
let csvPath = null let csvPath = null
for (const p of candidates) { for (const p of candidates) {
// eslint-disable-next-line no-await-in-loop
if (await exists(p)) { if (await exists(p)) {
csvPath = p csvPath = p
break break

View File

@@ -1,6 +1,7 @@
import fs from 'fs/promises' import fs from 'fs/promises'
import path from 'path' import path from 'path'
import { getUserFromToken } from '../../../utils/auth.js' import { getUserFromToken } from '../../../utils/auth.js'
import { getServerDataPath } from '../../../utils/paths.js'
export default defineEventHandler(async (event) => { export default defineEventHandler(async (event) => {
try { try {
@@ -14,7 +15,7 @@ export default defineEventHandler(async (event) => {
} }
// Upload-Verzeichnis finden (intern) // Upload-Verzeichnis finden (intern)
const uploadDir = path.join(process.cwd(), '..', 'server', 'data', 'uploads') const uploadDir = getServerDataPath('uploads')
console.log('Upload-Verzeichnis:', uploadDir) console.log('Upload-Verzeichnis:', uploadDir)
// Alle Dateien im Upload-Verzeichnis durchsuchen // Alle Dateien im Upload-Verzeichnis durchsuchen

View File

@@ -6,6 +6,7 @@ import path from 'path'
import { StandardFonts } from 'pdf-lib' import { StandardFonts } from 'pdf-lib'
import { getDownloadCookieOptionsWithMaxAge } from '../../utils/cookies.js' import { getDownloadCookieOptionsWithMaxAge } from '../../utils/cookies.js'
import { sendMembershipEmail as sendMembershipEmailUtil } from '../../utils/email-service.js' import { sendMembershipEmail as sendMembershipEmailUtil } from '../../utils/email-service.js'
import { getProjectPath, getServerDataPath } from '../../utils/paths.js'
// const require = createRequire(import.meta.url) // Nicht verwendet // const require = createRequire(import.meta.url) // Nicht verwendet
const execAsync = promisify(exec) const execAsync = promisify(exec)
@@ -306,16 +307,8 @@ Das ausgefüllte Formular ist als Anhang verfügbar.`
return `${filename}.txt` return `${filename}.txt`
} }
// nosemgrep: javascript.lang.security.audit.path-traversal.path-join-resolve-traversal.path-join-resolve-traversal
// filename is always a hardcoded constant (e.g., 'membership-applications'), never user input
function getDataPath(filename) { function getDataPath(filename) {
// Immer den absoluten Pfad zum Projekt-Root verwenden return getServerDataPath(filename)
// In der Entwicklung: process.cwd() ist bereits das Projekt-Root
// In der Produktion: process.cwd() ist .output, daher ein Verzeichnis zurück
const isDev = process.env.NODE_ENV === 'development'
const projectRoot = isDev ? process.cwd() : path.resolve(process.cwd(), '..')
// nosemgrep: javascript.lang.security.audit.path-traversal.path-join-resolve-traversal.path-join-resolve-traversal
return path.join(projectRoot, 'server', 'data', filename)
} }
// Use central email service // Use central email service
@@ -351,8 +344,9 @@ export default defineEventHandler(async (event) => {
const timestamp = Date.now() const timestamp = Date.now()
const filename = `beitrittserklärung_${timestamp}` const filename = `beitrittserklärung_${timestamp}`
// Temp-Verzeichnis erstellen // Temp-Verzeichnis erstellen (bewusst außerhalb von .output,
const tempDir = path.join(process.cwd(), '.output', 'temp', 'latex') // da Deploy-Artefakte dort je nach Setup schreibgeschützt sein können)
const tempDir = getServerDataPath('tmp', 'latex')
await fs.mkdir(tempDir, { recursive: true }) await fs.mkdir(tempDir, { recursive: true })
try { try {
@@ -360,8 +354,8 @@ export default defineEventHandler(async (event) => {
// Versuch: Original-PDF-Template herunterladen und AcroForm-Felder befüllen // Versuch: Original-PDF-Template herunterladen und AcroForm-Felder befüllen
async function fillPdfTemplate(data) { async function fillPdfTemplate(data) {
// Priorität: neues lokales Fillable-Template in server/templates, sonst ursprüngliches Template // Priorität: neues lokales Fillable-Template in server/templates, sonst ursprüngliches Template
const fillablePath = path.join(process.cwd(), 'server', 'templates', 'mitgliedschaft-fillable.pdf') const fillablePath = getProjectPath('server', 'templates', 'mitgliedschaft-fillable.pdf')
const localPath = (await fs.stat(fillablePath).then(() => fillablePath).catch(() => null)) || path.join(process.cwd(), 'server', 'templates', 'Aufnahmeantrag 2025.pdf') const localPath = (await fs.stat(fillablePath).then(() => fillablePath).catch(() => null)) || getProjectPath('server', 'templates', 'Aufnahmeantrag 2025.pdf')
let arrayBuffer let arrayBuffer
try { try {
const localExists = await fs.stat(localPath).then(() => true).catch(() => false) const localExists = await fs.stat(localPath).then(() => true).catch(() => false)

View File

@@ -6,6 +6,7 @@
import nodemailer from 'nodemailer' import nodemailer from 'nodemailer'
import fs from 'fs/promises' import fs from 'fs/promises'
import path from 'path' import path from 'path'
import { getServerDataPath } from './paths.js'
/** /**
* Gets the correct data path for config files * Gets the correct data path for config files
@@ -15,15 +16,7 @@ import path from 'path'
// nosemgrep: javascript.lang.security.audit.path-traversal.path-join-resolve-traversal.path-join-resolve-traversal // nosemgrep: javascript.lang.security.audit.path-traversal.path-join-resolve-traversal.path-join-resolve-traversal
// filename is always a hardcoded constant (e.g., 'config.json'), never user input // filename is always a hardcoded constant (e.g., 'config.json'), never user input
function getDataPath(filename) { function getDataPath(filename) {
const isProduction = process.env.NODE_ENV === 'production' return getServerDataPath(filename)
if (isProduction) {
// nosemgrep: javascript.lang.security.audit.path-traversal.path-join-resolve-traversal.path-join-resolve-traversal
return path.join(process.cwd(), '..', 'server', 'data', filename)
} else {
// nosemgrep: javascript.lang.security.audit.path-traversal.path-join-resolve-traversal.path-join-resolve-traversal
return path.join(process.cwd(), 'server', 'data', filename)
}
} }
/** /**

31
server/utils/paths.js Normal file
View File

@@ -0,0 +1,31 @@
import fs from 'fs'
import path from 'path'
function uniqueCandidates(candidates) {
return [...new Set(candidates.filter(Boolean))]
}
function hasServerDataDir(root) {
return fs.existsSync(path.join(root, 'server', 'data'))
}
export function resolveProjectRoot() {
const envRoot = process.env.APP_ROOT ? process.env.APP_ROOT.trim() : ''
const cwd = process.cwd()
const parent = path.resolve(cwd, '..')
const candidates = uniqueCandidates([envRoot, cwd, parent])
for (const root of candidates) {
if (hasServerDataDir(root)) return root
}
return cwd
}
export function getProjectPath(...segments) {
return path.join(resolveProjectRoot(), ...segments)
}
export function getServerDataPath(...segments) {
return getProjectPath('server', 'data', ...segments)
}