From 0ab71166aa9b377f0609cfad6108136501013c67 Mon Sep 17 00:00:00 2001 From: "Torsten Schulz (local)" Date: Wed, 15 Jul 2026 11:30:06 +0200 Subject: [PATCH 01/13] Refactor Spielplan-Filterlogik in separate Utility-Funktionen und verbessere die Filterung im PDF-Generierungsprozess --- pages/mannschaften/spielplaene.vue | 178 +---------------------------- server/api/spielplan/pdf.get.js | 111 +----------------- utils/spielplan-filter.js | 125 ++++++++++++++++++++ 3 files changed, 133 insertions(+), 281 deletions(-) create mode 100644 utils/spielplan-filter.js diff --git a/pages/mannschaften/spielplaene.vue b/pages/mannschaften/spielplaene.vue index 263fe07..2b18db9 100755 --- a/pages/mannschaften/spielplaene.vue +++ b/pages/mannschaften/spielplaene.vue @@ -360,6 +360,7 @@ From 75a7c409cd9268a175f1004c9fdf50dac2b22cf4 Mon Sep 17 00:00:00 2001 From: "Torsten Schulz (local)" Date: Fri, 17 Jul 2026 11:20:15 +0200 Subject: [PATCH 10/13] =?UTF-8?q?feat:=20f=C3=BCge=20Auswahl=20f=C3=BCr=20?= =?UTF-8?q?Spielplan-Team-ID=20hinzu=20und=20lade=20Teams=20von=20der=20AP?= =?UTF-8?q?I?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- components/cms/CmsMannschaften.vue | 35 +++++++++++++++++++++++++----- 1 file changed, 30 insertions(+), 5 deletions(-) diff --git a/components/cms/CmsMannschaften.vue b/components/cms/CmsMannschaften.vue index 4ce6c9b..901d818 100755 --- a/components/cms/CmsMannschaften.vue +++ b/components/cms/CmsMannschaften.vue @@ -204,15 +204,18 @@
- + + +

- Verknüpft diese Mannschaft eindeutig mit dem importierten Spielplan. + Nach dem myTischtennis-Import das passende Team auswählen.

