feat(cms): add season dropdown/create and restore baelle ratio
Some checks failed
Code Analysis and Production Deploy / analyze (push) Failing after 2m39s
Code Analysis and Production Deploy / deploy-production (push) Has been skipped
Code Analysis and Production Deploy / deploy-test (push) Has been skipped

This commit is contained in:
Torsten Schulz (local)
2026-05-20 17:49:19 +02:00
parent 2d42ef3ecd
commit f2f76dec56
3 changed files with 221 additions and 13 deletions

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

@@ -244,6 +244,9 @@
<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>
@@ -276,6 +279,9 @@
<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>
@@ -672,6 +678,13 @@ const formatSaetze = (row) => {
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}`

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()
}
}
})