Merge pull request 'dev' (#30) from dev into main
All checks were successful
Code Analysis and Production Deploy / analyze (push) Successful in 2m48s
Code Analysis and Production Deploy / deploy-production (push) Successful in 2m4s
Code Analysis and Production Deploy / deploy-test (push) Has been skipped

Reviewed-on: #30
This commit is contained in:
2026-05-20 18:42:31 +02:00
14 changed files with 1064 additions and 56 deletions

View File

@@ -52,7 +52,7 @@
<!-- Mannschaftsaufstellung -->
<div class="border-t border-gray-200 pt-6">
<h3 class="text-xl font-semibold text-gray-900 mb-4">
Mannschaftsaufstellung Saison 2025/26
Mannschaftsaufstellung Saison {{ selectedSeasonLabel }}
</h3>
<div class="grid sm:grid-cols-2 lg:grid-cols-4 gap-4">
<div
@@ -102,11 +102,36 @@
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { ref, onMounted, computed } from 'vue'
import { Users } from 'lucide-vue-next'
const props = defineProps({
season: {
type: String,
default: ''
}
})
const mannschaften = ref([])
const getCurrentSeasonSlug = () => {
const now = new Date()
const year = now.getFullYear()
const startYear = now.getMonth() >= 6 ? year : year - 1
const endYear = startYear + 1
return `${String(startYear).slice(-2)}--${String(endYear).slice(-2)}`
}
const selectedSeason = computed(() => {
const value = String(props.season || '').trim()
return /^\d{2}--\d{2}$/.test(value) ? value : getCurrentSeasonSlug()
})
const selectedSeasonLabel = computed(() => {
const match = String(selectedSeason.value || '').match(/^(\d{2})--(\d{2})$/)
return match ? `20${match[1]}/${match[2]}` : selectedSeason.value
})
async function fetchCsvText(url) {
const attempt = async () => {
const withBuster = `${url}${url.includes('?') ? '&' : '?'}_t=${Date.now()}`
@@ -125,7 +150,9 @@ async function fetchCsvText(url) {
const loadMannschaften = async () => {
try {
const csv = await fetchCsvText('/api/mannschaften')
const params = new URLSearchParams()
if (selectedSeason.value) params.set('season', selectedSeason.value)
const csv = await fetchCsvText(`/api/mannschaften${params.toString() ? `?${params.toString()}` : ''}`)
// Vereinfachter CSV-Parser
const lines = csv.split('\n').filter(line => line.trim() !== '')

View File

@@ -1,21 +1,58 @@
<template>
<div>
<div class="flex justify-between items-center mb-6">
<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>
<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 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
@@ -356,7 +393,10 @@ import { Plus, Trash2, Loader2, AlertCircle, Pencil, Users, ChevronUp, ChevronDo
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)
@@ -376,6 +416,45 @@ async function fetchCsvText(url) {
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 (_error) {
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)
@@ -392,7 +471,9 @@ function getPendingSpielerNamesForTeamIndex(teamIndex) {
const loadMannschaften = async () => {
isLoading.value = true
try {
const csv = await fetchCsvText('/api/mannschaften')
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 => {
@@ -405,6 +486,10 @@ const loadMannschaften = async () => {
} 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() }
@@ -451,7 +536,59 @@ const saveCSV = async () => {
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: 'mannschaften.csv', content: [header, ...rows].join('\n') } })
await $fetch('/api/cms/save-csv', {
method: 'POST',
body: {
filename: getMannschaftenFilenameForSeason(selectedSeason.value),
content: [header, ...rows].join('\n')
}
})
}
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) => {
@@ -462,5 +599,8 @@ const confirmDelete = (mannschaft, index) => {
} else { if (confirm(`Mannschaft "${mannschaft.mannschaft}" wirklich löschen?`)) { mannschaften.value.splice(index, 1); saveCSV(); loadMannschaften() } }
}
onMounted(() => { loadMannschaften().catch(() => {}) })
onMounted(async () => {
await loadSeasons()
await loadMannschaften().catch(() => {})
})
</script>

View File

@@ -1,6 +1,6 @@
{
"name": "harheimertc-website",
"version": "1.4.7",
"version": "1.5.0",
"description": "Moderne Webseite für den Harheimer Tischtennis Club",
"private": true,
"type": "module",

View File

@@ -51,7 +51,7 @@
<!-- Mannschaftsaufstellung -->
<div class="bg-white rounded-xl shadow-lg p-6">
<h2 class="text-2xl font-semibold text-gray-900 mb-6">
Mannschaftsaufstellung Saison 2025/26
Mannschaftsaufstellung Saison {{ mannschaftSeasonLabel }}
</h2>
<div class="grid sm:grid-cols-2 lg:grid-cols-4 gap-4">
<div
@@ -76,40 +76,64 @@
<!-- Aktueller Spielplan -->
<div class="bg-white rounded-xl shadow-lg overflow-hidden">
<div class="px-6 py-4 border-b border-gray-200">
<h2 class="text-2xl font-semibold text-gray-900">
Aktueller Spielplan
</h2>
<p
v-if="spielplanSeasonLabel"
class="text-sm text-gray-600 mt-1"
>
Saison {{ spielplanSeasonLabel }}
</p>
<div class="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div>
<h2 class="text-2xl font-semibold text-gray-900">
Aktueller Spielplan
</h2>
<p
v-if="spielplanSeasonLabel"
class="text-sm text-gray-600 mt-1"
>
Saison {{ spielplanSeasonLabel }}
</p>
</div>
<div class="inline-flex rounded-lg border border-gray-200 p-1 bg-gray-50">
<button
type="button"
class="px-4 py-2 text-sm font-medium rounded-md transition-colors"
:class="activePanelTab === 'matches' ? 'bg-white text-primary-700 shadow-sm' : 'text-gray-600 hover:text-gray-900'"
@click="activePanelTab = 'matches'"
>
Matches
</button>
<button
v-if="hasTableLink"
type="button"
class="px-4 py-2 text-sm font-medium rounded-md transition-colors"
:class="activePanelTab === 'table' ? 'bg-white text-primary-700 shadow-sm' : 'text-gray-600 hover:text-gray-900'"
@click="activePanelTab = 'table'"
>
Tabelle
</button>
</div>
</div>
</div>
<div
v-if="isSpielplanLoading"
v-if="activePanelTab === 'matches' && isSpielplanLoading"
class="p-6 text-sm text-gray-600"
>
Spielplan wird geladen...
</div>
<div
v-else-if="spielplanError"
v-else-if="activePanelTab === 'matches' && spielplanError"
class="p-6 text-sm text-red-600"
>
{{ spielplanError }}
</div>
<div
v-else-if="mannschaftSpielplan.length === 0"
v-else-if="activePanelTab === 'matches' && mannschaftSpielplan.length === 0"
class="p-6 text-sm text-gray-600"
>
Für diese Mannschaft sind im aktuellen Spielplan keine Spiele vorhanden.
</div>
<div
v-else
v-else-if="activePanelTab === 'matches'"
class="overflow-x-auto"
>
<table class="min-w-full divide-y divide-gray-200">
@@ -170,6 +194,101 @@
</tbody>
</table>
</div>
<div
v-if="activePanelTab === 'table' && isTableLoading"
class="p-6 text-sm text-gray-600"
>
Tabelle wird geladen...
</div>
<div
v-else-if="activePanelTab === 'table' && tableError"
class="p-6 text-sm text-red-600"
>
{{ tableError }}
</div>
<div
v-else-if="activePanelTab === 'table' && teamTableRows.length === 0"
class="p-6 text-sm text-gray-600"
>
Für diese Mannschaft ist aktuell keine Tabelle hinterlegt.
</div>
<div
v-else-if="activePanelTab === 'table'"
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">
Platz
</th>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Mannschaft
</th>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Spiele
</th>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
S
</th>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
U
</th>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
N
</th>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Sätze
</th>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Bälle
</th>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Punkte
</th>
</tr>
</thead>
<tbody class="bg-white divide-y divide-gray-200">
<tr
v-for="(row, index) in teamTableRows"
:key="`${row.team_id || row.team_name || 'row'}-${index}`"
:class="isCurrentTeamRow(row) ? 'bg-primary-50' : 'bg-white'"
>
<td class="px-4 py-3 whitespace-nowrap text-sm text-gray-900">
{{ row.table_rank ?? '-' }}
</td>
<td class="px-4 py-3 whitespace-nowrap text-sm text-gray-900 font-medium">
{{ row.team_name || '-' }}
</td>
<td class="px-4 py-3 whitespace-nowrap text-sm text-gray-900">
{{ row.meetings_count ?? '-' }}
</td>
<td class="px-4 py-3 whitespace-nowrap text-sm text-gray-900">
{{ row.meetings_won ?? 0 }}
</td>
<td class="px-4 py-3 whitespace-nowrap text-sm text-gray-900">
{{ row.meetings_tie ?? 0 }}
</td>
<td class="px-4 py-3 whitespace-nowrap text-sm text-gray-900">
{{ row.meetings_lost ?? 0 }}
</td>
<td class="px-4 py-3 whitespace-nowrap text-sm text-gray-900">
{{ formatSaetze(row) }}
</td>
<td class="px-4 py-3 whitespace-nowrap text-sm text-gray-900">
{{ formatBaelle(row) }}
</td>
<td class="px-4 py-3 whitespace-nowrap text-sm text-gray-900">
{{ formatPunkte(row) }}
</td>
</tr>
</tbody>
</table>
</div>
</div>
<!-- Links -->
@@ -196,14 +315,17 @@
<!-- Letzte Aktualisierung -->
<div class="bg-white rounded-xl shadow-lg p-6">
<p class="text-sm text-gray-500 text-center">
Zuletzt aktualisiert am: {{ formatDate(mannschaft.letzte_aktualisierung) }}
Mannschaftsinformationen aktualisiert am: {{ formatDate(mannschaft.letzte_aktualisierung) }}
</p>
<p class="text-sm text-gray-500 text-center mt-1">
Daten aktualisiert am: {{ dataUpdatedLabel }}
</p>
</div>
<!-- Zurück-Button -->
<div class="text-center">
<NuxtLink
to="/mannschaften"
:to="{ path: '/mannschaften', query: selectedSeason ? { season: selectedSeason } : {} }"
class="inline-flex items-center px-6 py-3 bg-primary-600 hover:bg-primary-700 text-white font-semibold rounded-lg transition-colors"
>
Zurück zur Übersicht
@@ -237,17 +359,55 @@ import { ref, computed, onMounted } from 'vue'
import { Users } from 'lucide-vue-next'
const route = useRoute()
const getCurrentSeasonSlug = () => {
const now = new Date()
const year = now.getFullYear()
const startYear = now.getMonth() >= 6 ? year : year - 1
const endYear = startYear + 1
return `${String(startYear).slice(-2)}--${String(endYear).slice(-2)}`
}
const selectedSeason = computed(() => {
const value = String(route.query.season || '').trim()
return /^\d{2}--\d{2}$/.test(value) ? value : getCurrentSeasonSlug()
})
const mannschaft = ref(null)
const mannschaftSpielplan = ref([])
const spielplanSeason = ref('')
const isSpielplanLoading = ref(false)
const spielplanError = ref('')
const activePanelTab = ref('matches')
const isTableLoading = ref(false)
const tableError = ref('')
const teamTableRows = ref([])
const spielplanDataUpdatedAt = ref('')
const tableDataUpdatedAt = ref('')
const hasTableLink = computed(() => {
const link = String(mannschaft.value?.weitere_informationen_link || '').trim()
return link.includes('/tabelle/')
})
const spielplanSeasonLabel = computed(() => {
const match = String(spielplanSeason.value || '').match(/^(\d{2})--(\d{2})$/)
return match ? `20${match[1]}/${match[2]}` : ''
})
const mannschaftSeasonLabel = computed(() => {
if (spielplanSeasonLabel.value) return spielplanSeasonLabel.value
const now = new Date()
const year = now.getFullYear()
const start = now.getMonth() >= 6 ? year : year - 1
return `${start}/${String(start + 1).slice(-2)}`
})
const dataUpdatedLabel = computed(() => {
const dateValue = tableDataUpdatedAt.value || spielplanDataUpdatedAt.value || mannschaft.value?.letzte_aktualisierung || ''
return formatDate(dateValue)
})
async function fetchCsvText(url) {
const attempt = async () => {
const withBuster = `${url}${url.includes('?') ? '&' : '?'}_t=${Date.now()}`
@@ -266,7 +426,9 @@ async function fetchCsvText(url) {
const loadMannschaften = async () => {
try {
const csv = await fetchCsvText('/api/mannschaften')
const params = new URLSearchParams()
if (selectedSeason.value) params.set('season', selectedSeason.value)
const csv = await fetchCsvText(`/api/mannschaften${params.toString() ? `?${params.toString()}` : ''}`)
if (!csv) return
const lines = csv.split('\n').filter(line => line.trim() !== '')
@@ -317,13 +479,55 @@ const loadMannschaften = async () => {
useHead({
title: `${mannschaft.value.mannschaft} - Harheimer TC`,
})
await loadSpielplan()
await Promise.all([loadSpielplan(), loadTeamTable()])
}
} catch (error) {
console.error('Fehler beim Laden der Mannschaften:', error)
}
}
const loadTeamTable = async () => {
if (!mannschaft.value) return
if (!hasTableLink.value) {
teamTableRows.value = []
tableError.value = ''
return
}
isTableLoading.value = true
tableError.value = ''
try {
const params = new URLSearchParams({ team: mannschaft.value.mannschaft })
if (selectedSeason.value) {
params.set('season', selectedSeason.value)
} else if (spielplanSeason.value) {
params.set('season', spielplanSeason.value)
}
const response = await fetch(`/api/spielplan/table?${params.toString()}`)
const result = await response.json()
if (!result.success) {
tableError.value = result.message || 'Tabelle konnte nicht geladen werden.'
teamTableRows.value = []
return
}
teamTableRows.value = Array.isArray(result?.table?.table?.leagueTable)
? result.table.table.leagueTable
: []
tableDataUpdatedAt.value = result?.importedAt || ''
} catch (error) {
console.error('Fehler beim Laden der Tabelle:', error)
tableError.value = 'Tabelle konnte nicht geladen werden.'
teamTableRows.value = []
} finally {
isTableLoading.value = false
}
}
const getTeamVariants = (cmsMannschaft) => {
const mannschaftMapping = {
'Erwachsene 1': ['harheimer tc'],
@@ -407,7 +611,9 @@ const loadSpielplan = async () => {
spielplanError.value = ''
try {
const response = await fetch('/api/spielplan')
const params = new URLSearchParams()
if (selectedSeason.value) params.set('season', selectedSeason.value)
const response = await fetch(`/api/spielplan${params.toString() ? `?${params.toString()}` : ''}`)
const result = await response.json()
if (!result.success) {
@@ -417,6 +623,7 @@ const loadSpielplan = async () => {
}
spielplanSeason.value = result.season || ''
spielplanDataUpdatedAt.value = result?.source?.updatedAt || result?.source?.importedAt || ''
mannschaftSpielplan.value = result.data
.filter(row => isSpielForMannschaft(row, mannschaft.value.mannschaft))
.sort((a, b) => parseTerminTimestamp(a) - parseTerminTimestamp(b))
@@ -495,6 +702,33 @@ const getRowClass = (row) => {
return 'bg-white'
}
const formatSaetze = (row) => {
const won = row?.sets_won
const lost = row?.sets_lost
if (won == null && lost == null) return row?.sets_relation || '-'
return `${won ?? 0}:${lost ?? 0}`
}
const formatBaelle = (row) => {
const won = row?.games_won
const lost = row?.games_lost
if (won == null && lost == null) return row?.games_relation || '-'
return `${won ?? 0}:${lost ?? 0}`
}
const formatPunkte = (row) => {
if (row?.points_won == null && row?.points_lost == null) return '-'
return `${row?.points_won ?? 0}:${row?.points_lost ?? 0}`
}
const isCurrentTeamRow = (row) => {
const teamName = String(row?.team_name || '').toLowerCase()
if (!teamName.includes('harheimer tc')) return false
const variants = getTeamVariants(mannschaft.value?.mannschaft || '')
return variants.some((variant) => isExactHarheimTeam(teamName, variant))
}
onMounted(() => {
loadMannschaften()
})

View File

@@ -7,10 +7,10 @@
<div class="w-24 h-1 bg-primary-600 mb-8" />
<p class="text-xl text-gray-600 mb-12">
Unsere aktiven Mannschaften in der Saison 2025/26
Unsere aktiven Mannschaften in der Saison {{ selectedSeasonLabel }}
</p>
<MannschaftenUebersicht />
<MannschaftenUebersicht :season="selectedSeason" />
<div class="mt-16">
<div class="bg-primary-50 p-8 rounded-xl border border-primary-100">
@@ -21,7 +21,7 @@
Alle aktuellen Spielpläne und Ergebnisse unserer Mannschaften finden Sie hier.
</p>
<NuxtLink
to="/mannschaften/spielplaene"
:to="{ path: '/mannschaften/spielplaene', query: { season: selectedSeason } }"
class="inline-flex items-center px-6 py-3 bg-primary-600 hover:bg-primary-700 text-white font-semibold rounded-lg transition-colors"
>
Zu den Spielplänen
@@ -33,8 +33,29 @@
</template>
<script setup>
import { computed } from 'vue'
import MannschaftenUebersicht from '~/components/MannschaftenUebersicht.vue'
const route = useRoute()
const getCurrentSeasonSlug = () => {
const now = new Date()
const year = now.getFullYear()
const startYear = now.getMonth() >= 6 ? year : year - 1
const endYear = startYear + 1
return `${String(startYear).slice(-2)}--${String(endYear).slice(-2)}`
}
const selectedSeason = computed(() => {
const value = String(route.query.season || '').trim()
return /^\d{2}--\d{2}$/.test(value) ? value : getCurrentSeasonSlug()
})
const selectedSeasonLabel = computed(() => {
const match = String(selectedSeason.value || '').match(/^(\d{2})--(\d{2})$/)
return match ? `20${match[1]}/${match[2]}` : selectedSeason.value
})
useHead({
title: 'Mannschaften - Harheimer TC',
})

View File

@@ -361,6 +361,9 @@
<script setup>
import { ref, onMounted } from 'vue'
const route = useRoute()
const router = useRouter()
useHead({
title: 'Spielpläne - Mannschaften - Harheimer TC'
})
@@ -378,6 +381,19 @@ const seasons = ref([])
const selectedSeason = ref('')
const hasLoadedSpielplan = ref(false)
function getCurrentSeasonSlug() {
const now = new Date()
const year = now.getFullYear()
const startYear = now.getMonth() >= 6 ? year : year - 1
const endYear = startYear + 1
return `${String(startYear).slice(-2)}--${String(endYear).slice(-2)}`
}
function normalizeSeasonOrDefault(value) {
const season = String(value || '').trim()
return /^\d{2}--\d{2}$/.test(season) ? season : getCurrentSeasonSlug()
}
async function fetchCsvText(url) {
const attempt = async () => {
const withBuster = `${url}${url.includes('?') ? '&' : '?'}_t=${Date.now()}`
@@ -405,7 +421,7 @@ const loadData = async () => {
const [spielplanResponse, mannschaftenResponse] = await Promise.all([
fetch(`/api/spielplan${params.toString() ? `?${params.toString()}` : ''}`),
fetchCsvText('/api/mannschaften')
fetchCsvText(`/api/mannschaften${params.toString() ? `?${params.toString()}` : ''}`)
])
const spielplanResult = await spielplanResponse.json()
@@ -494,11 +510,13 @@ const applyMannschaftenResponse = async (csvText) => {
}
const onSeasonChange = () => {
router.replace({ query: { ...route.query, season: selectedSeason.value } })
spielplanData.value = []
filteredData.value = []
headers.value = []
lastUpdated.value = ''
hasLoadedSpielplan.value = false
loadData()
}
const filterData = () => {
@@ -841,6 +859,7 @@ const getWettbewerbText = () => {
}
onMounted(() => {
selectedSeason.value = normalizeSeasonOrDefault(route.query.season)
loadData()
})

View File

@@ -1,10 +1,18 @@
#!/usr/bin/env node
import { importSpielplan } from '../server/utils/spielplan-import.js'
import { importLeagueTables } from '../server/utils/spielklassen-tables-import.js'
import { publishImportedSpielplan } from '../server/utils/spielplan-publish.js'
const result = await importSpielplan()
const published = await publishImportedSpielplan({ inputPath: result.jsonFile })
const tables = await importLeagueTables()
console.log(`Spielplan gespeichert: ${result.jsonFile}`)
console.log(`Roh-HTML gespeichert: ${result.htmlFile}`)
console.log(`Spiele: ${result.matchCount}`)
console.log(`Zeitraum: ${result.source.season.dateStart} bis ${result.source.season.dateEnd}`)
console.log(`Spielplan publiziert: ${published.internalSeasonPath}`)
console.log(`Spielplan publiziert (public): ${published.publicSeasonPath}`)
console.log(`Tabellen gespeichert: ${tables.outputFile}`)
console.log(`Tabellen importiert: ${tables.importedCount}/${tables.teamCount} (Fehler: ${tables.errorCount})`)

View File

@@ -51,8 +51,9 @@ export default defineEventHandler(async (event) => {
'termine.csv',
'spielplan.csv'
]
const isSeasonalMannschaftenFile = /^mannschaften_\d{2}--\d{2}\.csv$/.test(String(filename))
if (!allowedFiles.includes(filename)) {
if (!allowedFiles.includes(filename) && !isSeasonalMannschaftenFile) {
throw createError({
statusCode: 403,
statusMessage: 'Datei nicht erlaubt'
@@ -105,7 +106,9 @@ export default defineEventHandler(async (event) => {
'termine.csv': [`${cwd}/server/data/public-data/termine.csv`, `${cwd}/../server/data/public-data/termine.csv`],
'spielplan.csv': [`${cwd}/server/data/public-data/spielplan.csv`, `${cwd}/../server/data/public-data/spielplan.csv`]
}
const internalPaths = dataTargetsByFile[filename] || []
const internalPaths = isSeasonalMannschaftenFile
? [`${cwd}/server/data/public-data/${filename}`, `${cwd}/../server/data/public-data/${filename}`]
: (dataTargetsByFile[filename] || [])
const uniquePaths = [...new Set([...internalPaths])]
const writeResults = []

View File

@@ -1,5 +1,34 @@
import { promises as fs } from 'fs'
import path from 'path'
import { getCurrentSeasonSlug, validateSeasonSlug } from '../utils/spielplan-data.js'
function normalizeSeasonFilename(season) {
return `mannschaften_${season}.csv`
}
function isAllowedFilename(filename) {
return filename === 'mannschaften.csv' || /^mannschaften_\d{2}--\d{2}\.csv$/.test(String(filename || ''))
}
function buildCsvCandidates(cwd, filename) {
const safeFilename = String(filename || '').trim()
if (!isAllowedFilename(safeFilename)) {
throw createError({
statusCode: 400,
statusMessage: 'Ungueltiger Dateiname fuer Mannschaften'
})
}
return [
`${cwd}/server/data/public-data/${safeFilename}`,
`${cwd}/../server/data/public-data/${safeFilename}`,
`${cwd}/.output/server/data/${safeFilename}`,
`${cwd}/server/data/${safeFilename}`,
`${cwd}/.output/public/data/${safeFilename}`,
`${cwd}/public/data/${safeFilename}`,
`${cwd}/../.output/public/data/${safeFilename}`,
`${cwd}/../public/data/${safeFilename}`
]
}
async function exists(p) {
try {
@@ -13,20 +42,27 @@ async function exists(p) {
export default defineEventHandler(async (event) => {
try {
const cwd = process.cwd()
const filename = 'mannschaften.csv'
const query = getQuery(event)
const requestedSeason = query.season ? String(query.season).trim() : ''
if (requestedSeason && !validateSeasonSlug(requestedSeason)) {
throw createError({
statusCode: 400,
statusMessage: 'Ungueltiger Saison-Slug'
})
}
const defaultSeason = getCurrentSeasonSlug()
const candidateFileNames = requestedSeason
? [normalizeSeasonFilename(requestedSeason), 'mannschaften.csv']
: [normalizeSeasonFilename(defaultSeason), 'mannschaften.csv']
// Prefer CMS write target first (server/data/public-data),
// then legacy locations.
const candidates = [
path.join(cwd, 'server/data/public-data', filename),
path.join(cwd, '../server/data/public-data', filename),
path.join(cwd, '.output/server/data', filename),
path.join(cwd, 'server/data', filename),
path.join(cwd, '.output/public/data', filename),
path.join(cwd, 'public/data', filename),
path.join(cwd, '../.output/public/data', filename),
path.join(cwd, '../public/data', filename)
]
const candidates = []
for (const filename of candidateFileNames) {
candidates.push(...buildCsvCandidates(cwd, filename))
}
let csvPath = null
for (const p of candidates) {

View File

@@ -0,0 +1,55 @@
import { promises as fs } from 'fs'
import path from 'path'
import { getCurrentSeasonSlug } from '../../utils/spielplan-data.js'
const SEASON_FILE_PATTERN = /^mannschaften_(\d{2}--\d{2})\.csv$/
async function collectSeasonFiles(dirPath) {
try {
const files = await fs.readdir(dirPath)
return files
.map((name) => {
const match = name.match(SEASON_FILE_PATTERN)
return match ? match[1] : null
})
.filter(Boolean)
} catch (error) {
if (error?.code === 'ENOENT') return []
throw error
}
}
export default defineEventHandler(async () => {
try {
const cwd = process.cwd()
const dirs = [
path.join(cwd, 'server/data/public-data'),
path.join(cwd, '../server/data/public-data'),
path.join(cwd, '.output/public/data'),
path.join(cwd, 'public/data')
]
const seasonLists = await Promise.all(dirs.map((dir) => collectSeasonFiles(dir)))
const allSeasons = [...new Set(seasonLists.flat())].sort().reverse()
const currentSeason = getCurrentSeasonSlug()
const seasons = allSeasons.includes(currentSeason)
? allSeasons
: [currentSeason, ...allSeasons]
return {
success: true,
seasons,
currentSeason,
defaultSeason: seasons[0] || currentSeason
}
} catch (error) {
console.error('Fehler beim Laden der Mannschafts-Saisons:', error)
return {
success: false,
seasons: [getCurrentSeasonSlug()],
currentSeason: getCurrentSeasonSlug(),
defaultSeason: getCurrentSeasonSlug()
}
}
})

View File

@@ -0,0 +1,137 @@
import { promises as fs } from 'fs'
import path from 'path'
import { getCurrentSeasonSlug, validateSeasonSlug } from '../../utils/spielplan-data.js'
import { getServerDataPath } from '../../utils/paths.js'
import { error as loggerError } from '../../utils/logger.js'
const TABLES_FILE_PATTERN = /^tables_(\d{2}--\d{2})\.json$/
function normalizeTeamName(value) {
return String(value || '').trim().toLowerCase().replace(/\s+/g, ' ')
}
async function listTableSeasons() {
const importDir = getServerDataPath('spielplan-import')
try {
const files = await fs.readdir(importDir)
return files
.map((file) => {
const match = file.match(TABLES_FILE_PATTERN)
return match ? match[1] : null
})
.filter(Boolean)
.sort()
.reverse()
} catch (error) {
if (error?.code !== 'ENOENT') {
loggerError('Fehler beim Lesen der Tabellen-Saisons', { error })
}
return []
}
}
async function readTablesBySeason(season) {
const filePath = getServerDataPath('spielplan-import', `tables_${season}.json`)
const content = await fs.readFile(filePath, 'utf8')
const parsed = JSON.parse(content)
return { filePath, parsed }
}
function findTeamTable(tables, teamQuery) {
const normalizedQuery = normalizeTeamName(teamQuery)
const exact = tables.find((entry) => normalizeTeamName(entry?.teamName) === normalizedQuery)
if (exact) return exact
return tables.find((entry) => normalizeTeamName(entry?.teamName).includes(normalizedQuery)) || null
}
export default defineEventHandler(async (event) => {
try {
const query = getQuery(event)
const team = String(query.team || query.mannschaft || '').trim()
if (!team) {
return {
success: false,
message: 'Query-Parameter team (oder mannschaft) fehlt',
team: null,
table: null
}
}
const requestedSeason = query.season ? String(query.season).trim() : null
if (requestedSeason && !validateSeasonSlug(requestedSeason)) {
return {
success: false,
message: 'Ungueltiger Saison-Slug',
team,
table: null,
season: requestedSeason
}
}
const availableSeasons = await listTableSeasons()
const defaultSeason = getCurrentSeasonSlug()
const season = requestedSeason || defaultSeason
let loaded = null
try {
loaded = await readTablesBySeason(season)
} catch (error) {
if (error?.code === 'ENOENT' && !requestedSeason && availableSeasons.length > 0) {
loaded = await readTablesBySeason(availableSeasons[0])
} else {
throw error
}
}
if (!loaded) {
return {
success: false,
message: 'Keine Tabellen-Daten gefunden',
team,
table: null,
season,
availableSeasons
}
}
const payload = loaded.parsed || {}
const tables = Array.isArray(payload.tables) ? payload.tables : []
const found = findTeamTable(tables, team)
const effectiveSeason = payload?.source?.season?.seasonSlug || season
if (!found) {
return {
success: false,
message: 'Keine Tabellen-Daten fuer die Mannschaft gefunden',
team,
table: null,
season: effectiveSeason,
filePath: loaded.filePath,
availableTeams: tables.map((entry) => entry?.teamName).filter(Boolean)
}
}
return {
success: true,
message: 'Tabellen-Daten erfolgreich geladen',
team,
table: found,
season: effectiveSeason,
filePath: loaded.filePath,
importedAt: payload.importedAt || null,
availableSeasons
}
} catch (error) {
loggerError('Fehler beim Laden der Tabellen-Daten:', { error })
return {
success: false,
message: 'Fehler beim Laden der Tabellen-Daten',
team: null,
table: null
}
}
})

View File

@@ -1,4 +1,6 @@
import { importSpielplan } from '../utils/spielplan-import.js'
import { importLeagueTables } from '../utils/spielklassen-tables-import.js'
import { publishImportedSpielplan } from '../utils/spielplan-publish.js'
import { info as loggerInfo, error as loggerError } from '../utils/logger.js'
const TIME_ZONE = 'Europe/Berlin'
@@ -68,8 +70,25 @@ async function runImport(reason) {
running = true
try {
const result = await importSpielplan()
loggerInfo(`[spielplan-import] ${reason}: ${result.matchCount} Spiele importiert`, { range: `${result.source.season.dateStart} - ${result.source.season.dateEnd}` })
const spielplan = await importSpielplan()
loggerInfo(`[spielplan-import] ${reason}: ${spielplan.matchCount} Spiele importiert`, { range: `${spielplan.source.season.dateStart} - ${spielplan.source.season.dateEnd}` })
const published = await publishImportedSpielplan({ inputPath: spielplan.jsonFile })
loggerInfo(`[spielplan-import] ${reason}: Spielplan publiziert`, {
season: published.seasonSlug,
internalPath: published.internalSeasonPath
})
try {
const tables = await importLeagueTables()
loggerInfo(`[spielplan-import] ${reason}: ${tables.importedCount}/${tables.teamCount} Tabellen importiert`, {
season: tables.seasonSlug,
outputFile: tables.outputFile,
errors: tables.errorCount
})
} catch (error) {
loggerError('[spielplan-import] Tabellen-Import fehlgeschlagen:', { error })
}
} catch (error) {
loggerError('[spielplan-import] Import fehlgeschlagen:', { error })
} finally {

View File

@@ -0,0 +1,267 @@
import { promises as fs } from 'fs'
import { getServerDataPath, getProjectPath } from './paths.js'
import { getSpieljahrForDate } from './spielplan-import.js'
import { parseDelimitedLine } from './spielplan-data.js'
const OUTPUT_DIR = getServerDataPath('spielplan-import')
const DEFAULT_ASSOCIATION = 'HeTTV'
function normalizeHeaderName(value) {
return String(value || '').trim().toLowerCase()
}
function toUrlOrNull(value) {
const raw = String(value || '').trim()
if (!raw) return null
try {
return new URL(raw)
} catch {
return null
}
}
function isSupportedTableUrl(urlValue) {
const parsed = toUrlOrNull(urlValue)
if (!parsed) return false
const host = parsed.hostname.toLowerCase()
if (host !== 'www.mytischtennis.de' && host !== 'mytischtennis.de' && host !== 'click-tt.de' && host !== 'www.click-tt.de') {
return false
}
return parsed.pathname.includes('/tabelle/')
}
async function fileExists(filePath) {
try {
await fs.access(filePath)
return true
} catch {
return false
}
}
function extractRemixContext(html) {
const marker = 'window.__remixContext = '
const start = html.indexOf(marker)
if (start === -1) {
throw new Error('window.__remixContext nicht gefunden')
}
const jsonStart = start + marker.length
let depth = 0
let inString = false
let escaped = false
for (let i = jsonStart; i < html.length; i += 1) {
const char = html[i]
if (inString) {
if (escaped) {
escaped = false
} else if (char === '\\') {
escaped = true
} else if (char === '"') {
inString = false
}
continue
}
if (char === '"') {
inString = true
} else if (char === '{') {
depth += 1
} else if (char === '}') {
depth -= 1
if (depth === 0) {
return JSON.parse(html.slice(jsonStart, i + 1))
}
}
}
throw new Error('Ende von window.__remixContext nicht gefunden')
}
function getTableRoutePayload(loaderData) {
const exactKey = 'routes/click-tt+/$association+/$season+/$type+/$groupname.gruppe.$urlid+/tabelle.$filter'
if (loaderData?.[exactKey]) {
return { routeKey: exactKey, payload: loaderData[exactKey] }
}
const fallbackKey = Object.keys(loaderData || {}).find((key) => key.includes('/tabelle.$filter'))
if (fallbackKey && loaderData[fallbackKey]) {
return { routeKey: fallbackKey, payload: loaderData[fallbackKey] }
}
throw new Error('Tabellen-Route in loaderData nicht gefunden')
}
function normalizeTableData(url, routeKey, payload) {
const data = payload?.data || {}
if (!Array.isArray(data.league_table)) {
throw new Error('Ungueltige Tabellenstruktur: league_table fehlt')
}
const leagueTable = data.league_table
return {
url,
routeKey,
fetchedAt: new Date().toISOString(),
association: payload?.association || null,
season: payload?.season || null,
groupname: payload?.groupname || null,
urlid: payload?.urlid || null,
filter: payload?.filter || null,
headInfos: Array.isArray(data.head_infos) ? data.head_infos : [],
setCount: data.set_count ?? null,
gameCount: data.game_count ?? null,
noMeetings: data.no_meetings ?? null,
gamesPerSet: data.games_per_set ?? null,
leagueTtrAvg: data.league_ttr_avg ?? null,
subTableInfo: data.sub_table_info ?? null,
rowCount: leagueTable.length,
leagueTable
}
}
function extractSeasonSlugFromUrl(url) {
const match = String(url || '').match(/\/click-tt\/[^/]+\/(\d{2}--\d{2})\//)
return match?.[1] || null
}
function sanitizeSeasonSlug(value) {
const seasonSlug = String(value || '').trim()
return /^\d{2}--\d{2}$/.test(seasonSlug) ? seasonSlug : null
}
async function resolveMannschaftenCsvPath() {
const candidates = [
getServerDataPath('public-data', 'mannschaften.csv'),
getServerDataPath('mannschaften.csv'),
getProjectPath('.output', 'public', 'data', 'mannschaften.csv'),
getProjectPath('public', 'data', 'mannschaften.csv')
]
for (const candidate of candidates) {
if (await fileExists(candidate)) return candidate
}
throw new Error('Mannschaften-Datei nicht gefunden')
}
function extractRowsWithTableUrl(csvContent) {
const lines = csvContent.split(/\r?\n/).filter((line) => line.trim() !== '')
if (lines.length < 2) return []
const headers = parseDelimitedLine(lines[0], ',').map(normalizeHeaderName)
const teamIdx = headers.indexOf('mannschaft')
const leagueIdx = headers.indexOf('liga')
const infoLinkIdx = headers.indexOf('weitere informationen link')
if (infoLinkIdx === -1) {
throw new Error('CSV-Spalte weitere_informationen_link nicht gefunden')
}
const rows = []
for (const line of lines.slice(1)) {
const values = parseDelimitedLine(line, ',')
const tableUrl = values[infoLinkIdx] ? String(values[infoLinkIdx]).trim() : ''
if (!tableUrl) continue
if (!isSupportedTableUrl(tableUrl)) continue
rows.push({
teamName: teamIdx >= 0 ? (values[teamIdx] || null) : null,
leagueName: leagueIdx >= 0 ? (values[leagueIdx] || null) : null,
tableUrl
})
}
return rows
}
async function fetchTable(url) {
const response = await fetch(url, {
headers: {
accept: 'text/html,application/xhtml+xml',
'accept-language': 'de-DE,de;q=0.9'
}
})
if (!response.ok) {
throw new Error(`Tabellen-Download fehlgeschlagen: HTTP ${response.status}`)
}
const html = await response.text()
const context = extractRemixContext(html)
const loaderData = context?.state?.loaderData || {}
const { routeKey, payload } = getTableRoutePayload(loaderData)
return normalizeTableData(url, routeKey, payload)
}
export async function importLeagueTables(options = {}) {
const today = options.today ?? new Date()
const season = getSpieljahrForDate(today)
const csvPath = await resolveMannschaftenCsvPath()
const csvContent = await fs.readFile(csvPath, 'utf8')
const teams = extractRowsWithTableUrl(csvContent)
const entries = []
const errors = []
for (const team of teams) {
try {
const table = await fetchTable(team.tableUrl)
entries.push({
teamName: team.teamName,
leagueName: team.leagueName,
table
})
} catch (error) {
errors.push({
teamName: team.teamName,
leagueName: team.leagueName,
tableUrl: team.tableUrl,
error: error?.message || String(error)
})
}
}
const fallbackSeasonSlug = teams.length > 0 ? extractSeasonSlugFromUrl(teams[0].tableUrl) : null
const seasonSlug = sanitizeSeasonSlug(fallbackSeasonSlug) || sanitizeSeasonSlug(season.seasonSlug) || getSpieljahrForDate(today).seasonSlug
const outputFile = `${OUTPUT_DIR}/tables_${seasonSlug}.json`
const payload = {
format: 'harheimertc.tables.v1',
importedAt: new Date().toISOString(),
source: {
association: DEFAULT_ASSOCIATION,
season: {
seasonSlug,
dateStart: season.dateStart,
dateEnd: season.dateEnd
},
csvPath
},
teamCount: teams.length,
importedCount: entries.length,
errorCount: errors.length,
tables: entries,
errors
}
await fs.mkdir(OUTPUT_DIR, { recursive: true })
await fs.writeFile(outputFile, `${JSON.stringify(payload, null, 2)}\n`, 'utf8')
return {
outputFile,
seasonSlug,
teamCount: teams.length,
importedCount: entries.length,
errorCount: errors.length,
tables: entries,
errors
}
}

View File

@@ -0,0 +1,42 @@
import { promises as fs } from 'fs'
import { convertImportedSpielplanToJson, validateImportedSpielplan } from './spielplan-data.js'
import { getProjectPath, getServerDataPath } from './paths.js'
const DEFAULT_INPUT_PATH = getServerDataPath('spielplan-import', 'harheimer_tc_spielplan.json')
async function writeJsonAtomic(filePath, data) {
await fs.mkdir(filePath.substring(0, filePath.lastIndexOf('/')), { recursive: true })
const tmpPath = `${filePath}.tmp-${process.pid}-${Date.now()}`
const content = `${JSON.stringify(data, null, 2)}\n`
await fs.writeFile(tmpPath, content, 'utf8')
await fs.rename(tmpPath, filePath)
}
export async function publishImportedSpielplan(options = {}) {
const inputPath = options.inputPath || DEFAULT_INPUT_PATH
const raw = await fs.readFile(inputPath, 'utf8')
const imported = JSON.parse(raw)
validateImportedSpielplan(imported)
const spielplan = convertImportedSpielplanToJson(imported)
const seasonSlug = imported.source?.season?.seasonSlug
if (!seasonSlug) {
throw new Error('Saison-Slug fehlt in der Import-Datei')
}
const internalSeasonPath = getServerDataPath('public-data', 'spielplaene', `spielplan-${seasonSlug}.json`)
const publicSeasonPath = getProjectPath('public', 'data', 'spielplaene', `spielplan-${seasonSlug}.json`)
await writeJsonAtomic(internalSeasonPath, spielplan)
await writeJsonAtomic(publicSeasonPath, spielplan)
return {
seasonSlug,
matchCount: imported.matchCount,
inputPath,
internalSeasonPath,
publicSeasonPath
}
}