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
All checks were successful
Deploy tt-tagebuch / deploy (push) Successful in 47s
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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' });
|
||||
|
||||
74
backend/scripts/api_put_test_numberOfTables.js
Normal file
74
backend/scripts/api_put_test_numberOfTables.js
Normal 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();
|
||||
66
backend/scripts/local_test_update_numberOfTables.js
Normal file
66
backend/scripts/local_test_update_numberOfTables.js
Normal 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();
|
||||
@@ -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));
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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);
|
||||
|
||||
Binary file not shown.
@@ -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 Mitglieder‑Galerie anfordert, wechsle zu Members und öffne die Nested‑Ansicht. */
|
||||
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", "Mitglieder‑Galerie")) }
|
||||
|
||||
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", "Mitglieder‑Galerie")) },
|
||||
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) {
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user