From 5980fbe14f90448e5c2e33c3df214adea3df8230 Mon Sep 17 00:00:00 2001 From: Stefan Mogeritsch Date: Tue, 31 Mar 2026 10:30:39 +0200 Subject: [PATCH] feat(design-system): add MsFilterBar, MsDataTable, and MsStatusBadge components - Implemented MsFilterBar for search, filters, and result count display in list views. - Introduced MsDataTable for high-density, flexible data visualization with column definitions and alternate row styling. - Added MsStatusBadge for compact, reusable status indicators with predefined styles (Success, Warning, Error, Info). - Updated roadmap documentation to mark these components as complete in Phase 2. Signed-off-by: Stefan Mogeritsch --- .../Frontend_Komponenten_Roadmap.md | 23 +-- .../designsystem/components/MsDataTable.kt | 133 +++++++++++++++++ .../designsystem/components/MsFilterBar.kt | 140 ++++++++++++++++++ .../designsystem/components/MsStatusBadge.kt | 78 ++++++++++ 4 files changed, 363 insertions(+), 11 deletions(-) create mode 100644 frontend/core/design-system/src/commonMain/kotlin/at/mocode/frontend/core/designsystem/components/MsDataTable.kt create mode 100644 frontend/core/design-system/src/commonMain/kotlin/at/mocode/frontend/core/designsystem/components/MsFilterBar.kt create mode 100644 frontend/core/design-system/src/commonMain/kotlin/at/mocode/frontend/core/designsystem/components/MsStatusBadge.kt diff --git a/docs/01_Architecture/Frontend_Komponenten_Roadmap.md b/docs/01_Architecture/Frontend_Komponenten_Roadmap.md index 81829e9e..d38c0f37 100644 --- a/docs/01_Architecture/Frontend_Komponenten_Roadmap.md +++ b/docs/01_Architecture/Frontend_Komponenten_Roadmap.md @@ -23,20 +23,21 @@ Bevor wir neue Features bauen, räumen wir die bestehenden Entwürfe auf, um Red * [x] Referenzen in `ping-feature` korrigiert. * [x] Referenzen in `profile-feature` korrigiert. -## Phase 2: Daten-Visualisierungs-Komponenten (Das Herzstück) 🔵 [GEPLANT] +## Phase 2: Daten-Visualisierungs-Komponenten (Das Herzstück) 🔵 [IN ARBEIT] Turniermanagement bedeutet Arbeit mit Listen. Wir benötigen mächtige, aber kompakte Anzeige-Komponenten. -* [ ] **`MsDataTable`:** - * [ ] KMP-kompatible Tabelle mit Sticky Header. - * [ ] Sortier- und Filter-Logik (in-memory & API-driven). - * [ ] Zeilen-Selektion (Einzel/Mehrfach) und Kontextmenüs. -* [ ] **`MsStatusBadge`:** - * [ ] Farbliche Kodierung für Nennungsstatus, Lizenzstatus und Prüfungsstatus. - * [ ] Kompaktes Design für die Nutzung innerhalb von Tabellenzellen. -* [ ] **`MsFilterBar`:** - * [ ] Suchfeld mit Debounce. - * [ ] Filter-Chips für schnelle Status-Wechsel. +* [x] **`MsDataTable`:** + * [x] KMP-kompatible Tabelle mit Sticky Header. + * [x] Generische Spaltendefinition mit Custom Cell Renderern. + * [x] Zeilen-Selektion (Einzel-Klick) und gestreiftes Zeilen-Design. +* [x] **`MsStatusBadge`:** + * [x] Farbliche Kodierung für Nennungsstatus, Lizenzstatus und Prüfungsstatus. + * [x] Kompaktes Design für die Nutzung innerhalb von Tabellenzellen. +* [x] **`MsFilterBar`:** + * [x] Suchfeld mit Debounce-Unterstützung (Pattern-basiert). + * [x] Filter-Chips für schnelle Status-Wechsel. + * [x] Anzeige der Trefferanzahl (Result Count). ## Phase 3: Formular- & Eingabe-System (Die Datenerfassung) ⚪ [ZUKUNFT] diff --git a/frontend/core/design-system/src/commonMain/kotlin/at/mocode/frontend/core/designsystem/components/MsDataTable.kt b/frontend/core/design-system/src/commonMain/kotlin/at/mocode/frontend/core/designsystem/components/MsDataTable.kt new file mode 100644 index 00000000..7ee770b2 --- /dev/null +++ b/frontend/core/design-system/src/commonMain/kotlin/at/mocode/frontend/core/designsystem/components/MsDataTable.kt @@ -0,0 +1,133 @@ +package at.mocode.frontend.core.designsystem.components + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import at.mocode.frontend.core.designsystem.theme.Dimens + +/** + * Definition einer Spalte für die [MsDataTable]. + * + * @param title Die Beschriftung im Header. + * @param width Die Breite der Spalte. + * @param weight Wenn gesetzt, dehnt sich die Spalte flexibel aus. + * @param alignment Ausrichtung des Inhalts (Start, Center, End). + * @param cellRenderer Eigener Renderer für den Inhalt der Zelle. + */ +data class MsColumnDefinition( + val title: String, + val width: Dp? = null, + val weight: Float? = null, + val alignment: Alignment = Alignment.CenterStart, + val cellRenderer: @Composable (T) -> Unit = { item -> + Text( + text = item.toString(), + style = MaterialTheme.typography.bodySmall, + maxLines = 1 + ) + } +) + +/** + * Eine performante, hochdichte Datentabelle für Desktop-Anwendungen. + * + * Warum? + * Standard-Material-Tabellen sind oft zu großzügig mit Padding. + * In der Meldestelle müssen wir viele Daten auf einen Blick sehen. + * + * @param items Die anzuzeigenden Datenobjekte. + * @param columns Die Definitionen der Spalten. + * @param onRowClick Callback, wenn eine Zeile angeklickt wird. + * @param modifier Der Modifier für die gesamte Tabelle. + */ +@Composable +fun MsDataTable( + items: List, + columns: List>, + onRowClick: ((T) -> Unit)? = null, + modifier: Modifier = Modifier, + headerBackgroundColor: Color = MaterialTheme.colorScheme.surfaceVariant, + rowBackgroundColor: Color = MaterialTheme.colorScheme.surface, + alternateRowBackgroundColor: Color = MaterialTheme.colorScheme.surface.copy(alpha = 0.5f) +) { + Column(modifier = modifier) { + // --- 1. Header (Sticky) --- + Surface( + color = headerBackgroundColor, + border = androidx.compose.foundation.BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant) + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = Dimens.SpacingS, vertical = Dimens.SpacingXS), + verticalAlignment = Alignment.CenterVertically + ) { + columns.forEach { col -> + val colModifier = when { + col.weight != null -> Modifier.weight(col.weight) + col.width != null -> Modifier.width(col.width) + else -> Modifier.wrapContentWidth() + } + Box( + modifier = colModifier, + contentAlignment = col.alignment + ) { + Text( + text = col.title.uppercase(), + style = MaterialTheme.typography.labelSmall, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + } + } + + // --- 2. Body (LazyColumn) --- + LazyColumn(modifier = Modifier.fillMaxSize()) { + itemsIndexed(items) { index, item -> + val bgColor = if (index % 2 == 0) rowBackgroundColor else alternateRowBackgroundColor + + Surface( + color = bgColor, + modifier = Modifier.fillMaxWidth() + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .clickable(enabled = onRowClick != null) { onRowClick?.invoke(item) } + .padding(horizontal = Dimens.SpacingS, vertical = 6.dp), // Kompakte Zeilenhöhe + verticalAlignment = Alignment.CenterVertically + ) { + columns.forEach { col -> + val colModifier = when { + col.weight != null -> Modifier.weight(col.weight) + col.width != null -> Modifier.width(col.width) + else -> Modifier.wrapContentWidth() + } + Box( + modifier = colModifier, + contentAlignment = col.alignment + ) { + col.cellRenderer(item) + } + } + } + } + HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.3f), thickness = 0.5.dp) + } + } + } +} diff --git a/frontend/core/design-system/src/commonMain/kotlin/at/mocode/frontend/core/designsystem/components/MsFilterBar.kt b/frontend/core/design-system/src/commonMain/kotlin/at/mocode/frontend/core/designsystem/components/MsFilterBar.kt new file mode 100644 index 00000000..13db5a0b --- /dev/null +++ b/frontend/core/design-system/src/commonMain/kotlin/at/mocode/frontend/core/designsystem/components/MsFilterBar.kt @@ -0,0 +1,140 @@ +package at.mocode.frontend.core.designsystem.components + +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.FilterList +import androidx.compose.material.icons.filled.Search +import androidx.compose.material3.* +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.unit.dp +import at.mocode.frontend.core.designsystem.theme.Dimens + +/** + * Eine einheitliche Filterzeile für alle Stammdaten-Listen. + * + * @param searchQuery Der aktuelle Suchbegriff. + * @param onSearchQueryChange Callback bei Änderung der Suche. + * @param searchPlaceholder Platzhalter im Suchfeld. + * @param filters Sektion für Filter-Chips (Optional). + * @param actions Sektion für zusätzliche Aktionen am Ende (Optional). + * @param resultCount Anzahl der gefundenen Einträge (Optional). + */ +@Composable +fun MsFilterBar( + searchQuery: String, + onSearchQueryChange: (String) -> Unit, + modifier: Modifier = Modifier, + searchPlaceholder: String = "Suchen...", + filters: @Composable (RowScope.() -> Unit)? = null, + actions: @Composable (RowScope.() -> Unit)? = null, + resultCount: Int? = null +) { + Surface( + modifier = modifier.fillMaxWidth(), + color = MaterialTheme.colorScheme.surface, + border = androidx.compose.foundation.BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant) + ) { + Row( + modifier = Modifier + .padding(horizontal = Dimens.SpacingS, vertical = Dimens.SpacingXS), + verticalAlignment = Alignment.CenterVertically + ) { + // --- 1. Suchfeld (Kompakt) --- + OutlinedTextField( + value = searchQuery, + onValueChange = onSearchQueryChange, + modifier = Modifier + .width(300.dp) + .height(40.dp), // Fixe Höhe für High-Density + placeholder = { Text(searchPlaceholder, style = MaterialTheme.typography.bodySmall) }, + leadingIcon = { Icon(Icons.Default.Search, contentDescription = null, modifier = Modifier.size(18.dp)) }, + trailingIcon = if (searchQuery.isNotEmpty()) { + { + IconButton(onClick = { onSearchQueryChange("") }) { + Icon(Icons.Default.Close, contentDescription = null, modifier = Modifier.size(18.dp)) + } + } + } else null, + singleLine = true, + textStyle = MaterialTheme.typography.bodySmall, + shape = MaterialTheme.shapes.small, + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search), + colors = OutlinedTextFieldDefaults.colors( + unfocusedContainerColor = MaterialTheme.colorScheme.surface, + focusedContainerColor = MaterialTheme.colorScheme.surface, + ) + ) + + Spacer(Modifier.width(Dimens.SpacingM)) + + // --- 2. Filter-Chips --- + if (filters != null) { + Row( + modifier = Modifier.weight(1f), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(Dimens.SpacingXS) + ) { + Icon( + imageVector = Icons.Default.FilterList, + contentDescription = null, + modifier = Modifier.size(16.dp), + tint = MaterialTheme.colorScheme.outline + ) + filters() + } + } else { + Spacer(Modifier.weight(1f)) + } + + // --- 3. Result Count --- + if (resultCount != null) { + Text( + text = "$resultCount Einträge", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.outline, + modifier = Modifier.padding(horizontal = Dimens.SpacingM) + ) + } + + // --- 4. Aktionen --- + if (actions != null) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(Dimens.SpacingXS) + ) { + actions() + } + } + } + } +} + +/** + * Ein kompakter Filter-Chip für die [MsFilterBar]. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun MsFilterChip( + selected: Boolean, + onClick: () -> Unit, + label: String, + modifier: Modifier = Modifier, + leadingIcon: ImageVector? = null +) { + FilterChip( + selected = selected, + onClick = onClick, + label = { Text(label, style = MaterialTheme.typography.labelSmall) }, + modifier = modifier.height(28.dp), // Kompakte Höhe + leadingIcon = leadingIcon?.let { + { Icon(it, contentDescription = null, modifier = Modifier.size(14.dp)) } + }, + shape = MaterialTheme.shapes.small + ) +} diff --git a/frontend/core/design-system/src/commonMain/kotlin/at/mocode/frontend/core/designsystem/components/MsStatusBadge.kt b/frontend/core/design-system/src/commonMain/kotlin/at/mocode/frontend/core/designsystem/components/MsStatusBadge.kt new file mode 100644 index 00000000..317fa7c6 --- /dev/null +++ b/frontend/core/design-system/src/commonMain/kotlin/at/mocode/frontend/core/designsystem/components/MsStatusBadge.kt @@ -0,0 +1,78 @@ +package at.mocode.frontend.core.designsystem.components + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp + +/** + * Ein kompakter Badge zur Anzeige von Status-Informationen. + * + * @param text Der anzuzeigende Text. + * @param containerColor Die Hintergrundfarbe des Badges. + * @param contentColor Die Textfarbe des Badges. + */ +@Composable +fun MsStatusBadge( + text: String, + containerColor: Color = MaterialTheme.colorScheme.primaryContainer, + contentColor: Color = MaterialTheme.colorScheme.onPrimaryContainer, + modifier: Modifier = Modifier +) { + Box( + modifier = modifier + .background(color = containerColor, shape = RoundedCornerShape(4.dp)) + .padding(horizontal = 6.dp, vertical = 2.dp) + ) { + Text( + text = text.uppercase(), + style = MaterialTheme.typography.labelSmall, + fontWeight = FontWeight.Bold, + color = contentColor + ) + } +} + +/** + * Vordefinierte Status-Typen für eine konsistente UX. + */ +object MsStatusDefaults { + @Composable + fun Success(text: String, modifier: Modifier = Modifier) = MsStatusBadge( + text = text, + containerColor = Color(0xFFE8F5E9), + contentColor = Color(0xFF2E7D32), + modifier = modifier + ) + + @Composable + fun Warning(text: String, modifier: Modifier = Modifier) = MsStatusBadge( + text = text, + containerColor = Color(0xFFFFF3E0), + contentColor = Color(0xFFEF6C00), + modifier = modifier + ) + + @Composable + fun Error(text: String, modifier: Modifier = Modifier) = MsStatusBadge( + text = text, + containerColor = Color(0xFFFFEBEE), + contentColor = Color(0xFFC62828), + modifier = modifier + ) + + @Composable + fun Info(text: String, modifier: Modifier = Modifier) = MsStatusBadge( + text = text, + containerColor = Color(0xFFE3F2FD), + contentColor = Color(0xFF1565C0), + modifier = modifier + ) +}