Files
harheimertc/components/cms/CmsMannschaften.vue
Torsten Schulz (local) 0528334eb4
All checks were successful
Code Analysis and Production Deploy / analyze (push) Successful in 5m10s
Code Analysis and Production Deploy / deploy-production (push) Has been skipped
Code Analysis and Production Deploy / deploy-test (push) Successful in 2m14s
feat: replace success modal with non-blocking toast notification
feat: add global event listener for mannschaften updates in Navigation component

feat: notify app of mannschaften changes after CSV save and handle visibility changes

refactor: remove unused anlagen page

fix: update CmsMannschaften reference in sportbetrieb page for reactivity

fix: enhance authentication token retrieval in passkey API endpoints

feat: implement refresh session and access token generation for Android clients in passkey login

fix: unify token retrieval method across passkey API endpoints

feat: add MediaTypes utility for JSON content type in Android app

feat: create PasskeyRepository for handling passkey authentication and registration in Android app

feat: add validated text field and rich text components for Android UI

feat: implement newsletter subscription and unsubscription screens in Android app

feat: create public pages including Impressum with dynamic content loading
2026-05-28 08:33:28 +02:00

710 lines
32 KiB
Vue

<template>
<div>
<div class="flex justify-between items-center mb-6 gap-4 flex-wrap">
<div>
<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>
<div class="flex items-center gap-2 flex-wrap">
<label class="text-sm font-medium text-gray-700">Saison</label>
<select
v-model="selectedSeason"
class="px-3 py-2 border border-gray-300 rounded-lg bg-white focus:outline-none focus:ring-2 focus:ring-primary-500"
:disabled="isLoading || isSaving || isCreatingSeason"
@change="onSeasonChange"
>
<option
v-for="season in seasons"
:key="season"
:value="season"
>
{{ formatSeasonLabel(season) }}
</option>
</select>
<button
class="flex items-center px-4 py-2 bg-white border border-primary-300 text-primary-700 hover:bg-primary-50 font-semibold rounded-lg transition-colors disabled:opacity-50"
:disabled="isLoading || isSaving || isCreatingSeason"
@click="createNextSeason"
>
<Loader2
v-if="isCreatingSeason"
:size="18"
class="animate-spin mr-2"
/>
<Plus
v-else
:size="18"
class="mr-2"
/>
Neue Saison anlegen
</button>
<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"
>
<Plus
:size="20"
class="mr-2"
/> Mannschaft hinzufügen
</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-else
class="bg-white rounded-xl shadow-lg overflow-hidden"
>
<div class="overflow-x-auto">
<table class="min-w-full divide-y divide-gray-200">
<thead class="bg-gray-50">
<tr>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Mannschaft
</th>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Liga
</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>
</thead>
<tbody class="bg-white divide-y divide-gray-200">
<tr
v-for="(mannschaft, index) in mannschaften"
:key="index"
class="hover:bg-gray-50"
>
<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">
<button
class="p-2 border border-gray-300 rounded-lg hover:bg-gray-50 text-gray-600 disabled:opacity-50 disabled:cursor-not-allowed mr-1"
title="Nach oben"
:disabled="isSaving || index === 0"
@click="moveMannschaftUp(index)"
>
<ChevronUp :size="18" />
</button>
<button
class="p-2 border border-gray-300 rounded-lg hover:bg-gray-50 text-gray-600 disabled:opacity-50 disabled:cursor-not-allowed mr-2"
title="Nach unten"
:disabled="isSaving || index === mannschaften.length - 1"
@click="moveMannschaftDown(index)"
>
<ChevronDown :size="18" />
</button>
<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>
</tr>
</tbody>
</table>
</div>
</div>
<div
v-if="!isLoading && mannschaften.length === 0"
class="bg-white rounded-xl shadow-lg p-12 text-center"
>
<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>
<!-- 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 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">
<h2 class="text-2xl font-display font-bold text-gray-900">
{{ isEditing ? 'Mannschaft bearbeiten' : 'Neue Mannschaft' }}
</h2>
</div>
<form
class="p-6 space-y-4"
@submit.prevent="saveMannschaft"
>
<div>
<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"
>
</div>
<div>
<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"
>
</div>
<div class="grid grid-cols-2 gap-4">
<div>
<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"
>
</div>
<div>
<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"
>
</div>
</div>
<div class="grid grid-cols-2 gap-4">
<div>
<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"
>
</div>
<div>
<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"
>
</div>
</div>
<div>
<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"
>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">Spieler</label>
<div class="space-y-2">
<div
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">
<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">
<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
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 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"
>
<option
v-for="t in mannschaftenSelectOptions"
:key="t"
:value="t"
>
{{ t }}
</option>
</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>
</div>
</div>
</div>
</div>
<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>
<p class="text-xs text-gray-500">
Reihenfolge per / ändern.
</p>
</div>
</div>
<div>
<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"
>
</div>
<div
v-if="errorMessage"
class="flex items-center p-3 rounded-md bg-red-50 text-red-700 text-sm"
>
<AlertCircle
:size="20"
class="mr-2"
/> {{ errorMessage }}
</div>
<div class="flex justify-end space-x-4 pt-4">
<button
type="button"
class="px-6 py-2 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50 transition-colors"
:disabled="isSaving"
@click="closeModal"
>
Abbrechen
</button>
<button
type="submit"
class="px-6 py-2 bg-primary-600 hover:bg-primary-700 text-white font-semibold rounded-lg transition-colors flex items-center"
:disabled="isSaving"
>
<Loader2
v-if="isSaving"
:size="20"
class="animate-spin mr-2"
/><span>{{ isSaving ? 'Speichert...' : 'Speichern' }}</span>
</button>
</div>
</form>
</div>
</div>
</div>
</template>
<script setup>
import { ref, onMounted, computed, onUnmounted } from 'vue'
import { Plus, Trash2, Loader2, AlertCircle, Pencil, Users, ChevronUp, ChevronDown, ArrowRight } from 'lucide-vue-next'
const isLoading = ref(true)
const isSaving = ref(false)
const isCreatingSeason = ref(false)
const mannschaften = ref([])
const seasons = ref([])
const selectedSeason = ref('')
const showModal = ref(false)
const errorMessage = ref('')
const isEditing = ref(false)
const editingIndex = ref(-1)
const formData = ref({ mannschaft: '', liga: '', staffelleiter: '', telefon: '', heimspieltag: '', spielsystem: '', mannschaftsfuehrer: '', spielerListe: [], weitere_informationen_link: '', letzte_aktualisierung: '' })
const moveTargetBySpielerId = ref({})
const initialMoveTargetBySpielerId = ref({})
const pendingSpielerNamesByTeamIndex = ref({})
function nowIsoDate() { return new Date().toISOString().split('T')[0] }
function newSpielerItem(name = '') { return { id: `${Date.now()}-${Math.random().toString(16).slice(2)}`, name } }
function parseSpielerString(s) { if (!s) return []; return String(s).split(';').map(x => x.trim()).filter(Boolean).map(name => newSpielerItem(name)) }
function serializeSpielerList(list) { return (list || []).map(s => (s?.name || '').trim()).filter(Boolean).join('; ') }
function serializeSpielerNames(names) { return (names || []).map(s => String(s || '').trim()).filter(Boolean).join('; ') }
async function fetchCsvText(url) {
const attempt = async () => { const r = await fetch(`${url}${url.includes('?') ? '&' : '?'}_t=${Date.now()}`, { cache: 'no-store' }); if (!r.ok) throw new Error(`HTTP ${r.status}`); return await r.text() }
try { return await attempt() } catch { await new Promise(r => setTimeout(r, 150)); return await attempt() }
}
function getMannschaftenFilenameForSeason(seasonSlug = '') {
const season = String(seasonSlug || '').trim()
return season ? `mannschaften_${season}.csv` : 'mannschaften.csv'
}
function formatSeasonLabel(seasonSlug) {
const match = String(seasonSlug || '').match(/^(\d{2})--(\d{2})$/)
return match ? `20${match[1]}/${match[2]}` : String(seasonSlug || '')
}
function getNextSeasonSlug(seasonSlug) {
const match = String(seasonSlug || '').match(/^(\d{2})--(\d{2})$/)
if (!match) return ''
const start = Number(match[1])
const nextStart = (start + 1) % 100
const nextEnd = (nextStart + 1) % 100
return `${String(nextStart).padStart(2, '0')}--${String(nextEnd).padStart(2, '0')}`
}
async function loadSeasons() {
try {
const res = await fetch('/api/mannschaften/seasons')
const result = await res.json()
if (!result?.success || !Array.isArray(result.seasons) || result.seasons.length === 0) {
seasons.value = [result?.currentSeason || '']
selectedSeason.value = seasons.value[0] || ''
return
}
seasons.value = result.seasons
if (!selectedSeason.value || !seasons.value.includes(selectedSeason.value)) {
selectedSeason.value = result.defaultSeason || seasons.value[0]
}
} catch {
if (!seasons.value.length) seasons.value = ['']
if (!selectedSeason.value) selectedSeason.value = seasons.value[0] || ''
}
}
const mannschaftenSelectOptions = computed(() => {
const current = (formData.value.mannschaft || '').trim()
const names = mannschaften.value.map(m => (m?.mannschaft || '').trim()).filter(Boolean)
return [...new Set([current, ...names])].filter(Boolean)
})
function resetSpielerDraftState() { moveTargetBySpielerId.value = {}; initialMoveTargetBySpielerId.value = {}; pendingSpielerNamesByTeamIndex.value = {} }
function getPendingSpielerNamesForTeamIndex(teamIndex) {
if (pendingSpielerNamesByTeamIndex.value[teamIndex]) return pendingSpielerNamesByTeamIndex.value[teamIndex]
const existing = mannschaften.value[teamIndex]; const list = existing ? getSpielerListe(existing) : []
pendingSpielerNamesByTeamIndex.value[teamIndex] = [...list]; return pendingSpielerNamesByTeamIndex.value[teamIndex]
}
const loadMannschaften = async () => {
isLoading.value = true
try {
const params = new URLSearchParams()
if (selectedSeason.value) params.set('season', selectedSeason.value)
const csv = await fetchCsvText(`/api/mannschaften${params.toString() ? `?${params.toString()}` : ''}`)
const lines = csv.split('\n').filter(line => line.trim() !== '')
if (lines.length < 2) { mannschaften.value = []; return }
mannschaften.value = lines.slice(1).map(line => {
const values = []; let current = ''; let inQuotes = false
for (let i = 0; i < line.length; i++) { const char = line[i]; if (char === '"') { inQuotes = !inQuotes } else if (char === ',' && !inQuotes) { values.push(current.trim()); current = '' } else { current += char } }
values.push(current.trim())
if (values.length < 10) return null
return { mannschaft: values[0]?.trim() || '', liga: values[1]?.trim() || '', staffelleiter: values[2]?.trim() || '', telefon: values[3]?.trim() || '', heimspieltag: values[4]?.trim() || '', spielsystem: values[5]?.trim() || '', mannschaftsfuehrer: values[6]?.trim() || '', spieler: values[7]?.trim() || '', weitere_informationen_link: values[8]?.trim() || '', letzte_aktualisierung: values[9]?.trim() || '' }
}).filter(m => m !== null && m.mannschaft !== '')
} catch (error) { console.error('Fehler beim Laden:', error); errorMessage.value = 'Fehler beim Laden der Mannschaften'; throw error } finally { isLoading.value = false }
}
const onSeasonChange = async () => {
await loadMannschaften().catch(() => {})
}
const getSpielerListe = (m) => { if (!m.spieler) return []; return m.spieler.split(';').map(s => s.trim()).filter(s => s !== '') }
const openAddModal = () => { formData.value = { mannschaft: '', liga: '', staffelleiter: '', telefon: '', heimspieltag: '', spielsystem: '', mannschaftsfuehrer: '', spielerListe: [], weitere_informationen_link: '', letzte_aktualisierung: nowIsoDate() }; showModal.value = true; errorMessage.value = ''; isEditing.value = false; editingIndex.value = -1; resetSpielerDraftState() }
const closeModal = () => { showModal.value = false; errorMessage.value = ''; isEditing.value = false; editingIndex.value = -1; resetSpielerDraftState() }
const openEditModal = (mannschaft, index) => {
formData.value = { mannschaft: mannschaft.mannschaft || '', liga: mannschaft.liga || '', staffelleiter: mannschaft.staffelleiter || '', telefon: mannschaft.telefon || '', heimspieltag: mannschaft.heimspieltag || '', spielsystem: mannschaft.spielsystem || '', mannschaftsfuehrer: mannschaft.mannschaftsfuehrer || '', spielerListe: parseSpielerString(mannschaft.spieler || ''), weitere_informationen_link: mannschaft.weitere_informationen_link || '', letzte_aktualisierung: mannschaft.letzte_aktualisierung || nowIsoDate() }
isEditing.value = true; editingIndex.value = index; showModal.value = true; errorMessage.value = ''; resetSpielerDraftState()
const currentTeam = (formData.value.mannschaft || '').trim()
for (const s of formData.value.spielerListe) {
moveTargetBySpielerId.value[s.id] = currentTeam
initialMoveTargetBySpielerId.value[s.id] = currentTeam
}
}
const addSpieler = () => {
const item = newSpielerItem('')
const currentTeam = (formData.value.mannschaft || '').trim()
formData.value.spielerListe.push(item)
moveTargetBySpielerId.value[item.id] = currentTeam
initialMoveTargetBySpielerId.value[item.id] = currentTeam
}
const removeSpieler = (id) => {
const idx = formData.value.spielerListe.findIndex(s => s.id === id)
if (idx === -1) return
formData.value.spielerListe.splice(idx, 1)
delete moveTargetBySpielerId.value[id]
delete initialMoveTargetBySpielerId.value[id]
}
const moveSpielerUp = (index) => { if (index <= 0) return; const arr = formData.value.spielerListe; const item = arr[index]; arr.splice(index, 1); arr.splice(index - 1, 0, item) }
const moveSpielerDown = (index) => { const arr = formData.value.spielerListe; if (index < 0 || index >= arr.length - 1) return; const item = arr[index]; arr.splice(index, 1); arr.splice(index + 1, 0, item) }
const canMoveSpieler = (id) => {
const target = (moveTargetBySpielerId.value[id] || '').trim()
const initialTarget = (initialMoveTargetBySpielerId.value[id] || '').trim()
return Boolean(target) && Boolean(initialTarget) && target !== initialTarget
}
const moveSpielerToMannschaft = (spielerId) => {
if (!isEditing.value || editingIndex.value < 0) return false
const targetName = (moveTargetBySpielerId.value[spielerId] || '').trim(); if (!targetName) return false
const targetIndex = mannschaften.value.findIndex((m, idx) => { if (idx === editingIndex.value) return false; return (m?.mannschaft || '').trim() === targetName })
if (targetIndex === -1) { errorMessage.value = 'Ziel-Mannschaft nicht gefunden.'; return false }
const idx = formData.value.spielerListe.findIndex(s => s.id === spielerId); if (idx === -1) return false
const spielerName = (formData.value.spielerListe[idx]?.name || '').trim(); if (!spielerName) { errorMessage.value = 'Bitte zuerst einen Spielernamen eintragen.'; return false }
formData.value.spielerListe.splice(idx, 1)
const pendingList = getPendingSpielerNamesForTeamIndex(targetIndex); pendingList.push(spielerName)
delete moveTargetBySpielerId.value[spielerId]
delete initialMoveTargetBySpielerId.value[spielerId]
return true
}
const applySelectedSpielerTransfers = () => {
if (!isEditing.value || editingIndex.value < 0) return true
const pendingIds = formData.value.spielerListe
.filter(spieler => canMoveSpieler(spieler.id))
.map(spieler => spieler.id)
return pendingIds.every(spielerId => moveSpielerToMannschaft(spielerId))
}
const saveMannschaft = async () => {
isSaving.value = true; errorMessage.value = ''
try {
if (!applySelectedSpielerTransfers()) return
const spielerString = serializeSpielerList(formData.value.spielerListe)
const updated = { mannschaft: formData.value.mannschaft || '', liga: formData.value.liga || '', staffelleiter: formData.value.staffelleiter || '', telefon: formData.value.telefon || '', heimspieltag: formData.value.heimspieltag || '', spielsystem: formData.value.spielsystem || '', mannschaftsfuehrer: formData.value.mannschaftsfuehrer || '', spieler: spielerString, weitere_informationen_link: formData.value.weitere_informationen_link || '', letzte_aktualisierung: formData.value.letzte_aktualisierung || nowIsoDate() }
if (isEditing.value && editingIndex.value >= 0) { mannschaften.value[editingIndex.value] = { ...updated } } else { mannschaften.value.push({ ...updated }) }
const touchedTeamIndexes = Object.keys(pendingSpielerNamesByTeamIndex.value)
if (touchedTeamIndexes.length > 0) { const ts = nowIsoDate(); for (const idxStr of touchedTeamIndexes) { const idx = Number(idxStr); if (!Number.isFinite(idx)) continue; const existing = mannschaften.value[idx]; if (!existing) continue; mannschaften.value[idx] = { ...existing, spieler: serializeSpielerNames(pendingSpielerNamesByTeamIndex.value[idx]), letzte_aktualisierung: ts } } }
await saveCSV(); closeModal(); await loadMannschaften()
if (window.showSuccessModal) window.showSuccessModal('Erfolg', 'Mannschaft wurde erfolgreich gespeichert')
} catch (error) { console.error('Fehler:', error); errorMessage.value = error?.data?.statusMessage || error?.statusMessage || error?.data?.message || 'Fehler beim Speichern.'; if (window.showErrorModal) window.showErrorModal('Fehler', errorMessage.value) } finally { isSaving.value = false }
}
const saveCSV = async () => {
const header = 'Mannschaft,Liga,Staffelleiter,Telefon,Heimspieltag,Spielsystem,Mannschaftsführer,Spieler,Weitere Informationen Link,Letzte Aktualisierung'
const rows = mannschaften.value.map(m => {
const esc = (v) => { if (!v) return ''; const s = String(v); if (s.includes(',') || s.includes('"') || s.includes('\n')) return `"${s.replace(/"/g, '""')}"`; return s }
return [esc(m.mannschaft), esc(m.liga), esc(m.staffelleiter), esc(m.telefon), esc(m.heimspieltag), esc(m.spielsystem), esc(m.mannschaftsfuehrer), esc(m.spieler), esc(m.weitere_informationen_link), esc(m.letzte_aktualisierung)].join(',')
})
await $fetch('/api/cms/save-csv', {
method: 'POST',
body: {
filename: getMannschaftenFilenameForSeason(selectedSeason.value),
content: [header, ...rows].join('\n')
}
})
// Notify other parts of the app that mannschaften changed
try {
if (typeof window !== 'undefined') {
window.dispatchEvent(new CustomEvent('mannschaften:changed'))
}
} catch (e) { /* no-op */ }
}
const moveMannschaft = async (index, delta) => {
const to = index + delta
if (index < 0 || to < 0 || to >= mannschaften.value.length) return
isSaving.value = true
try {
const arr = mannschaften.value
const item = arr[index]
arr.splice(index, 1)
arr.splice(to, 0, item)
await saveCSV()
await loadMannschaften().catch(() => {})
if (window.showSuccessModal) window.showSuccessModal('Erfolg', 'Reihenfolge gespeichert')
} catch (err) {
console.error('Fehler beim Verschieben der Mannschaft:', err)
if (window.showErrorModal) window.showErrorModal('Fehler', 'Reihenfolge konnte nicht gespeichert werden')
} finally {
isSaving.value = false
}
}
const moveMannschaftUp = (index) => moveMannschaft(index, -1)
const moveMannschaftDown = (index) => moveMannschaft(index, 1)
const createNextSeason = async () => {
const baseSeason = selectedSeason.value || seasons.value[0]
const nextSeason = getNextSeasonSlug(baseSeason)
if (!nextSeason) {
errorMessage.value = 'Naechste Saison konnte nicht ermittelt werden.'
return
}
if (seasons.value.includes(nextSeason)) {
selectedSeason.value = nextSeason
await loadMannschaften().catch(() => {})
return
}
isCreatingSeason.value = true
errorMessage.value = ''
try {
const header = 'Mannschaft,Liga,Staffelleiter,Telefon,Heimspieltag,Spielsystem,Mannschaftsführer,Spieler,Weitere Informationen Link,Letzte Aktualisierung'
const rows = mannschaften.value.map(m => {
const esc = (v) => { if (!v) return ''; const s = String(v); if (s.includes(',') || s.includes('"') || s.includes('\n')) return `"${s.replace(/"/g, '""')}"`; return s }
return [esc(m.mannschaft), esc(m.liga), esc(m.staffelleiter), esc(m.telefon), esc(m.heimspieltag), esc(m.spielsystem), esc(m.mannschaftsfuehrer), esc(m.spieler), esc(m.weitere_informationen_link), esc(m.letzte_aktualisierung || nowIsoDate())].join(',')
})
await $fetch('/api/cms/save-csv', {
method: 'POST',
body: {
filename: getMannschaftenFilenameForSeason(nextSeason),
content: [header, ...rows].join('\n')
}
})
await loadSeasons()
selectedSeason.value = nextSeason
await loadMannschaften().catch(() => {})
if (window.showSuccessModal) {
window.showSuccessModal('Erfolg', `Neue Saison ${formatSeasonLabel(nextSeason)} wurde angelegt.`)
}
} catch (error) {
console.error('Fehler beim Anlegen der Saison:', error)
errorMessage.value = error?.data?.statusMessage || error?.statusMessage || 'Saison konnte nicht angelegt werden.'
if (window.showErrorModal) window.showErrorModal('Fehler', errorMessage.value)
} finally {
isCreatingSeason.value = false
}
}
const confirmDelete = (mannschaft, index) => {
if (window.showConfirmModal) {
window.showConfirmModal('Mannschaft löschen', `Möchten Sie die Mannschaft "${mannschaft.mannschaft}" wirklich löschen?`, async () => {
try { mannschaften.value.splice(index, 1); await saveCSV(); await loadMannschaften(); window.showSuccessModal('Erfolg', 'Mannschaft wurde erfolgreich gelöscht') } catch (error) { console.error('Fehler:', error); window.showErrorModal('Fehler', 'Fehler beim Löschen der Mannschaft') }
})
} else { if (confirm(`Mannschaft "${mannschaft.mannschaft}" wirklich löschen?`)) { mannschaften.value.splice(index, 1); saveCSV(); loadMannschaften() } }
}
onMounted(async () => {
await loadSeasons()
await loadMannschaften().catch(() => {})
})
// Expose load function to parent components
try { defineExpose({ loadMannschaften }) } catch (e) { /* noop if not supported in SSR context */ }
// Reload when tab/window becomes visible or window gains focus
const handleVisibilityOrFocus = () => {
try {
if (document.visibilityState === 'visible') {
loadMannschaften().catch(() => {})
}
} catch (e) {
// ignore
}
}
if (typeof window !== 'undefined') {
window.addEventListener('visibilitychange', handleVisibilityOrFocus)
window.addEventListener('focus', handleVisibilityOrFocus)
}
onUnmounted(() => {
if (typeof window !== 'undefined') {
window.removeEventListener('visibilitychange', handleVisibilityOrFocus)
window.removeEventListener('focus', handleVisibilityOrFocus)
}
})
</script>