From 6ef1d79a5f35a9f33e11ef027c658bb21ba9d4ea Mon Sep 17 00:00:00 2001 From: "Torsten Schulz (local)" Date: Thu, 14 May 2026 16:15:19 +0200 Subject: [PATCH] feat(TrainingStats): enhance training statistics view with collapsible panels and localization - Refactored the TrainingStatsView to implement collapsible sections for better organization of training statistics. - Added new localization keys for training statistics panels in both German and English. - Updated the mobile app's TrainingStatsScreen to utilize the new collapsible panel structure, improving user experience. - Enhanced the MembersManager to merge training statistics into member data, providing a comprehensive view of member participation. - Introduced new API methods for quick updates and transfers of member data, streamlining member management processes. --- frontend/src/i18n/locales/de.json | 9 +- frontend/src/i18n/locales/en-US.json | 9 +- frontend/src/views/TrainingStatsView.vue | 369 ++++-- mobile-app/TODO.md | 1 - .../de/tt_tagebuch/app/AppDependencies.kt | 4 +- .../app/pdf/MembersPhoneListPdf.kt | 156 +++ .../kotlin/de/tt_tagebuch/app/ui/AppRoot.kt | 1177 ++++++++++++++++- .../app/ui/MemberGroupPortraitCropRoute.kt | 540 ++++++++ .../tt_tagebuch/app/ui/MembersExtraScreens.kt | 386 ++++++ .../de/tt_tagebuch/app/ui/MembersTtAge.kt | 64 + .../tt_tagebuch/app/ui/TrainingStatsScreen.kt | 405 +++--- .../de/tt_tagebuch/shared/api/MembersApi.kt | 21 + .../api/models/ClubPermissionHelpers.kt | 5 + .../tt_tagebuch/shared/api/models/Member.kt | 2 + .../api/models/MemberQuickMutationResponse.kt | 10 + .../shared/api/models/MemberTransferDtos.kt | 13 + .../shared/state/MembersManager.kt | 54 +- 17 files changed, 2852 insertions(+), 373 deletions(-) create mode 100644 mobile-app/composeApp/src/androidMain/kotlin/de/tt_tagebuch/app/pdf/MembersPhoneListPdf.kt create mode 100644 mobile-app/composeApp/src/androidMain/kotlin/de/tt_tagebuch/app/ui/MemberGroupPortraitCropRoute.kt create mode 100644 mobile-app/composeApp/src/androidMain/kotlin/de/tt_tagebuch/app/ui/MembersExtraScreens.kt create mode 100644 mobile-app/composeApp/src/androidMain/kotlin/de/tt_tagebuch/app/ui/MembersTtAge.kt create mode 100644 mobile-app/shared/src/commonMain/kotlin/de/tt_tagebuch/shared/api/models/MemberQuickMutationResponse.kt diff --git a/frontend/src/i18n/locales/de.json b/frontend/src/i18n/locales/de.json index eec5012d..bd7f7b15 100644 --- a/frontend/src/i18n/locales/de.json +++ b/frontend/src/i18n/locales/de.json @@ -1353,7 +1353,14 @@ "participationsTotal": "Teilnahmen (Gesamt)", "lastTraining": "Letztes Training", "actions": "Aktionen", - "showDetails": "Details anzeigen" + "showDetails": "Details anzeigen", + "panelSummary": "Kennzahlen (Filter)", + "panelMonthlyTrend": "Monatlicher Verlauf", + "panelWeekdayStats": "Trainingstage nach Wochentag", + "panelMemberStructure": "Mitgliederstruktur", + "panelBestDay": "Stärkster Trainingstag", + "panelGroupPerformance": "Entwicklung pro Gruppe", + "panelAgeGroups": "Anwesenheit nach Altersklasse" }, "tournament": { "apply": "Übernehmen" diff --git a/frontend/src/i18n/locales/en-US.json b/frontend/src/i18n/locales/en-US.json index 95102ec3..1b444151 100644 --- a/frontend/src/i18n/locales/en-US.json +++ b/frontend/src/i18n/locales/en-US.json @@ -975,7 +975,14 @@ "participationsTotal": "Participations (total)", "lastTraining": "Last training", "actions": "Actions", - "showDetails": "Show details" + "showDetails": "Show details", + "panelSummary": "Key figures (filtered)", + "panelMonthlyTrend": "Monthly trend", + "panelWeekdayStats": "Training days by weekday", + "panelMemberStructure": "Member structure", + "panelBestDay": "Busiest training day", + "panelGroupPerformance": "Progress by group", + "panelAgeGroups": "Attendance by age class" }, "courtDrawingTool": { "title": "Table tennis exercise drawing", diff --git a/frontend/src/views/TrainingStatsView.vue b/frontend/src/views/TrainingStatsView.vue index f13a9c77..b7611a45 100644 --- a/frontend/src/views/TrainingStatsView.vue +++ b/frontend/src/views/TrainingStatsView.vue @@ -26,153 +26,194 @@ -
-
-
-

Aktive Mitglieder

-
{{ filteredMembers.length }}
-
-
-

Trainingstage 12 Monate

-
{{ filteredTrainingDays.length }}
-
-
-

Ø Teilnehmer je Training

-
{{ filteredOverview.averageParticipants.toFixed(1) }}
-
-
-

Teilnahmen gesamt

-
{{ filteredOverview.totalParticipants }}
-
-
-

Anwesenheitsquote 12 Monate

-
{{ filteredOverview.attendanceRate.toFixed(1) }}%
-
-
-

Nicht im Training

-
{{ filteredMembers.filter((member) => member.notInTraining).length }}
+
+
+

{{ $t('trainingStats.panelSummary') }}

+ + {{ panels.overview ? '▼' : '▶' }} + +
+
+
+
+
+

Aktive Mitglieder

+
{{ filteredMembers.length }}
+
+
+

Trainingstage 12 Monate

+
{{ filteredTrainingDays.length }}
+
+
+

Ø Teilnehmer je Training

+
{{ filteredOverview.averageParticipants.toFixed(1) }}
+
+
+

Teilnahmen gesamt

+
{{ filteredOverview.totalParticipants }}
+
+
+

Anwesenheitsquote 12 Monate

+
{{ filteredOverview.attendanceRate.toFixed(1) }}%
+
+
+

Nicht im Training

+
{{ filteredMembers.filter((member) => member.notInTraining).length }}
+
+
-
-
-

Monatlicher Verlauf

- {{ filteredMonthlyTrend.length }} Monate +
+
+

{{ $t('trainingStats.panelMonthlyTrend') }}

+ + + {{ panels.monthlyTrend ? '▼' : '▶' }} +
-
-
-
- {{ month.label }} - {{ month.trainingCount }} Trainingstage -
-
-
-
-
{{ month.averageParticipants.toFixed(1) }}
-
-
-
- -
-
-

Trainingstage nach Wochentag

- {{ filteredWeekdayStats.length }} Wochentage -
-
-
- {{ day.weekday }} - {{ day.trainingCount }} Termine - Ø {{ day.averageParticipants.toFixed(1) }} Teilnehmer -
-
-
- -
-
-

Mitgliederstruktur

-
-
-
- Sehr aktiv - {{ filteredMemberDistribution.highlyActive }} - mind. 75 % der Trainingstage -
-
- Regelmäßig - {{ filteredMemberDistribution.regular }} - 40 bis unter 75 % -
-
- Gelegentlich - {{ filteredMemberDistribution.occasional }} - unter 40 % -
-
- Ohne Teilnahme - {{ filteredMemberDistribution.inactive }} - 0 Teilnahmen in 12 Monaten -
-
-
- -
-
-

Stärkster Trainingstag

-
-
- {{ formatDate(filteredOverview.bestTrainingDay.date) }} - {{ getWeekday(filteredOverview.bestTrainingDay.date) }} -
{{ filteredOverview.bestTrainingDay.participantCount }}
- Teilnehmer beim bestbesuchten Training der letzten 12 Monate -
-
- Keine Daten -
-
- -
-
-

Entwicklung pro Gruppe

- {{ groupPerformance.length }} Gruppen -
-
-
-
- {{ group.name }} - {{ group.memberCount }} Mitglieder -
-
- {{ group.averageParticipations12Months.toFixed(1) }} Ø Teilnahmen / 12 Monate - {{ group.participationRate.toFixed(1) }}% Anwesenheit +
+
+
+
+ {{ month.label }} + {{ month.trainingCount }} Trainingstage +
+
+
+
+
{{ month.averageParticipants.toFixed(1) }}
-
+
-
-
-

Anwesenheit nach Altersklasse

- {{ ageGroupStats.length }} Klassen +
+
+

{{ $t('trainingStats.panelWeekdayStats') }}

+ + + {{ panels.weekdayStats ? '▼' : '▶' }} +
-
-
- {{ entry.label }} - {{ entry.memberCount }} - {{ entry.averageParticipations12Months.toFixed(1) }} Ø Teilnahmen / 12 Monate +
+
+
+ {{ day.weekday }} + {{ day.trainingCount }} Termine + Ø {{ day.averageParticipants.toFixed(1) }} Teilnehmer +
-
+
+ +
+
+

{{ $t('trainingStats.panelMemberStructure') }}

+ + {{ panels.memberStructure ? '▼' : '▶' }} + +
+
+
+
+ Sehr aktiv + {{ filteredMemberDistribution.highlyActive }} + mind. 75 % der Trainingstage +
+
+ Regelmäßig + {{ filteredMemberDistribution.regular }} + 40 bis unter 75 % +
+
+ Gelegentlich + {{ filteredMemberDistribution.occasional }} + unter 40 % +
+
+ Ohne Teilnahme + {{ filteredMemberDistribution.inactive }} + 0 Teilnahmen in 12 Monaten +
+
+
+
+ +
+
+

{{ $t('trainingStats.panelBestDay') }}

+ + {{ panels.bestDay ? '▼' : '▶' }} + +
+
+
+
+ {{ formatDate(filteredOverview.bestTrainingDay.date) }} + {{ getWeekday(filteredOverview.bestTrainingDay.date) }} +
{{ filteredOverview.bestTrainingDay.participantCount }}
+ Teilnehmer beim bestbesuchten Training der letzten 12 Monate +
+
+ Keine Daten +
+
+
+
+ +
+
+

{{ $t('trainingStats.panelGroupPerformance') }}

+ + + {{ panels.groupPerformance ? '▼' : '▶' }} + +
+
+
+
+
+ {{ group.name }} + {{ group.memberCount }} Mitglieder +
+
+ {{ group.averageParticipations12Months.toFixed(1) }} Ø Teilnahmen / 12 Monate + {{ group.participationRate.toFixed(1) }}% Anwesenheit +
+
+
+
+
+ +
+
+

{{ $t('trainingStats.panelAgeGroups') }}

+ + + {{ panels.ageGroup ? '▼' : '▶' }} + +
+
+
+
+ {{ entry.label }} + {{ entry.memberCount }} + {{ entry.averageParticipations12Months.toFixed(1) }} Ø Teilnahmen / 12 Monate +
+
+
+
-
-
+

{{ $t('trainingStats.trainingDays') }}

- {{ showTrainingDays ? '▼' : '▶' }} + {{ panels.trainingDays ? '▼' : '▶' }}
-
+
@@ -207,13 +248,13 @@ - +
-
+

{{ $t('trainingStats.memberParticipations') }}

