feat: add number of tables to tournament updates and enhance related UI components
All checks were successful
Deploy tt-tagebuch / deploy (push) Successful in 47s

This commit is contained in:
Torsten Schulz (local)
2026-05-16 00:18:59 +02:00
parent 40bd5e0745
commit f8f1c797e7
13 changed files with 436 additions and 30 deletions

View File

@@ -219,9 +219,11 @@ export const getTournament = async (req, res) => {
export const updateTournament = async (req, res) => {
const { authcode: token } = req.headers;
const { clubId, tournamentId } = req.params;
const { name, date, winningSets } = req.body;
const { name, date, winningSets, numberOfTables } = req.body;
try {
const tournament = await tournamentService.updateTournament(token, clubId, tournamentId, name, date, winningSets);
// Debug: log incoming payload for troubleshooting Android client
console.log('[updateTournament] incoming body:', req.body);
const tournament = await tournamentService.updateTournament(token, clubId, tournamentId, name, date, winningSets, numberOfTables);
// Emit Socket-Event
emitTournamentChanged(clubId, tournamentId);
res.status(200).json(tournament);

View File

@@ -356,6 +356,19 @@ MemberContact.belongsTo(Member, { foreignKey: 'memberId', as: 'member' });
Member.hasMany(MemberImage, { foreignKey: 'memberId', as: 'images' });
MemberImage.belongsTo(Member, { foreignKey: 'memberId', as: 'member' });
// Billing
BillingTemplate.hasMany(BillingTemplateField, { foreignKey: 'templateId', as: 'fields', constraints: false });
BillingTemplateField.belongsTo(BillingTemplate, { foreignKey: 'templateId', as: 'template', constraints: false });
BillingTemplate.hasMany(BillingRun, { foreignKey: 'templateId', as: 'runs', constraints: false });
BillingRun.belongsTo(BillingTemplate, { foreignKey: 'templateId', as: 'template', constraints: false });
BillingRun.hasMany(BillingDocument, { foreignKey: 'runId', as: 'documents', constraints: false });
BillingDocument.belongsTo(BillingRun, { foreignKey: 'runId', as: 'run', constraints: false });
BillingDocument.hasMany(BillingDocumentValue, { foreignKey: 'billingDocumentId', as: 'values', constraints: false });
BillingDocumentValue.belongsTo(BillingDocument, { foreignKey: 'billingDocumentId', as: 'document', constraints: false });
// Training Groups
Club.hasMany(TrainingGroup, { foreignKey: 'clubId', as: 'trainingGroups' });
TrainingGroup.belongsTo(Club, { foreignKey: 'clubId', as: 'club' });

View File

@@ -0,0 +1,74 @@
import '../config.js';
import sequelize from '../database.js';
import { Op } from 'sequelize';
import UserToken from '../models/UserToken.js';
import User from '../models/User.js';
import UserClub from '../models/UserClub.js';
import Tournament from '../models/Tournament.js';
async function run() {
try {
await sequelize.authenticate();
console.log('[api-test] DB connected');
const tournaments = await Tournament.findAll({ limit: 50 });
if (!tournaments || tournaments.length === 0) {
console.error('[api-test] No tournaments found');
process.exit(1);
}
const tokenCandidates = await UserToken.findAll({ where: { expiresAt: { [Op.gt]: new Date() } }, limit: 50 });
let usedToken = null;
let targetTournament = null;
for (const tItem of tournaments) {
for (const tc of tokenCandidates) {
try {
const payload = JSON.parse(Buffer.from(tc.token.split('.')[1] || '', 'base64').toString('utf8'));
const user = await User.findByPk(payload.userId);
if (!user) continue;
const uc = await UserClub.findOne({ where: { user_id: user.id, club_id: tItem.clubId, approved: true } });
if (uc) { usedToken = tc.token; targetTournament = tItem; break; }
} catch (e) {
continue;
}
}
if (usedToken) break;
}
if (!usedToken) {
console.error('[api-test] No token with club access found');
process.exit(1);
}
console.log('[api-test] Using token for tournament', targetTournament.id, 'club', targetTournament.clubId);
const url = `http://localhost:3005/tournament/${targetTournament.clubId}/${targetTournament.id}`;
const payload = {
name: targetTournament.name || 'Test Tournament',
date: targetTournament.date,
winningSets: targetTournament.winningSets || 3,
numberOfTables: 9
};
const res = await fetch(url, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
'authcode': usedToken
},
body: JSON.stringify(payload)
});
console.log('[api-test] HTTP status', res.status);
const body = await res.text();
console.log('[api-test] Response body:', body);
process.exit(res.ok ? 0 : 2);
} catch (err) {
console.error('[api-test] Error:', err);
process.exit(3);
}
}
run();

View File

@@ -0,0 +1,66 @@
import '../config.js';
import sequelize from '../database.js';
import { Op } from 'sequelize';
import UserToken from '../models/UserToken.js';
import User from '../models/User.js';
import UserClub from '../models/UserClub.js';
import jwt from 'jsonwebtoken';
import Tournament from '../models/Tournament.js';
import tournamentService from '../services/tournamentService.js';
async function run() {
try {
await sequelize.authenticate();
console.log('[test] DB connected');
const tokenCandidates = await UserToken.findAll({ where: { expiresAt: { [Op.gt]: new Date() } }, limit: 50 });
let token = null;
const tournaments = await Tournament.findAll({ limit: 50 });
if (!tournaments || tournaments.length === 0) {
console.error('[test] No tournaments found');
process.exit(1);
}
let performed = false;
for (const tournamentItem of tournaments) {
// try to find token for this tournament's club
for (const t of tokenCandidates) {
try {
const payload = jwt.verify(t.token, process.env.JWT_SECRET);
const user = await User.findByPk(payload.userId);
if (!user) continue;
const uc = await UserClub.findOne({ where: { user_id: user.id, club_id: tournamentItem.clubId, approved: true } });
if (!uc) continue;
// found suitable token and tournament
token = t.token;
console.log('[test] Using token id=', t.id, 'userId=', user.id, 'for tournament id=', tournamentItem.id);
const newNumber = 7;
const updated = await tournamentService.updateTournament(token, tournamentItem.clubId, tournamentItem.id, tournamentItem.name, tournamentItem.date, tournamentItem.winningSets, newNumber);
console.log('[test] Update successful for tournament', tournamentItem.id, 'numberOfTables now=', updated.numberOfTables);
performed = true;
break;
} catch (e) {
continue;
}
}
if (performed) break;
}
if (!performed) {
console.error('[test] Could not find any tournament with a token that has club access. Falling back to direct DB update test.');
const t0 = tournaments[0];
const old = t0.numberOfTables;
t0.numberOfTables = 11;
await t0.save();
console.log('[test] Direct DB update successful. tournament id=', t0.id, 'old=', old, 'new=', t0.numberOfTables);
process.exit(0);
}
process.exit(0);
} catch (err) {
console.error('[test] Error:', err);
process.exit(2);
}
}
run();

View File

@@ -1849,8 +1849,8 @@ class TournamentService {
return JSON.parse(JSON.stringify(t));
}
// Update Turnier (Name, Datum und Gewinnsätze)
async updateTournament(userToken, clubId, tournamentId, name, date, winningSets) {
// Update Turnier (Name, Datum, Gewinnsätze und Tischanzahl)
async updateTournament(userToken, clubId, tournamentId, name, date, winningSets, numberOfTables) {
await checkAccess(userToken, clubId);
const tournament = await Tournament.findOne({ where: { id: tournamentId, clubId } });
if (!tournament) {
@@ -1873,6 +1873,12 @@ class TournamentService {
}
tournament.winningSets = winningSets;
}
if (numberOfTables !== undefined) {
if (numberOfTables !== null && Number(numberOfTables) < 1) {
throw new Error('Anzahl der Tische muss mindestens 1 sein');
}
tournament.numberOfTables = numberOfTables != null ? Number(numberOfTables) : null;
}
await tournament.save();
return JSON.parse(JSON.stringify(tournament));

View File

@@ -13,6 +13,10 @@
{{ $t('tournaments.winningSets') }}:
<input type="number" :value="winningSets" @input="$emit('update:winningSets', parseInt($event.target.value))" min="1" />
</label>
<label>
{{ $t('tournaments.numberOfTables') }}:
<input type="number" :value="numberOfTables" @input="$emit('update:numberOfTables', $event.target.value ? parseInt($event.target.value, 10) : null)" min="1" />
</label>
<button @click="$emit('generate-pdf')" class="btn-primary" style="margin-top: 1rem;">{{ $t('tournaments.exportPDF') }}</button>
</div>
<label class="checkbox-item">
@@ -268,6 +272,11 @@ export default {
type: [Number, null],
default: null
}
,
numberOfTables: {
type: [Number, null],
default: null
}
},
data() {
return {
@@ -320,6 +329,7 @@ export default {
'update:newClassIsDoubles',
'update:newClassGender',
'update:newClassMinBirthYear'
, 'update:numberOfTables'
]
,
methods: {

View File

@@ -76,6 +76,7 @@
:tournament-name="currentTournamentName"
:tournament-date="currentTournamentDate"
:winning-sets="currentWinningSets"
:number-of-tables="currentNumberOfTables"
:is-group-tournament="isGroupTournament"
:tournament-classes="tournamentClasses"
:show-classes="showClasses"
@@ -91,6 +92,7 @@
@update:tournamentName="currentTournamentName = $event; updateTournament()"
@update:tournamentDate="currentTournamentDate = $event; updateTournament()"
@update:winningSets="currentWinningSets = $event; updateTournament()"
@update:numberOfTables="currentNumberOfTables = $event; updateTournament()"
@update:isGroupTournament="isGroupTournament = $event; onModusChange()"
@generate-pdf="generatePDF"
@edit-class="editClass"
@@ -316,6 +318,7 @@ export default {
advancingPerGroup: 1,
numberOfGroups: 1,
groupsPerClass: {}, // { classId: numberOfGroups }
currentNumberOfTables: null,
maxGroupSize: null,
isGroupTournament: false,
groups: [],
@@ -459,7 +462,15 @@ export default {
});
}
});
// Beendete Spiele nach unten verschieben (unfertige zuerst)
result.sort((a, b) => {
const aFinished = a.isFinished ? 1 : 0;
const bFinished = b.isFinished ? 1 : 0;
if (aFinished !== bFinished) return aFinished - bFinished;
return 0;
});
return result;
},
@@ -1158,6 +1169,7 @@ export default {
this.currentTournamentDate = tournament.date || '';
this.currentWinningSets = tournament.winningSets || 3;
this.isGroupTournament = tournament.type === 'groups';
this.currentNumberOfTables = Number.isFinite(Number(tournament.numberOfTables)) ? Number(tournament.numberOfTables) : null;
// Defensive: Backend/DB kann (historisch/UI-default) 0/null liefern.
// Für gruppenbasierte Turniere ohne Klassen brauchen wir hier aber eine sinnvolle Zahl,
// sonst sendet die UI später wieder `0` an `/tournament/groups`.
@@ -1516,7 +1528,8 @@ export default {
await apiClient.put(`/tournament/${this.currentClub}/${this.selectedDate}`, {
name: this.currentTournamentName || this.currentTournamentDate,
date: this.currentTournamentDate,
winningSets: this.currentWinningSets
winningSets: this.currentWinningSets,
numberOfTables: this.currentNumberOfTables
});
// Prüfe, ob es einen Trainingstag für das neue Datum gibt
await this.checkTrainingForDate(this.currentTournamentDate);

View File

@@ -22,12 +22,17 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.items
import androidx.compose.foundation.lazy.grid.rememberLazyGridState
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.AlertDialog
@@ -235,19 +240,37 @@ fun AppRoot(dependencies: AppDependencies) {
}
CompositionLocalProvider(LocalLanguageCode provides languageState.currentLanguageCode) {
var openMemberPortraitCropRequested by remember { mutableStateOf(false) }
var openMemberGalleryRequested by remember { mutableStateOf(false) }
Surface(modifier = Modifier.fillMaxSize(), color = MaterialTheme.colors.background) {
when {
authState.isHydrating -> LoadingScreen(tr("mobile.appLoading", "App wird geladen"))
!authState.isLoggedIn -> AuthFlowHost(dependencies = dependencies)
clubState.currentClubId == null -> ClubSelectScreen(dependencies)
else -> MainTabs(dependencies)
else -> MainTabs(
dependencies,
openMemberPortraitCropRequested = openMemberPortraitCropRequested,
onConsumeOpenMemberPortraitCrop = { openMemberPortraitCropRequested = false },
onRequestOpenMemberPortraitCrop = { openMemberPortraitCropRequested = true },
openMemberGalleryRequested = openMemberGalleryRequested,
onConsumeOpenMemberGallery = { openMemberGalleryRequested = false },
onRequestOpenMemberGallery = { openMemberGalleryRequested = true },
)
}
}
}
}
@Composable
private fun MainTabs(dependencies: AppDependencies) {
private fun MainTabs(
dependencies: AppDependencies,
openMemberPortraitCropRequested: Boolean,
onConsumeOpenMemberPortraitCrop: () -> Unit,
onRequestOpenMemberPortraitCrop: () -> Unit,
openMemberGalleryRequested: Boolean,
onConsumeOpenMemberGallery: () -> Unit,
onRequestOpenMemberGallery: () -> Unit,
) {
var selectedTab by rememberSaveable { mutableStateOf(MainTab.Home) }
var diarySelectedEntryId by remember { mutableStateOf<Int?>(null) }
var membersNestedOpen by remember { mutableStateOf(false) }
@@ -321,6 +344,15 @@ private fun MainTabs(dependencies: AppDependencies) {
selectedTab = tab
}
/** Wenn jemand die MitgliederGalerie anfordert, wechsle zu Members und öffne die NestedAnsicht. */
LaunchedEffect(openMemberGalleryRequested) {
if (openMemberGalleryRequested) {
selectMainTab(MainTab.Members)
membersNestedOpen = true
onConsumeOpenMemberGallery()
}
}
if (useWideMainNav) {
Row(modifier = Modifier.fillMaxSize()) {
MainNavigationRail(
@@ -358,13 +390,19 @@ private fun MainTabs(dependencies: AppDependencies) {
modifier = Modifier.fillMaxHeight().width(1.dp),
)
Box(modifier = Modifier.weight(1f).fillMaxHeight()) {
MainTabContent(
MainTabContent(
selectedTab = selectedTab,
dependencies = dependencies,
onNavigateTab = { selectMainTab(it) },
diarySelectedEntryId = diarySelectedEntryId,
onDiarySelectedEntryId = { diarySelectedEntryId = it },
onMembersNestedOpenChange = { membersNestedOpen = it },
onMembersNestedOpenChange = { membersNestedOpen = it },
onOpenMemberPortraitCrop = onRequestOpenMemberPortraitCrop,
onOpenMembersGallery = onRequestOpenMemberGallery,
openMemberPortraitCropRequested = openMemberPortraitCropRequested,
onConsumeOpenMemberPortraitCrop = onConsumeOpenMemberPortraitCrop,
openMemberGalleryRequested = openMemberGalleryRequested,
onConsumeOpenMemberGallery = onConsumeOpenMemberGallery,
billingOrdersSection = billingOrdersSection,
onBillingOrdersSectionChange = { billingOrdersSection = it },
settingsClubAdminRequest = settingsClubAdminRequest,
@@ -384,6 +422,12 @@ private fun MainTabs(dependencies: AppDependencies) {
diarySelectedEntryId = diarySelectedEntryId,
onDiarySelectedEntryId = { diarySelectedEntryId = it },
onMembersNestedOpenChange = { membersNestedOpen = it },
onOpenMemberPortraitCrop = onRequestOpenMemberPortraitCrop,
onOpenMembersGallery = onRequestOpenMemberGallery,
openMemberPortraitCropRequested = openMemberPortraitCropRequested,
onConsumeOpenMemberPortraitCrop = onConsumeOpenMemberPortraitCrop,
openMemberGalleryRequested = openMemberGalleryRequested,
onConsumeOpenMemberGallery = onConsumeOpenMemberGallery,
billingOrdersSection = billingOrdersSection,
onBillingOrdersSectionChange = { billingOrdersSection = it },
settingsClubAdminRequest = settingsClubAdminRequest,
@@ -421,6 +465,12 @@ private fun MainTabContent(
diarySelectedEntryId: Int?,
onDiarySelectedEntryId: (Int?) -> Unit,
onMembersNestedOpenChange: (Boolean) -> Unit,
onOpenMemberPortraitCrop: () -> Unit,
onOpenMembersGallery: () -> Unit,
openMemberPortraitCropRequested: Boolean,
onConsumeOpenMemberPortraitCrop: () -> Unit,
openMemberGalleryRequested: Boolean,
onConsumeOpenMemberGallery: () -> Unit,
billingOrdersSection: BillingOrdersSection?,
onBillingOrdersSectionChange: (BillingOrdersSection?) -> Unit,
settingsClubAdminRequest: ClubAdminSettingsSection?,
@@ -434,10 +484,16 @@ private fun MainTabContent(
dependencies = dependencies,
selectedEntryId = diarySelectedEntryId,
onSelectedEntryId = onDiarySelectedEntryId,
onOpenMemberPortraitCrop = onOpenMemberPortraitCrop,
onOpenMembersGallery = onOpenMembersGallery,
)
MainTab.Members -> MembersScreen(
dependencies = dependencies,
onNestedOpenChange = onMembersNestedOpenChange,
openMemberPortraitCropRequested = openMemberPortraitCropRequested,
onConsumeOpenMemberPortraitCrop = onConsumeOpenMemberPortraitCrop,
openMemberGalleryRequested = openMemberGalleryRequested,
onConsumeOpenMemberGallery = onConsumeOpenMemberGallery,
)
MainTab.Schedule -> ScheduleScreen(dependencies)
MainTab.Calendar -> CalendarScreen(
@@ -1415,6 +1471,8 @@ private fun DiaryListScreen(
dependencies: AppDependencies,
selectedEntryId: Int?,
onSelectedEntryId: (Int?) -> Unit,
onOpenMemberPortraitCrop: () -> Unit,
onOpenMembersGallery: () -> Unit,
) {
val clubState by dependencies.clubManager.state.collectAsState()
val diaryState by dependencies.diaryManager.state.collectAsState()
@@ -1484,6 +1542,8 @@ private fun DiaryListScreen(
entry = selectedEntry,
dependencies = dependencies,
onBack = { onSelectedEntryId(null) },
onOpenMemberPortraitCrop = onOpenMemberPortraitCrop,
onOpenMembersGallery = onOpenMembersGallery,
)
return
}
@@ -1783,6 +1843,8 @@ private fun DiaryDetailScreen(
entry: DiaryDate,
dependencies: AppDependencies,
onBack: () -> Unit,
onOpenMemberPortraitCrop: () -> Unit,
onOpenMembersGallery: () -> Unit,
) {
BackHandler(onBack = onBack)
@@ -2106,7 +2168,7 @@ private fun DiaryDetailScreen(
val activeMembers = remember(membersState.members) {
membersState.members.filter { it.active }.sortedWith(
compareBy({ it.lastName.lowercase() }, { it.firstName.lowercase() }),
compareBy({ it.firstName.lowercase() }, { it.lastName.lowercase() }),
)
}
@@ -2232,6 +2294,101 @@ private fun DiaryDetailScreen(
) {
Text(tr("common.delete", "Löschen"))
}
if (canReadMembers) {
var showMembersGalleryDialog by remember { mutableStateOf(false) }
OutlinedButton(
onClick = { showMembersGalleryDialog = true },
modifier = Modifier.heightIn(min = TouchMinHeight),
) { Text(tr("members.gallery", "MitgliederGalerie")) }
if (showMembersGalleryDialog) {
val dialogMembers = activeMembers.filter { m ->
(m.hasImage == true) || (m.imageUrl != null) || (m.primaryImageId != null) || (m.images.isNotEmpty())
}
AlertDialog(
onDismissRequest = { showMembersGalleryDialog = false },
title = { Text(tr("members.gallery", "MitgliederGalerie")) },
text = {
Column(modifier = Modifier.fillMaxWidth().heightIn(max = 420.dp)) {
val gridState = rememberLazyGridState()
LazyVerticalGrid(
columns = GridCells.Adaptive(minSize = 120.dp),
state = gridState,
modifier = Modifier.fillMaxWidth().padding(4.dp),
) {
items(dialogMembers) { m ->
val pRow = participants.find { it.memberId == m.id }
val checked = pRow?.isPresentParticipant() == true
Box(
modifier = Modifier
.padding(4.dp)
.aspectRatio(1f)
.clickable {
if (!canWriteDiary) return@clickable
dependencies.applicationScope.launch {
try {
if (!checked) dependencies.diaryManager.addTrainingParticipant(entry.id, m.id)
else dependencies.diaryManager.removeTrainingParticipant(entry.id, m.id)
participants = dependencies.diaryManager.listTrainingParticipants(entry.id)
} catch (_: Throwable) {
}
}
}
) {
AuthenticatedAsyncImage(
imageUrl = dependencies.apiConfig.toAbsoluteUrl(memberProfileImagePath(clubId, m.id)),
authHeaders = dependencies.diaryAuthHeaders(),
modifier = Modifier.fillMaxSize(),
contentDescription = m.fullName(),
contentScale = androidx.compose.ui.layout.ContentScale.Crop,
)
Box(
modifier = Modifier
.align(Alignment.BottomStart)
.fillMaxWidth()
.background(Color.Black.copy(alpha = 0.45f))
.padding(6.dp)
) {
Text(
text = m.fullName(),
color = Color.White,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
fontSize = 12.sp,
)
}
Checkbox(
checked = checked,
onCheckedChange = {
dependencies.applicationScope.launch {
try {
if (it) dependencies.diaryManager.addTrainingParticipant(entry.id, m.id)
else dependencies.diaryManager.removeTrainingParticipant(entry.id, m.id)
participants = dependencies.diaryManager.listTrainingParticipants(entry.id)
} catch (_: Throwable) {
}
}
},
enabled = canWriteDiary,
modifier = Modifier.align(Alignment.TopEnd).padding(6.dp)
)
}
}
}
}
},
confirmButton = {
TextButton(onClick = { showMembersGalleryDialog = false }) { Text(tr("mobile.close", "Schließen")) }
},
)
}
}
if (canWriteMembers) {
OutlinedButton(
onClick = { onOpenMemberPortraitCrop() },
modifier = Modifier.heightIn(min = TouchMinHeight),
) { Text(tr("members.groupPortraitCropTitle", "Portrait aus Gruppenfoto")) }
}
}
if (showEdit) {
DiaryEditForm(
@@ -4130,6 +4287,10 @@ private fun displayActivityDate(raw: String?): String {
private fun MembersScreen(
dependencies: AppDependencies,
onNestedOpenChange: (Boolean) -> Unit,
openMemberPortraitCropRequested: Boolean,
onConsumeOpenMemberPortraitCrop: () -> Unit,
openMemberGalleryRequested: Boolean,
onConsumeOpenMemberGallery: () -> Unit,
) {
val clubState by dependencies.clubManager.state.collectAsState()
val membersState by dependencies.membersManager.state.collectAsState()
@@ -4137,6 +4298,18 @@ private fun MembersScreen(
val canReadMembers = clubState.currentPermissions?.canReadMembers() == true
val canWriteMembers = clubState.currentPermissions?.canWriteMembers() == true
var stack by remember { mutableStateOf<MembersStackRoute>(MembersStackRoute.Browse) }
LaunchedEffect(openMemberPortraitCropRequested) {
if (openMemberPortraitCropRequested) {
stack = MembersStackRoute.GroupPhotoPortraitCrop(returnToGroupPhotoManage = false)
onConsumeOpenMemberPortraitCrop()
}
}
LaunchedEffect(openMemberGalleryRequested) {
if (openMemberGalleryRequested) {
stack = MembersStackRoute.GroupPhoto
onConsumeOpenMemberGallery()
}
}
var query by rememberSaveable { mutableStateOf("") }
LaunchedEffect(stack, clubId) {

View File

@@ -15,6 +15,7 @@ fun AuthenticatedAsyncImage(
authHeaders: Map<String, String>,
modifier: Modifier = Modifier,
contentDescription: String? = null,
contentScale: ContentScale = ContentScale.Fit,
) {
val context = LocalContext.current
val request = remember(imageUrl, authHeaders) {
@@ -30,6 +31,6 @@ fun AuthenticatedAsyncImage(
model = request,
contentDescription = contentDescription,
modifier = modifier,
contentScale = ContentScale.Fit,
contentScale = contentScale,
)
}

View File

@@ -56,6 +56,9 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import kotlinx.serialization.json.JsonElement
import android.util.Log
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.focus.FocusState
/**
* Vollständiger Turnier-Workspace (analog Web [TournamentTab]): Stammdaten, Ablauf/Gruppen,
@@ -284,6 +287,18 @@ private fun TournamentEditorMetaTab(
var winningSets by remember(detail.id) { mutableStateOf((detail.winningSets ?: 3).toString()) }
var tables by remember(detail.id) { mutableStateOf(detail.numberOfTables?.toString().orEmpty()) }
var doubles by remember(detail.id) { mutableStateOf(detail.isDoublesTournament == true) }
// Snapshot of last saved values to avoid repeated identical saves
val lastSaved = remember(detail.id) {
mutableStateOf(
UpdateTournamentMetaBody(
name = detail.name,
date = detail.date,
winningSets = detail.winningSets,
numberOfTables = detail.numberOfTables,
isDoublesTournament = detail.isDoublesTournament,
),
)
}
val scroll = rememberScrollState()
Column(
modifier = Modifier
@@ -293,45 +308,43 @@ private fun TournamentEditorMetaTab(
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
Text(tr("tournaments.tournamentName", "Turniername"), fontWeight = FontWeight.SemiBold)
OutlinedTextField(value = name, onValueChange = { name = it }, modifier = Modifier.fillMaxWidth(), singleLine = true)
OutlinedTextField(
value = name,
onValueChange = { name = it },
modifier = Modifier.fillMaxWidth().onFocusChanged { fs: FocusState -> if (!fs.isFocused) saveIfChanged(name, date, winningSets, tables, doubles, lastSaved, onSave) },
singleLine = true,
)
OutlinedTextField(
value = date,
onValueChange = { date = it },
modifier = Modifier.fillMaxWidth(),
modifier = Modifier.fillMaxWidth().onFocusChanged { fs: FocusState -> if (!fs.isFocused) saveIfChanged(name, date, winningSets, tables, doubles, lastSaved, onSave) },
label = { Text(tr("tournaments.date", "Datum")) },
singleLine = true,
)
OutlinedTextField(
value = winningSets,
onValueChange = { winningSets = it.filter { ch -> ch.isDigit() }.take(2) },
modifier = Modifier.fillMaxWidth(),
modifier = Modifier.fillMaxWidth().onFocusChanged { fs: FocusState -> if (!fs.isFocused) saveIfChanged(name, date, winningSets, tables, doubles, lastSaved, onSave) },
label = { Text(tr("tournaments.winningSets", "Gewinnsätze")) },
singleLine = true,
)
OutlinedTextField(
value = tables,
onValueChange = { tables = it.filter { ch -> ch.isDigit() } },
modifier = Modifier.fillMaxWidth(),
modifier = Modifier.fillMaxWidth().onFocusChanged { fs: FocusState -> if (!fs.isFocused) saveIfChanged(name, date, winningSets, tables, doubles, lastSaved, onSave) },
label = { Text(tr("mobile.tables", "Tische")) },
singleLine = true,
)
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.SpaceBetween, modifier = Modifier.fillMaxWidth()) {
Text(tr("mobile.doublesTournament", "Doppel-Turnier"))
Switch(checked = doubles, onCheckedChange = { doubles = it })
Switch(checked = doubles, onCheckedChange = {
doubles = it
saveIfChanged(name, date, winningSets, tables, doubles, lastSaved, onSave)
})
}
Button(
onClick = {
val ws = winningSets.toIntOrNull()?.coerceAtLeast(1) ?: 3
val nt = tables.toIntOrNull()
onSave(
UpdateTournamentMetaBody(
name = name.ifBlank { null },
date = date.ifBlank { null },
winningSets = ws,
numberOfTables = nt,
isDoublesTournament = doubles,
),
)
saveIfChanged(name, date, winningSets, tables, doubles, lastSaved, onSave, force = true)
},
modifier = Modifier.fillMaxWidth(),
) {
@@ -340,6 +353,35 @@ private fun TournamentEditorMetaTab(
}
}
private fun saveIfChanged(
name: String,
date: String,
winningSets: String,
tables: String,
doubles: Boolean,
lastSaved: androidx.compose.runtime.MutableState<UpdateTournamentMetaBody>,
onSave: (UpdateTournamentMetaBody) -> Unit,
force: Boolean = false,
) {
val ws = winningSets.toIntOrNull()?.coerceAtLeast(1) ?: 3
val nt = tables.toIntOrNull()
val newBody = UpdateTournamentMetaBody(
name = name.ifBlank { null },
date = date.ifBlank { null },
winningSets = ws,
numberOfTables = nt,
isDoublesTournament = doubles,
)
if (!force && newBody == lastSaved.value) return
try {
Log.d("InternalTournamentEditor", "saveIfChanged -> saving body: $newBody")
} catch (e: Exception) {
// ignore
}
onSave(newBody)
lastSaved.value = newBody
}
@Composable
private fun TournamentEditorFlowTab(
clubId: Int,

View File

@@ -1,7 +1,7 @@
[versions]
# composeApp (Play Store / „Über die App“-Build)
appVersionCode = "8"
appVersionName = "1.3.2"
appVersionCode = "9"
appVersionName = "1.3.3"
agp = "9.2.1"
android-compileSdk = "35"
android-minSdk = "24"

View File

@@ -91,6 +91,12 @@ class TournamentsApi(
}
suspend fun updateTournament(clubId: Int, tournamentId: Int, body: UpdateTournamentMetaBody): InternalTournamentDetailDto {
// Debug: print body so it appears in device logs (useful during development)
try {
println("[TournamentsApi] updateTournament body: $body")
} catch (t: Throwable) {
// ignore on platforms where println may not be available
}
return client.http.put("/api/tournament/$clubId/$tournamentId") {
setBody(body)
}.body()