Add 'Spielplan' links to Navigation component; update index page to include 'Spielplan' section; enhance 'spielplaene' page with filtering, loading states, and error handling for improved user experience.

This commit is contained in:
Torsten Schulz (local)
2025-10-24 00:55:04 +02:00
parent c6ce26773a
commit 7660f7cf7b
13 changed files with 2457 additions and 228 deletions

640
pages/cms/spielplaene.vue Normal file
View File

@@ -0,0 +1,640 @@
<template>
<div class="min-h-full bg-gray-50">
<!-- Fixed Header below navigation -->
<div class="fixed top-20 left-0 right-0 z-40 bg-white border-b border-gray-200 shadow-sm">
<div class="max-w-5xl mx-auto px-4 sm:px-6 lg:px-8 py-3 sm:py-4">
<div class="flex items-center justify-between">
<h1 class="text-xl sm:text-3xl font-display font-bold text-gray-900">Spielpläne bearbeiten</h1>
<div class="space-x-3">
<button @click="showUploadModal = true" 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">
<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"></path>
</svg>
CSV hochladen
</button>
<button @click="save" 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">Speichern</button>
</div>
</div>
</div>
</div>
<!-- Content with top padding -->
<div class="pt-20 pb-16">
<div class="max-w-5xl mx-auto px-4 sm:px-6 lg:px-8">
<!-- PDF Upload Section -->
<div class="mb-8 bg-white rounded-xl shadow-lg p-6">
<h2 class="text-xl font-semibold text-gray-900 mb-4">Spielplan PDF-Dateien</h2>
<p class="text-sm text-gray-600 mb-6">Laden Sie die PDF-Dateien für die verschiedenen Spielpläne hoch:</p>
<div class="grid gap-6 md:grid-cols-3">
<!-- Gesamt PDF -->
<div class="border border-gray-200 rounded-lg p-4">
<h3 class="font-medium text-gray-900 mb-2">Gesamt</h3>
<p class="text-sm text-gray-600 mb-3">Alle Mannschaften</p>
<input
ref="gesamtPdfInput"
type="file"
accept=".pdf"
@change="handlePdfUpload('gesamt', $event)"
class="hidden"
/>
<button @click="triggerPdfInput('gesamt')" class="w-full px-3 py-2 text-sm bg-blue-100 hover:bg-blue-200 text-blue-700 rounded-lg transition-colors">
PDF hochladen
</button>
<div v-if="uploadedPdfs.gesamt" class="mt-2 text-xs text-green-600">
{{ uploadedPdfs.gesamt }}
</div>
</div>
<!-- Erwachsene PDF -->
<div class="border border-gray-200 rounded-lg p-4">
<h3 class="font-medium text-gray-900 mb-2">Erwachsene</h3>
<p class="text-sm text-gray-600 mb-3">Erwachsenen-Mannschaften</p>
<input
ref="erwachsenePdfInput"
type="file"
accept=".pdf"
@change="handlePdfUpload('erwachsene', $event)"
class="hidden"
/>
<button @click="triggerPdfInput('erwachsene')" class="w-full px-3 py-2 text-sm bg-blue-100 hover:bg-blue-200 text-blue-700 rounded-lg transition-colors">
PDF hochladen
</button>
<div v-if="uploadedPdfs.erwachsene" class="mt-2 text-xs text-green-600">
{{ uploadedPdfs.erwachsene }}
</div>
</div>
<!-- Nachwuchs PDF -->
<div class="border border-gray-200 rounded-lg p-4">
<h3 class="font-medium text-gray-900 mb-2">Nachwuchs</h3>
<p class="text-sm text-gray-600 mb-3">Nachwuchs-Mannschaften</p>
<input
ref="nachwuchsPdfInput"
type="file"
accept=".pdf"
@change="handlePdfUpload('nachwuchs', $event)"
class="hidden"
/>
<button @click="triggerPdfInput('nachwuchs')" class="w-full px-3 py-2 text-sm bg-blue-100 hover:bg-blue-200 text-blue-700 rounded-lg transition-colors">
PDF hochladen
</button>
<div v-if="uploadedPdfs.nachwuchs" class="mt-2 text-xs text-green-600">
{{ uploadedPdfs.nachwuchs }}
</div>
</div>
</div>
</div>
<!-- CSV Upload Section -->
<div class="mb-8 bg-white rounded-xl shadow-lg p-6">
<h2 class="text-xl font-semibold text-gray-900 mb-4">Vereins-Spielplan (CSV)</h2>
<!-- Current File Info -->
<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">
<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"></path>
</svg>
<div>
<p class="text-sm font-medium text-green-800">{{ currentFile.name }}</p>
<p class="text-xs text-green-600">{{ currentFile.size }} bytes, {{ currentFile.lastModified ? new Date(currentFile.lastModified).toLocaleDateString('de-DE') : 'Unbekannt' }}</p>
</div>
</div>
<button @click="removeFile" class="px-3 py-1 text-sm bg-red-100 hover:bg-red-200 text-red-700 rounded-lg transition-colors">
Entfernen
</button>
</div>
</div>
<!-- Upload Area -->
<div
@click="triggerFileInput"
@dragover.prevent
@dragenter.prevent
@drop.prevent="handleFileDrop"
class="border-2 border-dashed border-gray-300 rounded-lg p-8 text-center hover:border-primary-400 hover:bg-primary-50 transition-colors cursor-pointer"
:class="{ 'border-primary-400 bg-primary-50': isDragOver }"
>
<svg class="w-12 h-12 text-gray-400 mx-auto mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12"></path>
</svg>
<p class="text-lg font-medium text-gray-900 mb-2">CSV-Datei hochladen</p>
<p class="text-sm text-gray-600 mb-4">Klicken Sie hier oder ziehen Sie eine CSV-Datei hierher</p>
<p class="text-xs text-gray-500">Unterstützte Formate: .csv</p>
</div>
<input
ref="fileInput"
type="file"
accept=".csv"
@change="handleFileSelect"
class="hidden"
/>
</div>
<!-- Column Selection -->
<div v-if="csvData.length > 0 && !columnsSelected" class="bg-white rounded-xl shadow-lg p-6 mb-8">
<h2 class="text-xl font-semibold text-gray-900 mb-4">Spalten auswählen</h2>
<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 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">
<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"
/>
<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>
<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="space-x-3">
<button @click="selectAllColumns" class="px-4 py-2 text-sm bg-gray-100 hover:bg-gray-200 text-gray-700 rounded-lg transition-colors">
Alle auswählen
</button>
<button @click="deselectAllColumns" class="px-4 py-2 text-sm bg-gray-100 hover:bg-gray-200 text-gray-700 rounded-lg transition-colors">
Alle abwählen
</button>
<button @click="confirmColumnSelection"
: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">
Auswahl bestätigen
</button>
</div>
</div>
</div>
<!-- Data Preview -->
<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">
<h2 class="text-xl font-semibold text-gray-900">Datenvorschau</h2>
<div class="flex space-x-2">
<button @click="exportCSV" class="px-3 py-1 text-sm bg-blue-100 hover:bg-blue-200 text-blue-700 rounded-lg transition-colors">
CSV exportieren
</button>
<button @click="clearData" class="px-3 py-1 text-sm bg-red-100 hover:bg-red-200 text-red-700 rounded-lg transition-colors">
Daten löschen
</button>
</div>
</div>
<!-- Data Table -->
<div class="overflow-x-auto">
<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>
<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'">
<td v-for="(cell, cellIndex) in row" :key="cellIndex"
class="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
{{ cell }}
</td>
</tr>
</tbody>
</table>
</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 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>
<!-- Empty State -->
<div v-else class="text-center py-12 bg-white rounded-xl shadow-lg">
<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"></path>
</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>
</div>
<!-- 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 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>
<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"
@change="handleModalFileSelect"
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
/>
</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> Spalten: Datum, Mannschaft, Gegner, Ort, Uhrzeit, etc.</p>
<p> Trennzeichen: Komma (,)</p>
<p> Text in Anführungszeichen bei Sonderzeichen</p>
</div>
</div>
</div>
<div class="flex justify-end space-x-3 pt-4">
<button
@click="closeUploadModal"
class="px-4 py-2 text-gray-700 bg-gray-100 hover:bg-gray-200 rounded-lg transition-colors"
>
Abbrechen
</button>
<button
@click="processSelectedFile"
:disabled="!selectedFile"
class="px-4 py-2 bg-primary-600 text-white rounded-lg hover:bg-primary-700 transition-colors disabled:bg-gray-400"
>
Hochladen
</button>
</div>
</div>
</div>
<!-- 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 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>
<h3 class="text-lg font-semibold text-gray-900 mb-2">Verarbeitung läuft...</h3>
<p class="text-sm text-gray-600">{{ processingMessage }}</p>
</div>
</div>
</div>
</template>
<script setup>
import { ref, onMounted, computed } from 'vue'
definePageMeta({
middleware: 'auth',
})
useHead({ title: 'CMS: Spielpläne' })
const fileInput = ref(null)
const modalFileInput = ref(null)
const gesamtPdfInput = ref(null)
const erwachsenePdfInput = ref(null)
const nachwuchsPdfInput = ref(null)
const showUploadModal = ref(false)
const isProcessing = ref(false)
const processingMessage = ref('')
const isDragOver = ref(false)
const currentFile = ref(null)
const selectedFile = ref(null)
const csvData = ref([])
const csvHeaders = ref([])
const selectedColumns = ref([])
const columnsSelected = ref(false)
const filteredCsvData = ref([])
const filteredCsvHeaders = ref([])
const uploadedPdfs = ref({
gesamt: null,
erwachsene: null,
nachwuchs: null
})
const triggerFileInput = () => {
fileInput.value?.click()
}
const triggerPdfInput = (type) => {
if (type === 'gesamt') {
gesamtPdfInput.value?.click()
} else if (type === 'erwachsene') {
erwachsenePdfInput.value?.click()
} else if (type === 'nachwuchs') {
nachwuchsPdfInput.value?.click()
}
}
const handlePdfUpload = async (type, event) => {
const file = event.target.files[0]
if (!file) return
if (file.type !== 'application/pdf') {
alert('Bitte wählen Sie eine PDF-Datei aus.')
return
}
try {
const formData = new FormData()
formData.append('pdf', file)
formData.append('type', type)
const response = await fetch('/api/cms/upload-spielplan-pdf', {
method: 'POST',
body: formData
})
if (response.ok) {
uploadedPdfs.value[type] = file.name
alert(`PDF für ${type} erfolgreich hochgeladen!`)
} else {
alert('Fehler beim Hochladen der PDF-Datei!')
}
} catch (error) {
console.error('Fehler beim PDF-Upload:', error)
alert('Fehler beim Hochladen der PDF-Datei!')
}
}
const handleFileSelect = (event) => {
const file = event.target.files[0]
if (file) {
processFile(file)
}
}
const handleModalFileSelect = (event) => {
selectedFile.value = event.target.files[0]
}
const handleFileDrop = (event) => {
isDragOver.value = false
const file = event.dataTransfer.files[0]
if (file && file.type === 'text/csv') {
processFile(file)
}
}
const processFile = async (file) => {
isProcessing.value = true
processingMessage.value = 'Datei wird gelesen...'
try {
const text = await file.text()
processingMessage.value = 'CSV wird geparst...'
const lines = text.split('\n').filter(line => line.trim() !== '')
if (lines.length < 2) {
throw new Error('CSV-Datei muss mindestens eine Kopfzeile und eine Datenzeile enthalten')
}
// CSV-Parser: Semikolon-getrennt, ohne Anführungszeichen
const parseCSVLine = (line) => {
return line.split(';').map(value => value.trim())
}
// Header-Zeile parsen
csvHeaders.value = parseCSVLine(lines[0])
// Datenzeilen parsen
csvData.value = lines.slice(1).map(line => parseCSVLine(line))
// Spaltenauswahl initialisieren (alle ausgewählt)
selectedColumns.value = new Array(csvHeaders.value.length).fill(true)
columnsSelected.value = false
// Datei-Info speichern
currentFile.value = {
name: file.name,
size: file.size,
lastModified: file.lastModified
}
processingMessage.value = 'Verarbeitung abgeschlossen!'
setTimeout(() => {
isProcessing.value = false
showUploadModal.value = false
}, 1000)
} catch (error) {
console.error('Fehler beim Verarbeiten der CSV-Datei:', error)
alert('Fehler beim Verarbeiten der CSV-Datei: ' + error.message)
isProcessing.value = false
}
}
const processSelectedFile = () => {
if (selectedFile.value) {
processFile(selectedFile.value)
}
}
const removeFile = () => {
currentFile.value = null
csvData.value = []
csvHeaders.value = []
selectedColumns.value = []
columnsSelected.value = false
filteredCsvData.value = []
filteredCsvHeaders.value = []
if (fileInput.value) {
fileInput.value.value = ''
}
}
// Computed properties for column selection
const selectedColumnsCount = computed(() => {
return selectedColumns.value.filter(selected => selected).length
})
const getColumnPreview = (index) => {
if (csvData.value.length === 0) return 'Keine Daten'
const sampleValues = csvData.value.slice(0, 3).map(row => row[index]).filter(val => val && val.trim() !== '')
return sampleValues.length > 0 ? `Beispiel: ${sampleValues.join(', ')}` : 'Leere Spalte'
}
const selectAllColumns = () => {
selectedColumns.value = selectedColumns.value.map(() => true)
}
const deselectAllColumns = () => {
selectedColumns.value = selectedColumns.value.map(() => false)
}
const confirmColumnSelection = () => {
// Filtere Daten basierend auf ausgewählten Spalten
const selectedIndices = selectedColumns.value.map((selected, index) => selected ? index : -1).filter(index => index !== -1)
filteredCsvHeaders.value = selectedIndices.map(index => csvHeaders.value[index])
filteredCsvData.value = csvData.value.map(row => selectedIndices.map(index => row[index]))
columnsSelected.value = true
}
const clearData = () => {
if (confirm('Möchten Sie alle Daten wirklich löschen?')) {
removeFile()
}
}
const exportCSV = () => {
const dataToExport = columnsSelected.value ? filteredCsvData.value : csvData.value
const headersToExport = columnsSelected.value ? filteredCsvHeaders.value : csvHeaders.value
if (dataToExport.length === 0) return
// CSV generieren (Semikolon-getrennt, ohne Anführungszeichen)
const csvContent = [
headersToExport.join(';'),
...dataToExport.map(row => row.join(';'))
].join('\n')
// Download
const blob = new Blob([csvContent], { type: 'text/csv' })
const url = window.URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `spielplan_export_${new Date().toISOString().split('T')[0]}.csv`
document.body.appendChild(a)
a.click()
window.URL.revokeObjectURL(url)
document.body.removeChild(a)
}
const save = async () => {
const dataToSave = columnsSelected.value ? filteredCsvData.value : csvData.value
const headersToSave = columnsSelected.value ? filteredCsvHeaders.value : csvHeaders.value
if (dataToSave.length === 0) {
alert('Keine Daten zum Speichern vorhanden.')
return
}
try {
// CSV generieren (Semikolon-getrennt, ohne Anführungszeichen)
const csvContent = [
headersToSave.join(';'),
...dataToSave.map(row => row.join(';'))
].join('\n')
// CSV speichern
const response = await fetch('/api/cms/save-csv', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
filename: 'spielplan.csv',
content: csvContent
})
})
if (response.ok) {
alert('Spielplan erfolgreich gespeichert!')
} else {
alert('Fehler beim Speichern des Spielplans!')
}
} catch (error) {
console.error('Fehler beim Speichern:', error)
alert('Fehler beim Speichern des Spielplans!')
}
}
const closeUploadModal = () => {
showUploadModal.value = false
selectedFile.value = null
if (modalFileInput.value) {
modalFileInput.value.value = ''
}
}
// Drag and Drop Events
const handleDragEnter = () => {
isDragOver.value = true
}
const handleDragLeave = () => {
isDragOver.value = false
}
onMounted(() => {
// Load existing data if available
loadExistingData()
})
const loadExistingData = async () => {
try {
const response = await fetch('/data/spielplan.csv')
if (response.ok) {
const text = await response.text()
const lines = text.split('\n').filter(line => line.trim() !== '')
if (lines.length >= 2) {
// Parse existing CSV
const parseCSVLine = (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())
return values
}
csvHeaders.value = parseCSVLine(lines[0])
csvData.value = lines.slice(1).map(line => parseCSVLine(line))
currentFile.value = {
name: 'spielplan.csv',
size: text.length,
lastModified: null
}
}
}
} catch (error) {
console.log('Keine bestehende Spielplan-Datei gefunden')
}
}
</script>