- {{ showMembers ? '▼' : '▶' }} + {{ panels.members ? '▼' : '▶' }}
-
+
@@ -576,8 +617,17 @@ export default { loading: false, sortField: 'name', sortDirection: 'asc', - showTrainingDays: true, - showMembers: false + panels: { + overview: false, + monthlyTrend: false, + weekdayStats: false, + memberStructure: false, + bestDay: false, + groupPerformance: false, + ageGroup: false, + trainingDays: false, + members: false + } }; }, @@ -626,15 +676,12 @@ export default { this.loading = false; } }, - - toggleTrainingDays() { - this.showTrainingDays = !this.showTrainingDays; + + togglePanel(key) { + if (!Object.prototype.hasOwnProperty.call(this.panels, key)) return; + this.panels = { ...this.panels, [key]: !this.panels[key] }; }, - - toggleMembers() { - this.showMembers = !this.showMembers; - }, - + getWeekday(dateString) { const date = new Date(dateString); const weekdays = ['Sonntag', 'Montag', 'Dienstag', 'Mittwoch', 'Donnerstag', 'Freitag', 'Samstag']; @@ -779,6 +826,42 @@ export default { margin-bottom: 1.5rem; } +.stats-panels-grid > .collapsible-section { + margin-bottom: 0; +} + +.stats-panel-collapsible .section-header { + margin-bottom: 0; +} + +.section-header-right { + display: flex; + align-items: center; + gap: 0.75rem; +} + +.section-meta { + font-size: 0.85rem; + color: var(--text-secondary); +} + +.section-header:hover .section-meta { + color: inherit; + opacity: 0.95; +} + +.panel-body-padding { + padding: 0 1.15rem 1.15rem; +} + +.stats-overview.panel-body-padding { + margin-bottom: 0; +} + +.collapsible-section.stats-panel-highlight { + background: linear-gradient(135deg, rgba(47, 122, 95, 0.08), rgba(255, 255, 255, 0.98)); +} + .stats-panel { background: white; border: 1px solid var(--border-color); diff --git a/mobile-app/TODO.md b/mobile-app/TODO.md index 236156a8..1245e0e5 100644 --- a/mobile-app/TODO.md +++ b/mobile-app/TODO.md @@ -212,7 +212,6 @@ Web-Route: `/calendar` · Referenz: `CalendarView.vue` (Aggregation mehrerer Dat ### Phase 12 – Backlog / offen -- [ ] **iOS:** Kalender-UI + Tab (derzeit nur Android `composeApp` / `MainTab.Calendar`) - [ ] **i18n:** Kalender-Keys in `MobileStrings.kt` für alle unterstützten Sprachen ergänzen (nicht nur Fallback im Code) - [ ] **Kalender vs. Web:** Offizielle Teilnahmen mobil per Browser vs. Web in-app – bewusst lassen oder später angleichen - [ ] **Release:** Bei `isMinifyEnabled = true` ProGuard/R8 für Ktor, `kotlinx.serialization`, ggf. Koin (`composeApp/proguard-rules.pro`) diff --git a/mobile-app/composeApp/src/androidMain/kotlin/de/tt_tagebuch/app/AppDependencies.kt b/mobile-app/composeApp/src/androidMain/kotlin/de/tt_tagebuch/app/AppDependencies.kt index c9e4e6fc..11dd9f69 100644 --- a/mobile-app/composeApp/src/androidMain/kotlin/de/tt_tagebuch/app/AppDependencies.kt +++ b/mobile-app/composeApp/src/androidMain/kotlin/de/tt_tagebuch/app/AppDependencies.kt @@ -123,13 +123,15 @@ class AppDependencies(context: Context) { AccidentApi(client), MemberGroupPhotosApi(client), ) + private val trainingStatsApi = TrainingStatsApi(client) val membersManager = MembersManager( MembersApi(client), TrainingGroupsApi(client), MemberActivitiesApi(client), TrainingTimesApi(client), + trainingStatsApi, ) - val trainingStatsManager = TrainingStatsManager(TrainingStatsApi(client)) + val trainingStatsManager = TrainingStatsManager(trainingStatsApi) val scheduleManager = ScheduleManager( ClubTeamsApi(client), matchesApi, diff --git a/mobile-app/composeApp/src/androidMain/kotlin/de/tt_tagebuch/app/pdf/MembersPhoneListPdf.kt b/mobile-app/composeApp/src/androidMain/kotlin/de/tt_tagebuch/app/pdf/MembersPhoneListPdf.kt new file mode 100644 index 00000000..949e3f5c --- /dev/null +++ b/mobile-app/composeApp/src/androidMain/kotlin/de/tt_tagebuch/app/pdf/MembersPhoneListPdf.kt @@ -0,0 +1,156 @@ +package de.tt_tagebuch.app.pdf + +import android.graphics.Canvas +import android.graphics.Color +import android.graphics.Paint +import android.graphics.Typeface +import android.graphics.pdf.PdfDocument +import android.text.Layout +import android.text.StaticLayout +import android.text.TextPaint +import de.tt_tagebuch.shared.api.models.Member +import de.tt_tagebuch.shared.api.models.MemberContactDto +import java.io.File +import java.io.FileOutputStream +import java.time.LocalDate +import java.time.format.DateTimeFormatter +import java.util.Locale + +private const val PAGE_W = 595 +private const val PAGE_H = 842 +private const val MARGIN = 40f +private const val MAX_TEXT_W = (PAGE_W - MARGIN * 2).toInt() + +private fun newTitlePaint(): TextPaint = TextPaint(Paint.ANTI_ALIAS_FLAG).apply { + color = Color.BLACK + textSize = 14f + typeface = Typeface.create(Typeface.DEFAULT, Typeface.BOLD) +} + +private fun newBodyPaint(): TextPaint = TextPaint(Paint.ANTI_ALIAS_FLAG).apply { + color = Color.BLACK + textSize = 10f + typeface = Typeface.create(Typeface.DEFAULT, Typeface.NORMAL) + textLocale = Locale.GERMANY +} + +private fun newBoldPaint(): TextPaint = TextPaint(Paint.ANTI_ALIAS_FLAG).apply { + color = Color.BLACK + textSize = 10f + typeface = Typeface.create(Typeface.DEFAULT, Typeface.BOLD) + textLocale = Locale.GERMANY +} + +private fun Canvas.drawStatic(text: String, paint: TextPaint, x: Float, y: Float): Float { + if (text.isEmpty()) return y + val layout = StaticLayout.Builder.obtain(text, 0, text.length, paint, MAX_TEXT_W) + .setAlignment(Layout.Alignment.ALIGN_NORMAL) + .setLineSpacing(0f, 1.05f) + .setIncludePad(false) + .build() + save() + translate(x, y) + layout.draw(this) + restore() + return y + layout.height + 4f +} + +private fun formatBirthDe(birthDate: String?): String { + if (birthDate.isNullOrBlank()) return "" + val day = birthDate.trim().take(10) + val d = runCatching { LocalDate.parse(day) }.getOrNull() ?: return "" + return d.format(DateTimeFormatter.ofPattern("dd.MM.yyyy", Locale.GERMANY)) +} + +private data class PhoneLine(val value: String, val label: String?) + +private fun memberPhoneLines(member: Member): List { + val phones = member.contacts + .filter { it.type == "phone" && it.value.isNotBlank() } + .sortedWith(compareBy { !it.isPrimary }.thenBy { it.id ?: 0 }) + val out = phones.map { c -> + val v = c.value.trim() + val suffix = if (c.isParent) { + val p = c.parentName?.trim().orEmpty() + if (p.isNotEmpty()) " ($p)" else " (Eltern)" + } else { + "" + } + PhoneLine(v + suffix, null) + }.toMutableList() + val legacy = member.phone?.trim().orEmpty() + if (out.isEmpty() && legacy.isNotEmpty()) { + out.add(PhoneLine(legacy, null)) + } + return out +} + +/** + * Telefonliste wie Web-[PDFGenerator.addPhoneList]: Name, Geburtsdatum, Telefonnummern. + */ +fun writeMembersPhoneListPdf( + outFile: File, + titleLine: String, + members: List, +) { + val sorted = members.sortedWith( + compareBy { it.lastName.lowercase(Locale.GERMANY) }.thenBy { it.firstName.lowercase(Locale.GERMANY) }, + ) + val doc = PdfDocument() + var pageSeq = 0 + fun openPage(): PdfDocument.Page { + pageSeq++ + return doc.startPage(PdfDocument.PageInfo.Builder(PAGE_W, PAGE_H, pageSeq).create()) + } + var page = openPage() + var canvas = page.canvas + var y = MARGIN + val titlePaint = newTitlePaint() + val bodyPaint = newBodyPaint() + val boldPaint = newBoldPaint() + + fun newPageIfNeeded(extra: Float) { + if (y + extra > PAGE_H - MARGIN) { + doc.finishPage(page) + page = openPage() + canvas = page.canvas + y = MARGIN + } + } + + y = canvas.drawStatic(titleLine, titlePaint, MARGIN, y) + y = canvas.drawStatic("", bodyPaint, MARGIN, y) + y = canvas.drawStatic("Name, Vorname", boldPaint, MARGIN, y) + y = canvas.drawStatic("Geburtsdatum / Telefon", bodyPaint, MARGIN, y) + y = canvas.drawStatic("—".repeat(42), bodyPaint, MARGIN, y) + + for (member in sorted) { + val phones = memberPhoneLines(member) + val nameLine = "${member.lastName}, ${member.firstName}".trim() + val birth = formatBirthDe(member.birthDate) + val blockHeight = 18f + phones.size.coerceAtLeast(1) * 14f + newPageIfNeeded(blockHeight) + y = canvas.drawStatic(nameLine, boldPaint, MARGIN, y) + val sub = buildString { + if (birth.isNotEmpty()) append("Geboren: $birth") + if (phones.isEmpty()) { + if (isNotEmpty()) append(" — ") + append("—") + } + } + y = canvas.drawStatic(sub, bodyPaint, MARGIN, y) + if (phones.isEmpty()) { + y += 4f + } else { + for (p in phones) { + newPageIfNeeded(16f) + y = canvas.drawStatic("Tel.: ${p.value}", bodyPaint, MARGIN + 12f, y) + } + } + y = canvas.drawStatic("", bodyPaint, MARGIN, y) + } + + doc.finishPage(page) + FileOutputStream(outFile).use { doc.writeTo(it) } + doc.close() +} diff --git a/mobile-app/composeApp/src/androidMain/kotlin/de/tt_tagebuch/app/ui/AppRoot.kt b/mobile-app/composeApp/src/androidMain/kotlin/de/tt_tagebuch/app/ui/AppRoot.kt index 7621d7fa..6623b443 100644 --- a/mobile-app/composeApp/src/androidMain/kotlin/de/tt_tagebuch/app/ui/AppRoot.kt +++ b/mobile-app/composeApp/src/androidMain/kotlin/de/tt_tagebuch/app/ui/AppRoot.kt @@ -1,5 +1,7 @@ package de.tt_tagebuch.app.ui +import android.content.Intent +import android.net.Uri import androidx.activity.compose.BackHandler import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts @@ -74,12 +76,14 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.layout.positionInParent +import androidx.compose.ui.platform.LocalClipboardManager import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.semantics import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.input.PasswordVisualTransformation import androidx.compose.ui.unit.dp @@ -87,6 +91,7 @@ import androidx.compose.ui.unit.sp import kotlin.math.max import de.tt_tagebuch.app.AppDependencies import de.tt_tagebuch.app.pdf.sharePdfFile +import de.tt_tagebuch.app.pdf.writeMembersPhoneListPdf import de.tt_tagebuch.app.pdf.writeTrainingDaySummaryPdf import de.tt_tagebuch.app.pdf.writeTrainingPlanPdf import de.tt_tagebuch.shared.api.memberProfileImagePath @@ -104,6 +109,7 @@ import de.tt_tagebuch.shared.api.models.canReadStatistics import de.tt_tagebuch.shared.api.models.canReadTournaments import de.tt_tagebuch.shared.api.models.canWriteDiary import de.tt_tagebuch.shared.api.models.canWriteMembers +import de.tt_tagebuch.shared.api.models.canWriteMyTischtennis import de.tt_tagebuch.shared.api.models.mainActivityImagePath import de.tt_tagebuch.shared.api.models.nestedActivityImagePath import de.tt_tagebuch.shared.api.models.AccidentReportDto @@ -128,7 +134,9 @@ import de.tt_tagebuch.shared.api.models.displayLabel import de.tt_tagebuch.shared.api.models.displayTitle import de.tt_tagebuch.shared.api.models.memberLabel import de.tt_tagebuch.shared.api.models.MemberActivityStatDto +import de.tt_tagebuch.shared.api.models.MemberContactDto import de.tt_tagebuch.shared.api.models.MemberContactSetBody +import de.tt_tagebuch.shared.api.models.MemberDataQualityRequirements import de.tt_tagebuch.shared.api.models.MemberLastParticipationDto import de.tt_tagebuch.shared.api.models.TrainingGroupDto import de.tt_tagebuch.shared.api.models.TrainingTimeDto @@ -1482,6 +1490,10 @@ private fun DiaryDetailScreen( var editPlanDuration by remember { mutableStateOf("") } var editPlanDurationText by remember { mutableStateOf("") } var editPlanGroupId by remember { mutableStateOf(null) } + var assigningParticipantsItem by remember { mutableStateOf(null) } + var assigningParticipantIds by remember { mutableStateOf(setOf()) } + var assignParticipantsBusy by remember { mutableStateOf(false) } + var assignParticipantsError by remember { mutableStateOf(null) } var assigningPlanItem by remember { mutableStateOf(null) } var assignPlanGroupId by remember { mutableStateOf(null) } var participants by remember { mutableStateOf>(emptyList()) } @@ -3236,7 +3248,23 @@ private fun DiaryDetailScreen( onToggleExpand = { expandedPlanActionsItemId = if (expandedPlanActionsItemId == item.id) null else item.id }, - onAssign = { + onAssignParticipants = { + assigningParticipantsItem = item + assignParticipantsBusy = true + assignParticipantsError = null + dependencies.applicationScope.launch { + try { + val links = dependencies.diaryManager.listMemberActivityLinks(clubId, item.id) + assigningParticipantIds = links.map { it.participantId }.toSet() + } catch (t: Throwable) { + assignParticipantsError = t.message + assigningParticipantIds = emptySet() + } finally { + assignParticipantsBusy = false + } + } + }, + onAssignGroup = { assignPlanGroupId = item.groupId assigningPlanItem = item }, @@ -3314,6 +3342,112 @@ private fun DiaryDetailScreen( } } + assigningParticipantsItem?.let { assignItem -> + val presentParticipantRows = participants + .filter { it.isPresentParticipant() } + .sortedBy { p -> activeMembers.find { it.id == p.memberId }?.fullName()?.lowercase() ?: "" } + AlertDialog( + onDismissRequest = { if (!assignParticipantsBusy) assigningParticipantsItem = null }, + title = { Text(tr("diary.assignParticipants", "Teilnehmer zuordnen")) }, + text = { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + Text( + assignItem.displayTitle(tr("diary.timeblock", "Zeitblock")), + style = MaterialTheme.typography.body2, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + assignParticipantsError?.let { + Text(it, style = MaterialTheme.typography.caption, color = MaterialTheme.colors.error) + } + Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) { + OutlinedButton( + onClick = { + assigningParticipantIds = presentParticipantRows.map { it.id }.toSet() + }, + enabled = !assignParticipantsBusy, + ) { Text(tr("common.all", "Alle")) } + OutlinedButton( + onClick = { assigningParticipantIds = emptySet() }, + enabled = !assignParticipantsBusy, + ) { Text(tr("common.none", "Keine")) } + } + Column( + modifier = Modifier.heightIn(max = 280.dp).verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + presentParticipantRows.forEach { p -> + val name = activeMembers.find { it.id == p.memberId }?.fullName() + ?: "${tr("mobile.member", "Mitglied")} ${p.memberId}" + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + ) { + Checkbox( + checked = p.id in assigningParticipantIds, + enabled = !assignParticipantsBusy, + onCheckedChange = { checked -> + assigningParticipantIds = if (checked) { + assigningParticipantIds + p.id + } else { + assigningParticipantIds - p.id + } + }, + ) + Text(name, style = MaterialTheme.typography.body2) + } + } + } + } + }, + confirmButton = { + TextButton( + enabled = canWriteDiary && !assignParticipantsBusy, + onClick = { + dependencies.applicationScope.launch { + assignParticipantsBusy = true + assignParticipantsError = null + try { + val existing = dependencies.diaryManager + .listMemberActivityLinks(clubId, assignItem.id) + .map { it.participantId } + .toSet() + val target = assigningParticipantIds + val toAdd = target - existing + val toRemove = existing - target + if (toAdd.isNotEmpty()) { + dependencies.diaryManager.addParticipantsToMemberActivity( + clubId, + assignItem.id, + toAdd.toList(), + ) + } + toRemove.forEach { pid -> + dependencies.diaryManager.removeParticipantFromMemberActivity( + clubId, + assignItem.id, + pid, + ) + } + assigningParticipantsItem = null + } catch (t: Throwable) { + assignParticipantsError = t.message + } finally { + assignParticipantsBusy = false + } + } + }, + ) { Text(tr("common.save", "Speichern")) } + }, + dismissButton = { + TextButton( + enabled = !assignParticipantsBusy, + onClick = { assigningParticipantsItem = null }, + ) { Text(tr("mobile.cancel", "Abbrechen")) } + }, + ) + } + assigningPlanItem?.let { assignItem -> var assignGroupMenu by remember { mutableStateOf(false) } AlertDialog( @@ -3637,6 +3771,10 @@ private sealed class MembersStackRoute { data class Detail(val memberId: Int) : MembersStackRoute() /** `null` = neues Mitglied anlegen */ data class Edit(val memberId: Int?) : MembersStackRoute() + object GroupPhoto : MembersStackRoute() + /** `returnToGroupPhotoManage` = true: Zurück zur Gruppenfoto-Verwaltung (von dort geöffnet). */ + data class GroupPhotoPortraitCrop(val returnToGroupPhotoManage: Boolean = false) : MembersStackRoute() + object TransferRun : MembersStackRoute() } private fun trainingWeekdayLabel(weekday: Int): String = when (weekday) { @@ -3735,40 +3873,368 @@ private fun MembersScreen( }, ) } + MembersStackRoute.GroupPhoto -> { + MemberGroupPhotoManageRoute( + clubId = clubId, + dependencies = dependencies, + canReadMembers = canReadMembers, + canWriteMembers = canWriteMembers, + onBack = { stack = MembersStackRoute.Browse }, + onOpenPortraitCrop = { + stack = MembersStackRoute.GroupPhotoPortraitCrop(returnToGroupPhotoManage = true) + }, + ) + } + is MembersStackRoute.GroupPhotoPortraitCrop -> { + MemberGroupPortraitCropRoute( + clubId = clubId, + members = membersState.members, + dependencies = dependencies, + canWriteMembers = canWriteMembers, + onBack = { + stack = if (route.returnToGroupPhotoManage) { + MembersStackRoute.GroupPhoto + } else { + MembersStackRoute.Browse + } + }, + ) + } + MembersStackRoute.TransferRun -> { + MemberTransferRunRoute( + clubId = clubId, + dependencies = dependencies, + canWriteMembers = canWriteMembers, + onBack = { stack = MembersStackRoute.Browse }, + ) + } MembersStackRoute.Browse -> { - val filteredMembers = remember(membersState.members, query) { + val languageCode = LocalLanguageCode.current + fun trStr(key: String, fallback: String): String = MobileStrings.get(languageCode, key, fallback) + val permissions = clubState.currentPermissions + val canWriteMyTt = permissions?.canWriteMyTischtennis() == true + var memberScope by rememberSaveable { mutableStateOf("active") } + var showInactiveMembers by rememberSaveable { mutableStateOf(false) } + var selectedTrainingGroupId by rememberSaveable { mutableStateOf(null) } + var lastTrainingFilter by rememberSaveable { mutableStateOf("all") } + var selectedSort by rememberSaveable { mutableStateOf("name") } + var sortAscending by rememberSaveable { mutableStateOf(true) } + var showTrainingParticipationsColumn by rememberSaveable { mutableStateOf(false) } + var groupFilterMenuOpen by remember { mutableStateOf(false) } + var ratingsBusy by remember { mutableStateOf(false) } + var membersActionNote by remember { mutableStateOf(null) } + var pendingBulkForms by remember { mutableStateOf(false) } + var pendingBulkTestOff by remember { mutableStateOf(false) } + var seasonStartYear by rememberSaveable { mutableStateOf(getSeasonStartYearFromDateToday()) } + var seasonMenuOpen by remember { mutableStateOf(false) } + var selectedAgeGroup by rememberSaveable { mutableStateOf("") } + var ageGroupMenuOpen by remember { mutableStateOf(false) } + var selectedAgeFrom by rememberSaveable { mutableStateOf("") } + var selectedAgeTo by rememberSaveable { mutableStateOf("") } + var selectedGender by rememberSaveable { mutableStateOf("") } + var genderMenuOpen by remember { mutableStateOf(false) } + val clipboard = LocalClipboardManager.current + val androidCtx = LocalContext.current + val qualityReq = remember(clubState.clubs, clubId) { + clubState.clubs.firstOrNull { it.id == clubId }?.memberDataQualityRequirements ?: MemberDataQualityRequirements() + } + val clubName = remember(clubState.clubs, clubId) { + clubState.clubs.firstOrNull { it.id == clubId }?.name ?: "Verein" + } + val trainingGroupFilterOptions = remember(membersState.members) { + val map = linkedMapOf() + membersState.members.forEach { m -> + m.trainingGroups.forEach { g -> + if (g.id != 0) { + map.putIfAbsent(g.id.toString(), g.name.ifBlank { "#${g.id}" }) + } + } + } + map.entries.sortedBy { it.value.lowercase() } + } + val filteredMembers = remember( + membersState.members, + query, + memberScope, + showInactiveMembers, + selectedTrainingGroupId, + lastTrainingFilter, + qualityReq, + seasonStartYear, + selectedAgeGroup, + selectedAgeFrom, + selectedAgeTo, + selectedGender, + selectedSort, + sortAscending, + ) { val normalizedQuery = query.trim().lowercase() - if (normalizedQuery.isEmpty()) { - membersState.members - } else { - membersState.members.filter { member -> - listOf( - member.fullName(), - member.firstName, - member.lastName, - member.birthDate.orEmpty(), - member.gender.orEmpty(), - member.city.orEmpty(), - member.street.orEmpty(), - ).any { value -> value.lowercase().contains(normalizedQuery) } + val scopeIncludesInactive = + memberScope == "test" || memberScope == "inactive" || memberScope == "dataIncomplete" + membersState.members + .filter { member -> + if (!member.active && !showInactiveMembers && !scopeIncludesInactive) { + return@filter false + } + when (memberScope) { + "all" -> Unit + "active" -> if (!member.active || member.testMembership == true) return@filter false + "test" -> if (member.testMembership != true) return@filter false + "activeTest" -> if (!member.active || member.testMembership != true) return@filter false + "inactive" -> if (member.active) return@filter false + "notTraining" -> if (member.notInTraining != true) return@filter false + "needsForm" -> if (member.memberFormHandedOver == true) return@filter false + "dataIncomplete" -> if (memberDataQualityIssues(member, qualityReq).isEmpty()) return@filter false + "activeDataIncomplete" -> { + if (!member.active || member.testMembership == true) return@filter false + if (memberDataQualityIssues(member, qualityReq).isEmpty()) return@filter false + } + else -> Unit + } + val gid = selectedTrainingGroupId + if (gid != null) { + val ids = member.trainingGroups.map { it.id.toString() } + if (!ids.contains(gid)) return@filter false + } + when (lastTrainingFilter) { + "hasDate" -> if (!memberHasValidLastTrainingDate(member)) return@filter false + "noDate" -> if (memberHasValidLastTrainingDate(member)) return@filter false + "notInTraining" -> if (member.notInTraining != true) return@filter false + } + val ag = selectedAgeGroup.trim() + if (ag.isNotEmpty() && ag != "range") { + if (!memberMatchesTtAgeClass(member, ag, seasonStartYear)) return@filter false + } + if (ag == "range") { + val age = memberAgeYears(member) ?: return@filter false + val minA = selectedAgeFrom.trim().toIntOrNull() + val maxA = selectedAgeTo.trim().toIntOrNull() + if (minA != null && age < minA) return@filter false + if (maxA != null && age > maxA) return@filter false + } + val gFilter = selectedGender.trim() + if (gFilter.isNotEmpty()) { + val mg = member.gender?.trim()?.lowercase().orEmpty() + if (mg != gFilter.lowercase()) return@filter false + } + if (normalizedQuery.isNotEmpty()) { + val haystack = listOf( + member.fullName(), + member.firstName, + member.lastName, + member.birthDate.orEmpty(), + member.gender.orEmpty(), + member.city.orEmpty(), + member.street.orEmpty(), + member.postalCode.orEmpty(), + member.email.orEmpty(), + member.phone.orEmpty(), + formatMemberPhonesLine(member), + formatMemberEmailsLine(member), + ).joinToString(" ").lowercase() + if (!haystack.contains(normalizedQuery)) return@filter false + } + true + } + .sortedWith( + Comparator { a, b -> + val base = when (selectedSort) { + "ttr" -> (a.ttr ?: Int.MIN_VALUE).compareTo(b.ttr ?: Int.MIN_VALUE) + "qttr" -> (a.qttr ?: Int.MIN_VALUE).compareTo(b.qttr ?: Int.MIN_VALUE) + "lastTraining" -> a.lastTraining.orEmpty().compareTo(b.lastTraining.orEmpty()) + "birthDate" -> a.birthDate.orEmpty().compareTo(b.birthDate.orEmpty()) + "firstName" -> a.firstName.lowercase().compareTo(b.firstName.lowercase()) + "age" -> (memberAgeYears(a) ?: Int.MIN_VALUE).compareTo(memberAgeYears(b) ?: Int.MIN_VALUE) + "openTasks" -> memberOpenTasksCount(a, qualityReq).compareTo(memberOpenTasksCount(b, qualityReq)) + else -> { + val byLast = a.lastName.lowercase().compareTo(b.lastName.lowercase()) + if (byLast != 0) byLast else a.firstName.lowercase().compareTo(b.firstName.lowercase()) + } + } + if (sortAscending) base else -base + }, + ) + } + val activeRegularCount = remember(membersState.members) { + membersState.members.count { it.active && it.testMembership != true } + } + val inactiveCount = remember(membersState.members) { membersState.members.count { !it.active } } + val testActiveCount = remember(membersState.members) { + membersState.members.count { it.testMembership == true && it.active } + } + fun scopeOptionCount(key: String): Int = when (key) { + "all" -> membersState.members.size + "active" -> membersState.members.count { it.active && it.testMembership != true } + "test" -> membersState.members.count { it.testMembership == true } + "activeTest" -> membersState.members.count { it.active && it.testMembership == true } + "notTraining" -> membersState.members.count { it.notInTraining == true } + "needsForm" -> membersState.members.count { it.memberFormHandedOver != true } + "activeDataIncomplete" -> membersState.members.count { m -> + m.active && m.testMembership != true && memberDataQualityIssues(m, qualityReq).isNotEmpty() + } + "dataIncomplete" -> membersState.members.count { memberDataQualityIssues(it, qualityReq).isNotEmpty() } + "inactive" -> membersState.members.count { !it.active } + else -> 0 + } + fun formatBirthAndAge(member: Member): String { + val raw = member.birthDate?.take(10).orEmpty() + if (raw.length < 10) return if (raw.isBlank()) "-" else raw + return try { + val date = java.time.LocalDate.parse(raw) + val age = java.time.Period.between(date, java.time.LocalDate.now()).years + "$raw ($age)" + } catch (_: Throwable) { + raw + } + } + val filteredEmailsForBcc = remember(filteredMembers) { + buildList { + val seen = mutableSetOf() + for (m in filteredMembers) { + for (e in extractEmailAddressesFromMember(m)) { + if (seen.add(e)) add(e) + } } } } + val filteredPhonesPlain = remember(filteredMembers) { + filteredMembers.flatMap { m -> + val line = formatMemberPhonesLine(m) + if (line == MEMBER_CONTACT_EMPTY) { + emptyList() + } else { + line.split(",").map { it.trim().substringBefore("(").trim() }.filter { it.isNotBlank() } + } + }.distinct() + } + val filteredEmailsPlain = remember(filteredMembers) { + filteredMembers.flatMap { extractEmailAddressesFromMember(it) }.distinct() + } + if (pendingBulkForms && canWriteMembers) { + val targets = filteredMembers.filter { it.memberFormHandedOver != true } + AlertDialog( + onDismissRequest = { pendingBulkForms = false }, + title = { Text(tr("members.bulkFormsTitle", "Formulare übergeben")) }, + text = { + Text( + if (targets.isEmpty()) { + tr("members.bulkFormsEmpty", "Keine passenden Mitglieder in der aktuellen Filterliste.") + } else { + tr( + "members.bulkFormsConfirm", + "Für %d Mitglieder (gefilterte Liste) das Formular als ausgehändigt markieren?", + ).format(targets.size) + }, + ) + }, + confirmButton = { + TextButton( + onClick = { + pendingBulkForms = false + if (targets.isEmpty()) return@TextButton + dependencies.applicationScope.launch { + var failed = 0 + for (m in targets) { + val r = dependencies.membersManager.quickUpdateMemberFormHandedOver(clubId, m.id) + if (r.success != true) failed++ + } + dependencies.membersManager.loadMembers(clubId) + membersActionNote = if (failed == 0) { + trStr("members.bulkFormsDone", "Formularstatus aktualisiert.") + } else { + trStr("members.bulkFormsPartial", "Teilweise fehlgeschlagen (%d).").replace("%d", failed.toString()) + } + } + }, + ) { Text(tr("mobile.ok", "OK")) } + }, + dismissButton = { + TextButton(onClick = { pendingBulkForms = false }) { + Text(tr("mobile.cancel", "Abbrechen")) + } + }, + ) + } + if (pendingBulkTestOff && canWriteMembers) { + val targets = filteredMembers.filter { it.testMembership == true } + AlertDialog( + onDismissRequest = { pendingBulkTestOff = false }, + title = { Text(tr("members.bulkTestOffTitle", "Testmitgliedschaften beenden")) }, + text = { + Text( + if (targets.isEmpty()) { + tr("members.bulkTestOffEmpty", "Keine Testmitglieder in der aktuellen Filterliste.") + } else { + tr( + "members.bulkTestOffConfirm", + "Bei %d Mitgliedern die Testmitgliedschaft entfernen?", + ).format(targets.size) + }, + ) + }, + confirmButton = { + TextButton( + onClick = { + pendingBulkTestOff = false + if (targets.isEmpty()) return@TextButton + dependencies.applicationScope.launch { + var failed = 0 + for (m in targets) { + val r = dependencies.membersManager.quickUpdateTestMembership(clubId, m.id) + if (r.success != true) failed++ + } + dependencies.membersManager.loadMembers(clubId) + membersActionNote = if (failed == 0) { + trStr("members.bulkTestOffDone", "Testmitgliedschaften aktualisiert.") + } else { + trStr("members.bulkTestOffPartial", "Teilweise fehlgeschlagen (%d).").replace("%d", failed.toString()) + } + } + }, + ) { Text(tr("mobile.ok", "OK")) } + }, + dismissButton = { + TextButton(onClick = { pendingBulkTestOff = false }) { + Text(tr("mobile.cancel", "Abbrechen")) + } + }, + ) + } + + val membersBrowseScroll = rememberScrollState() Column( modifier = Modifier .fillMaxSize() + .verticalScroll(membersBrowseScroll) .imePadding() .navigationBarsPadding() .padding(horizontal = ScreenHorizontalPadding, vertical = 16.dp), ) { Header(tr("members.title", "Mitglieder")) - Text( - tr("members.listHint", "Suche nach Namen oder tippe auf ein Mitglied für die Details."), - style = MaterialTheme.typography.body2, - color = MaterialTheme.colors.onSurface.copy(alpha = 0.72f), - modifier = Modifier.padding(bottom = 10.dp), - ) + Row( + modifier = Modifier.fillMaxWidth().padding(bottom = 8.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + Card(modifier = Modifier.weight(1f), elevation = 0.dp, backgroundColor = MaterialTheme.colors.onSurface.copy(alpha = 0.03f)) { + Column(Modifier.padding(8.dp)) { + Text(tr("members.scopeActive", "Aktive"), style = MaterialTheme.typography.caption) + Text(activeRegularCount.toString(), fontWeight = FontWeight.Bold) + } + } + Card(modifier = Modifier.weight(1f), elevation = 0.dp, backgroundColor = MaterialTheme.colors.onSurface.copy(alpha = 0.03f)) { + Column(Modifier.padding(8.dp)) { + Text(tr("mobile.inactive", "Inaktiv"), style = MaterialTheme.typography.caption) + Text(inactiveCount.toString(), fontWeight = FontWeight.Bold) + } + } + Card(modifier = Modifier.weight(1f), elevation = 0.dp, backgroundColor = MaterialTheme.colors.onSurface.copy(alpha = 0.03f)) { + Column(Modifier.padding(8.dp)) { + Text(tr("members.testMember", "Test (aktiv)"), style = MaterialTheme.typography.caption) + Text(testActiveCount.toString(), fontWeight = FontWeight.Bold) + } + } + } Row(horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.fillMaxWidth()) { OutlinedButton( onClick = { dependencies.applicationScope.launch { dependencies.membersManager.loadMembers(clubId) } }, @@ -3785,48 +4251,524 @@ private fun MembersScreen( ) { Text(tr("members.newMember", "Neu")) } } } + if (canWriteMyTt) { + Row(modifier = Modifier.fillMaxWidth().padding(top = 6.dp), horizontalArrangement = Arrangement.spacedBy(8.dp)) { + OutlinedButton( + onClick = { + dependencies.applicationScope.launch { + ratingsBusy = true + membersActionNote = null + runCatching { + dependencies.membersManager.updateRatingsFromMyTischtennis(clubId) + dependencies.membersManager.loadMembers(clubId) + }.onSuccess { + membersActionNote = trStr("members.ratingsUpdated", "Ratings aktualisiert.") + }.onFailure { + membersActionNote = it.message ?: trStr("members.ratingsUpdateFailed", "Ratings-Update fehlgeschlagen.") + } + ratingsBusy = false + } + }, + enabled = !ratingsBusy, + modifier = Modifier + .weight(1f) + .heightIn(min = TouchMinHeight), + ) { + Text( + if (ratingsBusy) tr("mobile.loading", "Laden…") else tr("members.updateRatings", "Ratings aktualisieren"), + ) + } + } + } + Row( + modifier = Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()).padding(top = 4.dp, bottom = 4.dp), + horizontalArrangement = Arrangement.spacedBy(6.dp), + ) { + OutlinedButton( + onClick = { + val list = membersState.members.filter { it.active && it.testMembership != true } + if (list.isEmpty()) { + membersActionNote = trStr("members.phoneListEmpty", "Keine aktiven regulären Mitglieder für die Telefonliste.") + } else { + dependencies.applicationScope.launch { + runCatching { + val f = File(androidCtx.cacheDir, "telefonliste-alle.pdf") + writeMembersPhoneListPdf( + f, + "$clubName – ${trStr("pdfGenerator.phoneList", "Telefonliste")}", + list, + ) + sharePdfFile(androidCtx, f, trStr("members.sharePhoneList", "Telefonliste teilen")) + }.onFailure { + membersActionNote = it.message + ?: trStr("members.phoneListError", "PDF konnte nicht erstellt werden.") + } + } + } + }, + modifier = Modifier.heightIn(min = 34.dp), + ) { Text(tr("members.generatePhoneList", "Telefonliste PDF"), style = MaterialTheme.typography.caption) } + OutlinedButton( + onClick = { + val list = filteredMembers.filter { it.active && it.testMembership != true } + if (list.isEmpty()) { + membersActionNote = trStr("members.phoneListEmpty", "Keine aktiven regulären Mitglieder für die Telefonliste.") + } else { + dependencies.applicationScope.launch { + runCatching { + val f = File(androidCtx.cacheDir, "telefonliste-filter.pdf") + writeMembersPhoneListPdf( + f, + "$clubName – ${trStr("members.phoneListSelectionFile", "Telefonliste (Auswahl)")}", + list, + ) + sharePdfFile(androidCtx, f, trStr("members.sharePhoneList", "Telefonliste teilen")) + }.onFailure { + membersActionNote = it.message + ?: trStr("members.phoneListError", "PDF konnte nicht erstellt werden.") + } + } + } + }, + modifier = Modifier.heightIn(min = 34.dp), + ) { Text(tr("members.phoneListFiltered", "Telefonliste (Filter)"), style = MaterialTheme.typography.caption) } + OutlinedButton( + onClick = { stack = MembersStackRoute.GroupPhoto }, + modifier = Modifier.heightIn(min = 34.dp), + ) { Text(tr("members.groupPhotoCrop", "Gruppenfoto"), style = MaterialTheme.typography.caption) } + if (canWriteMembers) { + OutlinedButton( + onClick = { stack = MembersStackRoute.GroupPhotoPortraitCrop() }, + modifier = Modifier.heightIn(min = 34.dp), + ) { Text(tr("members.portraitCropShort", "Portrait-Zuschnitt"), style = MaterialTheme.typography.caption) } + OutlinedButton( + onClick = { stack = MembersStackRoute.TransferRun }, + modifier = Modifier.heightIn(min = 34.dp), + ) { Text(tr("members.transferMembers", "Transfer"), style = MaterialTheme.typography.caption) } + } + } OutlinedTextField( value = query, onValueChange = { query = it }, label = { Text(tr("mobile.search", "Suche")) }, - modifier = Modifier.fillMaxWidth().padding(vertical = 10.dp), + modifier = Modifier.fillMaxWidth().padding(top = 10.dp, bottom = 6.dp), singleLine = true, ) + Row( + modifier = Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()).padding(bottom = 6.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + Text(tr("members.showInactive", "Inaktive anzeigen"), style = MaterialTheme.typography.caption) + Switch(checked = showInactiveMembers, onCheckedChange = { showInactiveMembers = it }) + } + Text(tr("members.scopeLabel", "Ansicht"), style = MaterialTheme.typography.caption, modifier = Modifier.padding(bottom = 4.dp)) + Row( + modifier = Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()).padding(bottom = 8.dp), + horizontalArrangement = Arrangement.spacedBy(6.dp), + ) { + listOf( + "all" to tr("members.scopeAll", "Alle"), + "active" to tr("members.scopeActive", "Aktive o. Test"), + "test" to tr("members.scopeTest", "Test"), + "activeTest" to tr("members.scopeActiveTest", "Aktive Test"), + "notTraining" to tr("members.scopeNotTraining", "Nicht im Training"), + "needsForm" to tr("members.scopeNeedsForm", "Formular fehlt"), + "activeDataIncomplete" to tr("members.scopeActiveDataIncomplete", "Aktive Daten lückenhaft"), + "dataIncomplete" to tr("members.scopeDataIncomplete", "Daten lückenhaft"), + "inactive" to tr("members.scopeInactive", "Inaktive"), + ).forEach { (key, label) -> + val c = scopeOptionCount(key) + OutlinedButton(onClick = { memberScope = key }, modifier = Modifier.heightIn(min = 34.dp)) { + Text( + (if (memberScope == key) "✓ " else "") + "$label ($c)", + style = MaterialTheme.typography.caption, + ) + } + } + } + Row( + modifier = Modifier.fillMaxWidth().padding(bottom = 6.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Box { + OutlinedButton(onClick = { groupFilterMenuOpen = true }, modifier = Modifier.heightIn(min = 36.dp)) { + Text( + selectedTrainingGroupId?.let { id -> trainingGroupFilterOptions.find { it.key == id }?.value } + ?: tr("members.allTrainingGroups", "Alle Trainingsgruppen"), + style = MaterialTheme.typography.caption, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + DropdownMenu(expanded = groupFilterMenuOpen, onDismissRequest = { groupFilterMenuOpen = false }) { + DropdownMenuItem(onClick = { + selectedTrainingGroupId = null + groupFilterMenuOpen = false + }) { + Text(tr("common.all", "Alle")) + } + trainingGroupFilterOptions.forEach { (id, name) -> + DropdownMenuItem(onClick = { + selectedTrainingGroupId = id + groupFilterMenuOpen = false + }) { + Text(name) + } + } + } + } + } + Row( + modifier = Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()).padding(bottom = 8.dp), + horizontalArrangement = Arrangement.spacedBy(6.dp), + ) { + Text(tr("members.lastTrainingFilter", "Letztes Training"), style = MaterialTheme.typography.caption, modifier = Modifier.padding(end = 4.dp)) + listOf( + "all" to tr("common.all", "Alle"), + "hasDate" to tr("members.lastTrainingHasDate", "Mit Datum"), + "noDate" to tr("members.lastTrainingNoDate", "Ohne Datum"), + "notInTraining" to tr("members.scopeNotTraining", "Nicht im Training"), + ).forEach { (key, label) -> + OutlinedButton(onClick = { lastTrainingFilter = key }, modifier = Modifier.heightIn(min = 34.dp)) { + Text( + if (lastTrainingFilter == key) "✓ $label" else label, + style = MaterialTheme.typography.caption, + ) + } + } + } + Text(tr("members.ttStichtagHint", "Altersklassen nach Stichtag 01.01., Saison Aug–Jul."), style = MaterialTheme.typography.caption, color = MaterialTheme.colors.onSurface.copy(alpha = 0.65f), modifier = Modifier.padding(bottom = 4.dp)) + Row( + modifier = Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()).padding(bottom = 6.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Box { + OutlinedButton(onClick = { seasonMenuOpen = true }, modifier = Modifier.heightIn(min = 36.dp)) { + Text( + "${tr("members.ttSeasonFilter", "Saison")}: ${formatSeasonSlash(seasonStartYear)}", + style = MaterialTheme.typography.caption, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + DropdownMenu(expanded = seasonMenuOpen, onDismissRequest = { seasonMenuOpen = false }) { + val y0 = getSeasonStartYearFromDateToday() + listOf(y0, y0 + 1).forEach { y -> + DropdownMenuItem(onClick = { + seasonStartYear = y + seasonMenuOpen = false + }) { + Text(formatSeasonSlash(y)) + } + } + } + } + Box { + OutlinedButton(onClick = { ageGroupMenuOpen = true }, modifier = Modifier.heightIn(min = 36.dp)) { + Text( + when (selectedAgeGroup.trim()) { + "" -> tr("members.ageGroup", "Altersklasse") + ": " + tr("common.all", "Alle") + "adult" -> tr("members.ttAdult", "Erwachsene") + "range" -> tr("members.ageRange", "Altersbereich") + else -> selectedAgeGroup.trim() + }, + style = MaterialTheme.typography.caption, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + DropdownMenu(expanded = ageGroupMenuOpen, onDismissRequest = { ageGroupMenuOpen = false }) { + DropdownMenuItem(onClick = { selectedAgeGroup = ""; ageGroupMenuOpen = false }) { + Text(tr("common.all", "Alle")) + } + DropdownMenuItem(onClick = { selectedAgeGroup = "adult"; ageGroupMenuOpen = false }) { + Text(tr("members.ttAdult", "Erwachsene")) + } + listOf("J19", "J15", "J13", "J11", "J9").forEach { k -> + DropdownMenuItem(onClick = { selectedAgeGroup = k; ageGroupMenuOpen = false }) { + Text(k) + } + } + listOf("M19", "M15", "M13", "M11", "M9").forEach { k -> + DropdownMenuItem(onClick = { selectedAgeGroup = k; ageGroupMenuOpen = false }) { + Text(k) + } + } + DropdownMenuItem(onClick = { selectedAgeGroup = "range"; ageGroupMenuOpen = false }) { + Text(tr("members.ageRange", "Altersbereich")) + } + } + } + Box { + OutlinedButton(onClick = { genderMenuOpen = true }, modifier = Modifier.heightIn(min = 36.dp)) { + Text( + when (selectedGender.trim()) { + "" -> tr("members.gender", "Geschlecht") + ": " + tr("common.all", "Alle") + "female" -> tr("members.genderFemale", "weiblich") + "male" -> tr("members.genderMale", "männlich") + "diverse" -> tr("members.genderDiverse", "divers") + "unknown" -> tr("members.genderUnknown", "unbekannt") + else -> selectedGender + }, + style = MaterialTheme.typography.caption, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + DropdownMenu(expanded = genderMenuOpen, onDismissRequest = { genderMenuOpen = false }) { + DropdownMenuItem(onClick = { selectedGender = ""; genderMenuOpen = false }) { + Text(tr("common.all", "Alle")) + } + DropdownMenuItem(onClick = { selectedGender = "female"; genderMenuOpen = false }) { + Text(tr("members.genderFemale", "weiblich")) + } + DropdownMenuItem(onClick = { selectedGender = "male"; genderMenuOpen = false }) { + Text(tr("members.genderMale", "männlich")) + } + DropdownMenuItem(onClick = { selectedGender = "diverse"; genderMenuOpen = false }) { + Text(tr("members.genderDiverse", "divers")) + } + DropdownMenuItem(onClick = { selectedGender = "unknown"; genderMenuOpen = false }) { + Text(tr("members.genderUnknown", "unbekannt")) + } + } + } + } + if (selectedAgeGroup.trim() == "range") { + Row( + modifier = Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()).padding(bottom = 8.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + OutlinedTextField( + value = selectedAgeFrom, + onValueChange = { selectedAgeFrom = it.filter { ch -> ch.isDigit() }.take(3) }, + label = { Text(tr("members.ageFromPlaceholder", "Alter von")) }, + modifier = Modifier.width(120.dp), + singleLine = true, + ) + Text("–", style = MaterialTheme.typography.body2) + OutlinedTextField( + value = selectedAgeTo, + onValueChange = { selectedAgeTo = it.filter { ch -> ch.isDigit() }.take(3) }, + label = { Text(tr("members.ageToPlaceholder", "Alter bis")) }, + modifier = Modifier.width(120.dp), + singleLine = true, + ) + } + } + Row( + modifier = Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()).padding(bottom = 8.dp), + horizontalArrangement = Arrangement.spacedBy(6.dp), + ) { + listOf( + "name" to tr("members.name", "Name"), + "firstName" to tr("members.sortFirstName", "Vorname"), + "ttr" to "TTR", + "qttr" to tr("members.sortQttr", "QTTR"), + "birthDate" to tr("members.birthDate", "Geburtsdatum"), + "age" to tr("members.sortAge", "Alter"), + "lastTraining" to tr("mobile.lastTraining", "Letztes Training"), + "openTasks" to tr("members.sortOpenTasks", "Aufgaben"), + ).forEach { (key, label) -> + OutlinedButton(onClick = { selectedSort = key }, modifier = Modifier.heightIn(min = 34.dp)) { + Text(if (selectedSort == key) "✓ $label" else label, style = MaterialTheme.typography.caption) + } + } + OutlinedButton(onClick = { sortAscending = !sortAscending }, modifier = Modifier.heightIn(min = 34.dp)) { + Text( + if (sortAscending) tr("members.sortAsc", "↑ Aufsteigend") else tr("members.sortDesc", "↓ Absteigend"), + style = MaterialTheme.typography.caption, + ) + } + OutlinedButton(onClick = { showTrainingParticipationsColumn = !showTrainingParticipationsColumn }, modifier = Modifier.heightIn(min = 34.dp)) { + Text( + if (showTrainingParticipationsColumn) "✓ ${tr("members.trainingParticipations", "Trainings-Teilnahmen")}" + else tr("members.trainingParticipations", "Trainings-Teilnahmen"), + style = MaterialTheme.typography.caption, + ) + } + OutlinedButton( + onClick = { + query = "" + memberScope = "active" + showInactiveMembers = false + selectedTrainingGroupId = null + lastTrainingFilter = "all" + seasonStartYear = getSeasonStartYearFromDateToday() + selectedAgeGroup = "" + selectedAgeFrom = "" + selectedAgeTo = "" + selectedGender = "" + selectedSort = "name" + sortAscending = true + showTrainingParticipationsColumn = false + membersActionNote = null + }, + modifier = Modifier.heightIn(min = 34.dp), + ) { + Text(tr("members.resetFilters", "Filter zurücksetzen"), style = MaterialTheme.typography.caption) + } + } + Row( + modifier = Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()).padding(bottom = 8.dp), + horizontalArrangement = Arrangement.spacedBy(6.dp), + ) { + OutlinedButton( + onClick = { clipboard.setText(AnnotatedString(filteredPhonesPlain.joinToString("; "))) }, + enabled = filteredPhonesPlain.isNotEmpty(), + modifier = Modifier.heightIn(min = 34.dp), + ) { Text(tr("members.copyFilteredPhones", "Telefone kopieren"), style = MaterialTheme.typography.caption) } + OutlinedButton( + onClick = { + val block = filteredMembers.mapNotNull { m -> + val p = formatMemberPhonesLine(m) + if (p == MEMBER_CONTACT_EMPTY) null else "${m.fullName()}: $p" + }.joinToString("\n") + clipboard.setText(AnnotatedString(block)) + }, + enabled = filteredMembers.any { formatMemberPhonesLine(it) != MEMBER_CONTACT_EMPTY }, + modifier = Modifier.heightIn(min = 34.dp), + ) { Text(tr("members.copyPhonesWithNames", "Telefone mit Namen"), style = MaterialTheme.typography.caption) } + OutlinedButton( + onClick = { clipboard.setText(AnnotatedString(filteredEmailsPlain.joinToString("; "))) }, + enabled = filteredEmailsPlain.isNotEmpty(), + modifier = Modifier.heightIn(min = 34.dp), + ) { Text(tr("members.copyFilteredEmails", "E-Mails kopieren"), style = MaterialTheme.typography.caption) } + OutlinedButton( + onClick = { + val block = filteredMembers.mapNotNull { m -> + val emails = extractEmailAddressesFromMember(m) + if (emails.isEmpty()) null else "${m.fullName()}: ${emails.joinToString(", ")}" + }.joinToString("\n") + clipboard.setText(AnnotatedString(block)) + }, + enabled = filteredMembers.any { extractEmailAddressesFromMember(it).isNotEmpty() }, + modifier = Modifier.heightIn(min = 34.dp), + ) { Text(tr("members.copyEmailsWithNames", "E-Mails mit Namen"), style = MaterialTheme.typography.caption) } + OutlinedButton( + onClick = { + clipboard.setText(AnnotatedString(buildMembersCsvExport(filteredMembers))) + membersActionNote = trStr("members.csvCopied", "CSV in die Zwischenablage kopiert.") + }, + enabled = filteredMembers.isNotEmpty(), + modifier = Modifier.heightIn(min = 34.dp), + ) { Text(tr("members.copyCsv", "CSV kopieren"), style = MaterialTheme.typography.caption) } + OutlinedButton( + onClick = { + if (filteredEmailsForBcc.isEmpty()) return@OutlinedButton + runCatching { + val bcc = filteredEmailsForBcc.joinToString(",") + val uri = Uri.parse("mailto:?bcc=${Uri.encode(bcc)}") + androidCtx.startActivity(Intent(Intent.ACTION_VIEW, uri)) + } + }, + enabled = filteredEmailsForBcc.isNotEmpty(), + modifier = Modifier.heightIn(min = 34.dp), + ) { Text(tr("members.openMailtoBcc", "E-Mail (Bcc)"), style = MaterialTheme.typography.caption) } + } + if (canWriteMembers) { + Row( + modifier = Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()).padding(bottom = 8.dp), + horizontalArrangement = Arrangement.spacedBy(6.dp), + ) { + OutlinedButton( + onClick = { pendingBulkForms = true }, + modifier = Modifier.heightIn(min = 34.dp), + ) { Text(tr("members.bulkHandOverForms", "Formulare übergeben"), style = MaterialTheme.typography.caption) } + OutlinedButton( + onClick = { pendingBulkTestOff = true }, + modifier = Modifier.heightIn(min = 34.dp), + ) { Text(tr("members.bulkEndTest", "Test beenden (Liste)"), style = MaterialTheme.typography.caption) } + } + } + membersActionNote?.let { note -> + Text(note, style = MaterialTheme.typography.caption, color = MaterialTheme.colors.primary, modifier = Modifier.padding(bottom = 6.dp)) + } if (membersState.isLoading) LoadingInline() ErrorText(membersState.error) if (!membersState.isLoading && filteredMembers.isEmpty()) { EmptyText(if (query.isBlank()) tr("mobile.noMembers", "Keine Mitglieder gefunden") else tr("mobile.noResults", "Keine Treffer")) } - LazyColumn( - modifier = Modifier.weight(1f), - verticalArrangement = Arrangement.spacedBy(8.dp), + val tableScroll = rememberScrollState() + val tableWidth = if (showTrainingParticipationsColumn) 1030.dp else 930.dp + Row( + modifier = Modifier + .fillMaxWidth() + .horizontalScroll(tableScroll), ) { - items(filteredMembers) { member -> - Card( - modifier = Modifier.fillMaxWidth().clickable { stack = MembersStackRoute.Detail(member.id) }, - elevation = 1.dp, + Column(modifier = Modifier.width(tableWidth)) { + Row( + modifier = Modifier.fillMaxWidth().padding(vertical = 6.dp), + verticalAlignment = Alignment.CenterVertically, ) { - Row(modifier = Modifier.padding(12.dp), verticalAlignment = Alignment.CenterVertically) { - val avatarUrl = memberProfileImagePath(clubId, member.id) - ?.let { dependencies.apiConfig.toAbsoluteUrl(it) } - if (canReadMembers && avatarUrl != null) { - AuthenticatedAsyncImage( - imageUrl = avatarUrl, - authHeaders = dependencies.diaryAuthHeaders(), - modifier = Modifier.size(44.dp), - contentDescription = member.fullName(), - ) - Spacer(modifier = Modifier.width(12.dp)) - } - Column(modifier = Modifier.weight(1f)) { - Text(member.fullName(), fontWeight = FontWeight.SemiBold) - Text( - listOfNotNull( - member.ttr?.let { "TTR $it" }, - member.qttr?.let { "QTTR $it" }, - ).joinToString(" · ").ifBlank { member.city ?: "" }, - style = MaterialTheme.typography.caption, - ) + Text(tr("members.name", "Name"), modifier = Modifier.width(220.dp), fontWeight = FontWeight.Bold, style = MaterialTheme.typography.caption) + Text(tr("members.status", "Status"), modifier = Modifier.width(92.dp), fontWeight = FontWeight.Bold, style = MaterialTheme.typography.caption) + Text("TTR/QTTR", modifier = Modifier.width(110.dp), fontWeight = FontWeight.Bold, style = MaterialTheme.typography.caption) + Text(tr("members.contact", "Kontakt"), modifier = Modifier.width(220.dp), fontWeight = FontWeight.Bold, style = MaterialTheme.typography.caption) + Text(tr("members.birthDate", "Geburtsdatum"), modifier = Modifier.width(160.dp), fontWeight = FontWeight.Bold, style = MaterialTheme.typography.caption) + if (showTrainingParticipationsColumn) { + Text(tr("members.trainingParticipations", "Trainings-Teilnahmen"), modifier = Modifier.width(100.dp), fontWeight = FontWeight.Bold, style = MaterialTheme.typography.caption) + } + Text(tr("members.actions", "Aktionen"), modifier = Modifier.width(120.dp), fontWeight = FontWeight.Bold, style = MaterialTheme.typography.caption) + } + Divider() + Column(modifier = Modifier.fillMaxWidth()) { + for (member in filteredMembers) { + key(member.id) { + Row( + modifier = Modifier + .fillMaxWidth() + .clickable { stack = MembersStackRoute.Detail(member.id) } + .padding(vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Row(modifier = Modifier.width(220.dp), verticalAlignment = Alignment.CenterVertically) { + val avatarUrl = memberProfileImagePath(clubId, member.id) + ?.let { dependencies.apiConfig.toAbsoluteUrl(it) } + if (canReadMembers && avatarUrl != null) { + AuthenticatedAsyncImage( + imageUrl = avatarUrl, + authHeaders = dependencies.diaryAuthHeaders(), + modifier = Modifier.size(28.dp), + contentDescription = member.fullName(), + ) + Spacer(modifier = Modifier.width(8.dp)) + } + Text(member.fullName(), maxLines = 1, overflow = TextOverflow.Ellipsis, fontWeight = FontWeight.SemiBold) + } + val status = when { + !member.active -> tr("mobile.inactive", "Inaktiv") + member.testMembership == true -> tr("members.testMember", "Test") + else -> tr("mobile.active", "Aktiv") + } + Text(status, modifier = Modifier.width(92.dp), style = MaterialTheme.typography.caption) + Text( + listOfNotNull(member.ttr?.let { "TTR $it" }, member.qttr?.let { "QTTR $it" }).joinToString(" · ").ifBlank { "-" }, + modifier = Modifier.width(110.dp), + style = MaterialTheme.typography.caption, + ) + val phoneLine = formatMemberPhonesLine(member) + val emailLine = formatMemberEmailsLine(member) + val contactLine = listOf(phoneLine, emailLine) + .filter { it != MEMBER_CONTACT_EMPTY } + .joinToString(" · ") + .ifBlank { "-" } + Text(contactLine, modifier = Modifier.width(220.dp), maxLines = 2, overflow = TextOverflow.Ellipsis, style = MaterialTheme.typography.caption) + Text(formatBirthAndAge(member), modifier = Modifier.width(160.dp), style = MaterialTheme.typography.caption) + if (showTrainingParticipationsColumn) { + Text((member.trainingParticipations ?: 0).toString(), modifier = Modifier.width(100.dp), style = MaterialTheme.typography.caption) + } + Row(modifier = Modifier.width(120.dp), horizontalArrangement = Arrangement.spacedBy(4.dp)) { + TextButton(onClick = { stack = MembersStackRoute.Detail(member.id) }, modifier = Modifier.heightIn(min = 28.dp)) { + Text(tr("mobile.detail", "Details"), style = MaterialTheme.typography.caption) + } + } + } + Divider() } } } @@ -4949,7 +5891,8 @@ private fun DiaryPlanEditableCard( canReadImages: Boolean, isExpanded: Boolean, onToggleExpand: () -> Unit, - onAssign: () -> Unit, + onAssignParticipants: () -> Unit, + onAssignGroup: () -> Unit, onOpenImage: (String) -> Unit, onEdit: () -> Unit, onDelete: () -> Unit, @@ -5054,7 +5997,12 @@ private fun DiaryPlanEditableCard( DiaryPlanQuickTextAction( label = tr("diary.planAssignGroup", "Zuordnen"), enabled = canMutate, - onClick = onAssign, + onClick = onAssignParticipants, + ) + DiaryPlanQuickTextAction( + label = tr("diary.group", "Gruppe"), + enabled = canMutate, + onClick = onAssignGroup, ) DiaryPlanQuickTextAction( label = tr("common.edit", "Bearbeiten"), @@ -5166,6 +6114,131 @@ private fun FlowRowText(items: List) { } } +private const val MEMBER_CONTACT_EMPTY = "–" + +private fun formatMemberPhonesLine(member: Member): String { + val phoneContacts = member.contacts + .filter { it.type == "phone" && it.value.isNotBlank() } + .sortedWith(compareBy { !it.isPrimary }.thenBy { it.id ?: 0 }) + val pieces = phoneContacts.map { c -> + val base = c.value.trim() + if (c.isParent) { + val pName = c.parentName + val label = when { + !pName.isNullOrBlank() -> pName.trim() + else -> "Eltern" + } + "$base ($label)" + } else { + base + } + }.toMutableList() + val legacyPhone = member.phone + if (pieces.isEmpty() && !legacyPhone.isNullOrBlank()) { + pieces.add(legacyPhone.trim()) + } + return if (pieces.isEmpty()) MEMBER_CONTACT_EMPTY else pieces.joinToString(", ") +} + +private fun formatMemberEmailsLine(member: Member): String { + val emailContacts = member.contacts + .filter { it.type == "email" && it.value.isNotBlank() } + .sortedWith(compareBy { !it.isPrimary }.thenBy { it.id ?: 0 }) + val pieces = emailContacts.map { c -> + val base = c.value.trim() + if (c.isParent) { + val pName = c.parentName + val label = when { + !pName.isNullOrBlank() -> pName.trim() + else -> "Eltern" + } + "$base ($label)" + } else { + base + } + }.toMutableList() + val legacyEmail = member.email + if (pieces.isEmpty() && !legacyEmail.isNullOrBlank()) { + pieces.add(legacyEmail.trim()) + } + return if (pieces.isEmpty()) MEMBER_CONTACT_EMPTY else pieces.joinToString(", ") +} + +private fun memberHasFormattedPhones(member: Member): Boolean = + formatMemberPhonesLine(member) != MEMBER_CONTACT_EMPTY + +private fun memberHasFormattedEmails(member: Member): Boolean = + formatMemberEmailsLine(member) != MEMBER_CONTACT_EMPTY + +private fun extractEmailAddressesFromMember(member: Member): List { + val raw = formatMemberEmailsLine(member) + if (raw == MEMBER_CONTACT_EMPTY) return emptyList() + return raw.split(",").mapNotNull { part -> + val trimmed = part.trim() + if (trimmed.isEmpty()) return@mapNotNull null + val withoutParen = trimmed.replace(Regex("\\s*\\([^)]*\\)\\s*$"), "").trim() + withoutParen.takeIf { it.contains("@") } + }.distinct() +} + +private fun memberHasValidLastTrainingDate(member: Member): Boolean { + val raw = member.lastTraining?.trim().orEmpty() + if (raw.isEmpty()) return false + val day = raw.take(10) + return runCatching { java.time.LocalDate.parse(day) }.isSuccess +} + +private fun memberDataQualityIssues(member: Member, requirements: MemberDataQualityRequirements): List { + val issues = mutableListOf() + val hasTrainingGroup = member.trainingGroups.isNotEmpty() + if (member.birthDate.isNullOrBlank()) { + issues.add("birthdate") + } + if (requirements.requirePhone && !memberHasFormattedPhones(member)) issues.add("phone") + if (requirements.requireEmail && !memberHasFormattedEmails(member)) issues.add("email") + if (requirements.requireStreet && member.street.isNullOrBlank()) issues.add("street") + if (requirements.requirePostalCode && member.postalCode.isNullOrBlank()) issues.add("postal") + if (requirements.requireCity && member.city.isNullOrBlank()) issues.add("city") + if (member.gender.isNullOrBlank() || member.gender == "unknown") issues.add("gender") + if (!hasTrainingGroup) issues.add("training-group") + return issues +} + +private fun memberOpenTasksCount(member: Member, requirements: MemberDataQualityRequirements): Int { + var n = 0 + if (member.memberFormHandedOver != true) n++ + if (member.testMembership == true) n++ + if (member.notInTraining == true) n++ + if (memberDataQualityIssues(member, requirements).isNotEmpty()) n++ + if (member.trainingGroups.isEmpty()) n++ + if (member.active && member.ttr == null && member.qttr == null) n++ + return n +} + +private fun buildMembersCsvExport(rows: List): String { + fun esc(s: String) = s.replace(";", ",") + val header = "Name;Status;TTR;QTTR;E-Mail;Telefon;Letztes Training;Teilnahmen" + val lines = rows.map { m -> + listOf( + esc(m.fullName()), + esc( + when { + !m.active -> "inaktiv" + m.testMembership == true -> "test" + else -> "aktiv" + }, + ), + m.ttr?.toString().orEmpty(), + m.qttr?.toString().orEmpty(), + esc(formatMemberEmailsLine(m).let { if (it == MEMBER_CONTACT_EMPTY) "" else it }), + esc(formatMemberPhonesLine(m).let { if (it == MEMBER_CONTACT_EMPTY) "" else it }), + esc(m.lastTraining.orEmpty()), + (m.trainingParticipations ?: 0).toString(), + ).joinToString(";") + } + return (listOf(header) + lines).joinToString("\n") +} + private fun Member.fullName(): String = listOf(firstName, lastName).filter { it.isNotBlank() }.joinToString(" ").ifBlank { "Mitglied $id" } private fun formatDate(value: String): String = value.take(10) diff --git a/mobile-app/composeApp/src/androidMain/kotlin/de/tt_tagebuch/app/ui/MemberGroupPortraitCropRoute.kt b/mobile-app/composeApp/src/androidMain/kotlin/de/tt_tagebuch/app/ui/MemberGroupPortraitCropRoute.kt new file mode 100644 index 00000000..5b6364d6 --- /dev/null +++ b/mobile-app/composeApp/src/androidMain/kotlin/de/tt_tagebuch/app/ui/MemberGroupPortraitCropRoute.kt @@ -0,0 +1,540 @@ +package de.tt_tagebuch.app.ui + +import android.graphics.Bitmap +import android.graphics.BitmapFactory +import android.net.Uri +import androidx.activity.compose.BackHandler +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.PickVisualMediaRequest +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.Image +import androidx.compose.foundation.gestures.detectDragGestures +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.imePadding +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +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.Checkbox +import androidx.compose.material.CircularProgressIndicator +import androidx.compose.material.MaterialTheme +import androidx.compose.material.OutlinedButton +import androidx.compose.material.OutlinedTextField +import androidx.compose.material.Text +import androidx.compose.material.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Rect +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Path +import androidx.compose.ui.graphics.PathFillType +import androidx.compose.ui.graphics.asImageBitmap +import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.layout.onSizeChanged +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.IntSize +import androidx.compose.ui.unit.dp +import de.tt_tagebuch.app.AppDependencies +import de.tt_tagebuch.shared.api.models.Member +import de.tt_tagebuch.shared.i18n.MobileStrings +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import java.io.ByteArrayOutputStream +import kotlin.math.abs +import kotlin.math.max +import kotlin.math.min +import kotlin.math.roundToInt + +private val CropFrameMaxHeight = 420.dp +private const val CropOutputSize = 600 +private const val MinSelectionPx = 24f +private const val SmallCropThresholdPx = 80f + +private fun decodeBitmapForCrop(resolver: android.content.ContentResolver, uri: Uri, maxSide: Int = 4096): Bitmap? { + return resolver.openInputStream(uri)?.use { input -> + val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true } + BitmapFactory.decodeStream(input, null, bounds) + if (bounds.outWidth <= 0 || bounds.outHeight <= 0) return@use null + var sample = 1 + var w = bounds.outWidth + var h = bounds.outHeight + while (w > maxSide || h > maxSide) { + sample *= 2 + w = bounds.outWidth / sample + h = bounds.outHeight / sample + } + val opts = BitmapFactory.Options().apply { inSampleSize = sample } + resolver.openInputStream(uri)?.use { stream -> + BitmapFactory.decodeStream(stream, null, opts) + } + } +} + +private fun bitmapToJpeg(bitmap: Bitmap, quality: Int = 90): ByteArray { + val out = ByteArrayOutputStream() + bitmap.compress(Bitmap.CompressFormat.JPEG, quality, out) + return out.toByteArray() +} + +private fun cropSquareForUpload( + source: Bitmap, + selX: Float, + selY: Float, + selSize: Float, + dispW: Float, + dispH: Float, +): Bitmap? { + if (selSize < MinSelectionPx || dispW <= 0f || dispH <= 0f) return null + val bw = source.width.toFloat() + val bh = source.height.toFloat() + val scaleX = bw / dispW + val scaleY = bh / dispH + val sx = (selX * scaleX).roundToInt().coerceIn(0, source.width - 1) + val sy = (selY * scaleY).roundToInt().coerceIn(0, source.height - 1) + val sideRaw = (selSize * min(scaleX, scaleY)).roundToInt().coerceAtLeast(1) + val side = min(sideRaw, min(source.width - sx, source.height - sy)) + if (side < 8) return null + val cropped = Bitmap.createBitmap(source, sx, sy, side, side) + return if (cropped.width == CropOutputSize && cropped.height == CropOutputSize) { + cropped + } else { + val scaled = Bitmap.createScaledBitmap(cropped, CropOutputSize, CropOutputSize, true) + if (scaled != cropped) cropped.recycle() + scaled + } +} + +@Composable +fun MemberGroupPortraitCropRoute( + clubId: Int, + members: List, + dependencies: AppDependencies, + canWriteMembers: Boolean, + onBack: () -> Unit, +) { + val languageCode = LocalLanguageCode.current + fun s(key: String, fb: String) = MobileStrings.get(languageCode, key, fb) + val context = LocalContext.current + val density = LocalDensity.current + val scope = rememberCoroutineScope() + + var sourceUri by remember { mutableStateOf(null) } + var fullBitmap by remember { mutableStateOf(null) } + var decodeError by remember { mutableStateOf(null) } + var containerSize by remember { mutableStateOf(IntSize.Zero) } + + var selectionStart by remember { mutableStateOf(null) } + var selectionRect by remember { mutableStateOf(null) } + var isDragging by remember { mutableStateOf(false) } + + var previewBytes by remember { mutableStateOf(null) } + var previewBitmap by remember { mutableStateOf(null) } + + var memberQuery by remember { mutableStateOf("") } + var selectedMemberId by remember { mutableStateOf(null) } + var makePrimary by remember { mutableStateOf(true) } + var memberPickerOpen by remember { mutableStateOf(false) } + var saving by remember { mutableStateOf(false) } + var statusMessage by remember { mutableStateOf(null) } + var errorMessage by remember { mutableStateOf(null) } + + BackHandler(onBack = onBack) + + LaunchedEffect(sourceUri) { + val uri = sourceUri ?: return@LaunchedEffect + decodeError = null + fullBitmap?.recycle() + fullBitmap = null + selectionRect = null + selectionStart = null + previewBytes = null + previewBitmap?.recycle() + previewBitmap = null + val bmp = withContext(Dispatchers.IO) { + decodeBitmapForCrop(context.contentResolver, uri) + } + if (bmp == null) { + decodeError = s("members.groupCropDecodeError", "Bild konnte nicht geladen werden.") + } else { + fullBitmap = bmp + } + } + + val pickImage = rememberLauncherForActivityResult(ActivityResultContracts.PickVisualMedia()) { uri -> + if (uri != null) sourceUri = uri + } + + fun resetSelection() { + selectionStart = null + selectionRect = null + isDragging = false + previewBytes = null + previewBitmap?.recycle() + previewBitmap = null + } + + fun recomputePreviewFromSelection(rect: Rect?) { + val bmp = fullBitmap ?: return + if (rect == null || rect.width < MinSelectionPx || rect.height < MinSelectionPx) { + previewBytes = null + previewBitmap?.recycle() + previewBitmap = null + return + } + val frame = computeImageDisplaySize(bmp.width.toFloat(), bmp.height.toFloat(), containerSize) + if (frame.dispW <= 0f || frame.dispH <= 0f) return + val cropped = cropSquareForUpload(bmp, rect.left, rect.top, rect.width, frame.dispW, frame.dispH) + if (cropped == null) { + previewBytes = null + previewBitmap?.recycle() + previewBitmap = null + return + } + previewBitmap?.recycle() + previewBitmap = cropped + previewBytes = bitmapToJpeg(cropped) + } + + fun updateSelectionFromDrag(start: Offset, current: Offset, maxW: Float, maxH: Float) { + val dx = current.x - start.x + val dy = current.y - start.y + var size = min(abs(dx), abs(dy)) + var x = if (dx < 0) start.x - size else start.x + var y = if (dy < 0) start.y - size else start.y + x = max(0f, min(x, maxW - size)) + y = max(0f, min(y, maxH - size)) + size = min(size, min(maxW - x, maxH - y)) + selectionRect = Rect(x, y, x + size, y + size) + } + + val filteredMembers = remember(members, memberQuery) { + val q = memberQuery.trim().lowercase() + members + .filter { m -> + if (q.isEmpty()) return@filter true + "${m.firstName} ${m.lastName}".lowercase().contains(q) || + "${m.lastName} ${m.firstName}".lowercase().contains(q) + } + .sortedWith(compareBy { it.lastName.lowercase() }.thenBy { it.firstName.lowercase() }) + } + + Column( + modifier = Modifier + .fillMaxSize() + .imePadding() + .navigationBarsPadding() + .verticalScroll(rememberScrollState()) + .padding(horizontal = 20.dp, vertical = 16.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + s("members.groupPortraitCropTitle", "Portrait aus Gruppenfoto"), + style = MaterialTheme.typography.h6, + ) + TextButton(onClick = onBack) { Text(s("mobile.back", "Zurück")) } + } + Text( + s( + "members.groupPortraitCropHint", + "Wähle ein Bild, ziehe mit dem Finger einen quadratischen Rahmen um eine Person, ordne das Mitglied zu und speichere.", + ), + style = MaterialTheme.typography.body2, + color = MaterialTheme.colors.onSurface.copy(alpha = 0.75f), + modifier = Modifier.padding(bottom = 12.dp), + ) + + if (!canWriteMembers) { + Text(s("members.groupCropNoWrite", "Keine Berechtigung zum Hochladen von Mitgliedsfotos.")) + return@Column + } + + OutlinedButton( + onClick = { + pickImage.launch(PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageOnly)) + }, + modifier = Modifier + .fillMaxWidth() + .heightIn(min = 48.dp), + ) { Text(s("members.groupCropPickImage", "Bild aus Galerie wählen")) } + + decodeError?.let { + Text(it, color = MaterialTheme.colors.error, modifier = Modifier.padding(top = 8.dp)) + } + + val bmp = fullBitmap + if (bmp != null) { + val frame = computeImageDisplaySize( + bmp.width.toFloat(), + bmp.height.toFloat(), + containerSize, + ) + val dispWdp = with(density) { frame.dispW.toDp() } + val dispHdp = with(density) { frame.dispH.toDp() } + + Box( + modifier = Modifier + .fillMaxWidth() + .height(CropFrameMaxHeight) + .padding(top = 12.dp) + .onSizeChanged { containerSize = it }, + contentAlignment = Alignment.Center, + ) { + if (frame.dispW > 0f && frame.dispH > 0f) { + Box( + modifier = Modifier + .size(dispWdp, dispHdp) + .pointerInput(frame.dispW, frame.dispH, bmp) { + detectDragGestures( + onDragStart = { start -> + isDragging = true + selectionStart = start + errorMessage = null + statusMessage = null + previewBytes = null + previewBitmap?.recycle() + previewBitmap = null + selectionRect = Rect(start.x, start.y, start.x, start.y) + }, + onDrag = { change, _ -> + val start = selectionStart ?: return@detectDragGestures + updateSelectionFromDrag(start, change.position, frame.dispW, frame.dispH) + }, + onDragEnd = { + isDragging = false + selectionStart = null + val r = selectionRect + if (r == null || r.width < MinSelectionPx) { + resetSelection() + } else { + recomputePreviewFromSelection(r) + } + }, + onDragCancel = { + isDragging = false + selectionStart = null + val r = selectionRect + if (r == null || r.width < MinSelectionPx) { + resetSelection() + } else { + recomputePreviewFromSelection(r) + } + }, + ) + }, + ) { + Image( + bitmap = bmp.asImageBitmap(), + contentDescription = null, + modifier = Modifier.fillMaxSize(), + ) + Canvas(Modifier.fillMaxSize()) { + val r = selectionRect + if (r != null && r.width > 0f && r.height > 0f) { + val path = Path().apply { + addRect(Rect(Offset.Zero, size)) + addRect(r) + fillType = PathFillType.EvenOdd + } + drawPath(path, Color.Black.copy(alpha = 0.48f)) + drawRect( + color = Color.White.copy(alpha = 0.85f), + topLeft = Offset(r.left, r.top), + size = Size(r.width, r.height), + style = Stroke(width = 2.dp.toPx()), + ) + } + } + } + } + } + + selectionRect?.let { r -> + if (r.width < SmallCropThresholdPx && r.width >= MinSelectionPx && !isDragging) { + Text( + s("members.groupCropSmallWarning", "Kleiner Ausschnitt — Qualität kann leiden."), + style = MaterialTheme.typography.caption, + color = MaterialTheme.colors.secondary, + modifier = Modifier.padding(top = 6.dp), + ) + } + } + + OutlinedButton( + onClick = { resetSelection() }, + enabled = selectionRect != null && !isDragging, + modifier = Modifier + .fillMaxWidth() + .padding(top = 8.dp), + ) { Text(s("members.groupCropResetSelection", "Auswahl zurücksetzen")) } + + Text(s("members.groupCropPreview", "Vorschau"), fontWeight = FontWeight.SemiBold, modifier = Modifier.padding(top = 16.dp)) + val prev = previewBitmap + if (prev != null) { + Image( + bitmap = prev.asImageBitmap(), + contentDescription = s("members.groupCropPreview", "Vorschau"), + modifier = Modifier + .size(160.dp) + .padding(top = 6.dp), + ) + } else { + Text( + s("members.groupCropNoPreview", "Noch keine Auswahl."), + style = MaterialTheme.typography.caption, + modifier = Modifier.padding(top = 6.dp), + ) + } + + OutlinedButton( + onClick = { memberPickerOpen = true }, + modifier = Modifier + .fillMaxWidth() + .padding(top = 16.dp) + .heightIn(min = 48.dp), + ) { + Text( + selectedMemberId?.let { id -> + members.firstOrNull { it.id == id }?.let { "${it.lastName}, ${it.firstName}" } + } ?: s("members.groupCropPickMember", "Mitglied wählen"), + ) + } + + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.padding(top = 8.dp), + ) { + Checkbox(checked = makePrimary, onCheckedChange = { makePrimary = it }) + Text(s("members.groupCropMakePrimary", "Als Hauptfoto verwenden")) + } + + if (saving) { + CircularProgressIndicator(modifier = Modifier.padding(16.dp)) + } + + Button( + onClick = { + val bytes = previewBytes + val mid = selectedMemberId + if (bytes == null || mid == null) return@Button + saving = true + errorMessage = null + statusMessage = null + scope.launch { + runCatching { + dependencies.membersManager.uploadMemberPortrait(clubId, mid, bytes, makePrimary = makePrimary) + dependencies.membersManager.loadMembers(clubId) + }.fold( + onSuccess = { + val m = members.firstOrNull { it.id == mid } + statusMessage = m?.let { + s("members.groupCropSavedFor", "Foto gespeichert für ${it.firstName} ${it.lastName}.") + } ?: s("members.groupCropSaved", "Foto gespeichert.") + resetSelection() + }, + onFailure = { + errorMessage = it.message ?: s("members.groupCropSaveError", "Speichern fehlgeschlagen.") + }, + ) + saving = false + } + }, + enabled = !saving && previewBytes != null && selectedMemberId != null, + modifier = Modifier + .fillMaxWidth() + .padding(top = 12.dp) + .heightIn(min = 48.dp), + ) { + Text(s("members.groupCropSave", "Als Mitgliedsfoto speichern")) + } + + statusMessage?.let { + Text(it, color = MaterialTheme.colors.primary, modifier = Modifier.padding(top = 8.dp)) + } + errorMessage?.let { + Text(it, color = MaterialTheme.colors.error, modifier = Modifier.padding(top = 8.dp)) + } + } + + Spacer(modifier = Modifier.height(24.dp)) + } + + if (memberPickerOpen) { + AlertDialog( + onDismissRequest = { memberPickerOpen = false }, + title = { Text(s("members.groupCropPickMember", "Mitglied wählen")) }, + text = { + Column(modifier = Modifier.fillMaxWidth()) { + OutlinedTextField( + value = memberQuery, + onValueChange = { memberQuery = it }, + label = { Text(s("mobile.search", "Suche")) }, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + ) + LazyColumn(modifier = Modifier.height(320.dp).padding(top = 8.dp)) { + items(filteredMembers, key = { it.id }) { m -> + TextButton( + onClick = { + selectedMemberId = m.id + memberPickerOpen = false + }, + modifier = Modifier.fillMaxWidth(), + ) { + Text("${m.lastName}, ${m.firstName}", modifier = Modifier.fillMaxWidth()) + } + } + } + } + }, + confirmButton = { + TextButton(onClick = { memberPickerOpen = false }) { + Text(s("common.close", "Schließen")) + } + }, + ) + } +} + +private data class ImageDisplayFrame(val dispW: Float, val dispH: Float) + +private fun computeImageDisplaySize(bitmapW: Float, bitmapH: Float, container: IntSize): ImageDisplayFrame { + if (container.width <= 0 || container.height <= 0 || bitmapW <= 0f || bitmapH <= 0f) { + return ImageDisplayFrame(0f, 0f) + } + val cw = container.width.toFloat() + val ch = container.height.toFloat() + val scale = min(cw / bitmapW, ch / bitmapH) + return ImageDisplayFrame(bitmapW * scale, bitmapH * scale) +} diff --git a/mobile-app/composeApp/src/androidMain/kotlin/de/tt_tagebuch/app/ui/MembersExtraScreens.kt b/mobile-app/composeApp/src/androidMain/kotlin/de/tt_tagebuch/app/ui/MembersExtraScreens.kt new file mode 100644 index 00000000..0ac9e84b --- /dev/null +++ b/mobile-app/composeApp/src/androidMain/kotlin/de/tt_tagebuch/app/ui/MembersExtraScreens.kt @@ -0,0 +1,386 @@ +package de.tt_tagebuch.app.ui + +import androidx.activity.compose.BackHandler +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.PickVisualMediaRequest +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +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.imePadding +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.Button +import androidx.compose.material.Card +import androidx.compose.material.CircularProgressIndicator +import androidx.compose.material.MaterialTheme +import androidx.compose.material.OutlinedButton +import androidx.compose.material.OutlinedTextField +import androidx.compose.material.Text +import androidx.compose.material.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.PasswordVisualTransformation +import androidx.compose.ui.unit.dp +import de.tt_tagebuch.app.AppDependencies +import de.tt_tagebuch.shared.api.models.MemberGroupPhotoDto +import de.tt_tagebuch.shared.api.models.MemberTransferConfigEnvelope +import de.tt_tagebuch.shared.api.models.MemberTransferRunBody +import de.tt_tagebuch.shared.api.toAbsoluteUrl +import de.tt_tagebuch.shared.i18n.MobileStrings +import kotlinx.coroutines.launch +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.booleanOrNull +import kotlinx.serialization.json.contentOrNull +import kotlinx.serialization.json.intOrNull +import kotlinx.serialization.json.jsonPrimitive +import kotlinx.serialization.json.buildJsonObject + +private val MembersExtraHorizontalPadding = 20.dp +private val MembersExtraTouchMin = 48.dp + +@Composable +fun MemberGroupPhotoManageRoute( + clubId: Int, + dependencies: AppDependencies, + canReadMembers: Boolean, + canWriteMembers: Boolean, + onBack: () -> Unit, + onOpenPortraitCrop: () -> Unit = {}, +) { + val languageCode = LocalLanguageCode.current + fun s(key: String, fb: String) = MobileStrings.get(languageCode, key, fb) + BackHandler(onBack = onBack) + val androidContext = LocalContext.current + var groupPhotos by remember { mutableStateOf>(emptyList()) } + var groupPhotoBusy by remember { mutableStateOf(false) } + var newGroupPhotoTitle by rememberSaveable { mutableStateOf("") } + var newGroupPhotoDescription by rememberSaveable { mutableStateOf("") } + var loadError by remember { mutableStateOf(null) } + + LaunchedEffect(clubId) { + groupPhotoBusy = true + loadError = null + groupPhotos = runCatching { dependencies.diaryManager.listMemberGroupPhotos(clubId) }.getOrElse { + loadError = it.message + emptyList() + } + groupPhotoBusy = false + } + + val pickGroupPhoto = rememberLauncherForActivityResult(ActivityResultContracts.PickVisualMedia()) { uri -> + if (uri == null || !canWriteMembers) return@rememberLauncherForActivityResult + val title = newGroupPhotoTitle.trim().ifBlank { "Gruppenfoto" } + dependencies.applicationScope.launch { + groupPhotoBusy = true + runCatching { + val bytes = androidContext.contentResolver.openInputStream(uri)?.use { it.readBytes() } ?: return@runCatching + dependencies.diaryManager.uploadMemberGroupPhoto( + clubId, + bytes, + title = title, + description = newGroupPhotoDescription.trim(), + ) + newGroupPhotoTitle = "" + newGroupPhotoDescription = "" + groupPhotos = dependencies.diaryManager.listMemberGroupPhotos(clubId) + } + groupPhotoBusy = false + } + } + + Column( + modifier = Modifier + .fillMaxSize() + .imePadding() + .navigationBarsPadding() + .verticalScroll(rememberScrollState()) + .padding(horizontal = MembersExtraHorizontalPadding, vertical = 16.dp), + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text(s("members.groupPhotoTitle", "Gruppenfoto"), style = MaterialTheme.typography.h6) + TextButton(onClick = onBack) { Text(s("mobile.back", "Zurück")) } + } + Text( + s( + "members.groupPhotoMobileHint", + "Gruppenfotos werden im Verein gespeichert (wie im Tagebuch). Einzelportraits legst du pro Mitglied unter „Profilbild“ fest.", + ), + style = MaterialTheme.typography.body2, + color = MaterialTheme.colors.onSurface.copy(alpha = 0.75f), + modifier = Modifier.padding(bottom = 12.dp), + ) + if (canWriteMembers) { + OutlinedButton( + onClick = onOpenPortraitCrop, + modifier = Modifier + .fillMaxWidth() + .padding(bottom = 12.dp) + .heightIn(min = MembersExtraTouchMin), + ) { + Text(s("members.openPortraitCrop", "Portrait aus Bild zuschneiden")) + } + } + loadError?.let { Text(it, color = MaterialTheme.colors.error, modifier = Modifier.padding(bottom = 8.dp)) } + if (!canReadMembers) { + Text(s("members.noReadPermission", "Keine Berechtigung.")) + return@Column + } + if (canWriteMembers) { + OutlinedTextField( + value = newGroupPhotoTitle, + onValueChange = { newGroupPhotoTitle = it }, + label = { Text(s("diary.groupPhotoTitle", "Titel")) }, + modifier = Modifier.fillMaxWidth(), + enabled = !groupPhotoBusy, + singleLine = true, + ) + OutlinedButton( + onClick = { + pickGroupPhoto.launch(PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageOnly)) + }, + enabled = !groupPhotoBusy, + modifier = Modifier + .fillMaxWidth() + .padding(top = 8.dp) + .heightIn(min = MembersExtraTouchMin), + ) { Text(s("diary.uploadGroupPhoto", "Foto wählen")) } + OutlinedTextField( + value = newGroupPhotoDescription, + onValueChange = { newGroupPhotoDescription = it }, + label = { Text(s("diary.groupPhotoDescription", "Beschreibung (optional)")) }, + modifier = Modifier.fillMaxWidth().padding(top = 8.dp), + enabled = !groupPhotoBusy, + singleLine = false, + maxLines = 3, + ) + } + if (groupPhotoBusy) { + CircularProgressIndicator(modifier = Modifier.padding(16.dp).align(Alignment.CenterHorizontally)) + } + if (groupPhotos.isEmpty() && !groupPhotoBusy) { + Text(s("diary.noGroupPhotos", "Keine Gruppenfotos."), modifier = Modifier.padding(top = 12.dp)) + } else { + val auth = dependencies.diaryAuthHeaders() + groupPhotos.forEach { ph -> + val url = ph.imageUrl?.let { dependencies.apiConfig.toAbsoluteUrl(it) } + Card( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 6.dp), + elevation = 1.dp, + ) { + Column(modifier = Modifier.padding(8.dp)) { + Text(ph.title.orEmpty(), fontWeight = FontWeight.SemiBold) + if (!ph.description.isNullOrBlank()) { + Text(ph.description.orEmpty(), style = MaterialTheme.typography.caption) + } + if (url != null) { + AuthenticatedAsyncImage( + imageUrl = url, + authHeaders = auth, + modifier = Modifier + .fillMaxWidth() + .height(200.dp) + .padding(top = 6.dp), + contentDescription = ph.title, + ) + } + if (canWriteMembers) { + TextButton( + enabled = !groupPhotoBusy, + onClick = { + dependencies.applicationScope.launch { + groupPhotoBusy = true + runCatching { + dependencies.diaryManager.deleteMemberGroupPhoto(clubId, ph.id) + groupPhotos = dependencies.diaryManager.listMemberGroupPhotos(clubId) + } + groupPhotoBusy = false + } + }, + ) { Text(s("common.delete", "Löschen")) } + } + } + } + } + } + } +} + +@Composable +fun MemberTransferRunRoute( + clubId: Int, + dependencies: AppDependencies, + canWriteMembers: Boolean, + onBack: () -> Unit, +) { + val languageCode = LocalLanguageCode.current + fun s(key: String, fb: String) = MobileStrings.get(languageCode, key, fb) + BackHandler(onBack = onBack) + var loading by remember { mutableStateOf(true) } + var envelope by remember { mutableStateOf(null) } + var transferBusy by remember { mutableStateOf(false) } + var loginUser by rememberSaveable { mutableStateOf("") } + var loginPass by rememberSaveable { mutableStateOf("") } + var statusText by remember { mutableStateOf(null) } + + LaunchedEffect(clubId) { + loading = true + envelope = runCatching { dependencies.memberTransferConfigApi.get(clubId) }.getOrNull() + loading = false + } + + Column( + modifier = Modifier + .fillMaxSize() + .imePadding() + .navigationBarsPadding() + .verticalScroll(rememberScrollState()) + .padding(horizontal = MembersExtraHorizontalPadding, vertical = 16.dp), + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text(s("members.transferMembers", "Mitgliedstransfer"), style = MaterialTheme.typography.h6) + TextButton(onClick = onBack) { Text(s("mobile.back", "Zurück")) } + } + if (!canWriteMembers) { + Text(s("members.transferNoWrite", "Keine Schreibberechtigung für Mitglieder.")) + return@Column + } + if (loading) { + CircularProgressIndicator(modifier = Modifier.padding(24.dp).align(Alignment.CenterHorizontally)) + return@Column + } + val cfg = envelope?.config + val hasConfig = cfg != null && + !cfg.transferEndpoint.isNullOrBlank() && + !cfg.transferTemplate.isNullOrBlank() && + !cfg.server.isNullOrBlank() + if (!hasConfig) { + Text( + s( + "members.transferConfigMissing", + "Es ist keine Transfer-Konfiguration hinterlegt. Lege sie unter „Mehr“ → Vereinsstammdaten → Mitgliedstransfer an.", + ), + modifier = Modifier.padding(top = 8.dp), + ) + return@Column + } + val c = cfg!! + Text( + s("members.transferActiveOnlyHint", "Es werden nur aktive Mitglieder übertragen (serverseitig)."), + style = MaterialTheme.typography.caption, + color = MaterialTheme.colors.onSurface.copy(alpha = 0.75f), + modifier = Modifier.padding(bottom = 8.dp), + ) + Text("${s("memberTransferDialog.server", "Server")}: ${c.server}", style = MaterialTheme.typography.body2) + Text("${s("memberTransferDialog.endpoint", "Endpoint")}: ${c.transferEndpoint}", style = MaterialTheme.typography.body2) + Text("${s("memberTransferDialog.method", "Methode")}: ${c.transferMethod ?: "POST"}", style = MaterialTheme.typography.body2) + Text( + "${s("memberTransferDialog.mode", "Modus")}: ${if (c.useBulkMode == true) s("memberTransferDialog.bulkImport", "Bulk") else s("memberTransferDialog.single", "Einzeln")}", + style = MaterialTheme.typography.body2, + modifier = Modifier.padding(bottom = 12.dp), + ) + if (!c.loginEndpoint.isNullOrBlank()) { + Text(s("memberTransferDialog.loginDataOverride", "Login (optional)"), fontWeight = FontWeight.SemiBold) + OutlinedTextField( + value = loginUser, + onValueChange = { loginUser = it }, + label = { Text(s("memberTransferDialog.usernameEmail", "Benutzername / E-Mail")) }, + modifier = Modifier.fillMaxWidth().padding(top = 6.dp), + enabled = !transferBusy, + singleLine = true, + ) + OutlinedTextField( + value = loginPass, + onValueChange = { loginPass = it }, + label = { Text(s("memberTransferDialog.passwordPlaceholder", "Passwort")) }, + modifier = Modifier.fillMaxWidth().padding(top = 6.dp), + enabled = !transferBusy, + singleLine = true, + visualTransformation = PasswordVisualTransformation(), + ) + } + statusText?.let { + Text(it, modifier = Modifier.padding(top = 12.dp), color = MaterialTheme.colors.primary) + } + Button( + onClick = { + dependencies.applicationScope.launch { + transferBusy = true + statusText = null + runCatching { + val creds = buildJsonObject { + val u = loginUser.trim() + val p = loginPass.trim() + if (u.isNotEmpty()) put("username", JsonPrimitive(u)) + if (p.isNotEmpty()) put("password", JsonPrimitive(p)) + } + val body = MemberTransferRunBody( + transferEndpoint = c.transferEndpoint.orEmpty(), + transferMethod = c.transferMethod ?: "POST", + transferFormat = c.transferFormat ?: "json", + transferTemplate = c.transferTemplate.orEmpty(), + useBulkMode = c.useBulkMode == true, + bulkWrapperTemplate = c.bulkWrapperTemplate?.takeIf { it.isNotBlank() }, + loginEndpoint = c.loginEndpoint?.takeIf { it.isNotBlank() }, + loginFormat = c.loginFormat?.takeIf { it.isNotBlank() }, + loginCredentials = creds.takeIf { it.isNotEmpty() }, + ) + val jo = dependencies.membersManager.transferMembers(clubId, body) + val ok = jo["success"]?.jsonPrimitive?.booleanOrNull == true + if (ok) { + val msg = jo["message"]?.jsonPrimitive?.contentOrNull + ?: s("memberTransferDialog.transferSuccessShort", "Transfer abgeschlossen.") + val transferred = jo["transferred"]?.jsonPrimitive?.let { p -> + p.intOrNull?.toString() ?: p.contentOrNull + } + val total = jo["total"]?.jsonPrimitive?.let { p -> + p.intOrNull?.toString() ?: p.contentOrNull + } + statusText = if (transferred != null && total != null) "$msg ($transferred / $total)" else msg + } else { + val err = jo["error"]?.jsonPrimitive?.contentOrNull + ?: jo["message"]?.jsonPrimitive?.contentOrNull + ?: s("memberTransferDialog.transferFailed", "Transfer fehlgeschlagen.") + statusText = err + } + }.onFailure { + statusText = it.message ?: s("memberTransferDialog.transferError", "Netzwerkfehler.") + } + transferBusy = false + } + }, + enabled = !transferBusy, + modifier = Modifier + .fillMaxWidth() + .padding(top = 16.dp) + .heightIn(min = MembersExtraTouchMin), + ) { + Text(if (transferBusy) s("memberTransferDialog.transferring", "Übertrage…") else s("memberTransferDialog.transfer", "Transfer starten")) + } + } +} diff --git a/mobile-app/composeApp/src/androidMain/kotlin/de/tt_tagebuch/app/ui/MembersTtAge.kt b/mobile-app/composeApp/src/androidMain/kotlin/de/tt_tagebuch/app/ui/MembersTtAge.kt new file mode 100644 index 00000000..f74691fd --- /dev/null +++ b/mobile-app/composeApp/src/androidMain/kotlin/de/tt_tagebuch/app/ui/MembersTtAge.kt @@ -0,0 +1,64 @@ +package de.tt_tagebuch.app.ui + +import de.tt_tagebuch.shared.api.models.Member +import java.time.LocalDate +import java.time.Period +import java.time.ZoneId + +/** Erstes Kalenderjahr der laufenden Spielzeit (Aug–Jul). */ +fun getSeasonStartYearFromDateToday(zone: ZoneId = ZoneId.systemDefault()): Int { + val d = LocalDate.now(zone) + return if (d.monthValue >= 8) d.year else d.year - 1 +} + +fun formatSeasonSlash(seasonStartYear: Int): String = + "${seasonStartYear}/${(seasonStartYear + 1).toString().takeLast(2)}" + +fun getStichtagDate(seasonStartYear: Int, classNum: Int): LocalDate = + LocalDate.of(seasonStartYear - (classNum - 1), 1, 1) + +private fun parseBirth(member: Member): LocalDate? { + val raw = member.birthDate?.trim()?.take(10) ?: return null + return runCatching { LocalDate.parse(raw) }.getOrNull() +} + +fun memberBirthLocalDate(member: Member): LocalDate? = parseBirth(member) + +fun memberAgeYears(member: Member, today: LocalDate = LocalDate.now()): Int? { + val b = parseBirth(member) ?: return null + return Period.between(b, today).years +} + +fun isFemaleGenderMember(member: Member): Boolean = + member.gender?.trim()?.lowercase() == "female" + +/** + * Jugendklasse exklusiv (J9…J19) oder adult; `null` = kein gültiges Geburtsdatum. + */ +fun getExclusiveJugendClass(member: Member, seasonStartYear: Int): String? { + val t = parseBirth(member)?.toEpochDay() ?: return null + fun c(k: Int) = getStichtagDate(seasonStartYear, k).toEpochDay() + val c9 = c(9) + val c11 = c(11) + val c13 = c(13) + val c15 = c(15) + val c19 = c(19) + if (t < c19) return "adult" + if (t >= c9) return "J9" + if (t >= c11 && t < c9) return "J11" + if (t >= c13 && t < c11) return "J13" + if (t >= c15 && t < c13) return "J15" + if (t >= c19 && t < c15) return "J19" + return "adult" +} + +fun memberMatchesTtAgeClass(member: Member, filterKey: String, seasonStartYear: Int): Boolean { + if (filterKey.isEmpty() || filterKey == "range") return true + val jClass = getExclusiveJugendClass(member, seasonStartYear) ?: return false + return when { + filterKey == "adult" -> jClass == "adult" + filterKey.startsWith("J") -> jClass == filterKey + filterKey.startsWith("M") -> isFemaleGenderMember(member) && jClass == "J${filterKey.substring(1)}" + else -> true + } +} diff --git a/mobile-app/composeApp/src/androidMain/kotlin/de/tt_tagebuch/app/ui/TrainingStatsScreen.kt b/mobile-app/composeApp/src/androidMain/kotlin/de/tt_tagebuch/app/ui/TrainingStatsScreen.kt index b59c2d03..9b508101 100644 --- a/mobile-app/composeApp/src/androidMain/kotlin/de/tt_tagebuch/app/ui/TrainingStatsScreen.kt +++ b/mobile-app/composeApp/src/androidMain/kotlin/de/tt_tagebuch/app/ui/TrainingStatsScreen.kt @@ -65,6 +65,22 @@ private val weekdayFilterLabels = listOf( "Samstag" to "6", ) +/** Bits für einklappbare Statistik-Bereiche (Start: alle zu). */ +private const val EXP_OVERVIEW = 0 +private const val EXP_MONTHLY = 1 +private const val EXP_WEEKDAY = 2 +private const val EXP_STRUCTURE = 3 +private const val EXP_BEST_DAY = 4 +private const val EXP_GROUPS = 5 +private const val EXP_AGE = 6 +private const val EXP_RAW = 7 +private const val EXP_TRAINING_DAYS = 8 +private const val EXP_MEMBERS = 9 + +private fun statsPanelExpanded(mask: Int, bit: Int): Boolean = (mask shr bit) and 1 == 1 + +private fun statsPanelToggle(mask: Int, bit: Int): Int = mask xor (1 shl bit) + @Composable internal fun TrainingStatsScreen(dependencies: AppDependencies) { val clubState by dependencies.clubManager.state.collectAsState() @@ -76,8 +92,7 @@ internal fun TrainingStatsScreen(dependencies: AppDependencies) { var selectedWeekday by rememberSaveable { mutableStateOf("all") } var selectedTrainingDay by rememberSaveable { mutableStateOf("all") } var selectedTrainingGroup by rememberSaveable { mutableStateOf("all") } - var showTrainingDays by rememberSaveable { mutableStateOf(true) } - var showMembers by rememberSaveable { mutableStateOf(false) } + var expandedPanelsMask by rememberSaveable { mutableStateOf(0) } var sortField by rememberSaveable { mutableStateOf("name") } var sortAsc by rememberSaveable { mutableStateOf(true) } @@ -287,193 +302,241 @@ internal fun TrainingStatsScreen(dependencies: AppDependencies) { } } - StatsSectionTitle(statsTr("trainingStats.summary", "Übersicht")) - Row(modifier = Modifier.fillMaxWidth()) { - StatMiniCard( - title = statsTr("members.activeMembers", "Aktive Mitglieder"), - value = filteredMembers.size.toString(), - modifier = Modifier.weight(1f), - ) - StatMiniCard( - title = statsTr("trainingStats.trainingDays12m", "Trainingstage (Filter)"), - value = filteredTrainingDays.size.toString(), - modifier = Modifier.weight(1f), - ) - } - Row(modifier = Modifier.fillMaxWidth()) { - StatMiniCard( - title = statsTr("trainingStats.averageParticipants", "Ø Teilnehmer je Training"), - value = "%.1f".format(filteredOverview.averageParticipants), - modifier = Modifier.weight(1f), - ) - StatMiniCard( - title = statsTr("trainingStats.totalParticipations", "Teilnahmen gesamt"), - value = filteredOverview.totalParticipants.toString(), - modifier = Modifier.weight(1f), - ) - } - Row(modifier = Modifier.fillMaxWidth()) { - StatMiniCard( - title = statsTr("trainingStats.attendanceRate12m", "Anwesenheitsquote %"), - value = "%.1f %%".format(filteredOverview.attendanceRate), - modifier = Modifier.weight(1f), - ) - StatMiniCard( - title = statsTr("trainingStats.notInTraining", "Nicht im Training"), - value = filteredMembers.count { it.notInTraining }.toString(), - modifier = Modifier.weight(1f), - ) - } - - StatsSectionTitle(statsTr("trainingStats.monthlyTrend", "Monatlicher Verlauf")) - Text( - "${filteredMonthlyTrend.size} Monate", - style = MaterialTheme.typography.caption, - modifier = Modifier.padding(bottom = 4.dp), + CollapsibleHeader( + title = statsTr("trainingStats.panelSummary", "Kennzahlen (Filter)"), + expanded = statsPanelExpanded(expandedPanelsMask, EXP_OVERVIEW), + onToggle = { expandedPanelsMask = statsPanelToggle(expandedPanelsMask, EXP_OVERVIEW) }, ) - filteredMonthlyTrend.forEach { month -> - Row( - modifier = Modifier - .fillMaxWidth() - .padding(vertical = 6.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - Column(modifier = Modifier.weight(1f)) { - Text(month.label, fontWeight = FontWeight.SemiBold) - Text( - "${month.trainingCount} ${statsTr("trainingStats.trainingDaysShort", "Trainingstage")}", - style = MaterialTheme.typography.caption, - ) - } - Column(modifier = Modifier.widthIn(min = 96.dp), horizontalAlignment = Alignment.End) { - Text("%.1f".format(month.averageParticipants), fontWeight = FontWeight.Medium) - } + if (statsPanelExpanded(expandedPanelsMask, EXP_OVERVIEW)) { + Row(modifier = Modifier.fillMaxWidth()) { + StatMiniCard( + title = statsTr("members.activeMembers", "Aktive Mitglieder"), + value = filteredMembers.size.toString(), + modifier = Modifier.weight(1f), + ) + StatMiniCard( + title = statsTr("trainingStats.trainingDays12m", "Trainingstage (Filter)"), + value = filteredTrainingDays.size.toString(), + modifier = Modifier.weight(1f), + ) + } + Row(modifier = Modifier.fillMaxWidth()) { + StatMiniCard( + title = statsTr("trainingStats.averageParticipants", "Ø Teilnehmer je Training"), + value = "%.1f".format(filteredOverview.averageParticipants), + modifier = Modifier.weight(1f), + ) + StatMiniCard( + title = statsTr("trainingStats.totalParticipations", "Teilnahmen gesamt"), + value = filteredOverview.totalParticipants.toString(), + modifier = Modifier.weight(1f), + ) + } + Row(modifier = Modifier.fillMaxWidth()) { + StatMiniCard( + title = statsTr("trainingStats.attendanceRate12m", "Anwesenheitsquote %"), + value = "%.1f %%".format(filteredOverview.attendanceRate), + modifier = Modifier.weight(1f), + ) + StatMiniCard( + title = statsTr("trainingStats.notInTraining", "Nicht im Training"), + value = filteredMembers.count { it.notInTraining }.toString(), + modifier = Modifier.weight(1f), + ) } - LinearProgressIndicator( - progress = (month.averageParticipants / maxMonthAvg).toFloat().coerceIn(0f, 1f), - modifier = Modifier - .fillMaxWidth() - .height(6.dp), - ) } - StatsSectionTitle(statsTr("trainingStats.weekdayStats", "Trainingstage nach Wochentag")) - Text( - "${filteredWeekdayStats.size} ${statsTr("trainingStats.weekdays", "Wochentage")}", - style = MaterialTheme.typography.caption, - modifier = Modifier.padding(bottom = 4.dp), + CollapsibleHeader( + title = statsTr("trainingStats.panelMonthlyTrend", "Monatlicher Verlauf"), + expanded = statsPanelExpanded(expandedPanelsMask, EXP_MONTHLY), + onToggle = { expandedPanelsMask = statsPanelToggle(expandedPanelsMask, EXP_MONTHLY) }, ) - filteredWeekdayStats.chunked(2).forEach { row -> - Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) { - row.forEach { w -> - StatMiniCard( - title = w.weekday, - value = "${w.trainingCount} Term.\nØ %.1f".format(w.averageParticipants), - modifier = Modifier.weight(1f), - ) + if (statsPanelExpanded(expandedPanelsMask, EXP_MONTHLY)) { + Text( + "${filteredMonthlyTrend.size} Monate", + style = MaterialTheme.typography.caption, + modifier = Modifier.padding(bottom = 4.dp), + ) + filteredMonthlyTrend.forEach { month -> + Row( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 6.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Column(modifier = Modifier.weight(1f)) { + Text(month.label, fontWeight = FontWeight.SemiBold) + Text( + "${month.trainingCount} ${statsTr("trainingStats.trainingDaysShort", "Trainingstage")}", + style = MaterialTheme.typography.caption, + ) + } + Column(modifier = Modifier.widthIn(min = 96.dp), horizontalAlignment = Alignment.End) { + Text("%.1f".format(month.averageParticipants), fontWeight = FontWeight.Medium) + } } - if (row.size == 1) Spacer(modifier = Modifier.weight(1f)) + LinearProgressIndicator( + progress = (month.averageParticipants / maxMonthAvg).toFloat().coerceIn(0f, 1f), + modifier = Modifier + .fillMaxWidth() + .height(6.dp), + ) } } - StatsSectionTitle(statsTr("trainingStats.memberStructure", "Mitgliederstruktur")) - Row(modifier = Modifier.fillMaxWidth()) { - StatMiniCard( - title = statsTr("trainingStats.distHighlyActive", "Sehr aktiv"), - value = filteredMemberDistribution.highlyActive.toString(), - modifier = Modifier.weight(1f), - ) - StatMiniCard( - title = statsTr("trainingStats.distRegular", "Regelmäßig"), - value = filteredMemberDistribution.regular.toString(), - modifier = Modifier.weight(1f), - ) - } - Row(modifier = Modifier.fillMaxWidth()) { - StatMiniCard( - title = statsTr("trainingStats.distOccasional", "Gelegentlich"), - value = filteredMemberDistribution.occasional.toString(), - modifier = Modifier.weight(1f), - ) - StatMiniCard( - title = statsTr("trainingStats.distInactive", "Ohne Teilnahme"), - value = filteredMemberDistribution.inactive.toString(), - modifier = Modifier.weight(1f), - ) - } - - StatsSectionTitle(statsTr("trainingStats.bestDay", "Stärkster Trainingstag")) - val best = filteredOverview.bestTrainingDay - Card(modifier = Modifier.fillMaxWidth(), elevation = 2.dp) { - Column(modifier = Modifier.padding(12.dp)) { - if (best != null) { - Text(TrainingStatsDerived.formatDateGerman(best.date), fontWeight = FontWeight.Bold) - Text(TrainingStatsDerived.weekdayGerman(best.date), style = MaterialTheme.typography.body2) - Text( - "${best.participantCount} ${statsTr("trainingStats.participants", "Teilnehmer")}", - style = MaterialTheme.typography.h6, - modifier = Modifier.padding(top = 4.dp), - ) - Text( - statsTr("trainingStats.bestDayHint", "Beim bestbesuchten Training im Filterzeitraum."), - style = MaterialTheme.typography.caption, - ) - } else { - Text(statsTr("trainingStats.noData", "Keine Daten")) - } - } - } - - StatsSectionTitle(statsTr("trainingStats.groupPerformance", "Entwicklung pro Gruppe")) - Text( - "${groupPerformance.size} ${statsTr("trainingStats.groups", "Gruppen")}", - style = MaterialTheme.typography.caption, + CollapsibleHeader( + title = statsTr("trainingStats.panelWeekdayStats", "Trainingstage nach Wochentag"), + expanded = statsPanelExpanded(expandedPanelsMask, EXP_WEEKDAY), + onToggle = { expandedPanelsMask = statsPanelToggle(expandedPanelsMask, EXP_WEEKDAY) }, ) - groupPerformance.forEach { g -> - Card( - modifier = Modifier - .fillMaxWidth() - .padding(vertical = 4.dp), - elevation = 1.dp, - ) { - Column(modifier = Modifier.padding(10.dp)) { - Text(g.name, fontWeight = FontWeight.SemiBold) - Text("${g.memberCount} ${statsTr("members.members", "Mitglieder")}", style = MaterialTheme.typography.caption) - Text( - "%.1f Ø / 12M · %.1f %% ${statsTr("trainingStats.presence", "Anwesenheit")}" - .format(g.averageParticipations12Months, g.participationRate), - style = MaterialTheme.typography.body2, - modifier = Modifier.padding(top = 4.dp), - ) + if (statsPanelExpanded(expandedPanelsMask, EXP_WEEKDAY)) { + Text( + "${filteredWeekdayStats.size} ${statsTr("trainingStats.weekdays", "Wochentage")}", + style = MaterialTheme.typography.caption, + modifier = Modifier.padding(bottom = 4.dp), + ) + filteredWeekdayStats.chunked(2).forEach { row -> + Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) { + row.forEach { w -> + StatMiniCard( + title = w.weekday, + value = "${w.trainingCount} Term.\nØ %.1f".format(w.averageParticipants), + modifier = Modifier.weight(1f), + ) + } + if (row.size == 1) Spacer(modifier = Modifier.weight(1f)) } } } - StatsSectionTitle(statsTr("trainingStats.ageGroups", "Anwesenheit nach Altersklasse")) - ageGroupStats.chunked(2).forEach { row -> - Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) { - row.forEach { a -> - StatMiniCard( - title = a.label, - value = "${a.memberCount}\nØ %.1f / 12M".format(a.averageParticipations12Months), - modifier = Modifier.weight(1f), - ) - } - if (row.size == 1) Spacer(modifier = Modifier.weight(1f)) + CollapsibleHeader( + title = statsTr("trainingStats.panelMemberStructure", "Mitgliederstruktur"), + expanded = statsPanelExpanded(expandedPanelsMask, EXP_STRUCTURE), + onToggle = { expandedPanelsMask = statsPanelToggle(expandedPanelsMask, EXP_STRUCTURE) }, + ) + if (statsPanelExpanded(expandedPanelsMask, EXP_STRUCTURE)) { + Row(modifier = Modifier.fillMaxWidth()) { + StatMiniCard( + title = statsTr("trainingStats.distHighlyActive", "Sehr aktiv"), + value = filteredMemberDistribution.highlyActive.toString(), + modifier = Modifier.weight(1f), + ) + StatMiniCard( + title = statsTr("trainingStats.distRegular", "Regelmäßig"), + value = filteredMemberDistribution.regular.toString(), + modifier = Modifier.weight(1f), + ) + } + Row(modifier = Modifier.fillMaxWidth()) { + StatMiniCard( + title = statsTr("trainingStats.distOccasional", "Gelegentlich"), + value = filteredMemberDistribution.occasional.toString(), + modifier = Modifier.weight(1f), + ) + StatMiniCard( + title = statsTr("trainingStats.distInactive", "Ohne Teilnahme"), + value = filteredMemberDistribution.inactive.toString(), + modifier = Modifier.weight(1f), + ) } } - StatsSectionTitle(statsTr("trainingStats.rawCounts", "Rohzahlen (Verein)")) - StatsDetailLine(statsTr("trainingStats.trainings12Months", "Trainings 12 Monate"), s.trainingsCount12Months.toString()) - StatsDetailLine(statsTr("trainingStats.trainings3Months", "Trainings 3 Monate"), s.trainingsCount3Months.toString()) - StatsDetailLine(statsTr("members.activeMembers", "Aktive Mitglieder (Backend)"), s.overview.activeMembersCount.toString()) + CollapsibleHeader( + title = statsTr("trainingStats.panelBestDay", "Stärkster Trainingstag"), + expanded = statsPanelExpanded(expandedPanelsMask, EXP_BEST_DAY), + onToggle = { expandedPanelsMask = statsPanelToggle(expandedPanelsMask, EXP_BEST_DAY) }, + ) + if (statsPanelExpanded(expandedPanelsMask, EXP_BEST_DAY)) { + val best = filteredOverview.bestTrainingDay + Card(modifier = Modifier.fillMaxWidth(), elevation = 2.dp) { + Column(modifier = Modifier.padding(12.dp)) { + if (best != null) { + Text(TrainingStatsDerived.formatDateGerman(best.date), fontWeight = FontWeight.Bold) + Text(TrainingStatsDerived.weekdayGerman(best.date), style = MaterialTheme.typography.body2) + Text( + "${best.participantCount} ${statsTr("trainingStats.participants", "Teilnehmer")}", + style = MaterialTheme.typography.h6, + modifier = Modifier.padding(top = 4.dp), + ) + Text( + statsTr("trainingStats.bestDayHint", "Beim bestbesuchten Training im Filterzeitraum."), + style = MaterialTheme.typography.caption, + ) + } else { + Text(statsTr("trainingStats.noData", "Keine Daten")) + } + } + } + } + + CollapsibleHeader( + title = statsTr("trainingStats.panelGroupPerformance", "Entwicklung pro Gruppe"), + expanded = statsPanelExpanded(expandedPanelsMask, EXP_GROUPS), + onToggle = { expandedPanelsMask = statsPanelToggle(expandedPanelsMask, EXP_GROUPS) }, + ) + if (statsPanelExpanded(expandedPanelsMask, EXP_GROUPS)) { + Text( + "${groupPerformance.size} ${statsTr("trainingStats.groups", "Gruppen")}", + style = MaterialTheme.typography.caption, + ) + groupPerformance.forEach { g -> + Card( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 4.dp), + elevation = 1.dp, + ) { + Column(modifier = Modifier.padding(10.dp)) { + Text(g.name, fontWeight = FontWeight.SemiBold) + Text("${g.memberCount} ${statsTr("members.members", "Mitglieder")}", style = MaterialTheme.typography.caption) + Text( + "%.1f Ø / 12M · %.1f %% ${statsTr("trainingStats.presence", "Anwesenheit")}" + .format(g.averageParticipations12Months, g.participationRate), + style = MaterialTheme.typography.body2, + modifier = Modifier.padding(top = 4.dp), + ) + } + } + } + } + + CollapsibleHeader( + title = statsTr("trainingStats.panelAgeGroups", "Anwesenheit nach Altersklasse"), + expanded = statsPanelExpanded(expandedPanelsMask, EXP_AGE), + onToggle = { expandedPanelsMask = statsPanelToggle(expandedPanelsMask, EXP_AGE) }, + ) + if (statsPanelExpanded(expandedPanelsMask, EXP_AGE)) { + ageGroupStats.chunked(2).forEach { row -> + Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) { + row.forEach { a -> + StatMiniCard( + title = a.label, + value = "${a.memberCount}\nØ %.1f / 12M".format(a.averageParticipations12Months), + modifier = Modifier.weight(1f), + ) + } + if (row.size == 1) Spacer(modifier = Modifier.weight(1f)) + } + } + } + + CollapsibleHeader( + title = statsTr("trainingStats.rawCounts", "Rohzahlen (Verein)"), + expanded = statsPanelExpanded(expandedPanelsMask, EXP_RAW), + onToggle = { expandedPanelsMask = statsPanelToggle(expandedPanelsMask, EXP_RAW) }, + ) + if (statsPanelExpanded(expandedPanelsMask, EXP_RAW)) { + StatsDetailLine(statsTr("trainingStats.trainings12Months", "Trainings 12 Monate"), s.trainingsCount12Months.toString()) + StatsDetailLine(statsTr("trainingStats.trainings3Months", "Trainings 3 Monate"), s.trainingsCount3Months.toString()) + StatsDetailLine(statsTr("members.activeMembers", "Aktive Mitglieder (Backend)"), s.overview.activeMembersCount.toString()) + } CollapsibleHeader( title = statsTr("trainingStats.trainingDays", "Trainingstage"), - expanded = showTrainingDays, - onToggle = { showTrainingDays = !showTrainingDays }, + expanded = statsPanelExpanded(expandedPanelsMask, EXP_TRAINING_DAYS), + onToggle = { expandedPanelsMask = statsPanelToggle(expandedPanelsMask, EXP_TRAINING_DAYS) }, ) - if (showTrainingDays) { + if (statsPanelExpanded(expandedPanelsMask, EXP_TRAINING_DAYS)) { filteredTrainingDays.forEach { day -> Card( modifier = Modifier @@ -510,10 +573,10 @@ internal fun TrainingStatsScreen(dependencies: AppDependencies) { CollapsibleHeader( title = statsTr("trainingStats.memberParticipations", "Mitglieder-Teilnahmen"), - expanded = showMembers, - onToggle = { showMembers = !showMembers }, + expanded = statsPanelExpanded(expandedPanelsMask, EXP_MEMBERS), + onToggle = { expandedPanelsMask = statsPanelToggle(expandedPanelsMask, EXP_MEMBERS) }, ) - if (showMembers) { + if (statsPanelExpanded(expandedPanelsMask, EXP_MEMBERS)) { Row( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp), diff --git a/mobile-app/shared/src/commonMain/kotlin/de/tt_tagebuch/shared/api/MembersApi.kt b/mobile-app/shared/src/commonMain/kotlin/de/tt_tagebuch/shared/api/MembersApi.kt index 5da5c1b0..9dc4becd 100644 --- a/mobile-app/shared/src/commonMain/kotlin/de/tt_tagebuch/shared/api/MembersApi.kt +++ b/mobile-app/shared/src/commonMain/kotlin/de/tt_tagebuch/shared/api/MembersApi.kt @@ -2,7 +2,9 @@ package de.tt_tagebuch.shared.api import de.tt_tagebuch.shared.api.http.AuthedHttpClient import de.tt_tagebuch.shared.api.models.Member +import de.tt_tagebuch.shared.api.models.MemberQuickMutationResponse import de.tt_tagebuch.shared.api.models.MemberSetBody +import de.tt_tagebuch.shared.api.models.MemberTransferRunBody import io.ktor.client.call.body import io.ktor.client.request.forms.formData import io.ktor.client.request.get @@ -14,6 +16,7 @@ import io.ktor.http.Headers import io.ktor.http.HttpHeaders import io.ktor.http.contentType import io.ktor.client.request.forms.MultiPartFormDataContent +import kotlinx.serialization.json.JsonObject class MembersApi( private val client: AuthedHttpClient, @@ -28,6 +31,24 @@ class MembersApi( } } + suspend fun updateRatingsFromMyTischtennis(clubId: Int) { + client.http.post("/api/clubmembers/update-ratings/$clubId") + } + + suspend fun quickUpdateTestMembership(clubId: Int, memberId: Int): MemberQuickMutationResponse { + return client.http.post("/api/clubmembers/quick-update-test-membership/$clubId/$memberId").body() + } + + suspend fun quickUpdateMemberFormHandedOver(clubId: Int, memberId: Int): MemberQuickMutationResponse { + return client.http.post("/api/clubmembers/quick-update-member-form/$clubId/$memberId").body() + } + + suspend fun transferMembers(clubId: Int, body: MemberTransferRunBody): JsonObject { + return client.http.post("/api/clubmembers/transfer/$clubId") { + setBody(body) + }.body() + } + suspend fun uploadMemberImage(clubId: Int, memberId: Int, imageBytes: ByteArray, makePrimary: Boolean = true) { client.http.post("/api/clubmembers/image/$clubId/$memberId") { if (makePrimary) { diff --git a/mobile-app/shared/src/commonMain/kotlin/de/tt_tagebuch/shared/api/models/ClubPermissionHelpers.kt b/mobile-app/shared/src/commonMain/kotlin/de/tt_tagebuch/shared/api/models/ClubPermissionHelpers.kt index 68a5276f..7cfae0ca 100644 --- a/mobile-app/shared/src/commonMain/kotlin/de/tt_tagebuch/shared/api/models/ClubPermissionHelpers.kt +++ b/mobile-app/shared/src/commonMain/kotlin/de/tt_tagebuch/shared/api/models/ClubPermissionHelpers.kt @@ -107,3 +107,8 @@ fun UserClubPermissions.canReadStatistics(): Boolean { if (isOwner) return true return permissions.boolAt("statistics", "read") } + +fun UserClubPermissions.canWriteMyTischtennis(): Boolean { + if (isOwner) return true + return permissions.boolAt("mytischtennis", "write") +} diff --git a/mobile-app/shared/src/commonMain/kotlin/de/tt_tagebuch/shared/api/models/Member.kt b/mobile-app/shared/src/commonMain/kotlin/de/tt_tagebuch/shared/api/models/Member.kt index dcf70873..81f2fbeb 100644 --- a/mobile-app/shared/src/commonMain/kotlin/de/tt_tagebuch/shared/api/models/Member.kt +++ b/mobile-app/shared/src/commonMain/kotlin/de/tt_tagebuch/shared/api/models/Member.kt @@ -27,6 +27,8 @@ data class Member( val trainingParticipations: Int? = null, val notInTraining: Boolean? = null, val missedTrainingWeeks: Int? = null, + /** Aus Training-Stats gemergt (aktive Mitglieder); leer wenn keine Zuordnung. */ + val trainingGroups: List = emptyList(), val contacts: List = emptyList(), val images: List = emptyList(), val primaryImageId: Int? = null, diff --git a/mobile-app/shared/src/commonMain/kotlin/de/tt_tagebuch/shared/api/models/MemberQuickMutationResponse.kt b/mobile-app/shared/src/commonMain/kotlin/de/tt_tagebuch/shared/api/models/MemberQuickMutationResponse.kt new file mode 100644 index 00000000..99edba53 --- /dev/null +++ b/mobile-app/shared/src/commonMain/kotlin/de/tt_tagebuch/shared/api/models/MemberQuickMutationResponse.kt @@ -0,0 +1,10 @@ +package de.tt_tagebuch.shared.api.models + +import kotlinx.serialization.Serializable + +@Serializable +data class MemberQuickMutationResponse( + val success: Boolean? = null, + val message: String? = null, + val error: String? = null, +) diff --git a/mobile-app/shared/src/commonMain/kotlin/de/tt_tagebuch/shared/api/models/MemberTransferDtos.kt b/mobile-app/shared/src/commonMain/kotlin/de/tt_tagebuch/shared/api/models/MemberTransferDtos.kt index 0d99b35a..7be1844f 100644 --- a/mobile-app/shared/src/commonMain/kotlin/de/tt_tagebuch/shared/api/models/MemberTransferDtos.kt +++ b/mobile-app/shared/src/commonMain/kotlin/de/tt_tagebuch/shared/api/models/MemberTransferDtos.kt @@ -45,3 +45,16 @@ data class MemberTransferConfigSaveBody( val useBulkMode: Boolean = false, val bulkWrapperTemplate: String? = null, ) + +@Serializable +data class MemberTransferRunBody( + val transferEndpoint: String, + val transferMethod: String = "POST", + val transferFormat: String = "json", + val transferTemplate: String, + val useBulkMode: Boolean = false, + val bulkWrapperTemplate: String? = null, + val loginEndpoint: String? = null, + val loginFormat: String? = null, + val loginCredentials: JsonObject? = null, +) diff --git a/mobile-app/shared/src/commonMain/kotlin/de/tt_tagebuch/shared/state/MembersManager.kt b/mobile-app/shared/src/commonMain/kotlin/de/tt_tagebuch/shared/state/MembersManager.kt index b5c8d1d1..a34eab05 100644 --- a/mobile-app/shared/src/commonMain/kotlin/de/tt_tagebuch/shared/state/MembersManager.kt +++ b/mobile-app/shared/src/commonMain/kotlin/de/tt_tagebuch/shared/state/MembersManager.kt @@ -3,24 +3,30 @@ package de.tt_tagebuch.shared.state import de.tt_tagebuch.shared.api.MemberActivitiesApi import de.tt_tagebuch.shared.api.MembersApi import de.tt_tagebuch.shared.api.TrainingGroupsApi +import de.tt_tagebuch.shared.api.TrainingStatsApi import de.tt_tagebuch.shared.api.TrainingTimesApi import de.tt_tagebuch.shared.api.models.CreateTrainingTimeBody import de.tt_tagebuch.shared.api.models.Member import de.tt_tagebuch.shared.api.models.MemberActivityStatDto +import de.tt_tagebuch.shared.api.models.MemberQuickMutationResponse import de.tt_tagebuch.shared.api.models.MemberLastParticipationDto import de.tt_tagebuch.shared.api.models.MemberSetBody +import de.tt_tagebuch.shared.api.models.MemberTransferRunBody import de.tt_tagebuch.shared.api.models.TrainingGroupDto +import de.tt_tagebuch.shared.api.models.TrainingStatsMember import de.tt_tagebuch.shared.api.models.TrainingTimeDto import de.tt_tagebuch.shared.api.models.UpdateTrainingTimeBody import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.serialization.json.JsonObject class MembersManager( private val membersApi: MembersApi, private val trainingGroupsApi: TrainingGroupsApi, private val memberActivitiesApi: MemberActivitiesApi, private val trainingTimesApi: TrainingTimesApi, + private val trainingStatsApi: TrainingStatsApi, ) { private val _state = MutableStateFlow(MembersState()) val state: StateFlow = _state.asStateFlow() @@ -29,19 +35,37 @@ class MembersManager( _state.value = _state.value.copy(isLoading = true, error = null) try { val members = membersApi.listMembers(clubId) + val merged = runCatching { trainingStatsApi.getStats(clubId) } + .fold( + onSuccess = { mergeTrainingStatsIntoMembers(members, it.members) }, + onFailure = { members }, + ) .sortedWith(compareBy { it.lastName.lowercase() }.thenBy { it.firstName.lowercase() }) - _state.value = _state.value.copy(members = members, isLoading = false) + _state.value = _state.value.copy(members = merged, isLoading = false) } catch (t: Throwable) { _state.value = _state.value.copy(isLoading = false, error = t.toUserMessage("Mitglieder konnten nicht geladen werden")) } } + suspend fun updateRatingsFromMyTischtennis(clubId: Int) { + membersApi.updateRatingsFromMyTischtennis(clubId) + } + + suspend fun quickUpdateTestMembership(clubId: Int, memberId: Int): MemberQuickMutationResponse = + membersApi.quickUpdateTestMembership(clubId, memberId) + + suspend fun quickUpdateMemberFormHandedOver(clubId: Int, memberId: Int): MemberQuickMutationResponse = + membersApi.quickUpdateMemberFormHandedOver(clubId, memberId) + + suspend fun transferMembers(clubId: Int, body: MemberTransferRunBody): JsonObject = + membersApi.transferMembers(clubId, body) + suspend fun saveMember(clubId: Int, body: MemberSetBody) { membersApi.setMember(clubId, body) } - suspend fun uploadMemberPortrait(clubId: Int, memberId: Int, imageBytes: ByteArray) { - membersApi.uploadMemberImage(clubId, memberId, imageBytes, makePrimary = true) + suspend fun uploadMemberPortrait(clubId: Int, memberId: Int, imageBytes: ByteArray, makePrimary: Boolean = true) { + membersApi.uploadMemberImage(clubId, memberId, imageBytes, makePrimary = makePrimary) } suspend fun listTrainingGroups(clubId: Int): List = trainingGroupsApi.listGroups(clubId) @@ -94,3 +118,27 @@ class MembersManager( _state.value = MembersState() } } + +private fun mergeTrainingStatsIntoMembers(members: List, stats: List): List { + val byId = stats.associateBy { it.id } + return members.map { m -> + val s = byId[m.id] + if (s == null) { + m.copy( + trainingParticipations = 0, + missedTrainingWeeks = 0, + notInTraining = false, + lastTraining = null, + trainingGroups = emptyList(), + ) + } else { + m.copy( + trainingParticipations = s.participationTotal, + lastTraining = s.lastTraining, + notInTraining = s.notInTraining, + missedTrainingWeeks = s.missedTrainingWeeks, + trainingGroups = s.trainingGroups, + ) + } + } +}