feat(TeamManagement): enhance team management features and introduce planning phase for Android
All checks were successful
Deploy tt-tagebuch / deploy (push) Successful in 44s

- Updated TODO.md to outline the new planning phase for Android, aligning it with existing web functionalities for team management.
- Refactored AppDependencies to include TeamDocumentsApi, improving API integration for team-related documents.
- Replaced MobileTeamsScreen with TeamManagementScreen in ClubStammdatenScreens for better navigation.
- Enhanced TeamManagementScreen with improved state management and UI updates for team editing and data loading.
- Added new API methods in ClubTeamsApi for managing team lineups, supporting better team planning and organization.
- Introduced new methods in MatchesApi and MyTischtennisApi to enhance match and team data handling.
This commit is contained in:
Torsten Schulz (local)
2026-05-14 18:31:15 +02:00
parent 2f3f4fb275
commit e0196a6617
12 changed files with 1960 additions and 328 deletions

View File

@@ -164,10 +164,51 @@ Web: `DiaryView.vue` (sehr groß). API-Cluster (Auszug in Web nach `apiClien
## Phase 8 Freigaben & Verwaltung
- [x] **Ausstehende Freigaben** `ClubApprovalsApi`, `PendingApprovalsManager`, Screen unter „Mehr“ → Club-Verwaltung (`ClubAdminScreens.kt`)
- [x] **Team-Management** Deep-Link `openBackendPath("/team-management")` bei `canReadTeams()` (volle Parität zur Web-`TeamManagementView` bewusst nicht mobil)
- [x] **Team-Management (Mannschaften + Editor)** `TeamManagementScreen` + `TeamEditorScreen`: Saison, Suche, CRUD Mannschaften; Tabs Stammdaten, Spielerstatistik (Liga), **Aufstellung/Meldung**, Dokumente, Scheduler-Jobs, MyTT (`ClubTeamsApi`, `MatchesApi`, `TeamDocumentsApi`, `ApiLogsApi`, `MyTischtennisApi`, Rechte `canReadTeams`/`canWriteTeams`)
- [ ] **Mannschafts-Planung (Android)** siehe Unterabschnitt **Phase 8a** (Web hat das Planungsboard bereits; mobil bewusst **offen**)
- [x] **Berechtigungen** erweiterte `PermissionsApi`, `PermissionsAdminManager`, UI Rolle/Status/Anpassen mit `RolePermissionMatrix` (`ClubAdminScreens.kt`)
- [x] **Logs** `ApiLogsApi`, `ApiLogsManager`, Liste + Pagination + Detail (`ClubAdminScreens.kt`); `AppDependencies` / Logout / 401 räumen Manager auf
### Phase 8a Mannschafts-Planung auf Android (Backlog / Plan)
**Ziel:** Gleiche **fachliche** Funktion wie Web **Team-Verwaltung → Planung**: Pool „möchte spielen“, mehrere Mannschaften der Saison parallel, Spieler nur **eligibility-konform** zuordnen, lokale Stammdaten (Name, geplante Liga, AK/GK) pro Team, **Meldung (Lineup)** je Team und gewählter **Halbserie** lesen/schreiben, optional **Debounced Autosave** wie Web.
**Web-Referenz (1:1 zum Durchlesen der Logik):**
| Bereich | Datei / Abschnitt |
|--------|-------------------|
| Umschalter „Mannschaften“ / „Planung“, Board einbinden | `frontend/src/views/TeamManagementView.vue` (`activeMainSection`, `TeamPlanningBoard`) |
| UI Pool, Teams, Suche, Interesse markieren | `frontend/src/components/team/TeamPlanningBoard.vue` |
| Lanes / Drag-Drop | `frontend/src/components/team/TeamPlanningLane.vue` |
**Backend-APIs (aus Web `apiClient`; im `shared` ergänzen, falls noch fehlend):**
- [ ] `GET /api/clubmembers/play-interest/:clubId` mit Query `seasonId`, `lineupHalf` geladene **Interessen** für Pool (`loadPlanningInterestedMemberIds`)
- [ ] `POST /api/clubmembers/play-interest/:clubId` Body `memberId`, `seasonId`, `lineupHalf`, `interested` Mitglied als „interessiert“ markieren (`onPlanningMarkMemberInterested`)
- [x] `GET /api/clubmembers/get/:clubId/:showAll` bereits `MembersApi.listMembers`
- [x] `GET /api/club-teams/...` Mannschaften Saison, `GET …/lineup?half=`, `PUT …/lineup` `ClubTeamsApi` (Planung lädt Lineups **aller** Teams der Saison parallel wie Web `loadPlanningAssignments`)
- [x] `PUT /api/club-teams/:id` Stammdaten Planungsteam (`updateClubTeam`)
- [x] `POST /api/club-teams/club/:clubId` neue Planungs-Mannschaft (`createClubTeam` o. ä.)
- [x] `DELETE /api/club-teams/:id` Planungs-Mannschaft löschen
**Shared / Domäne:**
- [ ] DTOs für Play-Interest (Response-Zeilen: `memberId`, `interested`, …) + `MembersApi` Erweiterung
- [ ] Optional: **PlanningState**-Use-Case (normalisierte Zuordnungen `teamId`/`memberId`/`position`, Halbserie, Recompute bei AK/GK-Wechsel) Web: `normalizePlanningAssignments`, `removeAllIneligiblePlanningAssignments`, `isEligibleForPlanningTeam` (analog `TeamEditorLineupLogic.isEligibleForTeam` + Team-spezifische AK/GK aus `planningLocalTeams`)
**Android UI (Vorschlag für Umsetzungsschritte):**
1. [ ] **Einstieg:** In `TeamManagementScreen` (oder `AppRoot`) zweiter Modus **„Planung“** neben **„Mannschaften“** (wie Web-Workspace-Buttons), gleiche Saison-Auswahl wie Liste
2. [ ] **Screen** `TeamPlanningScreen.kt` (oder modular `TeamPlanningPool.kt` / `TeamPlanningTeamCard.kt`): Pool + Liste der Teams; kein 1:1-Spiegel der Web-Grid-Optik nötig, aber **alle Aktionen** abdeckbar
3. [ ] **Drag-and-Drop:** Entweder Compose **Drag-and-Drop** (Plattform-API) oder **Fallback** ohne DnD: „Mitglied auswählen → Ziel-Mannschaft“ / „Aus Mannschaft entfernen“ / Reihenfolge **↑↓** in der Lane (wie Aufstellungs-Tab)
4. [ ] **Halbserie** (VR/RR) gemeinsam mit Web-Parameter `lineupHalf` an Play-Interest und Lineup-Requests
5. [ ] **„In Workspace öffnen“** / Team-Editor: Web `convertPlanningTeamToRegular` prüfen ggf. nur Navigation zum bestehenden `TeamEditorScreen` statt Konvertierungs-API
6. [ ] **Autosave:** Web nutzt Debounce (`schedulePlanningTeamAutosave`) mobil entweder gleichziehen oder explizit **„Speichern“** pro Team + Konflikt-Hinweis (Produktentscheidung)
7. [ ] **i18n:** Keys aus `teamManagement.planning*` / `markAsInterested` usw. in `MobileStrings` oder Fallback-Kette wie andere Admin-Screens
8. [ ] **Tests:** Serialisierung Play-Interest; optional Snapshot der Normalisierung `planningAssignments`
**Hinweis im Code:** Solange 8a offen ist, kann im Tab Aufstellung ein kurzer Verweis auf die **zukünftige** in-App-Planung stehen (nach Umsetzung Text anpassen oder entfernen).
---
## Phase 9 Vereins- & Stammdaten-Einstellungen

View File

@@ -30,6 +30,7 @@ import de.tt_tagebuch.shared.api.OfficialTournamentsApi
import de.tt_tagebuch.shared.api.PermissionsApi
import de.tt_tagebuch.shared.api.SeasonsApi
import de.tt_tagebuch.shared.api.SessionApi
import de.tt_tagebuch.shared.api.TeamDocumentsApi
import de.tt_tagebuch.shared.api.TrainingGroupsApi
import de.tt_tagebuch.shared.api.TrainingStatsApi
import de.tt_tagebuch.shared.api.TrainingTimesApi
@@ -101,11 +102,13 @@ class AppDependencies(context: Context) {
val pendingApprovalsManager = PendingApprovalsManager(ClubApprovalsApi(client))
val permissionsAdminManager = PermissionsAdminManager(permissionsApi)
val apiLogsManager = ApiLogsManager(ApiLogsApi(client))
val apiLogsApi = ApiLogsApi(client)
val apiLogsManager = ApiLogsManager(apiLogsApi)
val tournamentsApi = TournamentsApi(client)
val matchesApi = MatchesApi(client)
val clubTeamsApi = ClubTeamsApi(client)
val seasonsApi = SeasonsApi(client)
val teamDocumentsApi = TeamDocumentsApi(client)
val clubInternalTournamentsManager = ClubInternalTournamentsManager(tournamentsApi)
val officialTournamentsApi = OfficialTournamentsApi(client)
val officialTournamentsReadManager = OfficialTournamentsReadManager(officialTournamentsApi)
@@ -128,8 +131,9 @@ class AppDependencies(context: Context) {
MemberGroupPhotosApi(client),
)
private val trainingStatsApi = TrainingStatsApi(client)
val membersApi = MembersApi(client)
val membersManager = MembersManager(
MembersApi(client),
membersApi,
TrainingGroupsApi(client),
MemberActivitiesApi(client),
TrainingTimesApi(client),

View File

@@ -86,7 +86,7 @@ internal fun ClubStammdatenFlowScreen(
ClubStammdatenSection.ClubSettings -> MobileClubSettingsScreen(dependencies, onBack)
ClubStammdatenSection.PredefinedActivities -> MobilePredefinedActivitiesScreen(dependencies, onBack)
ClubStammdatenSection.MemberTransfer -> MobileMemberTransferScreen(dependencies, onBack)
ClubStammdatenSection.Teams -> MobileTeamsScreen(dependencies, onBack)
ClubStammdatenSection.Teams -> TeamManagementScreen(dependencies, onBack)
}
}
@@ -460,59 +460,6 @@ private fun MobileMemberTransferScreen(dependencies: AppDependencies, onBack: ()
}
}
@Composable
private fun MobileTeamsScreen(dependencies: AppDependencies, onBack: () -> Unit) {
val languageCode = LocalLanguageCode.current
fun tr(key: String, fb: String) = MobileStrings.get(languageCode, key, fb)
val clubState by dependencies.clubManager.state.collectAsState()
val clubId = clubState.currentClubId ?: return
val perms = clubState.currentPermissions
val scheduleState by dependencies.scheduleManager.state.collectAsState()
LaunchedEffect(clubId) {
dependencies.scheduleManager.loadClubTeams(clubId)
}
Column(
modifier = Modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(StammdatenPad),
) {
StammdatenTopBar(tr("mobile.teamManagement", "Team-Verwaltung"), onBack)
if (perms?.canReadMembers() != true) {
Text(tr("mobile.noAccess", "Keine Berechtigung."))
return@Column
}
scheduleState.error?.let { Text(it, color = MaterialTheme.colors.error) }
if (scheduleState.isLoading) {
CircularProgressIndicator(modifier = Modifier.padding(top = 24.dp))
return@Column
}
if (scheduleState.teams.isEmpty()) {
Text(tr("mobile.noTeams", "Keine Mannschaften gefunden."))
return@Column
}
Text(
tr("mobile.teamsHint", "Mannschaften werden aus Click-TT/nuLiga importiert und im Terminplan genutzt."),
style = MaterialTheme.typography.caption,
color = MaterialTheme.colors.onSurface.copy(alpha = 0.7f),
)
Spacer(modifier = Modifier.height(8.dp))
scheduleState.teams.forEach { t ->
Card(modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp), elevation = 1.dp) {
Column(modifier = Modifier.fillMaxWidth().padding(12.dp)) {
Text(t.name ?: tr("mobile.team", "Mannschaft"), fontWeight = FontWeight.SemiBold)
val league = t.league?.name ?: ""
if (league.isNotBlank()) {
Text(league, style = MaterialTheme.typography.caption)
}
}
}
}
}
}
@Composable
private fun RowSwitch(label: String, checked: Boolean, onChecked: (Boolean) -> Unit) {
Row(

View File

@@ -0,0 +1,223 @@
package de.tt_tagebuch.app.ui
import de.tt_tagebuch.shared.api.models.Member
import java.util.Calendar
import java.util.GregorianCalendar
/**
* Entspricht der Aufstellungs-/Meldungslogik in `TeamManagementView.vue`.
*/
internal object TeamEditorLineupLogic {
fun seasonReferenceYearFromLabel(seasonLabel: String): Int {
val s = seasonLabel.trim()
Regex("(\\d{4})\\s*/\\s*(\\d{4})").find(s)?.let {
return it.groupValues[2].toInt()
}
Regex("(\\d{4})\\s*/\\s*(\\d{2})").find(s)?.let {
val startYear = it.groupValues[1].toInt()
val endTwo = it.groupValues[2].toInt()
val century = (startYear / 100) * 100
return century + endTwo
}
Regex("(\\d{4})").find(s)?.let { return it.groupValues[1].toInt() }
return GregorianCalendar().get(Calendar.YEAR)
}
fun parseBirthCalendar(value: String?): Calendar? {
if (value.isNullOrBlank()) return null
val trimmed = value.trim()
Regex("^(\\d{1,2})\\.(\\d{1,2})\\.(\\d{4})$").find(trimmed)?.let {
return GregorianCalendar(
it.groupValues[3].toInt(),
it.groupValues[2].toInt() - 1,
it.groupValues[1].toInt(),
)
}
Regex("^(\\d{4})-(\\d{2})-(\\d{2})$").find(trimmed)?.let {
return GregorianCalendar(
it.groupValues[1].toInt(),
it.groupValues[2].toInt() - 1,
it.groupValues[3].toInt(),
)
}
return null
}
fun memberActualAge(member: Member, today: Calendar = GregorianCalendar()): Int? {
val birth = parseBirthCalendar(member.birthDate) ?: return null
var age = today.get(Calendar.YEAR) - birth.get(Calendar.YEAR)
val hadBirthday =
today.get(Calendar.MONTH) > birth.get(Calendar.MONTH) ||
(today.get(Calendar.MONTH) == birth.get(Calendar.MONTH) &&
today.get(Calendar.DAY_OF_MONTH) >= birth.get(Calendar.DAY_OF_MONTH))
if (!hadBirthday) age -= 1
return age
}
fun memberSeasonAge(member: Member, seasonLabel: String): Int? {
val birth = parseBirthCalendar(member.birthDate) ?: return null
val refYear = seasonReferenceYearFromLabel(seasonLabel)
return refYear - birth.get(Calendar.YEAR)
}
fun memberAgeGroupCode(member: Member, seasonLabel: String): String {
val seasonAge = memberSeasonAge(member, seasonLabel) ?: return "unknown"
return when {
seasonAge <= 11 -> "J11"
seasonAge <= 13 -> "J13"
seasonAge <= 15 -> "J15"
seasonAge <= 17 -> "J17"
seasonAge <= 19 -> "J19"
else -> "adult"
}
}
fun parseLeagueAgeGroupCode(leagueName: String?): String {
val source = leagueName.orEmpty()
Regex("([JM])(\\d{1,2})", RegexOption.IGNORE_CASE).find(source)?.let {
return "J${it.groupValues[2]}"
}
Regex("(?:jugend|mädchen)\\s*(\\d{1,2})", RegexOption.IGNORE_CASE).find(source)?.let {
return "J${it.groupValues[1]}"
}
return "adult"
}
fun parseLeagueGenderCode(leagueName: String?): String {
val s = leagueName.orEmpty().lowercase()
return if (Regex("(frauen|damen|mädchen|girls|women)").containsMatchIn(s)) "female" else "open"
}
fun configuredTeamAgeGroup(teamAgeGroup: String?, leagueName: String?): String =
teamAgeGroup?.takeIf { it.isNotBlank() } ?: parseLeagueAgeGroupCode(leagueName)
fun configuredTeamGender(teamGender: String?, leagueName: String?): String =
teamGender?.takeIf { it.isNotBlank() } ?: parseLeagueGenderCode(leagueName)
fun isFemaleEligible(member: Member, teamGender: String): Boolean {
if (teamGender != "female") return true
return member.gender == "female"
}
fun isEligibleForTeam(member: Member, teamAgeGroup: String, teamGender: String, seasonLabel: String): Boolean {
if (!member.active || !isFemaleEligible(member, teamGender)) return false
val memberAgeGroup = memberAgeGroupCode(member, seasonLabel)
if (memberAgeGroup == "unknown") return false
if (teamAgeGroup == "adult") {
val actual = memberActualAge(member)
if (actual != null && actual >= 18) return true
return member.adultReleaseApproved == true || member.adultReserveApproved == true
}
val order = listOf("J11", "J13", "J15", "J17", "J19")
val mi = order.indexOf(memberAgeGroup)
val ti = order.indexOf(teamAgeGroup)
if (mi < 0 || ti < 0) return false
return mi <= ti
}
fun lineupRatingValue(member: Member): Double {
val q = member.qttr ?: return Double.NEGATIVE_INFINITY
if (q <= 0) return Double.NEGATIVE_INFINITY
return q.toDouble()
}
fun lineupRatingLabel(member: Member): String {
val q = member.qttr ?: return ""
if (q <= 0) return ""
return q.toString()
}
fun eligibilityLabel(member: Member, teamAgeGroup: String, seasonLabel: String): String {
val mag = memberAgeGroupCode(member, seasonLabel)
if (teamAgeGroup != "adult" || mag == "adult") return "OK"
if (member.adultReleaseApproved == true && member.adultReserveApproved == true) {
return "Erw.+Reserve"
}
if (member.adultReleaseApproved == true) return "Erw.Freigabe"
if (member.adultReserveApproved == true) return "Reserve"
return "OK"
}
data class LineupGroup(
val code: String,
val label: String,
val members: List<MemberDisplay>,
)
data class MemberDisplay(
val member: Member,
val lineupRatingLabel: String,
val memberAgeGroupLabel: String,
val eligibilityLabel: String,
)
fun buildLineupGroups(
members: List<Member>,
teamAgeGroup: String,
teamGender: String,
seasonLabel: String,
labelAdults: String,
labelUnknown: String,
): List<LineupGroup> {
val eligible = members.filter { isEligibleForTeam(it, teamAgeGroup, teamGender, seasonLabel) }
if (eligible.isEmpty()) return emptyList()
val preferred = teamAgeGroup
val defaultOrder =
if (teamAgeGroup == "adult") {
listOf("adult", "J19", "J17", "J15", "J13", "J11", "unknown")
} else {
listOf(teamAgeGroup) + listOf("J19", "J17", "J15", "J13", "J11").filter { it != teamAgeGroup } + "unknown"
}
val groupOrder =
if (defaultOrder.contains(preferred)) {
listOf(preferred) + defaultOrder.filter { it != preferred }
} else {
defaultOrder
}
fun ageLabel(code: String) = when (code) {
"adult" -> labelAdults
"unknown" -> labelUnknown
else -> code
}
val byCode = linkedMapOf<String, MutableList<MemberDisplay>>()
for (m in eligible) {
val code = memberAgeGroupCode(m, seasonLabel)
val row = MemberDisplay(
member = m,
lineupRatingLabel = lineupRatingLabel(m),
memberAgeGroupLabel = ageLabel(code),
eligibilityLabel = eligibilityLabel(m, teamAgeGroup, seasonLabel),
)
byCode.getOrPut(code) { mutableListOf() }.add(row)
}
for (list in byCode.values) {
list.sortWith(
compareByDescending<MemberDisplay> { lineupRatingValue(it.member) }
.thenBy { it.member.lastName.lowercase() }
.thenBy { it.member.firstName.lowercase() },
)
}
return groupOrder.mapNotNull { code ->
val rows = byCode[code] ?: return@mapNotNull null
if (rows.isEmpty()) return@mapNotNull null
LineupGroup(code = code, label = ageLabel(code), members = rows)
}
}
fun lineupGapViolationMessage(selectedOrdered: List<Member>, template: (String, String) -> String): String? {
for (i in 0 until selectedOrdered.size - 1) {
val a = selectedOrdered[i]
val b = selectedOrdered[i + 1]
val ra = lineupRatingValue(a)
val rb = lineupRatingValue(b)
if (!ra.isFinite() || !rb.isFinite()) continue
if (rb > ra + 30) {
return template("${b.firstName} ${b.lastName}".trim(), "${a.firstName} ${a.lastName}".trim())
}
}
return null
}
}

View File

@@ -1,18 +1,16 @@
package de.tt_tagebuch.app.ui
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.AlertDialog
import androidx.compose.material.Button
import androidx.compose.material.Card
@@ -43,20 +41,17 @@ import androidx.compose.ui.unit.dp
import de.tt_tagebuch.app.AppDependencies
import de.tt_tagebuch.shared.api.ScheduleLogic
import de.tt_tagebuch.shared.api.models.ClubLeagueOptionDto
import de.tt_tagebuch.shared.api.models.ClubTeamCreateBody
import de.tt_tagebuch.shared.api.models.ClubTeamDto
import de.tt_tagebuch.shared.api.models.ClubTeamUpdateBody
import de.tt_tagebuch.shared.api.models.SeasonDto
import de.tt_tagebuch.shared.api.models.canReadTeams
import de.tt_tagebuch.shared.api.models.canWriteTeams
import de.tt_tagebuch.shared.i18n.MobileStrings
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
private val TeamPad = 20.dp
private val TeamAgeOptions = listOf("adult", "J19", "J17", "J15", "J13", "J11")
private val TeamGenderOptions = listOf("open", "female")
@Composable
internal fun TeamManagementScreen(
@@ -64,7 +59,9 @@ internal fun TeamManagementScreen(
onBack: () -> Unit,
) {
val languageCode = LocalLanguageCode.current
fun tr(key: String, fb: String) = MobileStrings.get(languageCode, key, fb)
val getMobileString: (String, String) -> String = remember(languageCode) {
{ k: String, fb: String -> MobileStrings.get(languageCode, k, fb) }
}
val clubState by dependencies.clubManager.state.collectAsState()
val clubId = clubState.currentClubId ?: return
val perms = clubState.currentPermissions
@@ -72,90 +69,73 @@ internal fun TeamManagementScreen(
val canWrite = perms?.canWriteTeams() == true
val scope = rememberCoroutineScope()
var seasons by remember { mutableStateOf<List<SeasonDto>>(emptyList()) }
var selectedSeasonId by remember { mutableStateOf<Int?>(null) }
var teams by remember { mutableStateOf<List<ClubTeamDto>>(emptyList()) }
var leagues by remember { mutableStateOf<List<ClubLeagueOptionDto>>(emptyList()) }
var loading by remember { mutableStateOf(true) }
var error by remember { mutableStateOf<String?>(null) }
var search by remember { mutableStateOf("") }
var seasons by remember(clubId) { mutableStateOf<List<SeasonDto>>(emptyList()) }
var selectedSeasonId by remember(clubId) { mutableStateOf<Int?>(null) }
var teams by remember(clubId) { mutableStateOf<List<ClubTeamDto>>(emptyList()) }
var leagues by remember(clubId) { mutableStateOf<List<ClubLeagueOptionDto>>(emptyList()) }
var loading by remember(clubId) { mutableStateOf(true) }
var error by remember(clubId) { mutableStateOf<String?>(null) }
var search by remember(clubId) { mutableStateOf("") }
var loadTick by remember(clubId) { mutableStateOf(0) }
var showForm by remember { mutableStateOf(false) }
var formIsNew by remember { mutableStateOf(true) }
var formTeamId by remember { mutableStateOf<Int?>(null) }
var formName by remember { mutableStateOf("") }
var formLeagueId by remember { mutableStateOf<Int?>(null) }
var formPlannedLeague by remember { mutableStateOf("") }
var formGender by remember { mutableStateOf("open") }
var formAge by remember { mutableStateOf("adult") }
var formBusy by remember { mutableStateOf(false) }
var editorOpen by remember { mutableStateOf(false) }
var editorTeamId by remember { mutableStateOf<Int?>(null) }
var seasonMenu by remember { mutableStateOf(false) }
var leagueMenu by remember { mutableStateOf(false) }
var genderMenu by remember { mutableStateOf(false) }
var ageMenu by remember { mutableStateOf(false) }
var deleteTarget by remember { mutableStateOf<ClubTeamDto?>(null) }
var infoMessage by remember { mutableStateOf<String?>(null) }
fun resetFormForNew() {
formIsNew = true
formTeamId = null
formName = ""
formLeagueId = null
formPlannedLeague = ""
formGender = "open"
formAge = "adult"
fun openEditor(team: ClubTeamDto) {
editorTeamId = team.id
editorOpen = true
}
fun openEdit(team: ClubTeamDto) {
formIsNew = false
formTeamId = team.id
formName = team.name
formLeagueId = team.leagueId
formPlannedLeague = team.plannedLeagueName.orEmpty()
formGender = team.teamGender?.takeIf { it.isNotBlank() } ?: "open"
formAge = team.teamAgeGroup?.takeIf { it.isNotBlank() } ?: "adult"
showForm = true
}
suspend fun reloadData() {
loading = true
error = null
runCatching {
val s = withContext(Dispatchers.IO) { dependencies.seasonsApi.listSeasons() }
seasons = s.sortedByDescending { it.id }
if (selectedSeasonId == null) {
val cur = withContext(Dispatchers.IO) { dependencies.seasonsApi.getCurrentSeason() }
selectedSeasonId = cur.id
}
val sid = selectedSeasonId
val t = withContext(Dispatchers.IO) { dependencies.clubTeamsApi.listClubTeams(clubId, sid) }
val lg = withContext(Dispatchers.IO) { dependencies.clubTeamsApi.listLeagues(clubId, sid) }
teams = ScheduleLogic.sortClubTeams(t)
leagues = lg.sortedBy { it.name.lowercase() }
}.onFailure { error = it.message ?: tr("mobile.teamLoadError", "Daten konnten nicht geladen werden.") }
loading = false
fun openNewTeamEditor() {
editorTeamId = null
editorOpen = true
}
// Saisons + Default-Saison ohne selectedSeasonId in denselben Keys wie den Team-Load zu mischen:
// Sonst setzt der Effekt selectedSeasonId, wird abgebrochen, runCatching zeigt die Cancellation-Meldung.
LaunchedEffect(clubId) {
selectedSeasonId = null
seasons = emptyList()
reloadData()
try {
val allSeasons = withContext(Dispatchers.IO) { dependencies.seasonsApi.listSeasons() }
.sortedByDescending { it.id }
seasons = allSeasons
if (allSeasons.isEmpty()) {
selectedSeasonId = null
teams = emptyList()
leagues = emptyList()
loading = false
return@LaunchedEffect
}
if (selectedSeasonId == null) {
selectedSeasonId = withContext(Dispatchers.IO) { dependencies.seasonsApi.getCurrentSeason() }.id
}
} catch (e: CancellationException) {
throw e
} catch (e: Throwable) {
error = e.message ?: getMobileString("mobile.teamLoadError", "Daten konnten nicht geladen werden.")
}
}
LaunchedEffect(clubId, selectedSeasonId) {
if (selectedSeasonId == null) return@LaunchedEffect
LaunchedEffect(clubId, selectedSeasonId, loadTick) {
val sid = selectedSeasonId ?: return@LaunchedEffect
loading = true
error = null
runCatching {
val sid = selectedSeasonId
try {
val t = withContext(Dispatchers.IO) { dependencies.clubTeamsApi.listClubTeams(clubId, sid) }
val lg = withContext(Dispatchers.IO) { dependencies.clubTeamsApi.listLeagues(clubId, sid) }
teams = ScheduleLogic.sortClubTeams(t)
leagues = lg.sortedBy { it.name.lowercase() }
}.onFailure { error = it.message ?: tr("mobile.teamLoadError", "Daten konnten nicht geladen werden.") }
loading = false
} catch (e: CancellationException) {
throw e
} catch (e: Throwable) {
error = e.message ?: getMobileString("mobile.teamLoadError", "Daten konnten nicht geladen werden.")
} finally {
loading = false
}
}
val filtered = remember(teams, search) {
@@ -168,49 +148,59 @@ internal fun TeamManagementScreen(
}
}
Column(Modifier.fillMaxSize().padding(TeamPad)) {
Box(Modifier.fillMaxSize()) {
val editorSeasonId = selectedSeasonId
if (editorOpen && editorSeasonId != null) {
TeamEditorScreen(
dependencies = dependencies,
clubId = clubId,
seasonId = editorSeasonId,
seasonLabel = seasons.find { it.id == editorSeasonId }?.season.orEmpty(),
initialTeamId = editorTeamId,
onBack = { editorOpen = false },
onSaved = { loadTick++ },
)
} else {
Column(Modifier.fillMaxSize().padding(TeamPad)) {
Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
IconButton(onClick = onBack) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = null)
}
Text(
tr("navigation.teamManagement", "Team-Verwaltung"),
getMobileString("navigation.teamManagement", "Team-Verwaltung"),
style = MaterialTheme.typography.h6,
fontWeight = FontWeight.SemiBold,
modifier = Modifier.weight(1f),
)
if (canWrite) {
Button(
onClick = {
resetFormForNew()
showForm = true
},
onClick = { openNewTeamEditor() },
enabled = !loading && selectedSeasonId != null,
) {
Text(tr("mobile.teamNew", "Neue Mannschaft"))
Text(getMobileString("mobile.teamNew", "Neue Mannschaft"))
}
}
}
if (!canRead) {
Text(tr("mobile.noTeamAccess", "Keine Berechtigung für die Team-Verwaltung."))
Text(getMobileString("mobile.noTeamAccess", "Keine Berechtigung für die Team-Verwaltung."))
return@Column
}
Text(
tr("mobile.teamsIntro", "Mannschaften pro Saison verwalten (Name, Liga, geplante Liga, Geschlechtsklasse, Altersklasse)."),
getMobileString("mobile.teamsIntro", "Mannschaften pro Saison verwalten (Name, Liga, geplante Liga, Geschlechtsklasse, Altersklasse)."),
style = MaterialTheme.typography.caption,
color = MaterialTheme.colors.onSurface.copy(alpha = 0.72f),
)
Spacer(Modifier.height(8.dp))
Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Text(tr("mobile.season", "Saison") + ":", style = MaterialTheme.typography.body2)
Text(getMobileString("mobile.season", "Saison") + ":", style = MaterialTheme.typography.body2)
Box {
OutlinedButton(onClick = { seasonMenu = true }, enabled = seasons.isNotEmpty()) {
Text(
seasons.find { it.id == selectedSeasonId }?.season
?: tr("mobile.seasonPick", "Saison wählen"),
?: getMobileString("mobile.seasonPick", "Saison wählen"),
)
}
DropdownMenu(expanded = seasonMenu, onDismissRequest = { seasonMenu = false }) {
@@ -232,7 +222,7 @@ internal fun TeamManagementScreen(
onValueChange = { search = it },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
label = { Text(tr("mobile.teamSearch", "Mannschaft suchen")) },
label = { Text(getMobileString("mobile.teamSearch", "Mannschaft suchen")) },
)
error?.let {
@@ -241,18 +231,22 @@ internal fun TeamManagementScreen(
}
if (loading && teams.isEmpty()) {
CircularProgressIndicator(Modifier.padding(top = 24.dp).align(Alignment.CenterHorizontally))
Box(Modifier.fillMaxWidth().padding(top = 24.dp), contentAlignment = Alignment.Center) {
CircularProgressIndicator()
}
return@Column
}
if (!loading && teams.isEmpty()) {
Spacer(Modifier.height(16.dp))
Text(tr("mobile.noTeams", "Keine Mannschaften für diese Saison."))
Text(getMobileString("mobile.noTeams", "Keine Mannschaften für diese Saison."))
return@Column
}
LazyColumn(
modifier = Modifier.fillMaxSize(),
modifier = Modifier
.weight(1f)
.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
items(filtered, key = { it.id }) { team ->
@@ -260,21 +254,23 @@ internal fun TeamManagementScreen(
Column(Modifier.padding(12.dp)) {
Text(team.name.ifBlank { "#${team.id}" }, fontWeight = FontWeight.SemiBold)
val lg = team.league?.name?.takeIf { it.isNotBlank() }
?: tr("mobile.noLeague", "Keine Liga")
?: getMobileString("mobile.noLeague", "Keine Liga")
Text(lg, style = MaterialTheme.typography.caption)
team.season?.season?.takeIf { it.isNotBlank() }?.let {
Text(it, style = MaterialTheme.typography.caption)
}
team.plannedLeagueName?.takeIf { it.isNotBlank() }?.let { pl ->
Text(
tr("mobile.plannedLeagueShort", "Geplant: ") + pl,
getMobileString("mobile.plannedLeagueShort", "Geplant: ") + pl,
style = MaterialTheme.typography.caption,
)
}
val g = team.teamGender ?: "open"
val a = team.teamAgeGroup ?: "adult"
val genShort = getMobileString("mobile.teamGenderShort", "Geschlecht")
val ageShort = getMobileString("mobile.teamAgeShort", "AK")
Text(
"${tr("mobile.teamGenderShort", "Geschlecht")}: ${labelGender(tr, g)} · ${tr("mobile.teamAgeShort", "AK")}: ${labelAge(tr, a)}",
"$genShort: ${labelGender(getMobileString, g)} · $ageShort: ${labelAge(getMobileString, a)}",
style = MaterialTheme.typography.caption,
)
team.myTischtennisTeamId?.takeIf { it.isNotBlank() }?.let { mid ->
@@ -282,11 +278,11 @@ internal fun TeamManagementScreen(
}
if (canWrite) {
Row(Modifier.padding(top = 8.dp), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
TextButton(onClick = { openEdit(team) }) {
Text(tr("common.edit", "Bearbeiten"))
TextButton(onClick = { openEditor(team) }) {
Text(getMobileString("common.edit", "Bearbeiten"))
}
TextButton(onClick = { deleteTarget = team }) {
Text(tr("common.delete", "Löschen"), color = MaterialTheme.colors.error)
Text(getMobileString("common.delete", "Löschen"), color = MaterialTheme.colors.error)
}
}
}
@@ -294,193 +290,59 @@ internal fun TeamManagementScreen(
}
}
}
}
}
}
if (showForm && selectedSeasonId != null) {
val sid = selectedSeasonId!!
AlertDialog(
onDismissRequest = { if (!formBusy) showForm = false },
title = {
Text(
if (formIsNew) tr("mobile.teamNew", "Neue Mannschaft")
else tr("mobile.teamEdit", "Mannschaft bearbeiten"),
)
},
text = {
Column(
Modifier
.heightIn(max = 420.dp)
.verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
OutlinedTextField(
value = formName,
onValueChange = { formName = it },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
label = { Text(tr("mobile.teamName", "Name")) },
)
Text(tr("mobile.league", "Liga"), style = MaterialTheme.typography.caption)
Box {
OutlinedButton(onClick = { leagueMenu = true }, modifier = Modifier.fillMaxWidth()) {
Text(
formLeagueId?.let { id -> leagues.find { it.id == id }?.name }
?: tr("mobile.noLeague", "Keine Liga"),
)
}
DropdownMenu(expanded = leagueMenu, onDismissRequest = { leagueMenu = false }) {
DropdownMenuItem(
onClick = {
formLeagueId = null
leagueMenu = false
},
) { Text(tr("mobile.noLeague", "Keine Liga")) }
leagues.forEach { lg ->
DropdownMenuItem(
onClick = {
formLeagueId = lg.id
leagueMenu = false
},
) { Text(lg.name) }
}
}
}
OutlinedTextField(
value = formPlannedLeague,
onValueChange = { formPlannedLeague = it },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
label = { Text(tr("mobile.plannedLeague", "Geplante Liga")) },
)
Text(tr("mobile.teamGenderShort", "Geschlecht"), style = MaterialTheme.typography.caption)
Box {
OutlinedButton(onClick = { genderMenu = true }, modifier = Modifier.fillMaxWidth()) {
Text(labelGender(tr, formGender))
}
DropdownMenu(expanded = genderMenu, onDismissRequest = { genderMenu = false }) {
TeamGenderOptions.forEach { g ->
DropdownMenuItem(
onClick = {
formGender = g
genderMenu = false
},
) { Text(labelGender(tr, g)) }
}
}
}
Text(tr("mobile.teamAgeShort", "Altersklasse"), style = MaterialTheme.typography.caption)
Box {
OutlinedButton(onClick = { ageMenu = true }, modifier = Modifier.fillMaxWidth()) {
Text(labelAge(tr, formAge))
}
DropdownMenu(expanded = ageMenu, onDismissRequest = { ageMenu = false }) {
TeamAgeOptions.forEach { a ->
DropdownMenuItem(
onClick = {
formAge = a
ageMenu = false
},
) { Text(labelAge(tr, a)) }
}
}
}
}
},
confirmButton = {
TextButton(
enabled = !formBusy && formName.isNotBlank(),
onClick = {
scope.launch {
formBusy = true
runCatching {
if (formIsNew) {
deleteTarget?.let { target ->
AlertDialog(
onDismissRequest = { deleteTarget = null },
title = { Text(getMobileString("mobile.teamDeleteTitle", "Mannschaft löschen?")) },
text = { Text(target.name.ifBlank { "#${target.id}" }) },
confirmButton = {
TextButton(
onClick = {
val id = target.id
deleteTarget = null
scope.launch {
runCatching {
withContext(Dispatchers.IO) { dependencies.clubTeamsApi.deleteClubTeam(id) }
withContext(Dispatchers.IO) {
dependencies.clubTeamsApi.createClubTeam(
clubId,
ClubTeamCreateBody(
name = formName.trim(),
leagueId = formLeagueId,
seasonId = sid,
teamGender = formGender,
teamAgeGroup = formAge,
plannedLeagueName = formPlannedLeague.trim().ifBlank { null },
),
)
}
} else {
val id = formTeamId ?: return@launch
withContext(Dispatchers.IO) {
dependencies.clubTeamsApi.updateClubTeam(
id,
ClubTeamUpdateBody(
name = formName.trim(),
leagueId = formLeagueId,
seasonId = sid,
teamGender = formGender,
teamAgeGroup = formAge,
plannedLeagueName = formPlannedLeague.trim().ifBlank { null },
),
)
dependencies.scheduleManager.loadClubTeams(clubId)
}
loadTick++
}.onFailure { ex ->
if (ex is CancellationException) throw ex
infoMessage = ex.message
}
reloadData()
dependencies.scheduleManager.loadClubTeams(clubId)
showForm = false
}.onFailure {
infoMessage = it.message ?: tr("common.error", "Fehler")
}
formBusy = false
}
},
) { Text(tr("common.save", "Speichern")) }
},
dismissButton = {
TextButton(onClick = { if (!formBusy) showForm = false }) {
Text(tr("common.cancel", "Abbrechen"))
}
},
)
}
},
) { Text(getMobileString("common.delete", "Löschen")) }
},
dismissButton = {
TextButton(onClick = { deleteTarget = null }) {
Text(getMobileString("common.cancel", "Abbrechen"))
}
},
)
}
deleteTarget?.let { target ->
AlertDialog(
onDismissRequest = { deleteTarget = null },
title = { Text(tr("mobile.teamDeleteTitle", "Mannschaft löschen?")) },
text = { Text(target.name.ifBlank { "#${target.id}" }) },
confirmButton = {
TextButton(
onClick = {
val id = target.id
deleteTarget = null
scope.launch {
runCatching {
withContext(Dispatchers.IO) { dependencies.clubTeamsApi.deleteClubTeam(id) }
reloadData()
dependencies.scheduleManager.loadClubTeams(clubId)
}.onFailure { infoMessage = it.message }
}
},
) { Text(tr("common.delete", "Löschen")) }
},
dismissButton = { TextButton(onClick = { deleteTarget = null }) { Text(tr("common.cancel", "Abbrechen")) } },
)
}
infoMessage?.let { msg ->
AlertDialog(
onDismissRequest = { infoMessage = null },
title = { Text(tr("common.error", "Fehler")) },
text = { Text(msg) },
confirmButton = { TextButton(onClick = { infoMessage = null }) { Text(tr("common.ok", "OK")) } },
)
infoMessage?.let { msg ->
AlertDialog(
onDismissRequest = { infoMessage = null },
title = { Text(getMobileString("common.error", "Fehler")) },
text = { Text(msg) },
confirmButton = { TextButton(onClick = { infoMessage = null }) { Text(getMobileString("common.ok", "OK")) } },
)
}
}
}
private fun labelGender(tr: (String, String) -> String, code: String): String =
private fun labelGender(t: (String, String) -> String, code: String): String =
when (code) {
"female" -> tr("mobile.teamGenderFemale", "Weiblich")
else -> tr("mobile.teamGenderOpen", "Offen")
"male" -> t("mobile.teamGenderMale", "Männlich")
"female" -> t("mobile.teamGenderFemale", "Weiblich")
else -> t("mobile.teamGenderOpen", "Offen")
}
private fun labelAge(tr: (String, String) -> String, code: String): String =
if (code == "adult") tr("mobile.teamAgeAdult", "Erwachsene") else code
private fun labelAge(t: (String, String) -> String, code: String): String =
if (code == "adult") t("mobile.teamAgeAdult", "Erwachsene") else code

View File

@@ -5,6 +5,7 @@ import de.tt_tagebuch.shared.api.models.ApiLogDetailDto
import de.tt_tagebuch.shared.api.models.ApiLogDetailEnvelopeDto
import de.tt_tagebuch.shared.api.models.ApiLogsListEnvelopeDto
import de.tt_tagebuch.shared.api.models.ApiLogsListPageDto
import de.tt_tagebuch.shared.api.models.SchedulerLastExecutionsEnvelopeDto
import io.ktor.client.call.body
import io.ktor.client.request.get
import io.ktor.client.request.parameter
@@ -35,4 +36,10 @@ class ApiLogsApi(
val env = client.http.get("/api/logs/$id").body<ApiLogDetailEnvelopeDto>()
return env.data
}
suspend fun getSchedulerLastExecutions(clubId: Int? = null): SchedulerLastExecutionsEnvelopeDto {
return client.http.get("/api/logs/scheduler/last-executions") {
clubId?.let { parameter("clubId", it) }
}.body()
}
}

View File

@@ -4,7 +4,9 @@ import de.tt_tagebuch.shared.api.http.AuthedHttpClient
import de.tt_tagebuch.shared.api.models.ClubLeagueOptionDto
import de.tt_tagebuch.shared.api.models.ClubTeamCreateBody
import de.tt_tagebuch.shared.api.models.ClubTeamDto
import de.tt_tagebuch.shared.api.models.ClubTeamLineupRowDto
import de.tt_tagebuch.shared.api.models.ClubTeamUpdateBody
import de.tt_tagebuch.shared.api.models.TeamLineupUpdateBody
import io.ktor.client.call.body
import io.ktor.client.request.delete
import io.ktor.client.request.get
@@ -47,4 +49,16 @@ class ClubTeamsApi(
suspend fun deleteClubTeam(clubTeamId: Int) {
client.http.delete("/api/club-teams/$clubTeamId")
}
suspend fun getLineup(clubTeamId: Int, half: String): List<ClubTeamLineupRowDto> {
return client.http.get("/api/club-teams/$clubTeamId/lineup") {
parameter("half", half)
}.body()
}
suspend fun updateLineup(clubTeamId: Int, body: TeamLineupUpdateBody): List<ClubTeamLineupRowDto> {
return client.http.put("/api/club-teams/$clubTeamId/lineup") {
setBody(body)
}.body()
}
}

View File

@@ -1,6 +1,7 @@
package de.tt_tagebuch.shared.api
import de.tt_tagebuch.shared.api.http.AuthedHttpClient
import de.tt_tagebuch.shared.api.models.LeaguePlayerStatDto
import de.tt_tagebuch.shared.api.models.LeagueTableRowDto
import de.tt_tagebuch.shared.api.models.ScheduleMatchDto
import de.tt_tagebuch.shared.api.models.UpdateMatchPlayersBody
@@ -29,6 +30,12 @@ class MatchesApi(
return client.http.get("/api/matches/leagues/$clubId/table/$leagueId").body()
}
suspend fun getLeaguePlayerStats(clubId: Int, leagueId: Int, seasonId: Int? = null): List<LeaguePlayerStatDto> {
return client.http.get("/api/matches/leagues/$clubId/stats/$leagueId") {
seasonId?.let { parameter("seasonid", it) }
}.body()
}
suspend fun updateMatchPlayers(matchId: Int, body: UpdateMatchPlayersBody) {
client.http.patch("/api/matches/$matchId/players") {
setBody(body)

View File

@@ -1,6 +1,12 @@
package de.tt_tagebuch.shared.api
import de.tt_tagebuch.shared.api.http.AuthedHttpClient
import de.tt_tagebuch.shared.api.models.MyTtConfigureLeagueBody
import de.tt_tagebuch.shared.api.models.MyTtConfigureTeamBody
import de.tt_tagebuch.shared.api.models.MyTtFetchJobEnvelopeDto
import de.tt_tagebuch.shared.api.models.MyTtFetchJobStartDto
import de.tt_tagebuch.shared.api.models.MyTtFetchTeamDataBody
import de.tt_tagebuch.shared.api.models.MyTtParseUrlBody
import de.tt_tagebuch.shared.api.models.MyTischtennisAccountEnvelope
import de.tt_tagebuch.shared.api.models.MyTischtennisAccountSaveResponse
import de.tt_tagebuch.shared.api.models.MyTischtennisAccountUpsertBody
@@ -46,4 +52,31 @@ class MyTischtennisApi(
suspend fun deleteAccount() {
client.http.delete("/api/mytischtennis/account")
}
suspend fun parseUrl(url: String): JsonObject =
client.http.post("/api/mytischtennis/parse-url") {
contentType(ContentType.Application.Json)
setBody(MyTtParseUrlBody(url))
}.body()
suspend fun configureTeam(body: MyTtConfigureTeamBody): JsonObject =
client.http.post("/api/mytischtennis/configure-team") {
contentType(ContentType.Application.Json)
setBody(body)
}.body()
suspend fun configureLeague(body: MyTtConfigureLeagueBody): JsonObject =
client.http.post("/api/mytischtennis/configure-league") {
contentType(ContentType.Application.Json)
setBody(body)
}.body()
suspend fun startFetchTeamDataJob(clubTeamId: Int): MyTtFetchJobStartDto =
client.http.post("/api/mytischtennis/fetch-team-data/async") {
contentType(ContentType.Application.Json)
setBody(MyTtFetchTeamDataBody(clubTeamId))
}.body()
suspend fun getFetchTeamDataJob(jobId: String): MyTtFetchJobEnvelopeDto =
client.http.get("/api/mytischtennis/fetch-team-data/jobs/$jobId").body()
}

View File

@@ -0,0 +1,66 @@
package de.tt_tagebuch.shared.api
import de.tt_tagebuch.shared.api.http.AuthedHttpClient
import de.tt_tagebuch.shared.api.models.TeamDocumentDto
import de.tt_tagebuch.shared.api.models.TeamDocumentParseEnvelopeDto
import io.ktor.client.call.body
import io.ktor.client.request.delete
import io.ktor.client.request.forms.MultiPartFormDataContent
import io.ktor.client.request.forms.formData
import io.ktor.client.request.get
import io.ktor.client.request.parameter
import io.ktor.client.request.post
import io.ktor.client.request.setBody
import io.ktor.http.ContentType
import io.ktor.http.Headers
import io.ktor.http.HttpHeaders
import io.ktor.http.contentType
class TeamDocumentsApi(
private val client: AuthedHttpClient,
) {
suspend fun listByClubTeam(clubTeamId: Int): List<TeamDocumentDto> {
return client.http.get("/api/team-documents/club-team/$clubTeamId").body()
}
suspend fun upload(
clubTeamId: Int,
documentType: String,
bytes: ByteArray,
fileName: String,
mimeType: String,
): TeamDocumentDto {
return client.http.post("/api/team-documents/club-team/$clubTeamId/upload") {
contentType(ContentType.MultiPart.FormData)
setBody(
MultiPartFormDataContent(
formData {
append("documentType", documentType)
append(
"document",
bytes,
Headers.build {
append(HttpHeaders.ContentType, mimeType)
append(HttpHeaders.ContentDisposition, "filename=\"$fileName\"")
},
)
},
),
)
}.body()
}
suspend fun parse(documentId: Int, leagueId: Int): TeamDocumentParseEnvelopeDto {
return client.http.post("/api/team-documents/$documentId/parse") {
parameter("leagueid", leagueId)
}.body()
}
suspend fun download(documentId: Int): ByteArray {
return client.http.get("/api/team-documents/$documentId/download").body()
}
suspend fun delete(documentId: Int) {
client.http.delete("/api/team-documents/$documentId")
}
}

View File

@@ -0,0 +1,139 @@
package de.tt_tagebuch.shared.api.models
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.JsonObject
@Serializable
data class LeaguePlayerStatDto(
val memberId: Int,
val firstName: String = "",
val lastName: String = "",
val totalSeason: Int = 0,
val totalFirstHalf: Int = 0,
val totalSecondHalf: Int = 0,
)
@Serializable
data class ClubTeamLineupRowDto(
val id: Int? = null,
val clubTeamId: Int? = null,
val memberId: Int,
val lineupHalf: String? = null,
val position: Int = 0,
val member: Member? = null,
)
@Serializable
data class TeamLineupAssignmentItem(
val memberId: Int,
val position: Int,
)
@Serializable
data class TeamLineupUpdateBody(
val assignments: List<TeamLineupAssignmentItem>,
val lineupHalf: String,
)
@Serializable
data class TeamDocumentDto(
val id: Int,
val fileName: String = "",
val originalFileName: String = "",
val documentType: String = "",
val clubTeamId: Int? = null,
val createdAt: String? = null,
val mimeType: String? = null,
)
@Serializable
data class TeamDocumentParseResultDto(
val matchesFound: Int = 0,
)
@Serializable
data class TeamDocumentSaveResultDto(
val created: Int = 0,
val updated: Int = 0,
)
@Serializable
data class TeamDocumentParseEnvelopeDto(
val parseResult: TeamDocumentParseResultDto? = null,
val saveResult: TeamDocumentSaveResultDto? = null,
)
@Serializable
data class SchedulerLastExecutionsEnvelopeDto(
val success: Boolean = false,
val data: SchedulerLastExecutionsDataDto? = null,
)
@Serializable
data class SchedulerLastExecutionsDataDto(
@SerialName("rating_updates")
val ratingUpdates: SchedulerJobBlockDto? = null,
@SerialName("match_results")
val matchResults: SchedulerJobBlockDto? = null,
)
@Serializable
data class SchedulerJobBlockDto(
val lastRun: String? = null,
val success: Boolean? = null,
val executionTime: Int? = null,
val updatedCount: Int? = null,
val fetchedCount: Int? = null,
val errorMessage: String? = null,
val teamDetails: List<SchedulerTeamDetailDto> = emptyList(),
)
@Serializable
data class SchedulerTeamDetailDto(
val clubTeamId: Int = 0,
val teamName: String? = null,
val success: Boolean = false,
val lastRun: String? = null,
)
@Serializable
data class MyTtParseUrlBody(val url: String)
@Serializable
data class MyTtConfigureTeamBody(
val url: String,
val clubTeamId: Int,
val createLeague: Boolean? = null,
val createSeason: Boolean? = null,
)
@Serializable
data class MyTtConfigureLeagueBody(
val url: String,
val createSeason: Boolean? = null,
)
@Serializable
data class MyTtFetchTeamDataBody(val clubTeamId: Int)
@Serializable
data class MyTtFetchJobStartDto(
val success: Boolean = false,
val jobId: String? = null,
val status: String? = null,
)
@Serializable
data class MyTtFetchJobEnvelopeDto(
val success: Boolean = false,
val job: MyTtFetchJobDto? = null,
)
@Serializable
data class MyTtFetchJobDto(
val jobId: String? = null,
val status: String? = null,
val result: JsonObject? = null,
val error: String? = null,
)