View File

@@ -6,10 +6,13 @@
<!-- 2. Kommende Termine -->
<HomeTermine />
<!-- 3. Aktuelles -->
<!-- 3. Nächste Spiele -->
<Spielplan />
<!-- 4. Aktuelles -->
<PublicNews />
<!-- 4. Mitglied werden / Kontakt aufnehmen -->
<!-- 5. Mitglied werden / Kontakt aufnehmen -->
<HomeActions />
</div>
</template>
@@ -17,6 +20,7 @@
<script setup>
import Hero from '~/components/Hero.vue'
import HomeTermine from '~/components/HomeTermine.vue'
import Spielplan from '~/components/Spielplan.vue'
import PublicNews from '~/components/PublicNews.vue'
import HomeActions from '~/components/HomeActions.vue'
</script>

View File

@@ -1,268 +1,659 @@
<template>
<div class="min-h-full py-16 bg-gray-50">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="text-center mb-12">
<h1 class="text-4xl sm:text-5xl font-display font-bold text-gray-900 mb-4">
Spielpläne
</h1>
<div class="w-24 h-1 bg-primary-600 mx-auto mb-6" />
<p class="text-xl text-gray-600 max-w-3xl mx-auto">
Aktuelle Spielpläne der Saison {{ aktuellesSaisonLabel }}
</p>
</div>
<!-- Spielpläne -->
<div v-if="spielplaene.length > 0" class="space-y-4 max-w-4xl mx-auto">
<div
v-for="(plan, index) in spielplaene"
:key="index"
class="bg-white rounded-xl shadow-lg border border-gray-100 p-6 hover:shadow-xl transition-shadow"
>
<div class="flex items-center justify-between">
<div class="flex items-center space-x-4">
<div class="w-12 h-12 bg-primary-100 rounded-lg flex items-center justify-center">
<FileText :size="24" class="text-primary-600" />
</div>
<div>
<h3 class="text-lg font-semibold text-gray-900">{{ plan.titel }}</h3>
<p class="text-sm text-gray-500">Saison {{ plan.saison }}</p>
</div>
</div>
<a
:href="plan.url"
download
class="inline-flex items-center px-4 py-2 bg-primary-600 hover:bg-primary-700 text-white font-medium rounded-lg transition-colors"
>
<Download :size="18" class="mr-2" />
Download
</a>
<div class="min-h-screen bg-gray-50">
<!-- Header -->
<div class="bg-white shadow-sm">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
<div class="flex items-center justify-between">
<div>
<h1 class="text-3xl font-bold text-gray-900">Spielpläne</h1>
<p class="mt-2 text-gray-600">Alle Spielpläne der Mannschaften</p>
</div>
</div>
</div>
</div>
<!-- Keine Spielpläne -->
<div v-else class="text-center py-16 bg-white rounded-xl shadow-lg max-w-4xl mx-auto">
<FileText :size="48" class="text-gray-400 mx-auto mb-4" />
<h3 class="text-xl font-semibold text-gray-900 mb-2">Keine Spielpläne verfügbar</h3>
<p class="text-gray-600">
Für die aktuelle Saison {{ aktuellesSaisonLabel }} sind noch keine Spielpläne verfügbar.
</p>
</div>
<!-- Online Spielpläne und Tabellen -->
<div class="mt-12 max-w-4xl mx-auto">
<h2 class="text-2xl font-display font-bold text-gray-900 mb-6 text-center">
Online Spielpläne & Tabellen
</h2>
<div v-if="mannschaftenMitLinks.length > 0" class="space-y-3">
<div
v-for="(mannschaft, index) in mannschaftenMitLinks"
:key="index"
class="bg-white rounded-lg shadow border border-gray-100 p-4 hover:shadow-md transition-shadow"
>
<div class="flex items-center justify-between">
<div>
<h3 class="font-semibold text-gray-900">{{ mannschaft.mannschaft }}</h3>
<p class="text-sm text-gray-500">{{ mannschaft.liga }}</p>
</div>
<a
:href="mannschaft.weitere_informationen_link"
target="_blank"
class="inline-flex items-center px-4 py-2 bg-gray-100 hover:bg-gray-200 text-gray-700 rounded-lg transition-colors text-sm"
<!-- Content -->
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<!-- Filter and Download Section -->
<div class="bg-white rounded-xl shadow-lg p-6 mb-8">
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
<!-- Filter Selection -->
<div class="flex flex-col sm:flex-row sm:items-center gap-4">
<!-- Wettbewerbs-Filter -->
<div class="flex items-center space-x-2">
<label for="wettbewerb-select" class="text-sm font-medium text-gray-700">
Wettbewerb:
</label>
<select
id="wettbewerb-select"
v-model="selectedWettbewerb"
@change="filterData"
class="px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500 bg-white text-sm"
>
<ExternalLink :size="16" class="mr-2" />
Online ansehen
</a>
<option value="punktrunde">Punktrunde</option>
<option value="pokal">Pokal</option>
<option value="alle">Alle</option>
</select>
</div>
<!-- Mannschafts-Filter -->
<div class="flex items-center space-x-2">
<label for="filter-select" class="text-sm font-medium text-gray-700">
Mannschaft:
</label>
<select
id="filter-select"
v-model="selectedFilter"
@change="filterData"
class="px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500 bg-white text-sm"
>
<option value="all">Gesamt</option>
<option value="erwachsene">Erwachsene</option>
<option value="nachwuchs">Nachwuchs</option>
<option v-for="mannschaft in mannschaften" :key="mannschaft" :value="mannschaft">
{{ mannschaft }}
</option>
</select>
</div>
</div>
<!-- Download Button -->
<button
@click="downloadPDF"
:disabled="isLoading || !filteredData.length"
class="inline-flex items-center px-4 py-2 bg-primary-600 text-white rounded-lg hover:bg-primary-700 transition-colors disabled:bg-gray-400"
>
<svg class="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
PDF Download
</button>
</div>
<!-- Filter Info -->
<div class="mt-4 text-sm text-gray-600">
<span v-if="selectedFilter === 'all'">
{{ getWettbewerbText() }} - Alle Mannschaften ({{ filteredData.length }} von {{ spielplanData.length }} Einträgen)
</span>
<span v-else-if="selectedFilter === 'erwachsene'">
{{ getWettbewerbText() }} - Erwachsenen-Mannschaften ({{ filteredData.length }} von {{ spielplanData.length }} Einträgen)
</span>
<span v-else-if="selectedFilter === 'nachwuchs'">
{{ getWettbewerbText() }} - Nachwuchs-Mannschaften ({{ filteredData.length }} von {{ spielplanData.length }} Einträgen)
</span>
<span v-else>
{{ getWettbewerbText() }} - {{ selectedFilter }} ({{ filteredData.length }} von {{ spielplanData.length }} Einträgen)
</span>
</div>
</div>
<!-- Info-Box -->
<div class="mt-12 max-w-4xl mx-auto bg-primary-50 border border-primary-100 rounded-xl p-6">
<h3 class="text-lg font-semibold text-primary-900 mb-2">
Hinweis
</h3>
<p class="text-primary-800">
Die Spielpläne werden automatisch für die aktuelle Saison angezeigt.
Ältere Spielpläne können auf Anfrage bereitgestellt werden.
</p>
<!-- Loading State -->
<div v-if="isLoading" class="text-center py-12">
<svg class="w-8 h-8 text-gray-400 mx-auto mb-4 animate-spin" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
<p class="text-gray-600">Spielpläne werden geladen...</p>
</div>
<!-- Error State -->
<div v-else-if="error" class="bg-red-50 border border-red-200 rounded-lg p-6 text-center">
<svg class="w-12 h-12 text-red-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="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L3.732 16.5c-.77.833.192 2.5 1.732 2.5z" />
</svg>
<h3 class="text-lg font-medium text-red-800 mb-2">Fehler beim Laden</h3>
<p class="text-red-600 mb-4">{{ error }}</p>
<button @click="loadData" class="px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700 transition-colors">
Erneut versuchen
</button>
</div>
<!-- Empty State -->
<div v-else-if="!spielplanData || spielplanData.length === 0" class="text-center py-12 bg-white rounded-xl shadow-lg">
<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 5H7a2 2 0 00-2 2v10a2 2 0 002 2h8a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2" />
</svg>
<h3 class="text-lg font-medium text-gray-900 mb-2">Keine Spielpläne verfügbar</h3>
<p class="text-gray-600">Es wurden noch keine Spielplandaten hochgeladen.</p>
</div>
<!-- Spielplan Table -->
<div v-else class="bg-white rounded-xl shadow-lg overflow-hidden">
<div class="px-6 py-4 border-b border-gray-200">
<h2 class="text-xl font-semibold text-gray-900">Spielplan</h2>
<p class="text-sm text-gray-600 mt-1">
{{ getWettbewerbText() }} - {{ filteredData.length }} von {{ spielplanData.length }} Einträgen
</p>
</div>
<div class="overflow-x-auto">
<table class="min-w-full divide-y divide-gray-200">
<thead class="bg-gray-50">
<tr>
<th v-for="header in headers" :key="header"
class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
{{ formatHeader(header) }}
</th>
</tr>
</thead>
<tbody class="bg-white divide-y divide-gray-200">
<tr v-for="(row, index) in filteredData" :key="index"
:class="getRowClass(row)">
<td v-for="header in headers" :key="header"
class="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
<span v-if="header.toLowerCase().includes('datum')" class="font-mono">
{{ formatDate(row[getOriginalHeader(header)]) }}
</span>
<span v-else-if="header.toLowerCase().includes('uhrzeit')" class="font-mono">
{{ formatTime(row[getOriginalHeader(header)]) }}
</span>
<span v-else-if="header.toLowerCase().includes('mannschaft')" class="font-medium">
{{ row[getOriginalHeader(header)] || '-' }}
</span>
<span v-else-if="header.toLowerCase().includes('runde')">
{{ formatRunde(row[getOriginalHeader(header)]) }}
</span>
<span v-else>
{{ row[getOriginalHeader(header)] || '-' }}
</span>
</td>
</tr>
</tbody>
</table>
</div>
<div class="px-6 py-4 bg-gray-50 border-t border-gray-200">
<p class="text-xs text-gray-500">
Letzte Aktualisierung: {{ lastUpdated }}
</p>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
import { FileText, Download, ExternalLink } from 'lucide-vue-next'
import { ref, onMounted, computed } from 'vue'
const spielplaene = ref([])
const mannschaftenMitLinks = ref([])
// Berechne die aktuelle Saison
const aktuellesSaison = computed(() => {
const jetzt = new Date()
const monat = jetzt.getMonth() + 1 // 1-12
const jahr = jetzt.getFullYear()
// Saison wechselt im Juli/August
if (monat >= 7) {
return { start: jahr, ende: jahr + 1 }
} else {
return { start: jahr - 1, ende: jahr }
}
useHead({
title: 'Spielpläne - Mannschaften - Harheimer TC'
})
const aktuellesSaisonLabel = computed(() => {
return `${aktuellesSaison.value.start}/${aktuellesSaison.value.ende}`
})
const spielplanData = ref([])
const headers = ref([])
const isLoading = ref(false)
const error = ref(null)
const lastUpdated = ref('')
const selectedFilter = ref('all')
const selectedWettbewerb = ref('punktrunde')
const filteredData = ref([])
const mannschaften = ref([])
// Funktion zum Extrahieren der Saison aus dem Dateinamen
const extractSaison = (filename) => {
console.log('extractSaison für:', filename)
const loadData = async () => {
isLoading.value = true
error.value = null
// Normalisiere alle möglichen Trennzeichen zu einem einzigen Zeichen
// Suche nach 4 Ziffern, gefolgt von irgendeinem Nicht-Ziffer-Zeichen, gefolgt von 4 Ziffern
let match = filename.match(/(\d{4})[^0-9](\d{4})/)
if (match) {
const start = parseInt(match[1])
const ende = parseInt(match[2])
console.log(' Gefunden (4-stellig):', start, ende)
return { start, ende, label: `${start}/${ende}` }
}
// Suche nach 2 Ziffern, gefolgt von irgendeinem Nicht-Ziffer-Zeichen, gefolgt von 2 Ziffern
match = filename.match(/(\d{2})[^0-9](\d{2})/)
if (match) {
let start = parseInt(match[1])
let ende = parseInt(match[2])
// Wenn Kurzform (25-26), zu Langform konvertieren
if (start < 100) {
start = 2000 + start
ende = 2000 + ende
}
console.log(' Gefunden (2-stellig):', start, ende)
return { start, ende, label: `${start}/${ende}` }
}
console.log(' Keine Saison gefunden')
return null
}
// Prüfe, ob eine Saison zur aktuellen Saison passt
const istAktuellesSaison = (saison) => {
if (!saison) return false
return saison.start === aktuellesSaison.value.start &&
saison.ende === aktuellesSaison.value.ende
}
// Lade Spielpläne
const loadSpielplaene = async () => {
try {
console.log('=== SPIELPLÄNE LADEN ===')
console.log('Aktuelle Saison:', aktuellesSaison.value)
console.log('Saison Label:', aktuellesSaisonLabel.value)
// Lade Spielplandaten und Mannschaften parallel
const [spielplanResponse, mannschaftenResponse] = await Promise.all([
fetch('/api/spielplan'),
fetch('/data/mannschaften.csv')
])
// Lade Dateien vom Server
const response = await fetch('/api/spielplaene')
if (!response.ok) {
console.error('Fehler beim Laden der Spielpläne:', response.status)
return
}
const dateien = await response.json()
console.log('Geladene Dateien:', dateien)
const gefiltert = dateien
.map(filename => {
console.log('Verarbeite Datei:', filename)
const saison = extractSaison(filename)
console.log(' Extrahierte Saison:', saison)
console.log(' Ist aktuelle Saison?', saison ? istAktuellesSaison(saison) : false)
if (!saison || !istAktuellesSaison(saison)) {
return null
}
// Extrahiere Titel aus Dateiname
const titel = filename
.replace(/\.(pdf|PDF|xlsx|XLSX|xls|XLS)$/, '')
.replace(/[-_]/g, ' ')
.replace(/\d{2,4}[-_\/]\d{2,4}/, '')
.trim()
return {
filename,
titel: titel || filename,
saison: saison.label,
url: `/spielplaene/${filename}`
}
// Spielplandaten verarbeiten
const spielplanResult = await spielplanResponse.json()
if (spielplanResult.success) {
spielplanData.value = spielplanResult.data
// Filtere unerwünschte Spalten heraus und benenne um
const originalHeaders = spielplanResult.headers
const headersToRemove = ['saison', 'meisterschaft', 'altersklasse', 'liga']
// Filtere unerwünschte Spalten heraus
const filteredHeaders = originalHeaders.filter(header => {
const headerLower = header.toLowerCase()
return !headersToRemove.some(toRemove => headerLower.includes(toRemove))
})
.filter(item => item !== null)
// Benenne "Staffel" in "Gruppe" um (nur für Anzeige)
headers.value = filteredHeaders.map(header => {
if (header.toLowerCase().includes('staffel')) {
return 'Gruppe'
}
return header
})
// Erstelle Mapping für Daten-Zugriff
const headerMapping = {}
headers.value.forEach((displayHeader, index) => {
headerMapping[displayHeader] = filteredHeaders[index]
})
// Speichere Mapping global für Zugriff in Template
window.spielplanHeaderMapping = headerMapping
lastUpdated.value = new Date().toLocaleString('de-DE')
} else {
error.value = spielplanResult.message
}
spielplaene.value = gefiltert
// Mannschaften aus CMS laden (manuell eingegebene Mannschaften)
if (mannschaftenResponse.ok) {
const csvText = await mannschaftenResponse.text()
const lines = csvText.split('\n').filter(line => line.trim() !== '')
if (lines.length > 1) {
mannschaften.value = lines.slice(1).map(line => {
// Besserer CSV-Parser: Respektiert Anführungszeichen
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())
return values[0] // Erste Spalte ist der Mannschaftsname
}).filter(name => name && name !== '')
}
}
console.log('Aktuelle Saison:', aktuellesSaisonLabel.value)
console.log('Gefundene Spielpläne:', spielplaene.value)
} catch (error) {
console.error('Fehler beim Laden der Spielpläne:', error)
filterData() // Initial filter
} catch (err) {
console.error('Fehler beim Laden der Daten:', err)
error.value = 'Fehler beim Laden der Daten'
} finally {
isLoading.value = false
}
}
// Lade Mannschaften aus CSV
const loadMannschaften = async () => {
try {
const response = await fetch('/data/mannschaften.csv')
if (!response.ok) return
const filterData = () => {
if (!spielplanData.value || spielplanData.value.length === 0) {
filteredData.value = []
return
}
// Zuerst nach aktueller Saison filtern (immer aktiv)
const currentDate = new Date()
const currentYear = currentDate.getFullYear()
// Da die Spiele bis 2026 gehen, nehmen wir die Saison 2025/26
// Saison läuft vom 01.07. bis 30.06. des Folgejahres
const saisonStartYear = 2025
const saisonEndYear = 2026
const saisonStart = new Date(saisonStartYear, 6, 1) // 01.07.2025
const saisonEnd = new Date(saisonEndYear, 5, 30) // 30.06.2026
let saisonFiltered = spielplanData.value.filter(row => {
const termin = row.Termin
if (!termin) return false
const csv = await response.text()
const lines = csv.split('\n').filter(line => line.trim() !== '')
if (lines.length < 2) return
mannschaftenMitLinks.value = lines.slice(1).map(line => {
// Besserer CSV-Parser: Respektiert Anführungszeichen
const values = []
let current = ''
let inQuotes = false
try {
// Parse deutsches Datumsformat: "27.10.2025 20:00"
let spielDatum
for (let i = 0; i < line.length; i++) {
const char = line[i]
if (termin.includes(' ')) {
// Uhrzeit entfernen: "27.10.2025 20:00" -> "27.10.2025"
const datumTeil = termin.split(' ')[0]
if (char === '"') {
inQuotes = !inQuotes
} else if (char === ',' && !inQuotes) {
values.push(current.trim())
current = ''
} else {
current += char
// Deutsches Format parsen: "27.10.2025" -> Date
const [tag, monat, jahr] = datumTeil.split('.')
spielDatum = new Date(jahr, monat - 1, tag) // Monat ist 0-basiert
} else {
spielDatum = new Date(termin)
}
if (isNaN(spielDatum.getTime())) return false
// Prüfe ob das Spiel in der aktuellen Saison liegt
const inSaison = spielDatum >= saisonStart && spielDatum <= saisonEnd
return inSaison
} catch (error) {
console.error('Fehler beim Parsen von Termin:', termin, error)
return false
}
})
// Dann nach Wettbewerb filtern
let wettbewerbFiltered = saisonFiltered
if (selectedWettbewerb.value === 'punktrunde') {
wettbewerbFiltered = saisonFiltered.filter(row => {
// Filtere nach Punktrunde-Spielen (VR = Vorrunde, RR = Rückrunde)
const runde = (row.Runde || '').toLowerCase()
const isMatch = runde === 'vr' || runde === 'rr' || runde.includes('vorrunde') || runde.includes('rückrunde')
// Debug: Zeige gefilterte Spiele
if (!isMatch && Math.random() < 0.1) { // 10% der gefilterten Spiele loggen
console.log('Gefiltert heraus:', row.Termin, 'Runde:', row.Runde)
}
if (isMatch && Math.random() < 0.05) { // 5% der akzeptierten Spiele loggen
console.log('Akzeptiert als Punktrunde:', row.Termin, 'Runde:', row.Runde)
}
return isMatch
})
} else if (selectedWettbewerb.value === 'pokal') {
wettbewerbFiltered = saisonFiltered.filter(row => {
// Filtere nach Pokal-Spielen
const runde = (row.Runde || '').toLowerCase()
return runde === 'pokal' || runde.includes('pokal')
})
}
// "alle" zeigt alle Spiele ohne weitere Filterung
console.log('selectedWettbewerb.value:', selectedWettbewerb.value)
console.log('Nach Wettbewerb-Filter:', wettbewerbFiltered.length, 'von', saisonFiltered.length)
// Dann nach Mannschaft filtern
if (selectedFilter.value === 'all') {
filteredData.value = wettbewerbFiltered
} else if (selectedFilter.value === 'erwachsene') {
filteredData.value = wettbewerbFiltered.filter(row => {
const heimMannschaft = (row.HeimMannschaft || '').toLowerCase()
const gastMannschaft = (row.GastMannschaft || '').toLowerCase()
const heimAltersklasse = (row.HeimMannschaftAltersklasse || '').toLowerCase()
const gastAltersklasse = (row.GastMannschaftAltersklasse || '').toLowerCase()
// Prüfe ob eine der Mannschaften Harheimer TC ist
const isHarheimerHeim = heimMannschaft.includes('harheimer tc')
const isHarheimerGast = gastMannschaft.includes('harheimer tc')
if (!isHarheimerHeim && !isHarheimerGast) {
return false // Kein Harheimer TC Spiel
}
// Filtere nach Erwachsenen-Mannschaften (NICHT Jugend)
const isErwachsenenHeim = isHarheimerHeim &&
heimAltersklasse.includes('erwachsene') &&
!heimAltersklasse.includes('jugend')
const isErwachsenenGast = isHarheimerGast &&
gastAltersklasse.includes('erwachsene') &&
!gastAltersklasse.includes('jugend')
return isErwachsenenHeim || isErwachsenenGast
})
} else if (selectedFilter.value === 'nachwuchs') {
filteredData.value = wettbewerbFiltered.filter(row => {
const heimMannschaft = (row.HeimMannschaft || '').toLowerCase()
const gastMannschaft = (row.GastMannschaft || '').toLowerCase()
const heimAltersklasse = (row.HeimMannschaftAltersklasse || '').toLowerCase()
const gastAltersklasse = (row.GastMannschaftAltersklasse || '').toLowerCase()
// Prüfe ob eine der Mannschaften Harheimer TC ist
const isHarheimerHeim = heimMannschaft.includes('harheimer tc')
const isHarheimerGast = gastMannschaft.includes('harheimer tc')
if (!isHarheimerHeim && !isHarheimerGast) {
return false // Kein Harheimer TC Spiel
}
// Filtere nach Jugend-Mannschaften (NUR Jugend)
const isJugendHeim = isHarheimerHeim &&
(heimAltersklasse.includes('jugend') || heimMannschaft.includes('jugend'))
const isJugendGast = isHarheimerGast &&
(gastAltersklasse.includes('jugend') || gastMannschaft.includes('jugend'))
return isJugendHeim || isJugendGast
})
} else {
// Spezifische Mannschaft - Mapping zwischen CMS-Mannschaften und CSV-Daten
filteredData.value = wettbewerbFiltered.filter(row => {
const heimMannschaft = (row.HeimMannschaft || '').toLowerCase()
const gastMannschaft = (row.GastMannschaft || '').toLowerCase()
const heimAltersklasse = (row.HeimMannschaftAltersklasse || '').toLowerCase()
const gastAltersklasse = (row.GastMannschaftAltersklasse || '').toLowerCase()
// Prüfe ob eine der Mannschaften Harheimer TC ist
const isHarheimerHeim = heimMannschaft.includes('harheimer tc')
const isHarheimerGast = gastMannschaft.includes('harheimer tc')
if (!isHarheimerHeim && !isHarheimerGast) {
return false // Kein Harheimer TC Spiel
}
const cmsMannschaft = selectedFilter.value
// Mapping zwischen CMS-Mannschaften und CSV-Daten
const mannschaftMapping = {
'Erwachsene 1': ['harheimer tc'], // Nur ohne römische Zahl
'Erwachsene 2': ['harheimer tc ii'],
'Erwachsene 3': ['harheimer tc iii'],
'Erwachsene 4': ['harheimer tc iv'],
'Erwachsene 5': ['harheimer tc v'],
'Jugendmannschaft': ['harheimer tc'] // Jugend hat keine römische Zahl
}
const csvVariants = mannschaftMapping[cmsMannschaft] || []
// Prüfe Mannschafts-Zuordnung UND Altersklasse
const mannschaftMatch = csvVariants.some(variant => {
// Strikte Übereinstimmung: Prüfe exakte Mannschaftsnamen
if (isHarheimerHeim) {
// Für "harheimer tc" (Erwachsene 1): Nur wenn KEINE römische Zahl folgt
if (variant === 'harheimer tc') {
return heimMannschaft === 'harheimer tc' ||
heimMannschaft.startsWith('harheimer tc ') &&
!heimMannschaft.match(/harheimer tc\s+[ivx]+/i)
}
// Für andere Mannschaften: Exakte Übereinstimmung
return heimMannschaft === variant || heimMannschaft.startsWith(variant + ' ')
}
}
values.push(current.trim())
if (isHarheimerGast) {
// Für "harheimer tc" (Erwachsene 1): Nur wenn KEINE römische Zahl folgt
if (variant === 'harheimer tc') {
return gastMannschaft === 'harheimer tc' ||
gastMannschaft.startsWith('harheimer tc ') &&
!gastMannschaft.match(/harheimer tc\s+[ivx]+/i)
}
// Für andere Mannschaften: Exakte Übereinstimmung
return gastMannschaft === variant || gastMannschaft.startsWith(variant + ' ')
}
return false
})
if (values.length < 10) return null
return {
mannschaft: values[0].trim(),
liga: values[1].trim(),
weitere_informationen_link: values[8].trim()
if (!mannschaftMatch) {
return false
}
}).filter(mannschaft => mannschaft !== null && mannschaft.weitere_informationen_link !== '')
// Zusätzliche Altersklassen-Prüfung für spezifische Mannschaften
if (cmsMannschaft.startsWith('Erwachsene')) {
// Erwachsenen-Mannschaften: MUSS Erwachsene sein, DARF NICHT Jugend sein
const isErwachsenenHeim = isHarheimerHeim &&
heimAltersklasse.includes('erwachsene') &&
!heimAltersklasse.includes('jugend')
const isErwachsenenGast = isHarheimerGast &&
gastAltersklasse.includes('erwachsene') &&
!gastAltersklasse.includes('jugend')
return isErwachsenenHeim || isErwachsenenGast
} else if (cmsMannschaft === 'Jugendmannschaft') {
// Jugend-Mannschaft: MUSS Jugend sein
const isJugendHeim = isHarheimerHeim &&
(heimAltersklasse.includes('jugend') || heimMannschaft.includes('jugend'))
const isJugendGast = isHarheimerGast &&
(gastAltersklasse.includes('jugend') || gastMannschaft.includes('jugend'))
return isJugendHeim || isJugendGast
}
return true // Fallback für unbekannte Mannschaften
})
}
console.log('Finale gefilterte Daten:', filteredData.value.length, 'von', spielplanData.value.length)
}
const downloadPDF = () => {
if (!filteredData.value || filteredData.value.length === 0) return
// Bestimme den Team-Parameter basierend auf dem Filter
let teamParam = ''
if (selectedFilter.value === 'all') {
teamParam = 'all'
} else if (selectedFilter.value === 'erwachsene') {
teamParam = 'erwachsene'
} else if (selectedFilter.value === 'nachwuchs') {
teamParam = 'nachwuchs'
} else {
// Für einzelne Mannschaften: Konvertiere Namen
teamParam = selectedFilter.value.replace(/\s+/g, '_').toLowerCase()
}
// Erstelle Download-URL für dynamische PDF-Generierung
const params = new URLSearchParams({
team: teamParam,
wettbewerb: selectedWettbewerb.value
})
const downloadUrl = `/api/spielplan/pdf?${params.toString()}`
// Öffne Download in neuem Tab
window.open(downloadUrl, '_blank')
}
const formatHeader = (header) => {
const headerMap = {
'Datum': 'Datum',
'Mannschaft': 'Mannschaft',
'Gegner': 'Gegner',
'Ort': 'Ort',
'Uhrzeit': 'Uhrzeit',
'Runde': 'Runde',
'Gruppe': 'Gruppe',
'Ergebnis': 'Ergebnis',
'Bemerkung': 'Bemerkung',
'Status': 'Status'
}
return headerMap[header] || header
}
const formatDate = (dateString) => {
if (!dateString) return '-'
try {
const date = new Date(dateString)
if (isNaN(date.getTime())) {
return dateString
}
console.log('Mannschaften mit Links:', mannschaftenMitLinks.value)
return date.toLocaleDateString('de-DE', {
day: '2-digit',
month: '2-digit',
year: 'numeric'
})
} catch {
return dateString
}
}
const formatTime = (timeString) => {
if (!timeString) return '-'
if (timeString.match(/^\d{1,2}:\d{2}$/)) {
return timeString
}
return timeString
}
const formatRunde = (rundeString) => {
if (!rundeString) return '-'
const runde = rundeString.toLowerCase()
// Ersetze "VR" durch "Vorrunde" und "RR" durch "Rückrunde"
if (runde === 'vr') {
return 'Vorrunde'
} else if (runde === 'rr') {
return 'Rückrunde'
} else if (runde === 'pokal') {
return 'Pokal'
}
return rundeString
}
const getOriginalHeader = (displayHeader) => {
// Verwende das Mapping um den ursprünglichen Header-Namen zu finden
if (window.spielplanHeaderMapping && window.spielplanHeaderMapping[displayHeader]) {
return window.spielplanHeaderMapping[displayHeader]
}
return displayHeader
}
const getRowClass = (row) => {
const termin = row[getOriginalHeader('Termin')] || row['Termin']
if (!termin) return 'bg-white'
try {
// Parse deutsches Datumsformat: "27.10.2025 20:00"
let spielDatum
if (termin.includes(' ')) {
// Uhrzeit entfernen: "27.10.2025 20:00" -> "27.10.2025"
const datumTeil = termin.split(' ')[0]
// Deutsches Format parsen: "27.10.2025" -> Date
const [tag, monat, jahr] = datumTeil.split('.')
spielDatum = new Date(jahr, monat - 1, tag) // Monat ist 0-basiert
} else {
// Fallback für andere Formate
spielDatum = new Date(termin)
}
if (isNaN(spielDatum.getTime())) return 'bg-white'
const heute = new Date()
heute.setHours(0, 0, 0, 0)
const in7Tagen = new Date(heute)
in7Tagen.setDate(in7Tagen.getDate() + 7)
spielDatum.setHours(0, 0, 0, 0)
// Heute: Hellgelb
if (spielDatum.getTime() === heute.getTime()) {
return 'bg-yellow-100'
}
// Nächste 7 Tage: Hellblau
if (spielDatum > heute && spielDatum <= in7Tagen) {
return 'bg-blue-100'
}
// Standard: Weiß
return 'bg-white'
} catch (error) {
console.error('Fehler beim Laden der Mannschaften:', error)
return 'bg-white'
}
}
const getWettbewerbText = () => {
switch (selectedWettbewerb.value) {
case 'punktrunde':
return 'Punktrunde (Vorrunde + Rückrunde)'
case 'pokal':
return 'Pokal'
case 'alle':
return 'Alle Wettbewerbe'
default:
return 'Punktrunde (Vorrunde + Rückrunde)'
}
}
onMounted(() => {
loadSpielplaene()
loadMannschaften()
loadData()
})
useHead({
title: 'Spielpläne - Harheimer TC',
})
</script>
</script>

195
pages/spielplan.vue Normal file
View File

@@ -0,0 +1,195 @@
<template>
<div class="min-h-screen bg-gray-50">
<!-- Header -->
<div class="bg-white shadow-sm">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
<div class="flex items-center justify-between">
<div>
<h1 class="text-3xl font-bold text-gray-900">Spielplan</h1>
<p class="mt-2 text-gray-600">Aktuelle Termine und Spiele des Vereins</p>
</div>
<div class="flex items-center space-x-4">
<button @click="refreshData"
:disabled="isLoading"
class="px-4 py-2 bg-primary-600 text-white rounded-lg hover:bg-primary-700 transition-colors disabled:bg-gray-400">
<svg v-if="isLoading" class="w-4 h-4 animate-spin" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
<svg v-else class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
Aktualisieren
</button>
</div>
</div>
</div>
</div>
<!-- Content -->
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<!-- Loading State -->
<div v-if="isLoading" class="text-center py-12">
<svg class="w-8 h-8 text-gray-400 mx-auto mb-4 animate-spin" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
<p class="text-gray-600">Spielplan wird geladen...</p>
</div>
<!-- Error State -->
<div v-else-if="error" class="bg-red-50 border border-red-200 rounded-lg p-6 text-center">
<svg class="w-12 h-12 text-red-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="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L3.732 16.5c-.77.833.192 2.5 1.732 2.5z" />
</svg>
<h3 class="text-lg font-medium text-red-800 mb-2">Fehler beim Laden</h3>
<p class="text-red-600 mb-4">{{ error }}</p>
<button @click="loadData" class="px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700 transition-colors">
Erneut versuchen
</button>
</div>
<!-- Empty State -->
<div v-else-if="!spielplanData || spielplanData.length === 0" class="text-center py-12 bg-white rounded-xl shadow-lg">
<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 5H7a2 2 0 00-2 2v10a2 2 0 002 2h8a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2" />
</svg>
<h3 class="text-lg font-medium text-gray-900 mb-2">Kein Spielplan verfügbar</h3>
<p class="text-gray-600">Es wurden noch keine Spielplandaten hochgeladen.</p>
</div>
<!-- Spielplan Table -->
<div v-else class="bg-white rounded-xl shadow-lg overflow-hidden">
<div class="px-6 py-4 border-b border-gray-200">
<h2 class="text-xl font-semibold text-gray-900">Aktuelle Spiele</h2>
<p class="text-sm text-gray-600 mt-1">{{ spielplanData.length }} Einträge</p>
</div>
<div class="overflow-x-auto">
<table class="min-w-full divide-y divide-gray-200">
<thead class="bg-gray-50">
<tr>
<th v-for="header in headers" :key="header"
class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
{{ formatHeader(header) }}
</th>
</tr>
</thead>
<tbody class="bg-white divide-y divide-gray-200">
<tr v-for="(row, index) in spielplanData" :key="index"
:class="index % 2 === 0 ? 'bg-white' : 'bg-gray-50'">
<td v-for="header in headers" :key="header"
class="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
<span v-if="header.toLowerCase().includes('datum')" class="font-mono">
{{ formatDate(row[header]) }}
</span>
<span v-else-if="header.toLowerCase().includes('uhrzeit')" class="font-mono">
{{ formatTime(row[header]) }}
</span>
<span v-else>
{{ row[header] || '-' }}
</span>
</td>
</tr>
</tbody>
</table>
</div>
<div class="px-6 py-4 bg-gray-50 border-t border-gray-200">
<p class="text-xs text-gray-500">
Letzte Aktualisierung: {{ lastUpdated }}
</p>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, onMounted, computed } from 'vue'
useHead({
title: 'Spielplan - Harheimer TC'
})
const spielplanData = ref([])
const headers = ref([])
const isLoading = ref(false)
const error = ref(null)
const lastUpdated = ref('')
const loadData = async () => {
isLoading.value = true
error.value = null
try {
const response = await fetch('/api/spielplan')
const result = await response.json()
if (result.success) {
spielplanData.value = result.data
headers.value = result.headers
lastUpdated.value = new Date().toLocaleString('de-DE')
} else {
error.value = result.message
}
} catch (err) {
console.error('Fehler beim Laden des Spielplans:', err)
error.value = 'Fehler beim Laden der Daten'
} finally {
isLoading.value = false
}
}
const refreshData = () => {
loadData()
}
const formatHeader = (header) => {
const headerMap = {
'Datum': 'Datum',
'Mannschaft': 'Mannschaft',
'Gegner': 'Gegner',
'Ort': 'Ort',
'Uhrzeit': 'Uhrzeit',
'Ergebnis': 'Ergebnis',
'Bemerkung': 'Bemerkung',
'Status': 'Status'
}
return headerMap[header] || header
}
const formatDate = (dateString) => {
if (!dateString) return '-'
try {
// Versuche verschiedene Datumsformate zu parsen
const date = new Date(dateString)
if (isNaN(date.getTime())) {
return dateString // Falls nicht parsbar, Original zurückgeben
}
return date.toLocaleDateString('de-DE', {
day: '2-digit',
month: '2-digit',
year: 'numeric'
})
} catch {
return dateString
}
}
const formatTime = (timeString) => {
if (!timeString) return '-'
// Einfache Zeitformatierung (HH:MM)
if (timeString.match(/^\d{1,2}:\d{2}$/)) {
return timeString
}
return timeString
}
onMounted(() => {
loadData()
})
</script>