Compare commits
11 Commits
f060c9771f
...
dev
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
281c25b05d | ||
|
|
1beb5c2eee | ||
|
|
461f2de8dc | ||
|
|
75a7c409cd | ||
|
|
751c302036 | ||
|
|
f0e1ad39b0 | ||
|
|
1dc84023d0 | ||
|
|
30465c7833 | ||
|
|
d260f00756 | ||
|
|
f0ac4b7e56 | ||
|
|
bd45beadd5 |
@@ -116,7 +116,7 @@ jobs:
|
|||||||
chmod +x osv-scanner
|
chmod +x osv-scanner
|
||||||
./osv-scanner --version
|
./osv-scanner --version
|
||||||
test -f ./package-lock.json
|
test -f ./package-lock.json
|
||||||
./osv-scanner --lockfile ./package-lock.json
|
./osv-scanner scan -L ./package-lock.json --config ./.osv-scanner.toml
|
||||||
|
|
||||||
deploy-production:
|
deploy-production:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
|||||||
4
.osv-scanner.toml
Normal file
4
.osv-scanner.toml
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
[[IgnoredVulns]]
|
||||||
|
id = "GHSA-v3m3-f69x-jf25"
|
||||||
|
ignoreUntil = 2026-12-31
|
||||||
|
reason = "Temporary exception: Quill 2.0.3 is required by the current RichTextEditor implementation, and OSV currently reports no fixed version. Track upstream fix and remove this ignore once a patched release is available."
|
||||||
@@ -652,6 +652,9 @@ interface ApiService {
|
|||||||
@POST("/api/cms/save-csv")
|
@POST("/api/cms/save-csv")
|
||||||
suspend fun saveCsv(@Body request: SaveCsvRequest): Response<SaveCsvResponse>
|
suspend fun saveCsv(@Body request: SaveCsvRequest): Response<SaveCsvResponse>
|
||||||
|
|
||||||
|
@POST("/api/cms/import-spielplan")
|
||||||
|
suspend fun importSpielplan(): Response<AuthMessageResponse>
|
||||||
|
|
||||||
@POST("/api/membership/generate-pdf")
|
@POST("/api/membership/generate-pdf")
|
||||||
suspend fun generateMembershipPdf(@Body request: MembershipRequest): Response<MembershipResponse>
|
suspend fun generateMembershipPdf(@Body request: MembershipRequest): Response<MembershipResponse>
|
||||||
|
|
||||||
|
|||||||
@@ -147,6 +147,12 @@ class CmsRepository @Inject constructor(
|
|||||||
response.body() ?: SaveCsvResponse(success = false, message = "Leere Antwort")
|
response.body() ?: SaveCsvResponse(success = false, message = "Leere Antwort")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
suspend fun importSpielplan(): Result<de.harheimertc.data.AuthMessageResponse> = runCatching {
|
||||||
|
val response = api.importSpielplan()
|
||||||
|
if (!response.isSuccessful) error("Spielplan konnte nicht importiert werden.")
|
||||||
|
response.body() ?: de.harheimertc.data.AuthMessageResponse(success = false, message = "Leere Antwort")
|
||||||
|
}
|
||||||
|
|
||||||
suspend fun spielplan(season: String? = null): Result<SpielplanResponse> = runCatching {
|
suspend fun spielplan(season: String? = null): Result<SpielplanResponse> = runCatching {
|
||||||
val response = api.spielplan(season)
|
val response = api.spielplan(season)
|
||||||
if (!response.isSuccessful) error("Spielplan konnte nicht geladen werden.")
|
if (!response.isSuccessful) error("Spielplan konnte nicht geladen werden.")
|
||||||
|
|||||||
@@ -458,6 +458,9 @@ fun CmsSportbetriebScreen(navController: NavController, showBackNavigation: Bool
|
|||||||
"mannschaften" to "Mannschaften",
|
"mannschaften" to "Mannschaften",
|
||||||
"spielplaene" to "Spielpläne",
|
"spielplaene" to "Spielpläne",
|
||||||
)
|
)
|
||||||
|
val spielplanTeamOptions = remember(state.sportSpielplanHeaders, state.sportSpielplanRows) {
|
||||||
|
importedSpielplanTeams(state.sportSpielplanHeaders, state.sportSpielplanRows)
|
||||||
|
}
|
||||||
val terminKategorien = listOf("Training", "Punktspiel", "Turnier", "Veranstaltung", "Sonstiges")
|
val terminKategorien = listOf("Training", "Punktspiel", "Turnier", "Veranstaltung", "Sonstiges")
|
||||||
|
|
||||||
LaunchedEffect(Unit) {
|
LaunchedEffect(Unit) {
|
||||||
@@ -606,6 +609,7 @@ fun CmsSportbetriebScreen(navController: NavController, showBackNavigation: Bool
|
|||||||
items(mannschaften.size) { index ->
|
items(mannschaften.size) { index ->
|
||||||
MannschaftEditorCard(
|
MannschaftEditorCard(
|
||||||
row = mannschaften[index],
|
row = mannschaften[index],
|
||||||
|
spielplanTeams = spielplanTeamOptions,
|
||||||
onChange = { updated -> mannschaften[index] = updated },
|
onChange = { updated -> mannschaften[index] = updated },
|
||||||
onRemove = { mannschaften.removeAt(index) },
|
onRemove = { mannschaften.removeAt(index) },
|
||||||
)
|
)
|
||||||
@@ -619,9 +623,19 @@ fun CmsSportbetriebScreen(navController: NavController, showBackNavigation: Bool
|
|||||||
InfoRow("Datei", fileName)
|
InfoRow("Datei", fileName)
|
||||||
InfoRow("Saison", seasonLabel)
|
InfoRow("Saison", seasonLabel)
|
||||||
InfoRow("Einträge", state.sportSpielplanRows.size.toString())
|
InfoRow("Einträge", state.sportSpielplanRows.size.toString())
|
||||||
|
Row(horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.fillMaxWidth()) {
|
||||||
|
Button(
|
||||||
|
onClick = { viewModel.importSportSpielplan() },
|
||||||
|
enabled = !state.sportSaving,
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
) {
|
||||||
|
Text(if (state.sportSaving) "Importiert..." else "Von myTischtennis importieren")
|
||||||
|
}
|
||||||
|
}
|
||||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.fillMaxWidth()) {
|
Row(horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.fillMaxWidth()) {
|
||||||
OutlinedButton(
|
OutlinedButton(
|
||||||
onClick = { viewModel.loadSportbetrieb() },
|
onClick = { viewModel.loadSportbetrieb() },
|
||||||
|
enabled = !state.sportSaving,
|
||||||
modifier = Modifier.weight(1f),
|
modifier = Modifier.weight(1f),
|
||||||
) {
|
) {
|
||||||
Text("Neu laden")
|
Text("Neu laden")
|
||||||
@@ -732,12 +746,32 @@ fun CmsSportbetriebScreen(navController: NavController, showBackNavigation: Bool
|
|||||||
@Composable
|
@Composable
|
||||||
private fun MannschaftEditorCard(
|
private fun MannschaftEditorCard(
|
||||||
row: CmsMannschaftRow,
|
row: CmsMannschaftRow,
|
||||||
|
spielplanTeams: List<ImportedSpielplanTeam>,
|
||||||
onChange: (CmsMannschaftRow) -> Unit,
|
onChange: (CmsMannschaftRow) -> Unit,
|
||||||
onRemove: () -> Unit,
|
onRemove: () -> Unit,
|
||||||
) {
|
) {
|
||||||
|
var teamPickerOpen by remember(row.mannschaft, spielplanTeams) { mutableStateOf(false) }
|
||||||
DataCard(row.mannschaft.ifBlank { "Mannschaft" }) {
|
DataCard(row.mannschaft.ifBlank { "Mannschaft" }) {
|
||||||
OutlinedTextField(value = row.mannschaft, onValueChange = { onChange(row.copy(mannschaft = it)) }, label = { Text("Mannschaft") }, modifier = Modifier.fillMaxWidth())
|
OutlinedTextField(value = row.mannschaft, onValueChange = { onChange(row.copy(mannschaft = it)) }, label = { Text("Mannschaft") }, modifier = Modifier.fillMaxWidth())
|
||||||
OutlinedTextField(value = row.spielplanMannschaftId, onValueChange = { onChange(row.copy(spielplanMannschaftId = it)) }, label = { Text("Spielplan-Team-ID") }, supportingText = { Text("Eindeutige myTischtennis-Team-ID") }, modifier = Modifier.fillMaxWidth())
|
OutlinedTextField(value = row.spielplanMannschaftId, onValueChange = { onChange(row.copy(spielplanMannschaftId = it)) }, label = { Text("Spielplan-Team-ID") }, supportingText = { Text("Eindeutige myTischtennis-Team-ID") }, modifier = Modifier.fillMaxWidth())
|
||||||
|
if (spielplanTeams.isNotEmpty()) {
|
||||||
|
Box(modifier = Modifier.fillMaxWidth()) {
|
||||||
|
OutlinedButton(onClick = { teamPickerOpen = true }, modifier = Modifier.fillMaxWidth()) {
|
||||||
|
Text("Aus importierten Teams auswählen")
|
||||||
|
}
|
||||||
|
DropdownMenu(expanded = teamPickerOpen, onDismissRequest = { teamPickerOpen = false }) {
|
||||||
|
spielplanTeams.forEach { team ->
|
||||||
|
DropdownMenuItem(
|
||||||
|
text = { Text(team.label) },
|
||||||
|
onClick = {
|
||||||
|
onChange(row.copy(spielplanMannschaftId = team.id))
|
||||||
|
teamPickerOpen = false
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
OutlinedTextField(value = row.liga, onValueChange = { onChange(row.copy(liga = it)) }, label = { Text("Liga") }, modifier = Modifier.fillMaxWidth())
|
OutlinedTextField(value = row.liga, onValueChange = { onChange(row.copy(liga = it)) }, label = { Text("Liga") }, modifier = Modifier.fillMaxWidth())
|
||||||
OutlinedTextField(value = row.staffelleiter, onValueChange = { onChange(row.copy(staffelleiter = it)) }, label = { Text("Staffelleiter") }, modifier = Modifier.fillMaxWidth())
|
OutlinedTextField(value = row.staffelleiter, onValueChange = { onChange(row.copy(staffelleiter = it)) }, label = { Text("Staffelleiter") }, modifier = Modifier.fillMaxWidth())
|
||||||
OutlinedTextField(value = row.telefon, onValueChange = { onChange(row.copy(telefon = it)) }, label = { Text("Telefon") }, modifier = Modifier.fillMaxWidth())
|
OutlinedTextField(value = row.telefon, onValueChange = { onChange(row.copy(telefon = it)) }, label = { Text("Telefon") }, modifier = Modifier.fillMaxWidth())
|
||||||
@@ -753,6 +787,35 @@ private fun MannschaftEditorCard(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private data class ImportedSpielplanTeam(val id: String, val label: String)
|
||||||
|
|
||||||
|
private fun importedSpielplanTeams(headers: List<String>, rows: List<List<String>>): List<ImportedSpielplanTeam> {
|
||||||
|
fun index(name: String) = headers.indexOf(name)
|
||||||
|
fun value(row: List<String>, column: String): String {
|
||||||
|
val columnIndex = index(column)
|
||||||
|
return row.getOrElse(columnIndex) { "" }.trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
val teams = linkedMapOf<String, ImportedSpielplanTeam>()
|
||||||
|
listOf("Heim", "Gast").forEach { side ->
|
||||||
|
rows.forEach { row ->
|
||||||
|
val isHarheimer = value(row, "${side}VereinNr") == "43030" || value(row, "${side}VereinName") == "Harheimer TC"
|
||||||
|
val id = value(row, "${side}MannschaftId")
|
||||||
|
if (!isHarheimer || id.isBlank()) return@forEach
|
||||||
|
|
||||||
|
val teamName = value(row, "${side}Mannschaft")
|
||||||
|
val ageClass = value(row, "${side}MannschaftAltersklasse")
|
||||||
|
val teamNumber = value(row, "${side}MannschaftNr")
|
||||||
|
val label = listOf(teamName, ageClass, teamNumber.takeIf { it.isNotBlank() }?.let { "Nr. $it" })
|
||||||
|
.filterNotNull()
|
||||||
|
.filter { it.isNotBlank() }
|
||||||
|
.joinToString(" · ")
|
||||||
|
teams.putIfAbsent(id, ImportedSpielplanTeam(id, "$label [$id]"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return teams.values.sortedBy { it.label }
|
||||||
|
}
|
||||||
|
|
||||||
private fun sportSpielplanCsvText(headers: List<String>, rows: List<List<String>>): String {
|
private fun sportSpielplanCsvText(headers: List<String>, rows: List<List<String>>): String {
|
||||||
if (headers.isEmpty()) return ""
|
if (headers.isEmpty()) return ""
|
||||||
return listOf(headers).plus(rows).joinToString("\n") { row -> row.joinToString(";") { it.csvCell(";") } }
|
return listOf(headers).plus(rows).joinToString("\n") { row -> row.joinToString(";") { it.csvCell(";") } }
|
||||||
|
|||||||
@@ -322,6 +322,33 @@ class CmsViewModel @Inject constructor(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun importSportSpielplan() {
|
||||||
|
viewModelScope.launch {
|
||||||
|
_state.value = _state.value.copy(sportSaving = true, error = null, message = null)
|
||||||
|
repository.importSpielplan()
|
||||||
|
.onSuccess { response ->
|
||||||
|
if (!response.success) {
|
||||||
|
_state.value = _state.value.copy(
|
||||||
|
sportSaving = false,
|
||||||
|
error = response.message ?: "Spielplan konnte nicht importiert werden.",
|
||||||
|
)
|
||||||
|
return@onSuccess
|
||||||
|
}
|
||||||
|
_state.value = _state.value.copy(
|
||||||
|
sportSaving = false,
|
||||||
|
message = response.message ?: "Spielplan aktualisiert.",
|
||||||
|
)
|
||||||
|
loadSportbetrieb()
|
||||||
|
}
|
||||||
|
.onFailure { err ->
|
||||||
|
_state.value = _state.value.copy(
|
||||||
|
sportSaving = false,
|
||||||
|
error = ErrorMapper.mapError(err) ?: "Spielplan konnte nicht importiert werden.",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fun saveSportSpielplan(headers: List<String>, rows: List<List<String>>) {
|
fun saveSportSpielplan(headers: List<String>, rows: List<List<String>>) {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
_state.value = _state.value.copy(sportSaving = true, error = null, message = null)
|
_state.value = _state.value.copy(sportSaving = true, error = null, message = null)
|
||||||
|
|||||||
@@ -151,11 +151,6 @@ const showConfirmModal = (title, message, action) => {
|
|||||||
showConfirm.value = true
|
showConfirm.value = true
|
||||||
}
|
}
|
||||||
|
|
||||||
const closeSuccess = () => {
|
|
||||||
showSuccessToast.value = false
|
|
||||||
if (toastTimeout) { clearTimeout(toastTimeout); toastTimeout = null }
|
|
||||||
}
|
|
||||||
|
|
||||||
const closeError = () => {
|
const closeError = () => {
|
||||||
showError.value = false
|
showError.value = false
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -203,15 +203,29 @@
|
|||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-sm font-medium text-gray-700 mb-2">Spielplan-Team-ID</label>
|
<label class="block text-sm font-medium text-gray-700 mb-2">Spielplan-Team-IDs</label>
|
||||||
<input
|
<div class="max-h-48 overflow-y-auto rounded-lg border border-gray-300 divide-y divide-gray-100">
|
||||||
v-model="formData.spielplan_mannschaft_id"
|
<label
|
||||||
type="text"
|
v-for="team in spielplanTeams"
|
||||||
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500"
|
:key="team.id"
|
||||||
placeholder="myTischtennis-Team-ID"
|
class="flex items-start gap-3 px-4 py-3 cursor-pointer hover:bg-gray-50"
|
||||||
:disabled="isSaving"
|
|
||||||
>
|
>
|
||||||
<p class="mt-1 text-xs text-gray-500">Verknüpft diese Mannschaft eindeutig mit dem importierten Spielplan.</p>
|
<input
|
||||||
|
:checked="selectedSpielplanTeamIds.includes(team.id)"
|
||||||
|
type="checkbox"
|
||||||
|
class="mt-0.5 h-4 w-4 rounded border-gray-300 text-primary-600 focus:ring-primary-500"
|
||||||
|
:disabled="isSaving"
|
||||||
|
@change="toggleSpielplanTeamId(team.id, $event.target.checked)"
|
||||||
|
>
|
||||||
|
<span class="text-sm text-gray-800">{{ team.label }}</span>
|
||||||
|
</label>
|
||||||
|
<p v-if="!spielplanTeams.length" class="px-4 py-3 text-sm text-gray-500">
|
||||||
|
Noch keine importierten Spielplan-Teams verfügbar.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<p class="mt-1 text-xs text-gray-500">
|
||||||
|
Alle passenden Einträge auswählen (z. B. Liga und Pokal). Die Auswahl wird beim Speichern beibehalten.
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-sm font-medium text-gray-700 mb-2">Liga *</label>
|
<label class="block text-sm font-medium text-gray-700 mb-2">Liga *</label>
|
||||||
@@ -422,6 +436,7 @@ const isLoading = ref(true)
|
|||||||
const isSaving = ref(false)
|
const isSaving = ref(false)
|
||||||
const isCreatingSeason = ref(false)
|
const isCreatingSeason = ref(false)
|
||||||
const mannschaften = ref([])
|
const mannschaften = ref([])
|
||||||
|
const spielplanTeams = ref([])
|
||||||
const seasons = ref([])
|
const seasons = ref([])
|
||||||
const selectedSeason = ref('')
|
const selectedSeason = ref('')
|
||||||
const showModal = ref(false)
|
const showModal = ref(false)
|
||||||
@@ -438,6 +453,13 @@ 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 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 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('; ') }
|
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 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) {
|
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() }
|
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() }
|
||||||
@@ -514,6 +536,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 }
|
} 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.Liga || row.Staffel, 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 () => {
|
const onSeasonChange = async () => {
|
||||||
await loadMannschaften().catch(() => {})
|
await loadMannschaften().catch(() => {})
|
||||||
}
|
}
|
||||||
@@ -689,7 +732,7 @@ const confirmDelete = (mannschaft, index) => {
|
|||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
await loadSeasons()
|
await loadSeasons()
|
||||||
await loadMannschaften().catch(() => {})
|
await Promise.all([loadMannschaften().catch(() => {}), loadSpielplanTeams()])
|
||||||
})
|
})
|
||||||
|
|
||||||
// Expose load function to parent components
|
// Expose load function to parent components
|
||||||
|
|||||||
@@ -6,6 +6,13 @@
|
|||||||
Spielpläne bearbeiten
|
Spielpläne bearbeiten
|
||||||
</h2>
|
</h2>
|
||||||
<div class="space-x-3">
|
<div class="space-x-3">
|
||||||
|
<button
|
||||||
|
:disabled="isImporting"
|
||||||
|
class="inline-flex items-center px-3 py-1.5 sm:px-4 sm:py-2 rounded-lg bg-blue-600 text-white hover:bg-blue-700 text-sm sm:text-base disabled:bg-blue-300"
|
||||||
|
@click="importFromMyTischtennis"
|
||||||
|
>
|
||||||
|
{{ isImporting ? 'Importiert...' : 'Von myTischtennis importieren' }}
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
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"
|
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"
|
||||||
@click="showUploadModal = true"
|
@click="showUploadModal = true"
|
||||||
@@ -343,6 +350,7 @@ const fileInput = ref(null)
|
|||||||
const modalFileInput = ref(null)
|
const modalFileInput = ref(null)
|
||||||
const showUploadModal = ref(false)
|
const showUploadModal = ref(false)
|
||||||
const isProcessing = ref(false)
|
const isProcessing = ref(false)
|
||||||
|
const isImporting = ref(false)
|
||||||
const processingMessage = ref('')
|
const processingMessage = ref('')
|
||||||
const isDragOver = ref(false)
|
const isDragOver = ref(false)
|
||||||
const currentFile = ref(null)
|
const currentFile = ref(null)
|
||||||
@@ -410,18 +418,37 @@ const save = async () => {
|
|||||||
} catch (error) { console.error('Fehler:', error); alert('Fehler beim Speichern!') }
|
} catch (error) { console.error('Fehler:', error); alert('Fehler beim Speichern!') }
|
||||||
}
|
}
|
||||||
|
|
||||||
const closeUploadModal = () => { showUploadModal.value = false; selectedFile.value = null; if (modalFileInput.value) modalFileInput.value.value = '' }
|
const loadCurrentSpielplan = async () => {
|
||||||
|
|
||||||
onMounted(() => {
|
|
||||||
(async () => {
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch('/api/spielplan'); if (!response.ok) return
|
const response = await fetch('/api/spielplan')
|
||||||
const result = await response.json(); if (!result.success || !Array.isArray(result.headers) || !Array.isArray(result.data)) return
|
if (!response.ok) return
|
||||||
|
const result = await response.json()
|
||||||
|
if (!result.success || !Array.isArray(result.headers) || !Array.isArray(result.data)) return
|
||||||
csvHeaders.value = result.headers
|
csvHeaders.value = result.headers
|
||||||
csvData.value = result.data.map(row => csvHeaders.value.map(header => row[header] || ''))
|
csvData.value = result.data.map(row => csvHeaders.value.map(header => row[header] || ''))
|
||||||
selectedColumns.value = new Array(csvHeaders.value.length).fill(true)
|
selectedColumns.value = new Array(csvHeaders.value.length).fill(true)
|
||||||
currentFile.value = { name: result.season ? `spielplan-${result.season}.json` : 'spielplan.csv', entries: csvData.value.length, lastModified: null }
|
currentFile.value = { name: result.season ? `spielplan-${result.season}.json` : 'spielplan.csv', entries: csvData.value.length, lastModified: null }
|
||||||
} catch { /* ignore */ }
|
} catch { /* ignore */ }
|
||||||
})()
|
}
|
||||||
})
|
|
||||||
|
const importFromMyTischtennis = async () => {
|
||||||
|
if (!confirm('Spielplan jetzt direkt von myTischtennis aktualisieren?')) return
|
||||||
|
isImporting.value = true
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/cms/import-spielplan', { method: 'POST' })
|
||||||
|
const result = await response.json()
|
||||||
|
if (!response.ok || !result.success) throw new Error(result.statusMessage || result.message || 'Import fehlgeschlagen')
|
||||||
|
await loadCurrentSpielplan()
|
||||||
|
alert(result.message || 'Spielplan erfolgreich aktualisiert.')
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Spielplan-Import fehlgeschlagen:', error)
|
||||||
|
alert(`Fehler beim Import: ${error.message || 'Unbekannter Fehler'}`)
|
||||||
|
} finally {
|
||||||
|
isImporting.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const closeUploadModal = () => { showUploadModal.value = false; selectedFile.value = null; if (modalFileInput.value) modalFileInput.value.value = '' }
|
||||||
|
|
||||||
|
onMounted(loadCurrentSpielplan)
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -25,14 +25,19 @@ export default [
|
|||||||
'useHead': 'readonly',
|
'useHead': 'readonly',
|
||||||
'useFetch': 'readonly',
|
'useFetch': 'readonly',
|
||||||
'definePageMeta': 'readonly',
|
'definePageMeta': 'readonly',
|
||||||
|
'defineNuxtPlugin': 'readonly',
|
||||||
|
'defineNitroPlugin': 'readonly',
|
||||||
'defineNuxtRouteMiddleware': 'readonly',
|
'defineNuxtRouteMiddleware': 'readonly',
|
||||||
'defineEventHandler': 'readonly',
|
'defineEventHandler': 'readonly',
|
||||||
'readBody': 'readonly',
|
'readBody': 'readonly',
|
||||||
|
'getMethod': 'readonly',
|
||||||
'getCookie': 'readonly',
|
'getCookie': 'readonly',
|
||||||
'setCookie': 'readonly',
|
'setCookie': 'readonly',
|
||||||
'deleteCookie': 'readonly',
|
'deleteCookie': 'readonly',
|
||||||
'getHeader': 'readonly',
|
'getHeader': 'readonly',
|
||||||
|
'getRequestURL': 'readonly',
|
||||||
'setHeader': 'readonly',
|
'setHeader': 'readonly',
|
||||||
|
'setResponseStatus': 'readonly',
|
||||||
'getRouterParam': 'readonly',
|
'getRouterParam': 'readonly',
|
||||||
'getQuery': 'readonly',
|
'getQuery': 'readonly',
|
||||||
'sendStream': 'readonly',
|
'sendStream': 'readonly',
|
||||||
@@ -66,8 +71,9 @@ export default [
|
|||||||
'vue/multi-word-component-names': 'off',
|
'vue/multi-word-component-names': 'off',
|
||||||
'vue/no-v-html': 'warn',
|
'vue/no-v-html': 'warn',
|
||||||
'no-unused-vars': ['warn', {
|
'no-unused-vars': ['warn', {
|
||||||
argsIgnorePattern: '^_',
|
argsIgnorePattern: '^_|^event$',
|
||||||
varsIgnorePattern: '^_'
|
varsIgnorePattern: '^_',
|
||||||
|
caughtErrorsIgnorePattern: '^_|^e$|^err$|^error$'
|
||||||
}],
|
}],
|
||||||
'vue/no-unused-vars': ['warn', {
|
'vue/no-unused-vars': ['warn', {
|
||||||
ignorePattern: '^_'
|
ignorePattern: '^_'
|
||||||
@@ -97,6 +103,18 @@ export default [
|
|||||||
'tests/**',
|
'tests/**',
|
||||||
'scripts/**'
|
'scripts/**'
|
||||||
]
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
files: [
|
||||||
|
'pages/cms/newsletter.vue',
|
||||||
|
'pages/verein/geschichte.vue',
|
||||||
|
'pages/verein/satzung.vue',
|
||||||
|
'pages/verein/tt-regeln.vue',
|
||||||
|
'pages/verein/ueber-uns.vue'
|
||||||
|
],
|
||||||
|
rules: {
|
||||||
|
'vue/no-v-html': 'off'
|
||||||
|
}
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
7401
package-lock.json
generated
Executable file → Normal file
7401
package-lock.json
generated
Executable file → Normal file
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "harheimertc-website",
|
"name": "harheimertc-website",
|
||||||
"version": "1.8.3",
|
"version": "1.8.4",
|
||||||
"description": "Moderne Webseite für den Harheimer Tischtennis Club",
|
"description": "Moderne Webseite für den Harheimer Tischtennis Club",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
@@ -49,11 +49,13 @@
|
|||||||
"vue": "^3.5.22"
|
"vue": "^3.5.22"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@eslint/js": "^10.0.1",
|
||||||
"@nuxtjs/tailwindcss": "^6.11.0",
|
"@nuxtjs/tailwindcss": "^6.11.0",
|
||||||
"@types/dompurify": "^3.0.5",
|
"@types/dompurify": "^3.0.5",
|
||||||
"autoprefixer": "^10.4.0",
|
"autoprefixer": "^10.4.0",
|
||||||
"commander": "^13.1.0",
|
"commander": "^13.1.0",
|
||||||
"dotenv": "^17.2.3",
|
"dotenv": "^17.2.3",
|
||||||
|
"eslint": "^10.7.0",
|
||||||
"eslint-plugin-vue": "^10.6.2",
|
"eslint-plugin-vue": "^10.6.2",
|
||||||
"globals": "^16.5.0",
|
"globals": "^16.5.0",
|
||||||
"lucide-vue-next": "^0.344.0",
|
"lucide-vue-next": "^0.344.0",
|
||||||
|
|||||||
@@ -167,6 +167,7 @@
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<!-- nosemgrep: javascript.vue.security.audit.xss.templates.avoid-v-html -->
|
<!-- nosemgrep: javascript.vue.security.audit.xss.templates.avoid-v-html -->
|
||||||
|
<!-- eslint-disable-next-line vue/no-v-html -->
|
||||||
<div
|
<div
|
||||||
class="text-sm text-gray-600 prose prose-sm max-w-none mb-3"
|
class="text-sm text-gray-600 prose prose-sm max-w-none mb-3"
|
||||||
v-html="useSanitizeHtml(post.content.substring(0, 200) + (post.content.length > 200 ? '...' : ''))"
|
v-html="useSanitizeHtml(post.content.substring(0, 200) + (post.content.length > 200 ? '...' : ''))"
|
||||||
|
|||||||
@@ -362,7 +362,7 @@
|
|||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, computed, onMounted } from 'vue'
|
import { ref, computed, onMounted } from 'vue'
|
||||||
import { Users } from 'lucide-vue-next'
|
import { rowMatchesTeamFilter } from '../../utils/spielplan-filter.js'
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
|
|
||||||
@@ -558,51 +558,16 @@ const isExactHarheimTeam = (teamName, variant) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const isSpielForMannschaft = (row, cmsMannschaft) => {
|
const isSpielForMannschaft = (row, cmsMannschaft) => {
|
||||||
const configuredTeamId = String(mannschaft.value?.spielplan_mannschaft_id || '').trim()
|
const configuredTeamIds = String(mannschaft.value?.spielplan_mannschaft_id || '')
|
||||||
if (configuredTeamId) {
|
.split(',')
|
||||||
return String(row.HeimMannschaftId || '').trim() === configuredTeamId ||
|
.map(id => id.trim())
|
||||||
String(row.GastMannschaftId || '').trim() === configuredTeamId
|
.filter(Boolean)
|
||||||
|
if (configuredTeamIds.length) {
|
||||||
|
return configuredTeamIds.includes(String(row.HeimMannschaftId || '').trim()) ||
|
||||||
|
configuredTeamIds.includes(String(row.GastMannschaftId || '').trim())
|
||||||
}
|
}
|
||||||
|
|
||||||
const variants = getTeamVariants(cmsMannschaft)
|
return rowMatchesTeamFilter(row, cmsMannschaft)
|
||||||
if (!variants.length) return false
|
|
||||||
|
|
||||||
const heimMannschaft = (row.HeimMannschaft || '').toLowerCase()
|
|
||||||
const gastMannschaft = (row.GastMannschaft || '').toLowerCase()
|
|
||||||
const heimAltersklasse = (row.HeimMannschaftAltersklasse || '').toLowerCase()
|
|
||||||
const gastAltersklasse = (row.GastMannschaftAltersklasse || '').toLowerCase()
|
|
||||||
const isHarheimerHeim = heimMannschaft.includes('harheimer tc')
|
|
||||||
const isHarheimerGast = gastMannschaft.includes('harheimer tc')
|
|
||||||
|
|
||||||
if (!isHarheimerHeim && !isHarheimerGast) return false
|
|
||||||
|
|
||||||
const mannschaftMatch = variants.some((variant) => {
|
|
||||||
if (isHarheimerHeim && isExactHarheimTeam(heimMannschaft, variant)) return true
|
|
||||||
if (isHarheimerGast && isExactHarheimTeam(gastMannschaft, variant)) return true
|
|
||||||
return false
|
|
||||||
})
|
|
||||||
|
|
||||||
if (!mannschaftMatch) return false
|
|
||||||
|
|
||||||
if (cmsMannschaft.startsWith('Erwachsene')) {
|
|
||||||
const isErwachsenenHeim = isHarheimerHeim &&
|
|
||||||
heimAltersklasse.includes('erwachsene') &&
|
|
||||||
!heimAltersklasse.includes('jugend')
|
|
||||||
const isErwachsenenGast = isHarheimerGast &&
|
|
||||||
gastAltersklasse.includes('erwachsene') &&
|
|
||||||
!gastAltersklasse.includes('jugend')
|
|
||||||
return isErwachsenenHeim || isErwachsenenGast
|
|
||||||
}
|
|
||||||
|
|
||||||
if (cmsMannschaft === 'Jugendmannschaft') {
|
|
||||||
const isJugendHeim = isHarheimerHeim &&
|
|
||||||
(heimAltersklasse.includes('jugend') || heimMannschaft.includes('jugend'))
|
|
||||||
const isJugendGast = isHarheimerGast &&
|
|
||||||
(gastAltersklasse.includes('jugend') || gastMannschaft.includes('jugend'))
|
|
||||||
return isJugendHeim || isJugendGast
|
|
||||||
}
|
|
||||||
|
|
||||||
return true
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const parseTerminTimestamp = (row) => {
|
const parseTerminTimestamp = (row) => {
|
||||||
|
|||||||
@@ -124,11 +124,9 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { User, Users, Newspaper, Check, Calendar } from 'lucide-vue-next'
|
import { User, Users, Newspaper, Calendar } from 'lucide-vue-next'
|
||||||
import { ref, onMounted } from 'vue'
|
import { ref, onMounted } from 'vue'
|
||||||
|
|
||||||
const authStore = useAuthStore()
|
|
||||||
|
|
||||||
const birthdays = ref([])
|
const birthdays = ref([])
|
||||||
const loadingBirthdays = ref(true)
|
const loadingBirthdays = ref(true)
|
||||||
|
|
||||||
|
|||||||
@@ -1000,11 +1000,6 @@ const canEdit = computed(() => {
|
|||||||
return authStore.hasAnyRole('admin', 'vorstand')
|
return authStore.hasAnyRole('admin', 'vorstand')
|
||||||
})
|
})
|
||||||
|
|
||||||
const canViewContactData = computed(() => {
|
|
||||||
// Explicitly check for 'vorstand' role only
|
|
||||||
return authStore.hasRole('vorstand')
|
|
||||||
})
|
|
||||||
|
|
||||||
const isBirthdateRequired = computed(() => {
|
const isBirthdateRequired = computed(() => {
|
||||||
return !editingMember.value || Boolean(editingMember.value?.geburtsdatum)
|
return !editingMember.value || Boolean(editingMember.value?.geburtsdatum)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -33,13 +33,22 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="pending" class="py-12 text-center text-gray-500">
|
<div
|
||||||
|
v-if="pending"
|
||||||
|
class="py-12 text-center text-gray-500"
|
||||||
|
>
|
||||||
Lade QTTR-Werte...
|
Lade QTTR-Werte...
|
||||||
</div>
|
</div>
|
||||||
<div v-else-if="error" class="rounded-lg border border-red-200 bg-red-50 p-4 text-red-800">
|
<div
|
||||||
|
v-else-if="error"
|
||||||
|
class="rounded-lg border border-red-200 bg-red-50 p-4 text-red-800"
|
||||||
|
>
|
||||||
{{ error.statusMessage || error.message || 'QTTR-Werte konnten nicht geladen werden.' }}
|
{{ error.statusMessage || error.message || 'QTTR-Werte konnten nicht geladen werden.' }}
|
||||||
</div>
|
</div>
|
||||||
<div v-else class="overflow-x-auto">
|
<div
|
||||||
|
v-else
|
||||||
|
class="overflow-x-auto"
|
||||||
|
>
|
||||||
<table class="min-w-full divide-y divide-gray-200">
|
<table class="min-w-full divide-y divide-gray-200">
|
||||||
<thead class="bg-gray-50">
|
<thead class="bg-gray-50">
|
||||||
<tr>
|
<tr>
|
||||||
|
|||||||
@@ -162,8 +162,6 @@ const selectedGroup = computed(() => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const isLoggedIn = computed(() => authStore.isLoggedIn)
|
const isLoggedIn = computed(() => authStore.isLoggedIn)
|
||||||
const userEmail = computed(() => authStore.user?.email || '')
|
|
||||||
const userName = computed(() => authStore.user?.name || '')
|
|
||||||
|
|
||||||
async function loadGroups() {
|
async function loadGroups() {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -11,13 +11,24 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="bg-white rounded-xl shadow-lg p-8">
|
<div class="bg-white rounded-xl shadow-lg p-8">
|
||||||
<form class="space-y-6" @submit.prevent="handleSubmit">
|
<form
|
||||||
<div v-if="!token" class="bg-red-50 border border-red-200 rounded-lg p-4">
|
class="space-y-6"
|
||||||
<p class="text-sm text-red-800">Der Reset-Link ist unvollständig. Fordern Sie bitte einen neuen Link an.</p>
|
@submit.prevent="handleSubmit"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
v-if="!token"
|
||||||
|
class="bg-red-50 border border-red-200 rounded-lg p-4"
|
||||||
|
>
|
||||||
|
<p class="text-sm text-red-800">
|
||||||
|
Der Reset-Link ist unvollständig. Fordern Sie bitte einen neuen Link an.
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label for="password" class="block text-sm font-medium text-gray-700 mb-2">
|
<label
|
||||||
|
for="password"
|
||||||
|
class="block text-sm font-medium text-gray-700 mb-2"
|
||||||
|
>
|
||||||
Neues Passwort
|
Neues Passwort
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
@@ -32,7 +43,10 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label for="passwordRepeat" class="block text-sm font-medium text-gray-700 mb-2">
|
<label
|
||||||
|
for="passwordRepeat"
|
||||||
|
class="block text-sm font-medium text-gray-700 mb-2"
|
||||||
|
>
|
||||||
Neues Passwort wiederholen
|
Neues Passwort wiederholen
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
@@ -46,13 +60,24 @@
|
|||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="errorMessage" class="bg-red-50 border border-red-200 rounded-lg p-4">
|
<div
|
||||||
<p class="text-sm text-red-800">{{ errorMessage }}</p>
|
v-if="errorMessage"
|
||||||
|
class="bg-red-50 border border-red-200 rounded-lg p-4"
|
||||||
|
>
|
||||||
|
<p class="text-sm text-red-800">
|
||||||
|
{{ errorMessage }}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="successMessage" class="bg-green-50 border border-green-200 rounded-lg p-4">
|
<div
|
||||||
|
v-if="successMessage"
|
||||||
|
class="bg-green-50 border border-green-200 rounded-lg p-4"
|
||||||
|
>
|
||||||
<p class="text-sm text-green-800 flex items-center">
|
<p class="text-sm text-green-800 flex items-center">
|
||||||
<Check :size="18" class="mr-2" />
|
<Check
|
||||||
|
:size="18"
|
||||||
|
class="mr-2"
|
||||||
|
/>
|
||||||
{{ successMessage }}
|
{{ successMessage }}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -62,12 +87,19 @@
|
|||||||
:disabled="isLoading || !token || Boolean(successMessage)"
|
:disabled="isLoading || !token || Boolean(successMessage)"
|
||||||
class="w-full px-6 py-3 bg-primary-600 hover:bg-primary-700 disabled:bg-gray-400 text-white font-semibold rounded-lg transition-colors flex items-center justify-center"
|
class="w-full px-6 py-3 bg-primary-600 hover:bg-primary-700 disabled:bg-gray-400 text-white font-semibold rounded-lg transition-colors flex items-center justify-center"
|
||||||
>
|
>
|
||||||
<Loader2 v-if="isLoading" :size="20" class="mr-2 animate-spin" />
|
<Loader2
|
||||||
|
v-if="isLoading"
|
||||||
|
:size="20"
|
||||||
|
class="mr-2 animate-spin"
|
||||||
|
/>
|
||||||
<span>{{ isLoading ? 'Wird gespeichert...' : 'Passwort speichern' }}</span>
|
<span>{{ isLoading ? 'Wird gespeichert...' : 'Passwort speichern' }}</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<div class="text-center">
|
<div class="text-center">
|
||||||
<NuxtLink to="/login" class="text-sm text-primary-600 hover:text-primary-700 font-medium">
|
<NuxtLink
|
||||||
|
to="/login"
|
||||||
|
class="text-sm text-primary-600 hover:text-primary-700 font-medium"
|
||||||
|
>
|
||||||
Zurück zum Login
|
Zurück zum Login
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -394,7 +394,6 @@ const formData = ref({
|
|||||||
const isLoading = ref(false)
|
const isLoading = ref(false)
|
||||||
const errorMessage = ref('')
|
const errorMessage = ref('')
|
||||||
const successMessage = ref('')
|
const successMessage = ref('')
|
||||||
const usePasskey = ref(false)
|
|
||||||
const isPasskeySupported = ref(false)
|
const isPasskeySupported = ref(false)
|
||||||
const passkeySupportReason = ref('')
|
const passkeySupportReason = ref('')
|
||||||
const setPasswordForPasskey = ref(true)
|
const setPasswordForPasskey = ref(true)
|
||||||
@@ -424,7 +423,6 @@ const handleFormSubmit = (event) => {
|
|||||||
// console.log('[DEBUG] Calling handleRegister...')
|
// console.log('[DEBUG] Calling handleRegister...')
|
||||||
handleRegister()
|
handleRegister()
|
||||||
}
|
}
|
||||||
const showDebugInfo = ref(false)
|
|
||||||
const debugChallenge = ref('')
|
const debugChallenge = ref('')
|
||||||
const debugRpId = ref('')
|
const debugRpId = ref('')
|
||||||
const debugRegistrationId = ref('')
|
const debugRegistrationId = ref('')
|
||||||
@@ -697,8 +695,6 @@ const handleRegisterWithPasskey = async () => {
|
|||||||
debugSmartphoneUrl.value = `${window.location.origin}/passkey-register-cross-device?registrationId=${pre.registrationId}`
|
debugSmartphoneUrl.value = `${window.location.origin}/passkey-register-cross-device?registrationId=${pre.registrationId}`
|
||||||
}
|
}
|
||||||
|
|
||||||
showDebugInfo.value = true
|
|
||||||
|
|
||||||
console.log('[DEBUG] QR-Code Info (for Cross-Device):', {
|
console.log('[DEBUG] QR-Code Info (for Cross-Device):', {
|
||||||
challenge: pre.options?.challenge,
|
challenge: pre.options?.challenge,
|
||||||
challengeLength: pre.options?.challenge?.length,
|
challengeLength: pre.options?.challenge?.length,
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
Geschichte
|
Geschichte
|
||||||
</h1>
|
</h1>
|
||||||
<!-- nosemgrep: javascript.vue.security.audit.xss.templates.avoid-v-html -->
|
<!-- nosemgrep: javascript.vue.security.audit.xss.templates.avoid-v-html -->
|
||||||
|
<!-- eslint-disable-next-line vue/no-v-html -->
|
||||||
<div
|
<div
|
||||||
class="prose prose-lg max-w-none"
|
class="prose prose-lg max-w-none"
|
||||||
v-html="content"
|
v-html="content"
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
</h1>
|
</h1>
|
||||||
|
|
||||||
<!-- nosemgrep: javascript.vue.security.audit.xss.templates.avoid-v-html -->
|
<!-- nosemgrep: javascript.vue.security.audit.xss.templates.avoid-v-html -->
|
||||||
|
<!-- eslint-disable-next-line vue/no-v-html -->
|
||||||
<div
|
<div
|
||||||
class="prose prose-lg max-w-none mb-8"
|
class="prose prose-lg max-w-none mb-8"
|
||||||
v-html="content"
|
v-html="content"
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
TT-Regeln
|
TT-Regeln
|
||||||
</h1>
|
</h1>
|
||||||
<!-- nosemgrep: javascript.vue.security.audit.xss.templates.avoid-v-html -->
|
<!-- nosemgrep: javascript.vue.security.audit.xss.templates.avoid-v-html -->
|
||||||
|
<!-- eslint-disable-next-line vue/no-v-html -->
|
||||||
<div
|
<div
|
||||||
class="prose prose-lg max-w-none"
|
class="prose prose-lg max-w-none"
|
||||||
v-html="content"
|
v-html="content"
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
Über uns
|
Über uns
|
||||||
</h1>
|
</h1>
|
||||||
<!-- nosemgrep: javascript.vue.security.audit.xss.templates.avoid-v-html -->
|
<!-- nosemgrep: javascript.vue.security.audit.xss.templates.avoid-v-html -->
|
||||||
|
<!-- eslint-disable-next-line vue/no-v-html -->
|
||||||
<div
|
<div
|
||||||
class="prose prose-lg max-w-none"
|
class="prose prose-lg max-w-none"
|
||||||
v-html="content"
|
v-html="content"
|
||||||
|
|||||||
@@ -9,9 +9,6 @@ import { getClientIp } from '../../../utils/rate-limit.js'
|
|||||||
|
|
||||||
// Local fallback for Nitro globals when lint/run env doesn't provide them
|
// Local fallback for Nitro globals when lint/run env doesn't provide them
|
||||||
const getMethod = globalThis.getMethod ?? ((e) => (e?.req?.method || e?.method || 'GET'))
|
const getMethod = globalThis.getMethod ?? ((e) => (e?.req?.method || e?.method || 'GET'))
|
||||||
const getRequestURL = globalThis.getRequestURL ?? ((e) => {
|
|
||||||
try { return new URL(e?.req?.url, 'http://localhost') } catch { return { href: String(e?.req?.url || ''), pathname: String(e?.req?.url || '').split('?')[0] || '' } }
|
|
||||||
})
|
|
||||||
|
|
||||||
function findUserByCredentialId(users, credentialId) {
|
function findUserByCredentialId(users, credentialId) {
|
||||||
const cid = String(credentialId || '')
|
const cid = String(credentialId || '')
|
||||||
|
|||||||
54
server/api/cms/import-spielplan.post.js
Normal file
54
server/api/cms/import-spielplan.post.js
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
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) => {
|
||||||
|
let token = getCookie(event, 'auth_token')
|
||||||
|
if (!token) {
|
||||||
|
const authorization = getHeader(event, 'authorization')
|
||||||
|
if (authorization?.startsWith('Bearer ')) token = authorization.substring(7).trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = token ? await getUserFromToken(token) : null
|
||||||
|
if (!user) {
|
||||||
|
throw createError({ statusCode: 401, statusMessage: 'Nicht authentifiziert' })
|
||||||
|
}
|
||||||
|
if (!hasAnyRole(user, 'admin', 'vorstand')) {
|
||||||
|
throw createError({ statusCode: 403, statusMessage: 'Keine Berechtigung' })
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const imported = await importSpielplan()
|
||||||
|
const published = await publishImportedSpielplan({ inputPath: imported.jsonFile })
|
||||||
|
const mapping = await synchronizeMannschaftenSpielplanIds(published.seasonSlug)
|
||||||
|
|
||||||
|
let tableMessage = ''
|
||||||
|
try {
|
||||||
|
const tables = await importLeagueTables()
|
||||||
|
tableMessage = ` Tabellen: ${tables.importedCount}/${tables.teamCount}.`
|
||||||
|
} catch (error) {
|
||||||
|
loggerError('[cms] Tabellen-Import fehlgeschlagen:', { error })
|
||||||
|
tableMessage = ' Spielplan wurde aktualisiert; Tabellen konnten nicht aktualisiert werden.'
|
||||||
|
}
|
||||||
|
|
||||||
|
loggerInfo('[cms] Spielplan manuell importiert', {
|
||||||
|
userId: user.id,
|
||||||
|
season: published.seasonSlug,
|
||||||
|
matchCount: imported.matchCount
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
message: `Spielplan ${published.seasonSlug} mit ${imported.matchCount} Spielen aktualisiert. ${mapping.updatedTeams} Mannschaften automatisch zugeordnet.${tableMessage}`,
|
||||||
|
season: published.seasonSlug,
|
||||||
|
matchCount: imported.matchCount,
|
||||||
|
mappedTeams: mapping.updatedTeams
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
loggerError('[cms] Manueller Spielplan-Import fehlgeschlagen:', { error })
|
||||||
|
throw createError({ statusCode: 502, statusMessage: 'Spielplan konnte nicht von myTischtennis importiert werden' })
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -371,7 +371,10 @@ export default defineEventHandler(async (event) => {
|
|||||||
arrayBuffer = await res.arrayBuffer()
|
arrayBuffer = await res.arrayBuffer()
|
||||||
}
|
}
|
||||||
} catch (templateLoadError) {
|
} catch (templateLoadError) {
|
||||||
throw new Error('Template-Laden fehlgeschlagen: ' + templateLoadError.message)
|
throw new Error(
|
||||||
|
'Template-Laden fehlgeschlagen: ' + (templateLoadError?.message || String(templateLoadError)),
|
||||||
|
{ cause: templateLoadError }
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const pdfDoc = await PDFDocument.load(arrayBuffer)
|
const pdfDoc = await PDFDocument.load(arrayBuffer)
|
||||||
@@ -610,7 +613,6 @@ export default defineEventHandler(async (event) => {
|
|||||||
// E-Mail senden via zentralen Service (pass full path)
|
// E-Mail senden via zentralen Service (pass full path)
|
||||||
emailResult = await sendMembershipEmailUtil(data, finalPdfPath)
|
emailResult = await sendMembershipEmailUtil(data, finalPdfPath)
|
||||||
// Antragsdaten verschlüsselt speichern
|
// Antragsdaten verschlüsselt speichern
|
||||||
const encryptionKey = process.env.ENCRYPTION_KEY || 'local_development_encryption_key_change_in_production'
|
|
||||||
const encryptedData = JSON.stringify(data)
|
const encryptedData = JSON.stringify(data)
|
||||||
// nosemgrep: javascript.lang.security.audit.path-traversal.path-join-resolve-traversal.path-join-resolve-traversal
|
// nosemgrep: javascript.lang.security.audit.path-traversal.path-join-resolve-traversal.path-join-resolve-traversal
|
||||||
// filename is generated from timestamp, not user input, path traversal prevented
|
// filename is generated from timestamp, not user input, path traversal prevented
|
||||||
@@ -671,7 +673,6 @@ export default defineEventHandler(async (event) => {
|
|||||||
emailResult = await sendMembershipEmailUtil(data, finalPdfPath)
|
emailResult = await sendMembershipEmailUtil(data, finalPdfPath)
|
||||||
|
|
||||||
// Antragsdaten verschlüsselt speichern
|
// Antragsdaten verschlüsselt speichern
|
||||||
const encryptionKey = process.env.ENCRYPTION_KEY || 'local_development_encryption_key_change_in_production'
|
|
||||||
const encryptedData = JSON.stringify(data)
|
const encryptedData = JSON.stringify(data)
|
||||||
// nosemgrep: javascript.lang.security.audit.path-traversal.path-join-resolve-traversal.path-join-resolve-traversal
|
// nosemgrep: javascript.lang.security.audit.path-traversal.path-join-resolve-traversal.path-join-resolve-traversal
|
||||||
// filename is generated from timestamp, not user input, path traversal prevented
|
// filename is generated from timestamp, not user input, path traversal prevented
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import fs from 'fs/promises'
|
import fs from 'fs/promises'
|
||||||
import path from 'path'
|
import path from 'path'
|
||||||
import { getUserFromToken, hasAnyRole } from '../../../../../utils/auth.js'
|
import { getUserFromToken, hasAnyRole } from '../../../../../utils/auth.js'
|
||||||
|
import { decryptObject } from '../../../../../utils/encryption.js'
|
||||||
|
|
||||||
// nosemgrep: javascript.lang.security.audit.path-traversal.path-join-resolve-traversal.path-join-resolve-traversal
|
// nosemgrep: javascript.lang.security.audit.path-traversal.path-join-resolve-traversal.path-join-resolve-traversal
|
||||||
// filename is always a hardcoded constant (e.g., 'newsletter-posts.json'), never user input
|
// filename is always a hardcoded constant (e.g., 'newsletter-posts.json'), never user input
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import { promises as fs } from 'fs'
|
import { promises as fs } from 'fs'
|
||||||
import path from 'path'
|
|
||||||
import { getCurrentSeasonSlug, validateSeasonSlug } from '../../utils/spielplan-data.js'
|
import { getCurrentSeasonSlug, validateSeasonSlug } from '../../utils/spielplan-data.js'
|
||||||
import { getServerDataPath } from '../../utils/paths.js'
|
import { getServerDataPath } from '../../utils/paths.js'
|
||||||
import { error as loggerError } from '../../utils/logger.js'
|
import { error as loggerError } from '../../utils/logger.js'
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { importSpielplan } from '../utils/spielplan-import.js'
|
|||||||
import { importLeagueTables } from '../utils/spielklassen-tables-import.js'
|
import { importLeagueTables } from '../utils/spielklassen-tables-import.js'
|
||||||
import { importQttrValues } from '../utils/qttr-import.js'
|
import { importQttrValues } from '../utils/qttr-import.js'
|
||||||
import { publishImportedSpielplan } from '../utils/spielplan-publish.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 { info as loggerInfo, error as loggerError } from '../utils/logger.js'
|
||||||
import { cleanupPasswordResetLogs } from '../utils/password-reset-log.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}` })
|
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 published = await publishImportedSpielplan({ inputPath: spielplan.jsonFile })
|
||||||
|
const mapping = await synchronizeMannschaftenSpielplanIds(published.seasonSlug)
|
||||||
loggerInfo(`[spielplan-import] ${reason}: Spielplan publiziert`, {
|
loggerInfo(`[spielplan-import] ${reason}: Spielplan publiziert`, {
|
||||||
season: published.seasonSlug,
|
season: published.seasonSlug,
|
||||||
internalPath: published.internalSeasonPath
|
internalPath: published.internalSeasonPath,
|
||||||
|
mappedTeams: mapping.updatedTeams
|
||||||
})
|
})
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -82,7 +82,7 @@ function decryptLegacyCBC(encryptedData, password) {
|
|||||||
return decrypted.toString('utf8')
|
return decrypted.toString('utf8')
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// Re-throw mit mehr Kontext
|
// Re-throw mit mehr Kontext
|
||||||
throw new Error(`Legacy CBC Entschlüsselung fehlgeschlagen: ${error.message}`)
|
throw new Error(`Legacy CBC Entschlüsselung fehlgeschlagen: ${error.message}`, { cause: error })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -121,7 +121,7 @@ function decryptV2GCM(encryptedData, password) {
|
|||||||
return decrypted.toString('utf8')
|
return decrypted.toString('utf8')
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// Re-throw mit mehr Kontext
|
// Re-throw mit mehr Kontext
|
||||||
throw new Error(`GCM v2 Entschlüsselung fehlgeschlagen: ${error.message}`)
|
throw new Error(`GCM v2 Entschlüsselung fehlgeschlagen: ${error.message}`, { cause: error })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -133,7 +133,7 @@ export function encrypt(text, password) {
|
|||||||
return encryptV2GCM(text, password)
|
return encryptV2GCM(text, password)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Verschlüsselungsfehler:', error)
|
console.error('Verschlüsselungsfehler:', error)
|
||||||
throw new Error('Fehler beim Verschlüsseln der Daten')
|
throw new Error('Fehler beim Verschlüsseln der Daten', { cause: error })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -163,7 +163,7 @@ export function decrypt(encryptedData, password) {
|
|||||||
if (error.message.includes('Entschlüsselung fehlgeschlagen')) {
|
if (error.message.includes('Entschlüsselung fehlgeschlagen')) {
|
||||||
throw error
|
throw error
|
||||||
}
|
}
|
||||||
throw new Error(`Fehler beim Entschlüsseln der Daten: ${error.message}`)
|
throw new Error(`Fehler beim Entschlüsseln der Daten: ${error.message}`, { cause: error })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
150
server/utils/mannschaften-spielplan-ids.js
Normal file
150
server/utils/mannschaften-spielplan-ids.js
Normal file
@@ -0,0 +1,150 @@
|
|||||||
|
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, league) {
|
||||||
|
const teamName = String(name || '').trim()
|
||||||
|
const youth = teamName.match(/^jugend(?:mannschaft)?\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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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(?:mannschaft)?\s*|j\s*\(?)(\d{1,2})\b/i)
|
||||||
|
if (ageMatch) {
|
||||||
|
const ordinal = teamName.match(/^jugend(?:mannschaft)?\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
|
||||||
|
}
|
||||||
|
|
||||||
|
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], row[1])
|
||||||
|
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 }
|
||||||
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import { promises as fs } from 'fs'
|
import { promises as fs } from 'fs'
|
||||||
import path from 'path'
|
import path from 'path'
|
||||||
import { randomUUID } from 'crypto'
|
import { randomUUID } from 'crypto'
|
||||||
import { encrypt, decrypt, encryptObject, decryptObject } from './encryption.js'
|
import { encryptObject, decryptObject } from './encryption.js'
|
||||||
import { writeDataFileWithRotation } from './data-file-rotation.js'
|
import { writeDataFileWithRotation } from './data-file-rotation.js'
|
||||||
|
|
||||||
// Handle both dev and production paths
|
// Handle both dev and production paths
|
||||||
|
|||||||
@@ -106,7 +106,7 @@ export async function fillFormFields(pdfDoc, form, data) {
|
|||||||
try {
|
try {
|
||||||
const helveticaFont = await pdfDoc.embedFont(StandardFonts.Helvetica)
|
const helveticaFont = await pdfDoc.embedFont(StandardFonts.Helvetica)
|
||||||
form.updateFieldAppearances(helveticaFont)
|
form.updateFieldAppearances(helveticaFont)
|
||||||
} catch (_error) {
|
} catch (error) {
|
||||||
console.warn('Could not update field appearances:', error.message)
|
console.warn('Could not update field appearances:', error.message)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -123,7 +123,7 @@ export async function fillPdfForm(pdfDoc, form, data) {
|
|||||||
|
|
||||||
// Check if PLZ/Ort field on page 1 is empty and fix it
|
// Check if PLZ/Ort field on page 1 is empty and fix it
|
||||||
await fixPLZOrtField(pdfDoc, data)
|
await fixPLZOrtField(pdfDoc, data)
|
||||||
} catch (_error) {
|
} catch (error) {
|
||||||
console.warn('Form filling failed, using fallback:', error.message)
|
console.warn('Form filling failed, using fallback:', error.message)
|
||||||
await fillFormFieldsPositionally(pdfDoc, data)
|
await fillFormFieldsPositionally(pdfDoc, data)
|
||||||
}
|
}
|
||||||
@@ -136,7 +136,6 @@ export async function fillPdfForm(pdfDoc, form, data) {
|
|||||||
*/
|
*/
|
||||||
async function fixPLZOrtField(pdfDoc, data) {
|
async function fixPLZOrtField(pdfDoc, data) {
|
||||||
try {
|
try {
|
||||||
const pages = pdfDoc.getPages()
|
|
||||||
await pdfDoc.embedFont(StandardFonts.Helvetica)
|
await pdfDoc.embedFont(StandardFonts.Helvetica)
|
||||||
|
|
||||||
// Draw PLZ/Ort at the correct position on page 1
|
// Draw PLZ/Ort at the correct position on page 1
|
||||||
@@ -160,7 +159,7 @@ async function fixPLZOrtField(pdfDoc, data) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
} catch (_error) {
|
} catch (error) {
|
||||||
console.warn('Could not fix PLZ/Ort field:', error.message)
|
console.warn('Could not fix PLZ/Ort field:', error.message)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -221,7 +220,7 @@ async function fillFormFieldsPositionally(pdfDoc, data) {
|
|||||||
firstPage.drawText('X', { x: 116, y: -8, size: 12, font: helveticaFont })
|
firstPage.drawText('X', { x: 116, y: -8, size: 12, font: helveticaFont })
|
||||||
}
|
}
|
||||||
|
|
||||||
} catch (_error) {
|
} catch (error) {
|
||||||
console.error('Positional filling failed:', error.message)
|
console.error('Positional filling failed:', error.message)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -54,7 +54,7 @@ export class PDFGeneratorService {
|
|||||||
const pdfBytes = await pdfDoc.save()
|
const pdfBytes = await pdfDoc.save()
|
||||||
|
|
||||||
return new PDFGenerationResult(true, Buffer.from(pdfBytes), filename)
|
return new PDFGenerationResult(true, Buffer.from(pdfBytes), filename)
|
||||||
} catch (_error) {
|
} catch (error) {
|
||||||
console.error('Template PDF generation failed:', error.message)
|
console.error('Template PDF generation failed:', error.message)
|
||||||
return new PDFGenerationResult(false, null, null, error.message)
|
return new PDFGenerationResult(false, null, null, error.message)
|
||||||
}
|
}
|
||||||
@@ -73,8 +73,8 @@ export class PDFGeneratorService {
|
|||||||
try {
|
try {
|
||||||
await fs.access(this.fallbackTemplatePath)
|
await fs.access(this.fallbackTemplatePath)
|
||||||
return this.fallbackTemplatePath
|
return this.fallbackTemplatePath
|
||||||
} catch (_fallbackError) {
|
} catch (fallbackError) {
|
||||||
throw new Error('No PDF template found')
|
throw new Error('No PDF template found', { cause: fallbackError })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -84,7 +84,7 @@ export class PDFGeneratorService {
|
|||||||
* @param {Object} data - Form data
|
* @param {Object} data - Form data
|
||||||
* @returns {string} Filename
|
* @returns {string} Filename
|
||||||
*/
|
*/
|
||||||
generateFilename(data) {
|
generateFilename(_data) {
|
||||||
const timestamp = Date.now()
|
const timestamp = Date.now()
|
||||||
return `beitrittserklärung_${timestamp}.pdf`
|
return `beitrittserklärung_${timestamp}.pdf`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -57,16 +57,6 @@ function toNumberOrNull(value) {
|
|||||||
return Number.isNaN(numberValue) ? null : numberValue
|
return Number.isNaN(numberValue) ? null : numberValue
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeName(value) {
|
|
||||||
return String(value || '')
|
|
||||||
.trim()
|
|
||||||
.toLowerCase()
|
|
||||||
.normalize('NFKD')
|
|
||||||
.replace(/[\u0300-\u036f]/g, '')
|
|
||||||
.replace(/\s+/g, ' ')
|
|
||||||
.replace(/[’'`]/g, '')
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeGender(value) {
|
function normalizeGender(value) {
|
||||||
const normalized = String(value || '').trim().toLowerCase()
|
const normalized = String(value || '').trim().toLowerCase()
|
||||||
if (normalized === 'm' || normalized === 'männlich') return 'männlich'
|
if (normalized === 'm' || normalized === 'männlich') return 'männlich'
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { promises as fs } from 'fs'
|
import { promises as fs } from 'fs'
|
||||||
import path from 'path'
|
import path from 'path'
|
||||||
import { getProjectPath, getServerDataPath } from './paths.js'
|
import { getProjectPath, getServerDataPath } from './paths.js'
|
||||||
import { error as loggerError, info as loggerInfo } from './logger.js'
|
import { error as loggerError } from './logger.js'
|
||||||
|
|
||||||
const SPIELPLAN_HEADERS = [
|
const SPIELPLAN_HEADERS = [
|
||||||
'Termin',
|
'Termin',
|
||||||
@@ -448,7 +448,7 @@ export async function listSpielplanSeasons() {
|
|||||||
const bySlug = new Map()
|
const bySlug = new Map()
|
||||||
|
|
||||||
for (const directory of directories) {
|
for (const directory of directories) {
|
||||||
let entries = []
|
let entries
|
||||||
try {
|
try {
|
||||||
entries = await fs.readdir(directory)
|
entries = await fs.readdir(directory)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -12,10 +12,6 @@ const OUTPUT_DIR = getServerDataPath('spielplan-import')
|
|||||||
const JSON_FILE = path.join(OUTPUT_DIR, 'harheimer_tc_spielplan.json')
|
const JSON_FILE = path.join(OUTPUT_DIR, 'harheimer_tc_spielplan.json')
|
||||||
const HTML_FILE = path.join(OUTPUT_DIR, 'harheimer_tc_spielplan.html')
|
const HTML_FILE = path.join(OUTPUT_DIR, 'harheimer_tc_spielplan.html')
|
||||||
|
|
||||||
function pad2(value) {
|
|
||||||
return String(value).padStart(2, '0')
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getSpieljahrForDate(date = new Date()) {
|
export function getSpieljahrForDate(date = new Date()) {
|
||||||
const year = date.getFullYear()
|
const year = date.getFullYear()
|
||||||
const startYear = date.getMonth() >= 6 ? year : year - 1
|
const startYear = date.getMonth() >= 6 ? year : year - 1
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import { promises as fs } from 'fs'
|
import { promises as fs } from 'fs'
|
||||||
import path from 'path'
|
|
||||||
import { randomUUID } from 'crypto'
|
import { randomUUID } from 'crypto'
|
||||||
import { writeDataFileWithRotation } from './data-file-rotation.js'
|
import { writeDataFileWithRotation } from './data-file-rotation.js'
|
||||||
|
|
||||||
|
|||||||
@@ -39,6 +39,38 @@ function isErwachsenenTeam(teamKey) {
|
|||||||
return teamKey === 'erwachsene' || teamKey.startsWith('erwachsene_')
|
return teamKey === 'erwachsene' || teamKey.startsWith('erwachsene_')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function romanToNumber(value) {
|
||||||
|
const roman = String(value || '').toUpperCase()
|
||||||
|
if (!/^[IVX]+$/.test(roman)) return null
|
||||||
|
|
||||||
|
const values = { I: 1, V: 5, X: 10 }
|
||||||
|
let total = 0
|
||||||
|
for (let index = 0; index < roman.length; index += 1) {
|
||||||
|
const current = values[roman[index]] || 0
|
||||||
|
const next = values[roman[index + 1]] || 0
|
||||||
|
total += current < next ? -current : current
|
||||||
|
}
|
||||||
|
return total || null
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseJugendTeamKey(teamKey) {
|
||||||
|
const match = String(teamKey || '').match(/^jugend(?:mannschaft)?_?j?(\d{1,2})(?:_([ivx]+|\d+))?$/i)
|
||||||
|
if (!match) return null
|
||||||
|
|
||||||
|
const teamNumber = match[2]
|
||||||
|
? (Number(match[2]) || romanToNumber(match[2]))
|
||||||
|
: null
|
||||||
|
|
||||||
|
return { age: match[1], teamNumber: teamNumber ? String(teamNumber) : '' }
|
||||||
|
}
|
||||||
|
|
||||||
|
function sideMatchesJugendTeam({ isHarheimer, ageClass, teamNumber }, team) {
|
||||||
|
if (!isHarheimer) return false
|
||||||
|
const ageMatch = String(ageClass || '').match(/(?:jugend\s*|j)(\d{1,2})\b/i)
|
||||||
|
if (!ageMatch || ageMatch[1] !== team.age) return false
|
||||||
|
return !team.teamNumber || String(teamNumber || '').trim() === team.teamNumber
|
||||||
|
}
|
||||||
|
|
||||||
export function rowMatchesWettbewerbFilter(row, rawWettbewerb) {
|
export function rowMatchesWettbewerbFilter(row, rawWettbewerb) {
|
||||||
const wettbewerb = normalizeText(rawWettbewerb || 'punktrunde')
|
const wettbewerb = normalizeText(rawWettbewerb || 'punktrunde')
|
||||||
const runde = normalizeText(row?.Runde)
|
const runde = normalizeText(row?.Runde)
|
||||||
@@ -79,6 +111,19 @@ export function rowMatchesTeamFilter(row, rawTeam) {
|
|||||||
return isJugendHeim || isJugendGast
|
return isJugendHeim || isJugendGast
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const jugendTeam = parseJugendTeamKey(teamKey)
|
||||||
|
if (jugendTeam) {
|
||||||
|
return sideMatchesJugendTeam({
|
||||||
|
isHarheimer: isHarheimerHeim,
|
||||||
|
ageClass: heimAltersklasse,
|
||||||
|
teamNumber: row?.HeimMannschaftNr
|
||||||
|
}, jugendTeam) || sideMatchesJugendTeam({
|
||||||
|
isHarheimer: isHarheimerGast,
|
||||||
|
ageClass: gastAltersklasse,
|
||||||
|
teamNumber: row?.GastMannschaftNr
|
||||||
|
}, jugendTeam)
|
||||||
|
}
|
||||||
|
|
||||||
const csvVariants = MANNSCHAFT_MAPPING[teamKey] || []
|
const csvVariants = MANNSCHAFT_MAPPING[teamKey] || []
|
||||||
if (csvVariants.length === 0) {
|
if (csvVariants.length === 0) {
|
||||||
return false
|
return false
|
||||||
|
|||||||
Reference in New Issue
Block a user