@@ -424,6 +427,7 @@ const isLoading = ref(true) const isSaving = ref(false) const isCreatingSeason = ref(false) const mannschaften = ref([]) +const spielplanTeams = ref([]) const seasons = ref([]) const selectedSeason = ref('') const showModal = ref(false) @@ -516,6 +520,27 @@ 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 loadSpielplanTeams = async () => { + try { + const response = await fetch('/api/spielplan', { cache: 'no-store' }) + const result = await response.json() + if (!result.success || !Array.isArray(result.data)) return + const teams = new Map() + for (const row of result.data) { + for (const side of ['Heim', 'Gast']) { + const isHarheimer = String(row[`${side}VereinNr`] || '') === '43030' || String(row[`${side}VereinName`] || '') === 'Harheimer TC' + const id = String(row[`${side}MannschaftId`] || '').trim() + if (!isHarheimer || !id || teams.has(id)) continue + const label = [row[`${side}Mannschaft`], row[`${side}MannschaftAltersklasse`], row[`${side}MannschaftNr`] ? `Nr. ${row[`${side}MannschaftNr`]}` : ''].filter(Boolean).join(' · ') + teams.set(id, { id, label: `${label} [${id}]` }) + } + } + spielplanTeams.value = [...teams.values()].sort((a, b) => a.label.localeCompare(b.label, 'de')) + } catch (error) { + console.error('Spielplan-Teams konnten nicht geladen werden:', error) + } +} + const onSeasonChange = async () => { await loadMannschaften().catch(() => {}) } @@ -691,7 +716,7 @@ const confirmDelete = (mannschaft, index) => { onMounted(async () => { await loadSeasons() - await loadMannschaften().catch(() => {}) + await Promise.all([loadMannschaften().catch(() => {}), loadSpielplanTeams()]) }) // Expose load function to parent components From 461f2de8dc7e432829c4295894d1a3ddd33a775c Mon Sep 17 00:00:00 2001 From: "Torsten Schulz (local)" Date: Fri, 17 Jul 2026 11:49:55 +0200 Subject: [PATCH 11/13] =?UTF-8?q?feat:=20unterst=C3=BCtze=20mehrere=20Spie?= =?UTF-8?q?lplan-Team-IDs=20und=20verbessere=20die=20Zuordnung=20importier?= =?UTF-8?q?ter=20Mannschaften?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- components/cms/CmsMannschaften.vue | 16 ++- pages/mannschaften/[slug].vue | 11 +- server/api/cms/import-spielplan.post.js | 7 +- server/plugins/spielplan-import-scheduler.js | 5 +- server/utils/mannschaften-spielplan-ids.js | 138 +++++++++++++++++++ 5 files changed, 165 insertions(+), 12 deletions(-) create mode 100644 server/utils/mannschaften-spielplan-ids.js diff --git a/components/cms/CmsMannschaften.vue b/components/cms/CmsMannschaften.vue index 901d818..e1ebdbf 100755 --- a/components/cms/CmsMannschaften.vue +++ b/components/cms/CmsMannschaften.vue @@ -203,19 +203,21 @@ >
- +

- Nach dem myTischtennis-Import das passende Team auswählen. + Nach dem myTischtennis-Import alle passenden Einträge auswählen (z. B. Liga und Pokal). Mit Strg bzw. Cmd lassen sich mehrere Einträge markieren.

@@ -444,6 +446,10 @@ function newSpielerItem(name = '') { return { id: `${Date.now()}-${Math.random() function parseSpielerString(s) { if (!s) return []; return String(s).split(';').map(x => x.trim()).filter(Boolean).map(name => newSpielerItem(name)) } function serializeSpielerList(list) { return (list || []).map(s => (s?.name || '').trim()).filter(Boolean).join('; ') } function serializeSpielerNames(names) { return (names || []).map(s => String(s || '').trim()).filter(Boolean).join('; ') } +const selectedSpielplanTeamIds = computed(() => String(formData.value.spielplan_mannschaft_id || '').split(',').map(id => id.trim()).filter(Boolean)) +const updateSpielplanTeamIds = (event) => { + formData.value.spielplan_mannschaft_id = Array.from(event.target.selectedOptions, option => option.value).join(',') +} async function fetchCsvText(url) { const attempt = async () => { const r = await fetch(`${url}${url.includes('?') ? '&' : '?'}_t=${Date.now()}`, { cache: 'no-store' }); if (!r.ok) throw new Error(`HTTP ${r.status}`); return await r.text() } @@ -531,7 +537,7 @@ const loadSpielplanTeams = async () => { const isHarheimer = String(row[`${side}VereinNr`] || '') === '43030' || String(row[`${side}VereinName`] || '') === 'Harheimer TC' const id = String(row[`${side}MannschaftId`] || '').trim() if (!isHarheimer || !id || teams.has(id)) continue - const label = [row[`${side}Mannschaft`], row[`${side}MannschaftAltersklasse`], row[`${side}MannschaftNr`] ? `Nr. ${row[`${side}MannschaftNr`]}` : ''].filter(Boolean).join(' · ') + const label = [row[`${side}Mannschaft`], row[`${side}MannschaftAltersklasse`], row.Liga || row.Staffel, row[`${side}MannschaftNr`] ? `Nr. ${row[`${side}MannschaftNr`]}` : ''].filter(Boolean).join(' · ') teams.set(id, { id, label: `${label} [${id}]` }) } } diff --git a/pages/mannschaften/[slug].vue b/pages/mannschaften/[slug].vue index 370df43..39a838d 100755 --- a/pages/mannschaften/[slug].vue +++ b/pages/mannschaften/[slug].vue @@ -558,10 +558,13 @@ const isExactHarheimTeam = (teamName, variant) => { } const isSpielForMannschaft = (row, cmsMannschaft) => { - const configuredTeamId = String(mannschaft.value?.spielplan_mannschaft_id || '').trim() - if (configuredTeamId) { - return String(row.HeimMannschaftId || '').trim() === configuredTeamId || - String(row.GastMannschaftId || '').trim() === configuredTeamId + const configuredTeamIds = String(mannschaft.value?.spielplan_mannschaft_id || '') + .split(',') + .map(id => id.trim()) + .filter(Boolean) + if (configuredTeamIds.length) { + return configuredTeamIds.includes(String(row.HeimMannschaftId || '').trim()) || + configuredTeamIds.includes(String(row.GastMannschaftId || '').trim()) } return rowMatchesTeamFilter(row, cmsMannschaft) diff --git a/server/api/cms/import-spielplan.post.js b/server/api/cms/import-spielplan.post.js index c34bb0a..82e170e 100644 --- a/server/api/cms/import-spielplan.post.js +++ b/server/api/cms/import-spielplan.post.js @@ -2,6 +2,7 @@ import { getUserFromToken, hasAnyRole } from '../../utils/auth.js' import { importSpielplan } from '../../utils/spielplan-import.js' import { importLeagueTables } from '../../utils/spielklassen-tables-import.js' import { publishImportedSpielplan } from '../../utils/spielplan-publish.js' +import { synchronizeMannschaftenSpielplanIds } from '../../utils/mannschaften-spielplan-ids.js' import { error as loggerError, info as loggerInfo } from '../../utils/logger.js' export default defineEventHandler(async (event) => { @@ -22,6 +23,7 @@ export default defineEventHandler(async (event) => { try { const imported = await importSpielplan() const published = await publishImportedSpielplan({ inputPath: imported.jsonFile }) + const mapping = await synchronizeMannschaftenSpielplanIds(published.seasonSlug) let tableMessage = '' try { @@ -40,9 +42,10 @@ export default defineEventHandler(async (event) => { return { success: true, - message: `Spielplan ${published.seasonSlug} mit ${imported.matchCount} Spielen aktualisiert.${tableMessage}`, + message: `Spielplan ${published.seasonSlug} mit ${imported.matchCount} Spielen aktualisiert. ${mapping.updatedTeams} Mannschaften automatisch zugeordnet.${tableMessage}`, season: published.seasonSlug, - matchCount: imported.matchCount + matchCount: imported.matchCount, + mappedTeams: mapping.updatedTeams } } catch (error) { loggerError('[cms] Manueller Spielplan-Import fehlgeschlagen:', { error }) diff --git a/server/plugins/spielplan-import-scheduler.js b/server/plugins/spielplan-import-scheduler.js index 4552f65..d647e05 100755 --- a/server/plugins/spielplan-import-scheduler.js +++ b/server/plugins/spielplan-import-scheduler.js @@ -2,6 +2,7 @@ import { importSpielplan } from '../utils/spielplan-import.js' import { importLeagueTables } from '../utils/spielklassen-tables-import.js' import { importQttrValues } from '../utils/qttr-import.js' import { publishImportedSpielplan } from '../utils/spielplan-publish.js' +import { synchronizeMannschaftenSpielplanIds } from '../utils/mannschaften-spielplan-ids.js' import { info as loggerInfo, error as loggerError } from '../utils/logger.js' import { cleanupPasswordResetLogs } from '../utils/password-reset-log.js' @@ -115,9 +116,11 @@ function createSpielplanJob(skipSpielplanImport) { loggerInfo(`[spielplan-import] ${reason}: ${spielplan.matchCount} Spiele importiert`, { range: `${spielplan.source.season.dateStart} - ${spielplan.source.season.dateEnd}` }) const published = await publishImportedSpielplan({ inputPath: spielplan.jsonFile }) + const mapping = await synchronizeMannschaftenSpielplanIds(published.seasonSlug) loggerInfo(`[spielplan-import] ${reason}: Spielplan publiziert`, { season: published.seasonSlug, - internalPath: published.internalSeasonPath + internalPath: published.internalSeasonPath, + mappedTeams: mapping.updatedTeams }) try { diff --git a/server/utils/mannschaften-spielplan-ids.js b/server/utils/mannschaften-spielplan-ids.js new file mode 100644 index 0000000..b5134dd --- /dev/null +++ b/server/utils/mannschaften-spielplan-ids.js @@ -0,0 +1,138 @@ +import { promises as fs } from 'fs' +import { getServerDataPath } from './paths.js' +import { readSpielplanData } from './spielplan-data.js' + +const CLUB_ID = '43030' + +function parseCsvRows(content) { + const rows = [] + let row = [] + let value = '' + let quoted = false + + for (let index = 0; index < content.length; index += 1) { + const char = content[index] + if (char === '"') { + if (quoted && content[index + 1] === '"') { + value += '"' + index += 1 + } else { + quoted = !quoted + } + } else if (char === ',' && !quoted) { + row.push(value) + value = '' + } else if ((char === '\n' || char === '\r') && !quoted) { + if (char === '\r' && content[index + 1] === '\n') index += 1 + row.push(value) + if (row.some(cell => cell !== '')) rows.push(row) + row = [] + value = '' + } else { + value += char + } + } + row.push(value) + if (row.some(cell => cell !== '')) rows.push(row) + return rows +} + +function csvCell(value) { + const text = String(value ?? '') + return /[",\n\r]/.test(text) ? `"${text.replace(/"/g, '""')}"` : text +} + +function romanToNumber(value) { + const roman = String(value || '').toUpperCase() + const values = { I: 1, V: 5, X: 10 } + let result = 0 + for (let index = 0; index < roman.length; index += 1) { + const current = values[roman[index]] || 0 + const next = values[roman[index + 1]] || 0 + result += current < next ? -current : current + } + return result || null +} + +function getCmsTeamCriteria(name) { + const youth = String(name || '').trim().match(/^jugend\s+j(\d{1,2})(?:\s+([ivx]+|\d+))?\b/i) + if (youth) { + const rawNumber = youth[2] + return { + ageClass: `Jugend ${youth[1]}`, + teamNumber: rawNumber ? (Number(rawNumber) || romanToNumber(rawNumber)) : null + } + } + + const adult = String(name || '').trim().match(/^erwachsene\s+(\d+)\b/i) + if (adult) return { ageClass: 'Erwachsene', teamNumber: Number(adult[1]) } + return null +} + +function collectImportedTeams(rows) { + const teams = new Map() + for (const row of rows) { + for (const side of ['Heim', 'Gast']) { + if (String(row[`${side}VereinNr`] || '') !== CLUB_ID) continue + const id = String(row[`${side}MannschaftId`] || '').trim() + if (!id) continue + teams.set(id, { + id, + ageClass: String(row[`${side}MannschaftAltersklasse`] || '').trim(), + teamNumber: Number(row[`${side}MannschaftNr`]) || null + }) + } + } + return [...teams.values()] +} + +/** + * Adds every league and cup team ID matching a CMS team to its CSV row. + * A manually chosen ID is retained if no automatic match can be derived. + */ +export async function synchronizeMannschaftenSpielplanIds(seasonSlug) { + const filePath = getServerDataPath('public-data', `mannschaften_${seasonSlug}.csv`) + let content + try { + content = await fs.readFile(filePath, 'utf8') + } catch (error) { + if (error?.code === 'ENOENT') return { updatedTeams: 0, skipped: true } + throw error + } + + const parsed = parseCsvRows(content) + if (parsed.length < 2) return { updatedTeams: 0 } + const header = parsed[0] + let idColumn = header.indexOf('Spielplan Mannschaft ID') + if (idColumn < 0) { + header.push('Spielplan Mannschaft ID') + idColumn = header.length - 1 + } + + const spielplan = await readSpielplanData({ season: seasonSlug }) + const importedTeams = collectImportedTeams(spielplan.data) + let updatedTeams = 0 + + for (const row of parsed.slice(1)) { + const criteria = getCmsTeamCriteria(row[0]) + if (!criteria) continue + const ids = importedTeams + .filter(team => team.ageClass === criteria.ageClass && (!criteria.teamNumber || team.teamNumber === criteria.teamNumber)) + .map(team => team.id) + .sort((a, b) => Number(a) - Number(b)) + if (!ids.length) continue + const nextValue = ids.join(',') + if (row[idColumn] === nextValue) continue + row[idColumn] = nextValue + updatedTeams += 1 + } + + if (updatedTeams) { + const output = `${parsed.map(row => header.map((_, index) => csvCell(row[index])).join(',')).join('\n')}\n` + const temporaryPath = `${filePath}.tmp-${process.pid}-${Date.now()}` + await fs.writeFile(temporaryPath, output, 'utf8') + await fs.rename(temporaryPath, filePath) + } + + return { updatedTeams, importedTeamCount: importedTeams.length } +} From 1beb5c2eeebc61eadf927feee4f65d69fd60295b Mon Sep 17 00:00:00 2001 From: "Torsten Schulz (local)" Date: Fri, 17 Jul 2026 12:05:22 +0200 Subject: [PATCH 12/13] feat: verbessere die Auswahl von Spielplan-Team-IDs durch Checkboxen und aktualisiere die Logik zur Verarbeitung von Team-Kriterien --- components/cms/CmsMannschaften.vue | 40 ++++++++++++++-------- server/utils/mannschaften-spielplan-ids.js | 20 ++++++++--- 2 files changed, 41 insertions(+), 19 deletions(-) diff --git a/components/cms/CmsMannschaften.vue b/components/cms/CmsMannschaften.vue index e1ebdbf..c956334 100755 --- a/components/cms/CmsMannschaften.vue +++ b/components/cms/CmsMannschaften.vue @@ -204,20 +204,27 @@
- +
+ +

+ Noch keine importierten Spielplan-Teams verfügbar. +

+

- Nach dem myTischtennis-Import alle passenden Einträge auswählen (z. B. Liga und Pokal). Mit Strg bzw. Cmd lassen sich mehrere Einträge markieren. + Alle passenden Einträge auswählen (z. B. Liga und Pokal). Die Auswahl wird beim Speichern beibehalten.

@@ -447,8 +454,11 @@ function parseSpielerString(s) { if (!s) return []; return String(s).split(';'). function serializeSpielerList(list) { return (list || []).map(s => (s?.name || '').trim()).filter(Boolean).join('; ') } function serializeSpielerNames(names) { return (names || []).map(s => String(s || '').trim()).filter(Boolean).join('; ') } const selectedSpielplanTeamIds = computed(() => String(formData.value.spielplan_mannschaft_id || '').split(',').map(id => id.trim()).filter(Boolean)) -const updateSpielplanTeamIds = (event) => { - formData.value.spielplan_mannschaft_id = Array.from(event.target.selectedOptions, option => option.value).join(',') +const toggleSpielplanTeamId = (id, checked) => { + const ids = new Set(selectedSpielplanTeamIds.value) + if (checked) ids.add(id) + else ids.delete(id) + formData.value.spielplan_mannschaft_id = [...ids].join(',') } async function fetchCsvText(url) { diff --git a/server/utils/mannschaften-spielplan-ids.js b/server/utils/mannschaften-spielplan-ids.js index b5134dd..72ead0d 100644 --- a/server/utils/mannschaften-spielplan-ids.js +++ b/server/utils/mannschaften-spielplan-ids.js @@ -54,8 +54,9 @@ function romanToNumber(value) { return result || null } -function getCmsTeamCriteria(name) { - const youth = String(name || '').trim().match(/^jugend\s+j(\d{1,2})(?:\s+([ivx]+|\d+))?\b/i) +function getCmsTeamCriteria(name, league) { + const teamName = String(name || '').trim() + const youth = teamName.match(/^jugend\s+j(\d{1,2})(?:\s+([ivx]+|\d+))?\b/i) if (youth) { const rawNumber = youth[2] return { @@ -64,7 +65,18 @@ function getCmsTeamCriteria(name) { } } - const adult = String(name || '').trim().match(/^erwachsene\s+(\d+)\b/i) + // In bestehenden CMS-Daten steht die Altersklasse häufig nur in der Liga, + // z. B. Mannschaft "Jugend I" mit Liga "Jugend 13 (J13)". + const ageMatch = `${teamName} ${String(league || '')}`.match(/\b(?:jugend\s*|j\s*\(?)(\d{1,2})\b/i) + if (ageMatch) { + const ordinal = teamName.match(/^jugend\s+([ivx]+|\d+)\b/i)?.[1] + return { + ageClass: `Jugend ${ageMatch[1]}`, + teamNumber: ordinal ? (Number(ordinal) || romanToNumber(ordinal)) : null + } + } + + const adult = teamName.match(/^erwachsene\s+(\d+)\b/i) if (adult) return { ageClass: 'Erwachsene', teamNumber: Number(adult[1]) } return null } @@ -114,7 +126,7 @@ export async function synchronizeMannschaftenSpielplanIds(seasonSlug) { let updatedTeams = 0 for (const row of parsed.slice(1)) { - const criteria = getCmsTeamCriteria(row[0]) + const criteria = getCmsTeamCriteria(row[0], row[1]) if (!criteria) continue const ids = importedTeams .filter(team => team.ageClass === criteria.ageClass && (!criteria.teamNumber || team.teamNumber === criteria.teamNumber)) From 281c25b05d6f4829bffbfdcc3176d2a51151ae1b Mon Sep 17 00:00:00 2001 From: "Torsten Schulz (local)" Date: Fri, 17 Jul 2026 13:46:25 +0200 Subject: [PATCH 13/13] feat: aktualisiere die Version auf 1.8.4 und verbessere die Erkennung von Jugendmannschaften in den Filterfunktionen --- package.json | 2 +- server/utils/mannschaften-spielplan-ids.js | 6 +++--- utils/spielplan-filter.js | 2 +- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/package.json b/package.json index 08c2849..a165628 100755 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "harheimertc-website", - "version": "1.8.3", + "version": "1.8.4", "description": "Moderne Webseite für den Harheimer Tischtennis Club", "private": true, "type": "module", diff --git a/server/utils/mannschaften-spielplan-ids.js b/server/utils/mannschaften-spielplan-ids.js index 72ead0d..3288d16 100644 --- a/server/utils/mannschaften-spielplan-ids.js +++ b/server/utils/mannschaften-spielplan-ids.js @@ -56,7 +56,7 @@ function romanToNumber(value) { function getCmsTeamCriteria(name, league) { const teamName = String(name || '').trim() - const youth = teamName.match(/^jugend\s+j(\d{1,2})(?:\s+([ivx]+|\d+))?\b/i) + const youth = teamName.match(/^jugend(?:mannschaft)?\s+j(\d{1,2})(?:\s+([ivx]+|\d+))?\b/i) if (youth) { const rawNumber = youth[2] return { @@ -67,9 +67,9 @@ function getCmsTeamCriteria(name, league) { // In bestehenden CMS-Daten steht die Altersklasse häufig nur in der Liga, // z. B. Mannschaft "Jugend I" mit Liga "Jugend 13 (J13)". - const ageMatch = `${teamName} ${String(league || '')}`.match(/\b(?:jugend\s*|j\s*\(?)(\d{1,2})\b/i) + const ageMatch = `${teamName} ${String(league || '')}`.match(/\b(?:jugend(?:mannschaft)?\s*|j\s*\(?)(\d{1,2})\b/i) if (ageMatch) { - const ordinal = teamName.match(/^jugend\s+([ivx]+|\d+)\b/i)?.[1] + const ordinal = teamName.match(/^jugend(?:mannschaft)?\s+([ivx]+|\d+)\b/i)?.[1] return { ageClass: `Jugend ${ageMatch[1]}`, teamNumber: ordinal ? (Number(ordinal) || romanToNumber(ordinal)) : null diff --git a/utils/spielplan-filter.js b/utils/spielplan-filter.js index 20462c2..a471137 100644 --- a/utils/spielplan-filter.js +++ b/utils/spielplan-filter.js @@ -54,7 +54,7 @@ function romanToNumber(value) { } function parseJugendTeamKey(teamKey) { - const match = String(teamKey || '').match(/^jugend_?j?(\d{1,2})(?:_([ivx]+|\d+))?$/i) + const match = String(teamKey || '').match(/^jugend(?:mannschaft)?_?j?(\d{1,2})(?:_([ivx]+|\d+))?$/i) if (!match) return null const teamNumber = match[2]