diff --git a/docs/01_Architecture/Roadmap_ZNS_Importer.md b/docs/01_Architecture/Roadmap_ZNS_Importer.md index 3b80aa48..9dfba2a2 100644 --- a/docs/01_Architecture/Roadmap_ZNS_Importer.md +++ b/docs/01_Architecture/Roadmap_ZNS_Importer.md @@ -65,15 +65,18 @@ gesteuert wird und die Daten persistent im Backend (`actor-context`) ablegt. ### Phase 3: Frontend-Integration (🎨 Frontend Expert) -* [ ] **UI-Komponenten (Compose Desktop):** - * Erstellung eines "Stammdaten-Import" Screens in der Master-Desktop-App. - * Integration eines nativen File-Pickers (nur Auswahl von `.zip` zulassen). -* [ ] **Netzwerk & State-Management (KMP):** - * Ktor-Client anpassen für Multipart-Uploads. - * Implementierung eines Polling-Mechanismus: Nach erfolgreichem Upload alle X Sekunden den Status-Endpunkt abfragen. -* [ ] **User Feedback:** - * Anzeige einer Progress-Bar mit Live-Updates (z. B. "Verarbeite Reiter: 1250/5000"). - * Fehler-Handling (z. B. falsches Dateiformat, Timeouts) sauber darstellen. +* [x] **UI-Komponenten (Compose Desktop):** + * `StammdatenImportScreen` in `meldestelle-desktop` erstellt. ✅ + * Nativer File-Picker (`JFileChooser`, nur `.zip`) integriert. ✅ + * `AppScreen.StammdatenImport` + Nav-Rail-Eintrag "Stammdaten-Import" hinzugefügt. ✅ +* [x] **Netzwerk & State-Management (KMP):** + * `ZnsImportViewModel` mit Ktor Multipart-Upload (`POST /api/v1/import/zns`). ✅ + * Polling-Mechanismus (alle 2 Sekunden, `GET /api/v1/import/zns/{jobId}/status`). ✅ + * `ZnsImportViewModel` via Koin `viewModel { }` in `desktopModule` registriert. ✅ +* [x] **User Feedback:** + * `LinearProgressIndicator` mit Live-Prozent + `progressDetail`-Text. ✅ + * `StatusChip` für alle Job-Zustände (AUSSTEHEND → ABGESCHLOSSEN/FEHLER). ✅ + * Fehler-Banner + scrollbare Fehler-Liste (max. 50 Einträge). ✅ ### Phase 4: Testing & QA (🧐 QA Specialist) diff --git a/docs/99_Journal/2026-03-25_Session_Log_Frontend_Architektur_Bereinigung.md b/docs/99_Journal/2026-03-25_Session_Log_Frontend_Architektur_Bereinigung.md new file mode 100644 index 00000000..0b038be6 --- /dev/null +++ b/docs/99_Journal/2026-03-25_Session_Log_Frontend_Architektur_Bereinigung.md @@ -0,0 +1,78 @@ +--- +date: 2026-03-25 +type: Session Log +agents: [ Lead Architect, Frontend Expert, Curator ] +status: ABGESCHLOSSEN +--- + +# Session Log: Frontend-Architektur-Bereinigung & ZNS-Import Phase 3 + +## Zusammenfassung + +Diese Session umfasste zwei Hauptthemen: + +1. Vollständige Implementierung von **Phase 3 (ZNS-Import Frontend)** +2. Analyse und Bereinigung von **5 Frontend-Architektur-Problemen** + +--- + +## Phase 3: ZNS-Import Frontend (✅ Abgeschlossen) + +### Implementierte Komponenten + +| Datei | Beschreibung | +|-----------------------------|-----------------------------------------------------------------| +| `AppScreen.kt` | `StammdatenImport`-Route + `fromRoute`-Mapping | +| `ZnsImportViewModel.kt` | Ktor Multipart-Upload, Polling (2s), Auth-Token-Injection | +| `StammdatenImportScreen.kt` | File-Picker (JFileChooser, nur .zip), ProgressBar, Fehler-Liste | +| `DesktopMainLayout.kt` | Nav-Rail-Eintrag "Stammdaten-Import" | +| `DesktopModule.kt` | ZnsImportViewModel via Koin registriert | + +--- + +## Architektur-Bereinigung (✅ Abgeschlossen) + +### Problem 1 – NetworkModule.kt (🔴 Hoch) + +- **Was:** Doppelter `HttpSend`-Interceptor + ~90 Zeilen auskommentierter Debug-Code +- **Fix:** Bereinigt auf 83 Zeilen, ein sauberer Interceptor + +### Problem 2 – meldestelle-portal (🟡 Mittel) + +- **Was:** Toter Prototyp (JVM+JS+WASM) ohne Package, ohne DI, mit falschem Fenstertitel +- **Fix:** Verzeichnis gelöscht, `settings.gradle.kts` bereinigt +- **Hinweis:** Web-Portal ist in MASTER_ROADMAP als zukünftige Phase vorgesehen – wird neu & sauber aufgebaut + +### Problem 3 – AppScreen.fromRoute (🟡 Mittel) + +- **Was:** Fehlende Mappings für parametrisierte Routen (VeranstaltungDetail, TurnierDetail, TurnierNeu) +- **Fix:** Regex-Parsing ergänzt + +### Problem 4 – NavigationPort Interface (🟢 Niedrig) + +- **Was:** `navigateToScreen` und `currentScreen` fehlten im Interface +- **Fix:** Interface erweitert, `DesktopNavigationPort` implementiert beide Methoden + +### Problem 5 – ZnsImportViewModel Auslagerung (🟢 Niedrig) + +- **Was:** ViewModel mit Business-Logik in der Shell statt im Feature-Modul +- **Fix:** Neues Feature-Modul `frontend/features/zns-import-feature` erstellt + +--- + +## Build-Fehler behoben + +| Fehler | Fix | +|---------------------------------------------------|----------------------------------------------------| +| `:frontend:shells:meldestelle-portal` not found | `architecture-tests/build.gradle.kts` aktualisiert | +| `libs.ktor.client.content.negotiation` unresolved | → `libs.ktor.client.contentNegotiation` | +| `libs.ktor.serialization.kotlinx.json` unresolved | → `libs.ktor.client.serialization.kotlinx.json` | +| `libs.androidx.lifecycle.viewmodel` unresolved | → `libs.androidx.lifecycle.viewmodelCompose` | + +--- + +## Offene Punkte (nächste Session) + +- [ ] Phase 4: QA & Testing des ZNS-Imports (🧐 QA Specialist) +- [ ] `competition-context` Backend (👷 Backend Developer) +- [ ] `event-management-context` Backend (👷 Backend Developer) diff --git a/frontend/core/navigation/build.gradle.kts b/frontend/core/navigation/build.gradle.kts index 2241c397..b3679d71 100644 --- a/frontend/core/navigation/build.gradle.kts +++ b/frontend/core/navigation/build.gradle.kts @@ -23,6 +23,7 @@ kotlin { commonMain.dependencies { // Depend on core domain for User/Role types used by navigation API implementation(projects.frontend.core.domain) + implementation(libs.kotlinx.coroutines.core) } commonTest.dependencies { implementation(libs.kotlin.test) diff --git a/frontend/core/navigation/src/commonMain/kotlin/at/mocode/frontend/core/navigation/AppScreen.kt b/frontend/core/navigation/src/commonMain/kotlin/at/mocode/frontend/core/navigation/AppScreen.kt index 7ca92e76..78bee102 100644 --- a/frontend/core/navigation/src/commonMain/kotlin/at/mocode/frontend/core/navigation/AppScreen.kt +++ b/frontend/core/navigation/src/commonMain/kotlin/at/mocode/frontend/core/navigation/AppScreen.kt @@ -28,8 +28,13 @@ sealed class AppScreen(val route: String) { data object Funktionaere : AppScreen("/funktionaere") data object Meisterschaften : AppScreen("/meisterschaften") data object Cups : AppScreen("/cups") + data object StammdatenImport : AppScreen("/stammdaten/import") companion object { + private val VERANSTALTUNG_DETAIL = Regex("/veranstaltung/(\\d+)$") + private val TURNIER_DETAIL = Regex("/veranstaltung/(\\d+)/turnier/(\\d+)$") + private val TURNIER_NEU = Regex("/veranstaltung/(\\d+)/turnier/neu$") + fun fromRoute(route: String): AppScreen { return when (route) { Routes.HOME -> Landing @@ -49,7 +54,19 @@ sealed class AppScreen(val route: String) { "/funktionaere" -> Funktionaere "/meisterschaften" -> Meisterschaften "/cups" -> Cups - else -> Landing // Default fallback + "/stammdaten/import" -> StammdatenImport + else -> { + TURNIER_DETAIL.matchEntire(route)?.destructured?.let { (vId, tId) -> + return TurnierDetail(vId.toLong(), tId.toLong()) + } + TURNIER_NEU.matchEntire(route)?.destructured?.let { (vId) -> + return TurnierNeu(vId.toLong()) + } + VERANSTALTUNG_DETAIL.matchEntire(route)?.destructured?.let { (id) -> + return VeranstaltungDetail(id.toLong()) + } + Landing // Default fallback + } } } } diff --git a/frontend/core/navigation/src/commonMain/kotlin/at/mocode/frontend/core/navigation/NavigationPort.kt b/frontend/core/navigation/src/commonMain/kotlin/at/mocode/frontend/core/navigation/NavigationPort.kt index 40eca067..3f851ae8 100644 --- a/frontend/core/navigation/src/commonMain/kotlin/at/mocode/frontend/core/navigation/NavigationPort.kt +++ b/frontend/core/navigation/src/commonMain/kotlin/at/mocode/frontend/core/navigation/NavigationPort.kt @@ -1,9 +1,18 @@ package at.mocode.frontend.core.navigation +import kotlinx.coroutines.flow.StateFlow + /** - * Minimal navigation abstraction used by core navigation components. - * The actual implementation lives in shells/apps and delegates to the app's router. + * Navigations-Abstraktion für alle Shells. + * Die konkrete Implementierung liegt in der jeweiligen Shell (z.B. DesktopNavigationPort). */ interface NavigationPort { + /** Aktuell angezeigter Screen als reaktiver State. */ + val currentScreen: StateFlow + + /** Navigation via Route-String (z.B. für Deep-Links). */ fun navigateTo(route: String) + + /** Typsichere Navigation direkt via AppScreen-Objekt. */ + fun navigateToScreen(screen: AppScreen) } diff --git a/frontend/core/network/src/commonMain/kotlin/at/mocode/frontend/core/network/NetworkModule.kt b/frontend/core/network/src/commonMain/kotlin/at/mocode/frontend/core/network/NetworkModule.kt index 2d89076c..1b995645 100644 --- a/frontend/core/network/src/commonMain/kotlin/at/mocode/frontend/core/network/NetworkModule.kt +++ b/frontend/core/network/src/commonMain/kotlin/at/mocode/frontend/core/network/NetworkModule.kt @@ -11,18 +11,20 @@ import org.koin.core.qualifier.named import org.koin.dsl.module /** - * Simple token provider interface so the core network module does not depend on auth-feature. + * Schnittstelle zur Token-Bereitstellung – entkoppelt core-network von core-auth. */ interface TokenProvider { fun getAccessToken(): String? } /** - * Koin module providing HttpClients. + * Koin-Modul mit zwei HttpClient-Instanzen: + * - "baseHttpClient": Roh-Client für Auth/Keycloak (kein Token-Header) + * - "apiClient": Konfigurierter Client für das API-Gateway (Auth-Header, Retry, Timeout) */ val networkModule = module { - // 1. Base Client (Raw, for Auth/Keycloak) + // 1. Basis-Client (für Auth-Endpunkte, ohne Bearer-Token) single(named("baseHttpClient")) { HttpClient { install(ContentNegotiation) { @@ -39,48 +41,27 @@ val networkModule = module { } } - // 2. API Client (Configured for Gateway & Auth Header) + // 2. API-Client (Gateway, mit Bearer-Token, Retry & Timeout) single(named("apiClient")) { - // Resolve TokenProvider lazily to avoid circular dependency issues during init - // We use a provider lambda to get the TokenProvider instance when needed - // This avoids resolving it immediately during module definition + val koinScope = this@single HttpClient { - // JSON (kotlinx) configuration install(ContentNegotiation) { - json( - Json { - ignoreUnknownKeys = true - isLenient = true - encodeDefaults = true - } - ) + json(Json { ignoreUnknownKeys = true; isLenient = true; encodeDefaults = true }) } - - // Request timeouts install(HttpTimeout) { requestTimeoutMillis = 15_000 connectTimeoutMillis = 10_000 socketTimeoutMillis = 15_000 } - - // Automatic simple retry on network exceptions and 5xx install(HttpRequestRetry) { maxRetries = 3 - retryIf { _, response -> - val s = response.status.value - s == 0 || s >= 500 - } + retryIf { _, response -> response.status.value.let { it == 0 || it >= 500 } } exponentialDelay() } - - // Base URL configuration defaultRequest { - val base = NetworkConfig.baseUrl.trimEnd('/') - url(base) + url(NetworkConfig.baseUrl.trimEnd('/')) } - - // Logging for development install(Logging) { logger = object : Logger { override fun log(message: String) { @@ -90,79 +71,13 @@ val networkModule = module { level = LogLevel.INFO } }.also { client -> - // Dynamic Auth Header Injection via HttpSend plugin - // This ensures we get the CURRENT token for each request + // Bearer-Token pro Request dynamisch injizieren (lazy, damit kein Circular-Dependency) client.plugin(HttpSend).intercept { request -> try { - // Resolve TokenProvider dynamically from Koin scope - // This assumes Koin is initialized and accessible - // Since we are inside a Koin component, we should be able to get it? - // No, 'this' here is HttpSendScope. - - // We need to capture the Koin scope or use GlobalContext if necessary, - // BUT better: we inject the TokenProvider into the module definition lambda - // and use it here. - - // However, `get()` might fail if not yet registered. - // Let's try to resolve it safely. - - // The issue with the previous code was likely that `get()` was called - // during module definition time (or bean creation time), and if it wasn't ready or - // if it was null (due to try-catch), the interceptor logic was skipped or broken. - - // Let's try to get it from the Koin instance that created this client. - // But we are inside `single { ... }`. - - // We can capture the `Scope` from the `single` block. - // val scope = this // Koin Scope - - // But we can't easily pass `scope` into `intercept`. - - // Let's try to resolve TokenProvider lazily using a lazy delegate or similar. - // Or just resolve it inside the interceptor if we can access Koin. - - // Since we are in `single`, we can get the provider. - // The previous error `TypeError: this.getToken_wiq2bn_k$ is not a function` - // was in AuthModule, which we fixed. - - // The current error `Error_0: Fail to fetch` is a CORS error on the network level, - // NOT a JS runtime error in the interceptor (unless the interceptor causes it). - - // Wait, the logs show: - // [baseClient] REQUEST: .../token - // Access to fetch at ... blocked by CORS policy - - // This confirms it is a CORS issue on the Keycloak server side or the browser side. - // The JS error `TypeError` is GONE in the latest log! - - // So the interceptor logic in NetworkModule might be fine, or at least not the cause of the CORS error. - // But let's make it robust anyway. - - // We will use a safe lazy resolution pattern. - } catch (_: Exception) { - // ignore - } - execute(request) - } - - // Re-applying the logic with proper Koin resolution - val koinScope = this@single - - client.plugin(HttpSend).intercept { request -> - try { - // Attempt to resolve TokenProvider from the capturing scope - val tokenProvider = try { - koinScope.get() - } catch (_: Exception) { - null - } - - val token = tokenProvider?.getAccessToken() - if (token != null) { - request.header("Authorization", "Bearer $token") - } + val token = koinScope.get().getAccessToken() + if (token != null) request.header("Authorization", "Bearer $token") } catch (e: Exception) { - println("[apiClient] Error injecting auth header: $e") + println("[apiClient] Auth-Header konnte nicht gesetzt werden: $e") } execute(request) } diff --git a/frontend/features/zns-import-feature/build.gradle.kts b/frontend/features/zns-import-feature/build.gradle.kts new file mode 100644 index 00000000..290e12ae --- /dev/null +++ b/frontend/features/zns-import-feature/build.gradle.kts @@ -0,0 +1,28 @@ +/** + * Feature-Modul: ZNS-Stammdaten-Import (Desktop-only) + * Kapselt ViewModel, State und API-Kommunikation für den ZNS-Import. + */ +plugins { + alias(libs.plugins.kotlinMultiplatform) + alias(libs.plugins.composeCompiler) +} +group = "at.mocode.clients" +version = "1.0.0" + +kotlin { + jvm() + sourceSets { + jvmMain.dependencies { + implementation(projects.frontend.core.network) + implementation(projects.frontend.core.auth) + implementation(libs.bundles.kmp.common) + implementation(libs.koin.core) + implementation(libs.ktor.client.core) + implementation(libs.ktor.client.contentNegotiation) + implementation(libs.ktor.client.serialization.kotlinx.json) + implementation(libs.androidx.lifecycle.viewmodelCompose) + implementation(libs.kotlinx.coroutines.core) + implementation(libs.kotlinx.serialization.json) + } + } +} diff --git a/frontend/features/zns-import-feature/src/jvmMain/kotlin/at/mocode/zns/feature/ZnsImportViewModel.kt b/frontend/features/zns-import-feature/src/jvmMain/kotlin/at/mocode/zns/feature/ZnsImportViewModel.kt new file mode 100644 index 00000000..19ba2cd2 --- /dev/null +++ b/frontend/features/zns-import-feature/src/jvmMain/kotlin/at/mocode/zns/feature/ZnsImportViewModel.kt @@ -0,0 +1,140 @@ +package at.mocode.zns.feature + +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import at.mocode.frontend.core.auth.data.AuthTokenManager +import at.mocode.frontend.core.network.NetworkConfig +import io.ktor.client.* +import io.ktor.client.request.* +import io.ktor.client.request.forms.* +import io.ktor.client.statement.* +import io.ktor.http.* +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.Json +import java.io.File + +data class ZnsImportState( + val selectedFilePath: String? = null, + val isUploading: Boolean = false, + val jobId: String? = null, + val jobStatus: String? = null, + val progress: Int = 0, + val progressDetail: String = "", + val errors: List = emptyList(), + val errorMessage: String? = null, + val isFinished: Boolean = false, +) + +@Serializable +internal data class JobIdResponse(val jobId: String) + +@Serializable +internal data class JobStatusResponse( + val jobId: String, + val status: String, + val progress: Int = 0, + val progressDetail: String = "", + val errors: List = emptyList(), +) + +private val TERMINAL_STATES = setOf("ABGESCHLOSSEN", "FEHLER") +private const val POLLING_INTERVAL_MS = 2000L +private const val MAX_VISIBLE_ERRORS = 50 + +class ZnsImportViewModel( + private val httpClient: HttpClient, + private val authTokenManager: AuthTokenManager, +) : ViewModel() { + + var state by mutableStateOf(ZnsImportState()) + private set + + private var pollingJob: Job? = null + private val json = Json { ignoreUnknownKeys = true } + + fun onFileSelected(path: String) { + state = ZnsImportState(selectedFilePath = path) + } + + fun startImport() { + val filePath = state.selectedFilePath ?: return + val file = File(filePath) + if (!file.exists() || !file.name.endsWith(".zip", ignoreCase = true)) { + state = state.copy(errorMessage = "Bitte eine gültige .zip-Datei auswählen.") + return + } + + viewModelScope.launch { + state = state.copy( + isUploading = true, errorMessage = null, isFinished = false, + jobId = null, progress = 0, progressDetail = "", errors = emptyList() + ) + try { + val token = authTokenManager.authState.value.token + val response: HttpResponse = httpClient.post("${NetworkConfig.baseUrl}/api/v1/import/zns") { + if (token != null) header(HttpHeaders.Authorization, "Bearer $token") + setBody(MultiPartFormDataContent(formData { + append("file", file.readBytes(), Headers.build { + append(HttpHeaders.ContentDisposition, "filename=\"${file.name}\"") + append(HttpHeaders.ContentType, "application/zip") + }) + })) + } + if (response.status == HttpStatusCode.Accepted) { + val body = json.decodeFromString(response.bodyAsText()) + state = state.copy(isUploading = false, jobId = body.jobId, jobStatus = "AUSSTEHEND") + startPolling(body.jobId) + } else { + state = state.copy(isUploading = false, errorMessage = "Upload fehlgeschlagen: HTTP ${response.status.value}") + } + } catch (e: Exception) { + state = state.copy(isUploading = false, errorMessage = "Fehler beim Upload: ${e.message}") + } + } + } + + private fun startPolling(jobId: String) { + pollingJob?.cancel() + pollingJob = viewModelScope.launch { + while (true) { + try { + val token = authTokenManager.authState.value.token + val response: HttpResponse = httpClient.get("${NetworkConfig.baseUrl}/api/v1/import/zns/$jobId/status") { + if (token != null) header(HttpHeaders.Authorization, "Bearer $token") + } + if (response.status.isSuccess()) { + val status = json.decodeFromString(response.bodyAsText()) + state = state.copy( + jobStatus = status.status, + progress = status.progress, + progressDetail = status.progressDetail, + errors = status.errors.takeLast(MAX_VISIBLE_ERRORS), + isFinished = status.status in TERMINAL_STATES, + ) + if (status.status in TERMINAL_STATES) break + } + } catch (e: Exception) { + state = state.copy(errorMessage = "Polling-Fehler: ${e.message}", isFinished = true) + break + } + delay(POLLING_INTERVAL_MS) + } + } + } + + fun reset() { + pollingJob?.cancel() + state = ZnsImportState() + } + + override fun onCleared() { + super.onCleared() + pollingJob?.cancel() + } +} diff --git a/frontend/features/zns-import-feature/src/jvmMain/kotlin/at/mocode/zns/feature/di/ZnsImportModule.kt b/frontend/features/zns-import-feature/src/jvmMain/kotlin/at/mocode/zns/feature/di/ZnsImportModule.kt new file mode 100644 index 00000000..d1f02cd8 --- /dev/null +++ b/frontend/features/zns-import-feature/src/jvmMain/kotlin/at/mocode/zns/feature/di/ZnsImportModule.kt @@ -0,0 +1,9 @@ +package at.mocode.zns.feature.di + +import at.mocode.zns.feature.ZnsImportViewModel +import org.koin.core.qualifier.named +import org.koin.dsl.module + +val znsImportModule = module { + factory { ZnsImportViewModel(get(named("apiClient")), get()) } +} diff --git a/frontend/shells/meldestelle-desktop/build.gradle.kts b/frontend/shells/meldestelle-desktop/build.gradle.kts index 911329e4..684e6795 100644 --- a/frontend/shells/meldestelle-desktop/build.gradle.kts +++ b/frontend/shells/meldestelle-desktop/build.gradle.kts @@ -29,6 +29,7 @@ kotlin { // Feature-Module implementation(projects.frontend.features.nennungFeature) implementation(projects.frontend.features.pingFeature) + implementation(projects.frontend.features.znsImportFeature) // Compose Desktop implementation(compose.desktop.currentOs) diff --git a/frontend/shells/meldestelle-desktop/src/jvmMain/kotlin/at/mocode/desktop/main.kt b/frontend/shells/meldestelle-desktop/src/jvmMain/kotlin/at/mocode/desktop/main.kt index c80b1f6a..ba74d68e 100644 --- a/frontend/shells/meldestelle-desktop/src/jvmMain/kotlin/at/mocode/desktop/main.kt +++ b/frontend/shells/meldestelle-desktop/src/jvmMain/kotlin/at/mocode/desktop/main.kt @@ -13,6 +13,7 @@ import at.mocode.frontend.core.network.networkModule import at.mocode.frontend.core.sync.di.syncModule import at.mocode.nennung.feature.di.nennungFeatureModule import at.mocode.ping.feature.di.pingFeatureModule +import at.mocode.zns.feature.di.znsImportModule import kotlinx.coroutines.runBlocking import org.koin.core.context.GlobalContext import org.koin.core.context.loadKoinModules @@ -29,6 +30,7 @@ fun main() = application { localDbModule, pingFeatureModule, nennungFeatureModule, + znsImportModule, desktopModule, ) } diff --git a/frontend/shells/meldestelle-desktop/src/jvmMain/kotlin/at/mocode/desktop/navigation/DesktopNavigationPort.kt b/frontend/shells/meldestelle-desktop/src/jvmMain/kotlin/at/mocode/desktop/navigation/DesktopNavigationPort.kt index 8c510462..f68bec6a 100644 --- a/frontend/shells/meldestelle-desktop/src/jvmMain/kotlin/at/mocode/desktop/navigation/DesktopNavigationPort.kt +++ b/frontend/shells/meldestelle-desktop/src/jvmMain/kotlin/at/mocode/desktop/navigation/DesktopNavigationPort.kt @@ -12,7 +12,7 @@ import kotlinx.coroutines.flow.asStateFlow */ class DesktopNavigationPort : NavigationPort { private val _currentScreen = MutableStateFlow(AppScreen.Login()) - val currentScreen: StateFlow = _currentScreen.asStateFlow() + override val currentScreen: StateFlow = _currentScreen.asStateFlow() override fun navigateTo(route: String) { val screen = AppScreen.fromRoute(route) @@ -20,7 +20,7 @@ class DesktopNavigationPort : NavigationPort { _currentScreen.value = screen } - fun navigateToScreen(screen: AppScreen) { + override fun navigateToScreen(screen: AppScreen) { println("[DesktopNav] navigateToScreen -> $screen") _currentScreen.value = screen } diff --git a/frontend/shells/meldestelle-desktop/src/jvmMain/kotlin/at/mocode/desktop/screens/DesktopMainLayout.kt b/frontend/shells/meldestelle-desktop/src/jvmMain/kotlin/at/mocode/desktop/screens/DesktopMainLayout.kt index 736d5a9d..b7b27bba 100644 --- a/frontend/shells/meldestelle-desktop/src/jvmMain/kotlin/at/mocode/desktop/screens/DesktopMainLayout.kt +++ b/frontend/shells/meldestelle-desktop/src/jvmMain/kotlin/at/mocode/desktop/screens/DesktopMainLayout.kt @@ -67,6 +67,7 @@ private val navItems = listOf( NavItem("Funktionäre", Icons.Default.Badge, AppScreen.Funktionaere), NavItem("Meisterschaften", Icons.Default.EmojiEvents, AppScreen.Meisterschaften), NavItem("Cups", Icons.Default.WorkspacePremium, AppScreen.Cups), + NavItem("Stammdaten-Import", Icons.Default.CloudUpload, AppScreen.StammdatenImport), ) @Composable @@ -221,6 +222,7 @@ private fun DesktopContentArea( is AppScreen.Funktionaere -> FunktionaereScreen() is AppScreen.Meisterschaften -> MeisterschaftenScreen() is AppScreen.Cups -> CupsScreen() + is AppScreen.StammdatenImport -> StammdatenImportScreen() // Fallback für alle anderen Screens (Dashboard, Ping etc.) else -> VeranstaltungenScreen( onVeranstaltungNeu = { onNavigate(AppScreen.VeranstaltungNeu) }, diff --git a/frontend/shells/meldestelle-desktop/src/jvmMain/kotlin/at/mocode/desktop/screens/StammdatenImportScreen.kt b/frontend/shells/meldestelle-desktop/src/jvmMain/kotlin/at/mocode/desktop/screens/StammdatenImportScreen.kt new file mode 100644 index 00000000..db88a2ef --- /dev/null +++ b/frontend/shells/meldestelle-desktop/src/jvmMain/kotlin/at/mocode/desktop/screens/StammdatenImportScreen.kt @@ -0,0 +1,257 @@ +package at.mocode.desktop.screens + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.* +import androidx.compose.material3.* +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.unit.dp +import at.mocode.zns.feature.ZnsImportViewModel +import org.koin.compose.viewmodel.koinViewModel +import javax.swing.JFileChooser +import javax.swing.filechooser.FileNameExtensionFilter + +@Composable +fun StammdatenImportScreen( + viewModel: ZnsImportViewModel = koinViewModel(), +) { + val state = viewModel.state + + Column( + modifier = Modifier + .fillMaxSize() + .padding(24.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + // Titel + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) { + Icon(Icons.Default.CloudUpload, contentDescription = null, tint = MaterialTheme.colorScheme.primary) + Text("Stammdaten-Import (ZNS)", style = MaterialTheme.typography.headlineSmall) + } + + HorizontalDivider() + + // Datei-Auswahl + Card(modifier = Modifier.fillMaxWidth()) { + Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) { + Text("Datei auswählen", style = MaterialTheme.typography.titleMedium) + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + modifier = Modifier.fillMaxWidth(), + ) { + OutlinedTextField( + value = state.selectedFilePath ?: "", + onValueChange = {}, + readOnly = true, + placeholder = { Text("Keine Datei ausgewählt…") }, + modifier = Modifier.weight(1f), + singleLine = true, + ) + Button( + onClick = { + val path = pickZipFile() + if (path != null) viewModel.onFileSelected(path) + }, + enabled = !state.isUploading && !(!state.isFinished && state.jobId != null), + ) { + Icon(Icons.Default.FolderOpen, contentDescription = null) + Spacer(Modifier.width(4.dp)) + Text("Durchsuchen") + } + } + + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + Button( + onClick = { viewModel.startImport() }, + enabled = state.selectedFilePath != null && !state.isUploading && !(state.jobId != null && !state.isFinished), + ) { + if (state.isUploading) { + CircularProgressIndicator( + modifier = Modifier.size(16.dp), + strokeWidth = 2.dp, + color = MaterialTheme.colorScheme.onPrimary + ) + Spacer(Modifier.width(8.dp)) + } else { + Icon(Icons.Default.Upload, contentDescription = null) + Spacer(Modifier.width(4.dp)) + } + Text("Import starten") + } + if (state.isFinished || state.errorMessage != null) { + OutlinedButton(onClick = { viewModel.reset() }) { + Icon(Icons.Default.Refresh, contentDescription = null) + Spacer(Modifier.width(4.dp)) + Text("Zurücksetzen") + } + } + } + } + } + + // Fehler-Banner + if (state.errorMessage != null) { + Card( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.errorContainer), + ) { + Row( + modifier = Modifier.padding(12.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + Icon(Icons.Default.Error, contentDescription = null, tint = MaterialTheme.colorScheme.error) + Text(state.errorMessage, color = MaterialTheme.colorScheme.onErrorContainer) + } + } + } + + // Fortschritt + if (state.jobId != null) { + Card(modifier = Modifier.fillMaxWidth()) { + Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text("Status", style = MaterialTheme.typography.titleMedium) + StatusChip(state.jobStatus) + } + + LinearProgressIndicator( + progress = { state.progress / 100f }, + modifier = Modifier.fillMaxWidth().height(8.dp), + ) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Text( + state.progressDetail.ifBlank { "Warte auf Server…" }, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Text( + "${state.progress}%", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + + if (state.isFinished && state.jobStatus == "ABGESCHLOSSEN") { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp), + ) { + Icon( + Icons.Default.CheckCircle, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(16.dp) + ) + Text( + "Import erfolgreich abgeschlossen.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.primary + ) + } + } + } + } + } + + // Fehler-Liste + if (state.errors.isNotEmpty()) { + Card( + modifier = Modifier.fillMaxWidth().weight(1f), + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant), + ) { + Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(6.dp), + ) { + Icon( + Icons.Default.Warning, + contentDescription = null, + tint = MaterialTheme.colorScheme.error, + modifier = Modifier.size(18.dp) + ) + Text( + "Import-Fehler (${state.errors.size})", + style = MaterialTheme.typography.titleSmall, + color = MaterialTheme.colorScheme.error, + ) + } + HorizontalDivider() + LazyColumn( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + items(state.errors) { error -> + Row( + horizontalArrangement = Arrangement.spacedBy(6.dp), + modifier = Modifier + .fillMaxWidth() + .background(MaterialTheme.colorScheme.surface, RoundedCornerShape(4.dp)) + .padding(horizontal = 8.dp, vertical = 4.dp), + ) { + Text("•", color = MaterialTheme.colorScheme.error) + Text( + error, + style = MaterialTheme.typography.bodySmall.copy(fontFamily = FontFamily.Monospace), + ) + } + } + } + } + } + } + } +} + +@Composable +private fun StatusChip(status: String?) { + val (label, color) = when (status) { + "AUSSTEHEND" -> "Ausstehend" to MaterialTheme.colorScheme.outline + "ENTPACKEN" -> "Entpacken…" to MaterialTheme.colorScheme.tertiary + "LADE_VEREINE" -> "Lade Vereine…" to MaterialTheme.colorScheme.secondary + "LADE_REITER" -> "Lade Reiter…" to MaterialTheme.colorScheme.secondary + "LADE_PFERDE" -> "Lade Pferde…" to MaterialTheme.colorScheme.secondary + "LADE_RICHTER" -> "Lade Richter…" to MaterialTheme.colorScheme.secondary + "ABGESCHLOSSEN" -> "Abgeschlossen ✓" to MaterialTheme.colorScheme.primary + "FEHLER" -> "Fehler ✗" to MaterialTheme.colorScheme.error + else -> (status ?: "–") to MaterialTheme.colorScheme.outline + } + Surface( + shape = RoundedCornerShape(12.dp), + color = color.copy(alpha = 0.15f), + ) { + Text( + label, + modifier = Modifier.padding(horizontal = 10.dp, vertical = 4.dp), + style = MaterialTheme.typography.labelSmall, + color = color, + ) + } +} + +/** Öffnet einen nativen JFileChooser (JVM-only) und gibt den Pfad der gewählten ZIP zurück. */ +private fun pickZipFile(): String? { + val chooser = JFileChooser() + chooser.dialogTitle = "ZNS.zip auswählen" + chooser.fileFilter = FileNameExtensionFilter("ZIP-Archiv (*.zip)", "zip") + chooser.isAcceptAllFileFilterUsed = false + val result = chooser.showOpenDialog(null) + return if (result == JFileChooser.APPROVE_OPTION) chooser.selectedFile.absolutePath else null +} diff --git a/frontend/shells/meldestelle-portal/build.gradle.kts b/frontend/shells/meldestelle-portal/build.gradle.kts deleted file mode 100644 index 6a3e76cd..00000000 --- a/frontend/shells/meldestelle-portal/build.gradle.kts +++ /dev/null @@ -1,134 +0,0 @@ -@file:OptIn(ExperimentalKotlinGradlePluginApi::class) -@file:Suppress("DEPRECATION") - -import org.jetbrains.compose.desktop.application.dsl.TargetFormat -import org.jetbrains.kotlin.gradle.ExperimentalKotlinGradlePluginApi -import org.jetbrains.kotlin.gradle.targets.js.webpack.KotlinWebpackConfig - -/** - * Dieses Modul ist der "Host". Es kennt alle Features und die Shared-Module und - * setzt sie zu einer lauffähigen Anwendung zusammen. - */ -plugins { - alias(libs.plugins.kotlinMultiplatform) - alias(libs.plugins.composeCompiler) - alias(libs.plugins.composeMultiplatform) - alias(libs.plugins.kotlinSerialization) -} - -kotlin { - // JVM Target für Desktop - jvm { - binaries { - executable { - mainClass.set("MainKt") - } - } - } - - // JavaScript Target für Web - js { - browser { - - commonWebpackConfig { - cssSupport { enabled = true } - mode = if (project.hasProperty("production")) - KotlinWebpackConfig.Mode.PRODUCTION - else - KotlinWebpackConfig.Mode.DEVELOPMENT - - // Source Maps: Im Production-Mode standardmäßig AUS (außer explizit via -PenableSourceMaps). - // Beschleunigt den Build massiv und reduziert Bundle-Größe. - if (mode == KotlinWebpackConfig.Mode.PRODUCTION && !project.hasProperty("enableSourceMaps")) { - sourceMaps = false - } - } - - webpackTask { - mainOutputFileName = "web-app.js" - // Minification wird via webpack.config.d/z_disable-minification.js deaktiviert, - // um den Terser-Crash mit SQLite-WASM (sqlite3-worker1.mjs) zu verhindern. - // Siehe: webpack.config.d/z_disable-minification.js - } - - // Development Server konfigurieren - runTask { - mainOutputFileName.set("web-app.js") - } - } - binaries.executable() - } - - sourceSets { - commonMain.dependencies { - // Shared modules - implementation(projects.frontend.core.domain) - implementation(projects.frontend.core.designSystem) - implementation(projects.frontend.core.navigation) - implementation(projects.frontend.core.network) - implementation(projects.frontend.core.sync) - implementation(projects.frontend.core.localDb) - implementation(projects.frontend.core.auth) - implementation(projects.frontend.features.pingFeature) - - // DI (Koin) needed to call initKoin { modules(...) } - implementation(libs.koin.core) - implementation(libs.koin.compose) - implementation(libs.koin.compose.viewmodel) - - // Compose Multiplatform - implementation(compose.runtime) - implementation(compose.foundation) - implementation(compose.material3) - implementation(compose.ui) - implementation(compose.components.resources) - implementation(compose.materialIconsExtended) - implementation(compose.components.uiToolingPreview) - - // Bundles - implementation(libs.bundles.kmp.common) // Coroutines, Serialization, DateTime - implementation(libs.bundles.compose.common) // ViewModel & Lifecycle - } - - jvmMain.dependencies { - implementation(compose.desktop.currentOs) - implementation(libs.kotlinx.coroutines.swing) - implementation(compose.uiTooling) - implementation(libs.koin.core) - implementation(project(":frontend:features:nennung-feature")) - } - - jsMain.dependencies { - implementation(compose.html.core) - // Benötigt für custom webpack config (wasm.js) - implementation(devNpm("copy-webpack-plugin", libs.versions.copyWebpackPlugin.get())) - } - - commonTest.dependencies { - implementation(libs.kotlin.test) - } - } -} - -// Duplicate-Handling für Distribution (Zentralisiert in Root build.gradle.kts, aber hier spezifisch für Distribution Tasks) -tasks.withType { - duplicatesStrategy = DuplicatesStrategy.EXCLUDE -} - -tasks.withType { - duplicatesStrategy = DuplicatesStrategy.EXCLUDE -} - -// Desktop Application Configuration -compose.desktop { - application { - mainClass = "MainKt" - - nativeDistributions { - targetFormats(TargetFormat.Dmg, TargetFormat.Msi, TargetFormat.Deb) - packageName = "Meldestelle" - packageVersion = "1.0.0" - description = "Meldestelle Development App" - } - } -} diff --git a/frontend/shells/meldestelle-portal/src/commonMain/kotlin/DevelopmentMode.kt b/frontend/shells/meldestelle-portal/src/commonMain/kotlin/DevelopmentMode.kt deleted file mode 100644 index 39687079..00000000 --- a/frontend/shells/meldestelle-portal/src/commonMain/kotlin/DevelopmentMode.kt +++ /dev/null @@ -1 +0,0 @@ -expect fun isDevelopmentMode(): Boolean diff --git a/frontend/shells/meldestelle-portal/src/commonMain/kotlin/MainApp.kt b/frontend/shells/meldestelle-portal/src/commonMain/kotlin/MainApp.kt deleted file mode 100644 index bb36da86..00000000 --- a/frontend/shells/meldestelle-portal/src/commonMain/kotlin/MainApp.kt +++ /dev/null @@ -1,163 +0,0 @@ -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Surface -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.collectAsState -import androidx.compose.runtime.getValue -import androidx.compose.ui.Modifier -import at.mocode.frontend.core.auth.data.AuthTokenManager -import at.mocode.frontend.core.auth.presentation.LoginScreen -import at.mocode.frontend.core.auth.presentation.LoginViewModel -import at.mocode.frontend.core.designsystem.theme.AppTheme -import at.mocode.frontend.core.domain.PlatformType -import at.mocode.frontend.core.domain.currentPlatform -import at.mocode.frontend.core.navigation.AppScreen -import at.mocode.ping.feature.presentation.PingScreen -import at.mocode.ping.feature.presentation.PingViewModel -import navigation.StateNavigationPort -import org.koin.compose.koinInject -import org.koin.compose.viewmodel.koinViewModel -import screens.* - -@Composable -fun MainApp() { - AppTheme { - Surface( - modifier = Modifier.fillMaxSize(), - color = MaterialTheme.colorScheme.background - ) { - val navigationPort = koinInject() - val currentScreen by navigationPort.currentScreen.collectAsState() - val authTokenManager = koinInject() - val pingViewModel: PingViewModel = koinViewModel() - val loginViewModel: LoginViewModel = koinViewModel() - - when (val screen = currentScreen) { - is AppScreen.Landing -> { - if (currentPlatform() == PlatformType.DESKTOP) { - val authState = authTokenManager.authState.collectAsState().value - if (authState.isAuthenticated) { - DashboardScreen( - authTokenManager = authTokenManager, - onLogout = { - authTokenManager.clearToken() - navigationPort.navigateToScreen(AppScreen.Login(returnTo = AppScreen.Dashboard)) - }, - onCreateTournament = { navigationPort.navigateToScreen(AppScreen.CreateTournament) } - ) - } else { - LaunchedEffect(Unit) { - navigationPort.navigateToScreen(AppScreen.Login(returnTo = AppScreen.Dashboard)) - } - } - } else { - LandingScreen( - onPrimaryCta = { navigationPort.navigateToScreen(AppScreen.Login(returnTo = AppScreen.Dashboard)) }, - onOpenPing = { navigationPort.navigateToScreen(AppScreen.Login(returnTo = AppScreen.OrganizerProfile)) } - ) - } - } - - is AppScreen.Dashboard -> DashboardScreen( - authTokenManager = authTokenManager, - onNennungOeffnen = { navigationPort.navigateToScreen(AppScreen.Nennung) }, - onLogout = { - authTokenManager.clearToken() - if (currentPlatform() == PlatformType.DESKTOP) { - navigationPort.navigateToScreen(AppScreen.Login(returnTo = AppScreen.Dashboard)) - } else { - navigationPort.navigateToScreen(AppScreen.Landing) - } - }, - onCreateTournament = { navigationPort.navigateToScreen(AppScreen.CreateTournament) } - ) - - is AppScreen.Home -> DashboardScreen( - authTokenManager = authTokenManager, - onLogout = { - authTokenManager.clearToken() - if (currentPlatform() == PlatformType.DESKTOP) { - navigationPort.navigateToScreen(AppScreen.Login(returnTo = AppScreen.Dashboard)) - } else { - navigationPort.navigateToScreen(AppScreen.Landing) - } - }, - onCreateTournament = { navigationPort.navigateToScreen(AppScreen.CreateTournament) } - ) - - is AppScreen.CreateTournament -> CreateTournamentScreen( - onBack = { navigationPort.navigateToScreen(AppScreen.Dashboard) }, - onSave = { navigationPort.navigateToScreen(AppScreen.Dashboard) } - ) - - is AppScreen.Login -> { - LoginScreen( - viewModel = loginViewModel, - onLoginSuccess = { - val returnTo = screen.returnTo - if (returnTo != null) { - navigationPort.navigateToScreen(returnTo) - } else { - navigationPort.navigateToScreen(AppScreen.Dashboard) - } - }, - onBack = { - if (currentPlatform() == PlatformType.DESKTOP) { - // Desktop hat keine Landing Page — bleibt auf Login - } else { - navigationPort.navigateToScreen(AppScreen.Landing) - } - } - ) - } - - is AppScreen.Ping -> PingScreen( - viewModel = pingViewModel, - onBack = { - if (currentPlatform() == PlatformType.DESKTOP) { - navigationPort.navigateToScreen(AppScreen.Dashboard) - } else { - navigationPort.navigateToScreen(AppScreen.Landing) - } - } - ) - - is AppScreen.Nennung -> { - // NennungsMaske wird über das nennung-feature eingebunden (jvmMain only) - // Placeholder, bis das Feature vollständig integriert ist - NennungScreenContent() - } - - is AppScreen.OrganizerProfile -> OrganizerProfileScreen( - authTokenManager = authTokenManager, - onLogout = { - authTokenManager.clearToken() - navigationPort.navigateToScreen(AppScreen.Landing) - }, - onNavigateToDashboard = { navigationPort.navigateToScreen(AppScreen.Dashboard) } - ) - - is AppScreen.AuthCallback -> { /* OIDC Callback wird vom Auth-Modul verarbeitet */ - } - - is AppScreen.Profile -> AuthStatusScreen( - authTokenManager = authTokenManager, - onNavigateToLogin = { - navigationPort.navigateToScreen(AppScreen.Login(returnTo = AppScreen.Profile)) - }, - onNavigateToPing = { navigationPort.navigateToScreen(AppScreen.Ping) }, - onBackToHome = { - if (currentPlatform() == PlatformType.DESKTOP) { - navigationPort.navigateToScreen(AppScreen.Dashboard) - } else { - navigationPort.navigateToScreen(AppScreen.Landing) - } - } - ) - - else -> {} - } - } - } -} diff --git a/frontend/shells/meldestelle-portal/src/commonMain/kotlin/NennungScreenContent.kt b/frontend/shells/meldestelle-portal/src/commonMain/kotlin/NennungScreenContent.kt deleted file mode 100644 index 67e0f4f4..00000000 --- a/frontend/shells/meldestelle-portal/src/commonMain/kotlin/NennungScreenContent.kt +++ /dev/null @@ -1,4 +0,0 @@ -import androidx.compose.runtime.Composable - -@Composable -expect fun NennungScreenContent() diff --git a/frontend/shells/meldestelle-portal/src/commonMain/kotlin/components/TournamentCard.kt b/frontend/shells/meldestelle-portal/src/commonMain/kotlin/components/TournamentCard.kt deleted file mode 100644 index 59f8d345..00000000 --- a/frontend/shells/meldestelle-portal/src/commonMain/kotlin/components/TournamentCard.kt +++ /dev/null @@ -1,145 +0,0 @@ -package components - -import androidx.compose.foundation.BorderStroke -import androidx.compose.foundation.layout.* -import androidx.compose.foundation.shape.CircleShape -import androidx.compose.material3.* -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.text.style.TextAlign -import androidx.compose.ui.unit.dp - -// Dummy-Datenklasse für Turnier-Einträge (wird später durch echtes Domain-Model ersetzt) -data class TournamentData( - val id: String, - val date: String, - val title: String, - val location: String -) - -@Composable -fun TournamentCard(data: TournamentData) { - OutlinedCard( - modifier = Modifier.fillMaxWidth(), - ) { - Row( - modifier = Modifier - .fillMaxWidth() - .padding(16.dp), - verticalAlignment = Alignment.CenterVertically - ) { - // Left: Logo Placeholder - Surface( - modifier = Modifier.size(100.dp), - color = MaterialTheme.colorScheme.surfaceVariant, - shape = MaterialTheme.shapes.medium - ) { - Box(contentAlignment = Alignment.Center) { - Text( - "URFV\nLogo", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - } - } - - Spacer(modifier = Modifier.width(24.dp)) - - // Middle: Info - Column( - modifier = Modifier.weight(1f), - horizontalAlignment = Alignment.CenterHorizontally - ) { - Text( - text = data.title, - style = MaterialTheme.typography.titleMedium, - fontWeight = FontWeight.Bold - ) - Spacer(modifier = Modifier.height(4.dp)) - Text( - text = "${data.location} ${data.date}", - style = MaterialTheme.typography.bodyMedium - ) - Spacer(modifier = Modifier.height(4.dp)) - Text( - text = "Turnier-Nr.:${data.id}", - style = MaterialTheme.typography.bodyMedium - ) - } - - Spacer(modifier = Modifier.width(24.dp)) - - // Right: Actions - Column( - verticalArrangement = Arrangement.spacedBy(8.dp), - modifier = Modifier.width(200.dp) - ) { - OutlinedButton( - onClick = { /* TODO */ }, - modifier = Modifier.fillMaxWidth() - ) { - Text("Ausschreibung") - } - OutlinedButton( - onClick = { /* TODO */ }, - modifier = Modifier.fillMaxWidth() - ) { - Text("Nennen") - } - OutlinedButton( - onClick = { /* TODO */ }, - modifier = Modifier.fillMaxWidth() - ) { - Text("Start- Ergebnislisten") - } - } - } - } -} - -@Composable -fun ToggleRow(label: String, isOnline: Boolean, isInteractive: Boolean = false) { - Surface( - border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant), - shape = MaterialTheme.shapes.small, - modifier = Modifier.fillMaxWidth().height(40.dp), - color = if (isInteractive) MaterialTheme.colorScheme.surface else MaterialTheme.colorScheme.surfaceVariant - ) { - Row( - modifier = Modifier.fillMaxSize().padding(horizontal = 12.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.SpaceBetween - ) { - Text(label, style = MaterialTheme.typography.bodyMedium) - Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) { - val statusColor = if (isOnline) Color(0xFF4CAF50) else Color(0xFF9E9E9E) - Surface( - modifier = Modifier.size(16.dp), - shape = CircleShape, - color = statusColor - ) {} - - if (isInteractive) { - Surface( - border = BorderStroke(1.dp, MaterialTheme.colorScheme.outline), - modifier = Modifier.width(40.dp).height(24.dp) - ) { - Box(contentAlignment = Alignment.Center) { - Text(if (isOnline) "on" else "off", style = MaterialTheme.typography.labelSmall) - } - } - } else { - Text( - if (isOnline) "Online" else "Offline", - style = MaterialTheme.typography.labelSmall, - modifier = Modifier.width(40.dp), - textAlign = TextAlign.Center - ) - } - } - } - } -} diff --git a/frontend/shells/meldestelle-portal/src/commonMain/kotlin/navigation/ShellNavigationModule.kt b/frontend/shells/meldestelle-portal/src/commonMain/kotlin/navigation/ShellNavigationModule.kt deleted file mode 100644 index 01076fb0..00000000 --- a/frontend/shells/meldestelle-portal/src/commonMain/kotlin/navigation/ShellNavigationModule.kt +++ /dev/null @@ -1,55 +0,0 @@ -package navigation - -import at.mocode.frontend.core.auth.data.AuthTokenManager -import at.mocode.frontend.core.domain.models.User -import at.mocode.frontend.core.navigation.AppScreen -import at.mocode.frontend.core.navigation.CurrentUserProvider -import at.mocode.frontend.core.navigation.DeepLinkHandler -import at.mocode.frontend.core.navigation.NavigationPort -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asStateFlow -import org.koin.dsl.module - -class ShellCurrentUserProvider( - private val authTokenManager: AuthTokenManager, -) : CurrentUserProvider { - override fun getCurrentUser(): User? { - val state = authTokenManager.authState.value - if (!state.isAuthenticated) return null - // Roles are not yet modeled in AuthState; provide an empty list for now - return User( - id = state.userId ?: state.username ?: "unknown", - username = state.username ?: state.userId ?: "unknown", - displayName = null, - roles = emptyList(), - ) - } -} - -/** - * A real implementation of NavigationPort that updates a StateFlow. - * This allows the MainApp to observe changes and update the UI. - */ -class StateNavigationPort : NavigationPort { - private val _currentScreen = MutableStateFlow(AppScreen.Landing) - val currentScreen: StateFlow = _currentScreen.asStateFlow() - - override fun navigateTo(route: String) { - val screen = AppScreen.fromRoute(route) - println("[NavigationPort] navigateTo $route -> $screen") - _currentScreen.value = screen - } - - fun navigateToScreen(screen: AppScreen) { - _currentScreen.value = screen - } -} - -val navigationModule = module { - single { ShellCurrentUserProvider(get()) } - // Bind as both NavigationPort (for Core) and StateNavigationPort (for Shell) - single { StateNavigationPort() } - single { get() } - single { DeepLinkHandler(get(), get()) } -} diff --git a/frontend/shells/meldestelle-portal/src/commonMain/kotlin/screens/AuthStatusScreen.kt b/frontend/shells/meldestelle-portal/src/commonMain/kotlin/screens/AuthStatusScreen.kt deleted file mode 100644 index 7ff8604c..00000000 --- a/frontend/shells/meldestelle-portal/src/commonMain/kotlin/screens/AuthStatusScreen.kt +++ /dev/null @@ -1,68 +0,0 @@ -package screens - -import androidx.compose.foundation.layout.* -import androidx.compose.material3.* -import androidx.compose.runtime.Composable -import androidx.compose.runtime.collectAsState -import androidx.compose.runtime.getValue -import androidx.compose.ui.Modifier -import androidx.compose.ui.unit.dp -import at.mocode.frontend.core.auth.data.AuthTokenManager - -@Composable -fun AuthStatusScreen( - authTokenManager: AuthTokenManager, - onNavigateToLogin: () -> Unit, - onNavigateToPing: () -> Unit, - onBackToHome: () -> Unit -) { - val authState by authTokenManager.authState.collectAsState() - Column( - modifier = Modifier - .fillMaxSize() - .padding(24.dp), - verticalArrangement = Arrangement.spacedBy(16.dp) - ) { - Text("Ping-Service / System Status", style = MaterialTheme.typography.headlineMedium) - - Card(modifier = Modifier.fillMaxWidth()) { - Column(modifier = Modifier.padding(24.dp), verticalArrangement = Arrangement.spacedBy(16.dp)) { - if (authState.isAuthenticated) { - Text( - "Du bist angemeldet als: ${authState.username ?: authState.userId ?: "unbekannt"}", - style = MaterialTheme.typography.titleMedium, - color = MaterialTheme.colorScheme.primary - ) - Spacer(Modifier.height(8.dp)) - Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) { - Button(onClick = onNavigateToPing) { - Text("Ping-Service Tests durchführen") - } - OutlinedButton(onClick = { - authTokenManager.clearToken() - }) { Text("Abmelden") } - } - - } else { - Text( - "Du bist abgemeldet.", - style = MaterialTheme.typography.titleMedium, - color = MaterialTheme.colorScheme.error - ) - Spacer(Modifier.height(8.dp)) - Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) { - Button(onClick = onNavigateToLogin) { - Text("Login") - } - OutlinedButton(onClick = onNavigateToPing) { - Text("Ping-Service (eingeschränkt testen)") - } - } - } - - HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp)) - OutlinedButton(onClick = onBackToHome) { Text("Zurück zur Startseite") } - } - } - } -} diff --git a/frontend/shells/meldestelle-portal/src/commonMain/kotlin/screens/CreateTournamentScreen.kt b/frontend/shells/meldestelle-portal/src/commonMain/kotlin/screens/CreateTournamentScreen.kt deleted file mode 100644 index 6c5f7efc..00000000 --- a/frontend/shells/meldestelle-portal/src/commonMain/kotlin/screens/CreateTournamentScreen.kt +++ /dev/null @@ -1,390 +0,0 @@ -package screens - -import androidx.compose.foundation.layout.* -import androidx.compose.material3.* -import androidx.compose.runtime.* -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.unit.dp - -@Composable -fun CreateTournamentScreen( - onBack: () -> Unit, - onSave: () -> Unit -) { - var currentStep by remember { mutableStateOf(1) } - - Column(modifier = Modifier.fillMaxSize()) { - // App Header - Surface( - color = MaterialTheme.colorScheme.surface, - shadowElevation = 2.dp, - modifier = Modifier.fillMaxWidth() - ) { - Row( - modifier = Modifier.padding(horizontal = 24.dp, vertical = 16.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.SpaceBetween - ) { - Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(16.dp)) { - TextButton(onClick = onBack) { - Text("← Zurück") - } - Text( - text = "Neues Turnier anlegen (Desktop Client)", - style = MaterialTheme.typography.titleLarge, - fontWeight = FontWeight.Bold - ) - } - } - } - - // Stepper / Progress Bar - Surface(color = MaterialTheme.colorScheme.surfaceVariant, modifier = Modifier.fillMaxWidth()) { - Row( - modifier = Modifier.padding(16.dp).fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceEvenly - ) { - StepIndicator(step = 1, title = "Transfer", isActive = currentStep == 1, isCompleted = currentStep > 1) - StepIndicator(step = 2, title = "Stammdaten", isActive = currentStep == 2, isCompleted = currentStep > 2) - StepIndicator(step = 3, title = "Konfiguration", isActive = currentStep == 3, isCompleted = currentStep > 3) - StepIndicator(step = 4, title = "Funktionäre", isActive = currentStep == 4, isCompleted = currentStep > 4) - StepIndicator(step = 5, title = "Bewerbe", isActive = currentStep == 5, isCompleted = currentStep > 5) - } - } - - // Wizard Content Area - Box(modifier = Modifier.weight(1f).padding(24.dp)) { - when (currentStep) { - 1 -> TournamentStepTransfer() - 2 -> TournamentStepStammdaten() - 3 -> TournamentStepKonfiguration() - 4 -> TournamentStepFunktionaere() - 5 -> TournamentStepBewerbe() - } - } - - // Bottom Navigation Bar - Surface(shadowElevation = 8.dp, modifier = Modifier.fillMaxWidth()) { - Row( - modifier = Modifier.padding(24.dp).fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween - ) { - if (currentStep > 1) { - OutlinedButton(onClick = { currentStep-- }) { Text("Zurück") } - } else { - Spacer(modifier = Modifier.width(1.dp)) - } - - if (currentStep < 5) { - Button(onClick = { currentStep++ }) { Text("Weiter") } - } else { - Button(onClick = onSave) { Text("Turnier speichern") } - } - } - } - } -} - -@Composable -fun StepIndicator(step: Int, title: String, isActive: Boolean, isCompleted: Boolean) { - val color = when { - isActive -> MaterialTheme.colorScheme.primary - isCompleted -> MaterialTheme.colorScheme.primary.copy(alpha = 0.5f) - else -> MaterialTheme.colorScheme.onSurface.copy(alpha = 0.3f) - } - val fontWeight = if (isActive) FontWeight.Bold else FontWeight.Normal - - Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) { - Surface( - shape = MaterialTheme.shapes.small, - color = color, - modifier = Modifier.size(24.dp) - ) { - Box(contentAlignment = Alignment.Center) { - Text(step.toString(), color = MaterialTheme.colorScheme.onPrimary, style = MaterialTheme.typography.labelSmall) - } - } - Text(title, color = color, fontWeight = fontWeight) - } -} - -@Composable -fun TournamentStepTransfer() { - Column(verticalArrangement = Arrangement.spacedBy(24.dp), modifier = Modifier.fillMaxWidth(0.8f)) { - Text("Schritt 1: Transfer & Initialisierung", style = MaterialTheme.typography.headlineSmall) - Text( - "In diesem Schritt erschaffen wir eine separate Datenbank für dieses spezifische Turnier. " + - "Diese Datenbank kann für den komplett isolierten Offline-Betrieb (z.B. am USB-Stick) auf andere Laptops übertragen werden.", - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - - Row(horizontalArrangement = Arrangement.spacedBy(16.dp), modifier = Modifier.fillMaxWidth()) { - OutlinedTextField( - value = "", - onValueChange = {}, - label = { Text("Turniernummer OEPS (z.B. 26128)") }, - modifier = Modifier.weight(1f) - ) - Button(onClick = { /*TODO*/ }, modifier = Modifier.align(Alignment.CenterVertically)) { - Text("Turnierdatenbank Initialisieren") - } - } - - HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp)) - - Text("Datenaustausch (OEPS / Externe Systeme)", style = MaterialTheme.typography.titleMedium) - - Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) { - Card(modifier = Modifier.weight(1f)) { - Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) { - Text("ZNS / Stammdaten Import", fontWeight = FontWeight.Bold) - Text( - "Aktualisieren Sie die Reiter, Pferde und Funktionäre aus dem zentralen System.", - style = MaterialTheme.typography.bodyMedium - ) - Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) { - OutlinedTextField( - value = "", - onValueChange = {}, - label = { Text("Pfad zur ZNS.zip") }, - modifier = Modifier.weight(1f), - readOnly = true - ) - Button(onClick = { /* Öffnet File Picker */ }) { Text("...") } - } - Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) { - OutlinedTextField( - value = "", - onValueChange = {}, - label = { Text("Pfad zur AWÖ/Zucht-Datei") }, - modifier = Modifier.weight(1f), - readOnly = true - ) - Button(onClick = { /* Öffnet File Picker */ }) { Text("...") } - } - } - } - - Card(modifier = Modifier.weight(1f)) { - Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) { - Text("OEPS Export", fontWeight = FontWeight.Bold) - Text( - "Erzeugt die xxxxx.erg Datei für die offizielle Ergebnismeldung nach dem Turnier.", - style = MaterialTheme.typography.bodyMedium - ) - Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) { - OutlinedTextField( - value = "", - onValueChange = {}, - label = { Text("Ziel-Ordner für .erg Export") }, - modifier = Modifier.weight(1f), - readOnly = true - ) - Button(onClick = { /* Öffnet Directory Picker */ }) { Text("...") } - } - Button( - onClick = { /*TODO*/ }, - modifier = Modifier.fillMaxWidth(), - enabled = false - ) { Text("Ergebnis-Export (.erg)") } - } - } - } - - HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp)) - - Text("Offline-Sync (USB-Stick / Lokales Netzwerk)", style = MaterialTheme.typography.titleMedium) - Text( - "Übertragen Sie den kompletten Turnierstand zwischen Master-Meldestelle und Richterturm-Laptops ohne Internet.", - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - - Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) { - Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(8.dp)) { - Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) { - OutlinedTextField( - value = "", - onValueChange = {}, - label = { Text("Ziel-Pfad (z.B. D:/Export)") }, - modifier = Modifier.weight(1f), - readOnly = true - ) - Button(onClick = { /* Öffnet Directory Picker */ }) { Text("...") } - } - OutlinedButton(onClick = { /*TODO*/ }, modifier = Modifier.fillMaxWidth()) { Text("↑ Turnier Exportieren") } - } - - Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(8.dp)) { - Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) { - OutlinedTextField( - value = "", - onValueChange = {}, - label = { Text("Quell-Datei (z.B. D:/turnier.db)") }, - modifier = Modifier.weight(1f), - readOnly = true - ) - Button(onClick = { /* Öffnet File Picker */ }) { Text("...") } - } - OutlinedButton(onClick = { /*TODO*/ }, modifier = Modifier.fillMaxWidth()) { Text("↓ Turnier Importieren") } - } - } - } -} - -@Composable -fun TournamentStepStammdaten() { - Column(verticalArrangement = Arrangement.spacedBy(16.dp), modifier = Modifier.fillMaxWidth(0.6f)) { - Text("Schritt 2: Turnier-Stammdaten", style = MaterialTheme.typography.headlineSmall) - - OutlinedTextField( - value = "CSN-C NEU Neumarkt", - onValueChange = {}, - label = { Text("Turniername") }, - modifier = Modifier.fillMaxWidth() - ) - - Row(horizontalArrangement = Arrangement.spacedBy(16.dp), modifier = Modifier.fillMaxWidth()) { - OutlinedTextField( - value = "25.04.2026", - onValueChange = {}, - label = { Text("Datum von") }, - modifier = Modifier.weight(1f) - ) - OutlinedTextField( - value = "25.04.2026", - onValueChange = {}, - label = { Text("Datum bis") }, - modifier = Modifier.weight(1f) - ) - } - } -} - -@Composable -fun TournamentStepKonfiguration() { - Column(verticalArrangement = Arrangement.spacedBy(16.dp), modifier = Modifier.fillMaxWidth(0.6f)) { - Text("Schritt 3: Konfiguration", style = MaterialTheme.typography.headlineSmall) - Text("Austragungsplätze und Preisliste") - - OutlinedCard(modifier = Modifier.fillMaxWidth()) { - Column(modifier = Modifier.padding(16.dp)) { - Text("Austragungsplätze", fontWeight = FontWeight.Bold) - Row(horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.padding(top = 8.dp)) { - FilterChip(selected = true, onClick = {}, label = { Text("Platz 1 (Sand/Vlies 45x65m)") }) - FilterChip(selected = false, onClick = {}, label = { Text("Halle (Sand/Vlies 20x40m)") }) - FilterChip(selected = false, onClick = {}, label = { Text("+ Hinzufügen") }) - } - } - } - } -} - -@Composable -fun TournamentStepFunktionaere() { - Column(verticalArrangement = Arrangement.spacedBy(16.dp), modifier = Modifier.fillMaxWidth(0.6f)) { - Text("Schritt 4: Team & Funktionäre", style = MaterialTheme.typography.headlineSmall) - Text("Zuweisung von Richtern und Parcoursbauern (aus ZNS)") - - OutlinedTextField( - value = "Rudi Kreupl", - onValueChange = {}, - label = { Text("Turnierbeauftragter (Suche nach Name oder ID)") }, - modifier = Modifier.fillMaxWidth() - ) - - OutlinedTextField( - value = "Helmut Riedler", - onValueChange = {}, - label = { Text("Richter (Suche nach Name oder ID)") }, - modifier = Modifier.fillMaxWidth() - ) - - OutlinedTextField( - value = "Kurt Reitetschlägerr", - onValueChange = {}, - label = { Text("Parcoursbauchef") }, - modifier = Modifier.fillMaxWidth() - ) - } -} - -@Composable -fun TournamentStepBewerbe() { - Column(verticalArrangement = Arrangement.spacedBy(16.dp), modifier = Modifier.fillMaxSize()) { - Text("Schritt 5: Bewerbe anlegen", style = MaterialTheme.typography.headlineSmall) - - Row(modifier = Modifier.fillMaxSize(), horizontalArrangement = Arrangement.spacedBy(16.dp)) { - // Left: List of Bewerbe - Card(modifier = Modifier.weight(1f).fillMaxHeight()) { - Column(modifier = Modifier.padding(16.dp)) { - Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) { - Text("Bewerbe", fontWeight = FontWeight.Bold) - TextButton(onClick = {}) { Text("+ Neu") } - } - HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp)) - Text("1: Pony Stilspringprüfung (60cm)", modifier = Modifier.padding(vertical = 4.dp)) - Text("2: Einlaufspringprüfung (60cm)", modifier = Modifier.padding(vertical = 4.dp)) - Text("3: Pony Stilspringprüfung (70cm)", modifier = Modifier.padding(vertical = 4.dp)) - Text("4: Einlaufspringprüfung (70cm)", modifier = Modifier.padding(vertical = 4.dp)) - Text("...", modifier = Modifier.padding(vertical = 4.dp), color = MaterialTheme.colorScheme.onSurfaceVariant) - } - } - - // Right: Detail Tabs for selected Bewerb - Card(modifier = Modifier.weight(2f).fillMaxHeight()) { - Column { - PrimaryTabRow(selectedTabIndex = 0) { - Tab(selected = true, onClick = {}, text = { Text("Bewertung") }) - Tab(selected = false, onClick = {}, text = { Text("Geldpreis") }) - Tab(selected = false, onClick = {}, text = { Text("Ort/Zeit") }) - } - - Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(16.dp)) { - Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) { - OutlinedTextField( - value = "2", - onValueChange = {}, - label = { Text("Bewerb Nr.") }, - modifier = Modifier.width(100.dp) - ) - OutlinedTextField( - value = "Einlaufspringprüfung", - onValueChange = {}, - label = { Text("Bezeichnung") }, - modifier = Modifier.weight(1f) - ) - } - - Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) { - OutlinedTextField( - value = "60cm", - onValueChange = {}, - label = { Text("Klasse / Höhe") }, - modifier = Modifier.weight(1f) - ) - OutlinedTextField( - value = "§ 218", - onValueChange = {}, - label = { Text("Richtverfahren") }, - modifier = Modifier.weight(1f) - ) - } - - Text("Abteilungen", fontWeight = FontWeight.Bold, modifier = Modifier.padding(top = 8.dp)) - Row(verticalAlignment = Alignment.CenterVertically) { - Checkbox(checked = true, onCheckedChange = {}) - Text("1. Abt: lizenzfrei") - } - Row(verticalAlignment = Alignment.CenterVertically) { - Checkbox(checked = true, onCheckedChange = {}) - Text("2. Abt: mit Lizenz") - } - } - } - } - } - } -} diff --git a/frontend/shells/meldestelle-portal/src/commonMain/kotlin/screens/DashboardScreen.kt b/frontend/shells/meldestelle-portal/src/commonMain/kotlin/screens/DashboardScreen.kt deleted file mode 100644 index 9de57026..00000000 --- a/frontend/shells/meldestelle-portal/src/commonMain/kotlin/screens/DashboardScreen.kt +++ /dev/null @@ -1,382 +0,0 @@ -package screens - -import androidx.compose.foundation.BorderStroke -import androidx.compose.foundation.layout.* -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.verticalScroll -import androidx.compose.material3.* -import androidx.compose.runtime.* -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.text.style.TextAlign -import androidx.compose.ui.unit.dp -import at.mocode.frontend.core.auth.data.AuthTokenManager -import at.mocode.frontend.core.domain.PlatformType -import at.mocode.frontend.core.domain.currentPlatform -import components.ToggleRow -import components.TournamentData - -@Composable -fun DashboardScreen( - authTokenManager: AuthTokenManager, - onLogout: () -> Unit, - onCreateTournament: () -> Unit, - onNennungOeffnen: () -> Unit = {}, -) { - val authState by authTokenManager.authState.collectAsState() - val scrollState = rememberScrollState() - val isDesktop = currentPlatform() == PlatformType.DESKTOP - - // Security Check für das Dashboard - if (!authState.isAuthenticated) { - Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { - CircularProgressIndicator() - } - return - } - - val isAdmin = authTokenManager.isAdmin() - - Column( - modifier = Modifier.fillMaxSize() - ) { - // App Header (Meldestelle Toolbar) - Surface( - color = MaterialTheme.colorScheme.surface, - shadowElevation = 2.dp, - modifier = Modifier.fillMaxWidth() - ) { - Row( - modifier = Modifier.padding(horizontal = 24.dp, vertical = 16.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.SpaceBetween - ) { - Text( - text = if (isDesktop) "Master-Meldestelle Steuerungszentrale" else if (isAdmin) "Admin-Dashboard (Web)" else "Veranstalter-Dashboard", - style = MaterialTheme.typography.titleLarge, - fontWeight = FontWeight.Bold - ) - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(16.dp) - ) { - Text( - text = "Angemeldet als: ${authState.username ?: "Admin"}", - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - TextButton(onClick = onLogout) { - Text("Abmelden") - } - } - } - } - - // Main Content Area - Column( - modifier = Modifier.fillMaxSize().padding(24.dp).verticalScroll(scrollState), - verticalArrangement = Arrangement.spacedBy(24.dp) - ) { - - if (isDesktop && isAdmin) { - // DESKTOP VIEW - STEUERUNGSZENTRALE FÜR DEN ADMIN - Button( - onClick = onNennungOeffnen, - modifier = Modifier.fillMaxWidth().height(64.dp) - ) { - Text( - text = "📋 Nennungs-Maske öffnen", - style = MaterialTheme.typography.titleMedium - ) - } - OutlinedButton( - onClick = onCreateTournament, - modifier = Modifier.fillMaxWidth().height(64.dp) - ) { - Text( - text = "+ neues Turnier anlegen", - style = MaterialTheme.typography.titleMedium - ) - } - - Text( - text = "Alle verwalteten Turniere", - style = MaterialTheme.typography.headlineSmall, - fontWeight = FontWeight.Bold - ) - - // Filters (Mockup) - Surface( - border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant), - shape = MaterialTheme.shapes.small, - modifier = Modifier.fillMaxWidth() - ) { - Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) { - Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) { - Text("Zeitraum:", style = MaterialTheme.typography.bodyMedium) - OutlinedTextField( - value = "März", - onValueChange = {}, - modifier = Modifier.width(100.dp).height(48.dp), - singleLine = true - ) - Text("bis", style = MaterialTheme.typography.bodyMedium) - OutlinedTextField( - value = "Dezember", - onValueChange = {}, - modifier = Modifier.width(120.dp).height(48.dp), - singleLine = true - ) - OutlinedTextField( - value = "2026", - onValueChange = {}, - modifier = Modifier.width(80.dp).height(48.dp), - singleLine = true - ) - OutlinedTextField( - value = "Bundesland", - onValueChange = {}, - modifier = Modifier.width(150.dp).height(48.dp), - singleLine = true - ) - Button( - onClick = {}, - colors = ButtonDefaults.buttonColors(containerColor = Color(0xFFE67E22)) - ) { - Text("Anzeigen") - } - } - Text("Zusätzliche Filter auf Suchergebnisse:", style = MaterialTheme.typography.bodyMedium) - Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { - OutlinedTextField( - value = "Veranstalter (Verein)", - onValueChange = {}, - modifier = Modifier.width(180.dp).height(48.dp), - singleLine = true - ) - OutlinedTextField( - value = "Ort", - onValueChange = {}, - modifier = Modifier.width(120.dp).height(48.dp), - singleLine = true - ) - OutlinedTextField( - value = "Sparte", - onValueChange = {}, - modifier = Modifier.width(120.dp).height(48.dp), - singleLine = true - ) - OutlinedTextField( - value = "Turnierart", - onValueChange = {}, - modifier = Modifier.width(120.dp).height(48.dp), - singleLine = true - ) - } - } - } - - // Desktop Tournament Card (Steuerungszentrale Ansicht) - Card( - modifier = Modifier.fillMaxWidth(), - border = BorderStroke(2.dp, MaterialTheme.colorScheme.outlineVariant), - colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface) - ) { - Row( - modifier = Modifier.padding(16.dp).fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.SpaceBetween - ) { - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(16.dp), - modifier = Modifier.weight(1f) - ) { - Surface( - modifier = Modifier.size(120.dp), - border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant), - shape = MaterialTheme.shapes.small - ) { - Box(contentAlignment = Alignment.Center) { - Text("NEUMARKT\nLOGO", textAlign = TextAlign.Center, style = MaterialTheme.typography.bodySmall) - } - } - - Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { - Text("CDN-C NEU CDNP-C", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold) - Text( - "Veranstalter: URFV Neumarkt", - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.primary - ) - Text("NEUMARKT/M., OÖ 26. APRIL 2026", style = MaterialTheme.typography.bodyMedium) - Text("Turnier-Nr.: 26129", style = MaterialTheme.typography.bodyMedium) - } - } - - Column(verticalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.width(300.dp)) { - var meldestelleOnline by remember { mutableStateOf(true) } - var nennsystemOnline by remember { mutableStateOf(true) } - var startlisteOnline by remember { mutableStateOf(true) } - ToggleRow("Meldestelle-Desktop online", isOnline = meldestelleOnline, isInteractive = false) - ToggleRow("Nennsystem online", isOnline = nennsystemOnline, isInteractive = true) - ToggleRow("Start- Ergebnislisten online", isOnline = startlisteOnline, isInteractive = true) - - Spacer(modifier = Modifier.height(4.dp)) - OutlinedButton( - onClick = { /* Link kopieren oder Email Dialog öffnen */ }, - modifier = Modifier.fillMaxWidth() - ) { - Text("Veranstalter-Link senden") - } - } - } - } - - } else if (isDesktop && !isAdmin) { - // DESKTOP VIEW - VERANSTALTER (Meldestelle am Platz) - Text( - "Willkommen in der Meldestellen-Software für Turnier 26129", - style = MaterialTheme.typography.headlineMedium - ) - Text("Bitte initialisieren Sie die lokale Datenbank oder importieren Sie einen Stand vom USB-Stick.") - Spacer(Modifier.height(16.dp)) - Button(onClick = onCreateTournament) { - Text("Turnier initialisieren / Importieren") - } - } else if (!isDesktop && isAdmin) { - // WEB VIEW - ADMIN PORTAL - Text( - text = "Alle verwalteten Turniere", - style = MaterialTheme.typography.headlineSmall, - fontWeight = FontWeight.Bold - ) - - Card( - modifier = Modifier.fillMaxWidth(), - colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.primaryContainer) - ) { - Row( - modifier = Modifier.padding(16.dp).fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically - ) { - Column { - Text( - "CDN-C NEU CDNP-C Neumarkt", - style = MaterialTheme.typography.titleMedium, - fontWeight = FontWeight.Bold - ) - Text("Veranstalter: URFV Neumarkt", style = MaterialTheme.typography.bodyMedium) - Text("Nr: 26129 | 26. APRIL 2026", style = MaterialTheme.typography.bodyMedium) - } - Button(onClick = { /* TODO: Download Trigger for Master App */ }) { - Text("Master-Desktop-App herunterladen") - } - } - } - } else { - // WEB VIEW - VERANSTALTER PORTAL - Card( - modifier = Modifier.fillMaxWidth(), - colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.secondaryContainer) - ) { - Column(modifier = Modifier.padding(24.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { - Text( - "Aktuelles Turnier: CDN-C NEU CDNP-C Neumarkt", - style = MaterialTheme.typography.headlineSmall, - fontWeight = FontWeight.Bold, - color = MaterialTheme.colorScheme.onSecondaryContainer - ) - Text( - "Turnier-Nr.: 26129 | 26. APRIL 2026", - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSecondaryContainer - ) - - Spacer(Modifier.height(8.dp)) - - Text( - "Bitte laden Sie die Desktop-Anwendung herunter, um die Meldestelle lokal an Ihrem Turnierplatz zu betreiben.", - style = MaterialTheme.typography.bodyLarge, - color = MaterialTheme.colorScheme.onSecondaryContainer - ) - - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(16.dp), - modifier = Modifier.padding(top = 8.dp) - ) { - Button(onClick = { /* TODO: Download Trigger for generic app */ }) { - Text("Meldestelle-App herunterladen (.exe)") - } - Surface( - shape = MaterialTheme.shapes.small, - color = MaterialTheme.colorScheme.surface, - border = BorderStroke(1.dp, MaterialTheme.colorScheme.outline) - ) { - Text( - "Ihr Aktivierungs-Code: X7F9-K2M4", - modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp), - style = MaterialTheme.typography.labelLarge - ) - } - } - } - } - - // Turnier-Historie - Column( - verticalArrangement = Arrangement.spacedBy(16.dp), - modifier = Modifier.fillMaxWidth() - ) { - Text("Turnier-Historie (Archiv)", style = MaterialTheme.typography.headlineSmall) - - val turniere = listOf( - TournamentData( - id = "25044", - date = "24. APRIL 2025", - title = "CSN-C CSNP-C Neumarkt", - location = "NEUMARKT/M., OÖ" - ), - TournamentData( - id = "24012", - date = "28. APRIL 2024", - title = "CDN-C CDNP-C Neumarkt", - location = "NEUMARKT/M., OÖ" - ) - ) - - turniere.forEach { turnier -> - Card( - modifier = Modifier.fillMaxWidth(), - colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface), - border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant) - ) { - Row( - modifier = Modifier.padding(16.dp).fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically - ) { - Column { - Text(turnier.title, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold) - Text("Nr: ${turnier.id} | ${turnier.date}", style = MaterialTheme.typography.bodyMedium) - } - Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { - OutlinedButton(onClick = { /* TODO: Open PDF Archive */ }) { - Text("Ergebnislisten (PDF)") - } - OutlinedButton(onClick = { /* TODO: Open Stats */ }) { - Text("Statistiken") - } - } - } - } - } - } - } - } - } -} diff --git a/frontend/shells/meldestelle-portal/src/commonMain/kotlin/screens/LandingScreen.kt b/frontend/shells/meldestelle-portal/src/commonMain/kotlin/screens/LandingScreen.kt deleted file mode 100644 index 73b24a2b..00000000 --- a/frontend/shells/meldestelle-portal/src/commonMain/kotlin/screens/LandingScreen.kt +++ /dev/null @@ -1,165 +0,0 @@ -package screens - -import androidx.compose.foundation.layout.* -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.verticalScroll -import androidx.compose.material3.* -import androidx.compose.runtime.Composable -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.unit.dp -import at.mocode.frontend.core.designsystem.components.AppFooter - -@Composable -fun LandingScreen( - onPrimaryCta: () -> Unit, - onOpenPing: () -> Unit -) { - val scrollState = rememberScrollState() - - Column( - modifier = Modifier - .fillMaxSize() - .verticalScroll(scrollState) - ) { - // Top Bar - Surface( - color = MaterialTheme.colorScheme.surface, - shadowElevation = 2.dp, - modifier = Modifier.fillMaxWidth() - ) { - Row( - modifier = Modifier.padding(horizontal = 24.dp, vertical = 16.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.SpaceBetween - ) { - Text( - text = "mo-code.at", - style = MaterialTheme.typography.titleLarge, - fontWeight = FontWeight.Bold, - color = MaterialTheme.colorScheme.primary - ) - Button(onClick = onPrimaryCta) { - Text("Login Meldestelle") - } - } - } - - // Hero - Column( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 24.dp, vertical = 60.dp), - verticalArrangement = Arrangement.spacedBy(24.dp), - horizontalAlignment = Alignment.CenterHorizontally - ) { - Text( - text = "Die moderne Meldestelle", - style = MaterialTheme.typography.displayMedium, - fontWeight = FontWeight.Bold - ) - Text( - text = "Von Praktikern für Praktiker. Schneller Nennen, fehlerfrei Richten und stressfrei Auswerten – konform nach ÖTO & FEI.", - style = MaterialTheme.typography.headlineSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - } - - // Manifest / Intro - Surface(color = MaterialTheme.colorScheme.surfaceVariant) { - Column( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 24.dp, vertical = 60.dp), - verticalArrangement = Arrangement.spacedBy(16.dp) - ) { - Text("Unser Anspruch: Ein durchdachtes System.", style = MaterialTheme.typography.headlineMedium) - Text( - "Die Meldestelle ist das Herzstück jedes Turniers. Wenn sie stolpert, stockt der Sport. Wir verstehen den Balanceakt zwischen Veranstaltern, Reitern und den Verbänden.", - style = MaterialTheme.typography.bodyLarge - ) - Text( - "Deshalb entwickeln wir diese Plattform nicht am Reißbrett, sondern direkt am Turnierplatz – aus der Sicht der Meldestelle, der Richter, der Zeitnehmer und aller Funktionäre.", - style = MaterialTheme.typography.bodyLarge - ) - Text( - "Mit Fokus auf die Praxis: Tastaturbedienung für höchste Geschwindigkeit, Offline-Fähigkeit für das 'Plumpsklo' am Rand des Abreiteplatzes und eine integrierte Kassenführung.", - style = MaterialTheme.typography.bodyLarge - ) - } - } - - // Kern-Säulen - Column( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 24.dp, vertical = 60.dp), - verticalArrangement = Arrangement.spacedBy(32.dp) - ) { - Text("Die Kern-Säulen", style = MaterialTheme.typography.headlineMedium) - Column(verticalArrangement = Arrangement.spacedBy(24.dp)) { - LandingFeatureCard( - number = "01", - title = "Regelwerks-Intelligenz (ÖTO)", - body = "Wir nehmen Ihnen die Validierungs-Komplexität ab. Von der Lizenzprüfung der Reiter bis zur Kontrolle der Richterqualifikationen beim Anlegen der Bewerbe." - ) - LandingFeatureCard( - number = "02", - title = "Offline-First & Resilient", - body = "Stabil auf dem Laptop. Dank Offline-Unterstützung und lokaler Datenbank arbeiten Sie nahtlos weiter, selbst wenn die Internetverbindung am Platz wieder einmal abreißt." - ) - LandingFeatureCard( - number = "03", - title = "Speed-Workflow", - body = "Die Nennungsmaske und die Ergebniserfassung sind kompromisslos auf Geschwindigkeit und Tastaturbedienung (Enter & Tab) optimiert. Weil am Turniertag jede Sekunde zählt." - ) - LandingFeatureCard( - number = "04", - title = "Smarte Kassenführung", - body = "Kontobasierte Abrechnung für Reiter und Besitzer. Nenngelder, Startgelder und Nachnenngebühren sauber getrennt – selbst ein Nennungstausch wird als einfacher Transfer verbucht." - ) - } - } - - // System Status Link - Column( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 24.dp, vertical = 24.dp), - horizontalAlignment = Alignment.CenterHorizontally - ) { - OutlinedButton(onClick = onOpenPing) { - Text("System Status (Ping-Service)") - } - } - - AppFooter() - } -} - -@Composable -private fun LandingFeatureCard(number: String, title: String, body: String) { - Card( - modifier = Modifier.fillMaxWidth(), - elevation = CardDefaults.cardElevation(defaultElevation = 2.dp) - ) { - Row( - modifier = Modifier.fillMaxWidth().padding(16.dp), - verticalAlignment = Alignment.Top - ) { - Text( - text = number, - style = MaterialTheme.typography.headlineMedium, - color = MaterialTheme.colorScheme.primary, - fontWeight = FontWeight.Black, - modifier = Modifier.width(64.dp) - ) - Column(modifier = Modifier.weight(1f)) { - Text(title, style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold) - Spacer(Modifier.height(8.dp)) - Text(body, style = MaterialTheme.typography.bodyLarge, color = MaterialTheme.colorScheme.onSurfaceVariant) - } - } - } -} diff --git a/frontend/shells/meldestelle-portal/src/commonMain/kotlin/screens/OrganizerProfileScreen.kt b/frontend/shells/meldestelle-portal/src/commonMain/kotlin/screens/OrganizerProfileScreen.kt deleted file mode 100644 index c3d2aa48..00000000 --- a/frontend/shells/meldestelle-portal/src/commonMain/kotlin/screens/OrganizerProfileScreen.kt +++ /dev/null @@ -1,345 +0,0 @@ -package screens - -import androidx.compose.foundation.BorderStroke -import androidx.compose.foundation.layout.* -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.verticalScroll -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.filled.ArrowBack -import androidx.compose.material3.* -import androidx.compose.runtime.* -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.unit.dp -import at.mocode.frontend.core.auth.data.AuthTokenManager - -@OptIn(ExperimentalMaterial3Api::class) -@Composable -fun OrganizerProfileScreen( - authTokenManager: AuthTokenManager, - onLogout: () -> Unit, - onNavigateToDashboard: () -> Unit -) { - val authState by authTokenManager.authState.collectAsState() - val scrollState = rememberScrollState() - - // Formular-Felder - var vereinsname by remember { mutableStateOf("URFV Neumarkt") } - var vereinskuerzel by remember { mutableStateOf("URFV") } - var adresse by remember { mutableStateOf("") } - var plz by remember { mutableStateOf("") } - var ort by remember { mutableStateOf("") } - var land by remember { mutableStateOf("Österreich") } - var mapsLink by remember { mutableStateOf("") } - - // Ansprechpersonen - var kontakt1Name by remember { mutableStateOf("") } - var kontakt1Email by remember { mutableStateOf("") } - var kontakt1Telefon by remember { mutableStateOf("") } - var kontakt2Name by remember { mutableStateOf("") } - var kontakt2Email by remember { mutableStateOf("") } - var kontakt2Telefon by remember { mutableStateOf("") } - - // Social / Links - var webseite by remember { mutableStateOf("") } - var facebook by remember { mutableStateOf("") } - var instagram by remember { mutableStateOf("") } - var youtube by remember { mutableStateOf("") } - - // Weitere Infos - var vereinsbeschreibung by remember { mutableStateOf("") } - var bankverbindung by remember { mutableStateOf("") } - var uid by remember { mutableStateOf("") } - - var saveSuccess by remember { mutableStateOf(false) } - - Scaffold( - topBar = { - TopAppBar( - title = { Text("Veranstalter Profil") }, - navigationIcon = { - IconButton(onClick = onNavigateToDashboard) { - Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Zurück") - } - }, - actions = { - Text( - text = authState.username ?: "", - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.padding(end = 8.dp) - ) - TextButton(onClick = onLogout) { Text("Abmelden") } - } - ) - } - ) { paddingValues -> - Column( - modifier = Modifier - .fillMaxSize() - .padding(paddingValues) - .verticalScroll(scrollState) - .padding(24.dp), - verticalArrangement = Arrangement.spacedBy(24.dp) - ) { - - // --- Logo & Vereinsname --- - Card(modifier = Modifier.fillMaxWidth()) { - Column(modifier = Modifier.padding(20.dp), verticalArrangement = Arrangement.spacedBy(16.dp)) { - Text("Verein / Veranstalter", style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold) - - // Logo Placeholder - Surface( - modifier = Modifier.fillMaxWidth(), - shape = MaterialTheme.shapes.medium, - color = MaterialTheme.colorScheme.surfaceVariant, - border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant) - ) { - Column( - modifier = Modifier.padding(24.dp).fillMaxWidth(), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(8.dp) - ) { - Text("🏆", style = MaterialTheme.typography.displayMedium) - Text( - "Vereins-/Veranstaltungslogo", - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - OutlinedButton(onClick = { /* TODO: File Picker */ }) { - Text("Logo hochladen") - } - } - } - - OutlinedTextField( - value = vereinsname, - onValueChange = { vereinsname = it }, - label = { Text("Vereinsname / Veranstalter") }, - singleLine = true, - modifier = Modifier.fillMaxWidth() - ) - OutlinedTextField( - value = vereinskuerzel, - onValueChange = { vereinskuerzel = it }, - label = { Text("Kürzel") }, - singleLine = true, - modifier = Modifier.fillMaxWidth() - ) - OutlinedTextField( - value = vereinsbeschreibung, - onValueChange = { vereinsbeschreibung = it }, - label = { Text("Kurzbeschreibung / Über uns") }, - minLines = 3, - maxLines = 6, - modifier = Modifier.fillMaxWidth() - ) - } - } - - // --- Adresse --- - Card(modifier = Modifier.fillMaxWidth()) { - Column(modifier = Modifier.padding(20.dp), verticalArrangement = Arrangement.spacedBy(16.dp)) { - Text("Adresse", style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold) - - OutlinedTextField( - value = adresse, - onValueChange = { adresse = it }, - label = { Text("Straße & Hausnummer") }, - singleLine = true, - modifier = Modifier.fillMaxWidth() - ) - Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) { - OutlinedTextField( - value = plz, - onValueChange = { plz = it }, - label = { Text("PLZ") }, - singleLine = true, - modifier = Modifier.width(100.dp) - ) - OutlinedTextField( - value = ort, - onValueChange = { ort = it }, - label = { Text("Ort") }, - singleLine = true, - modifier = Modifier.weight(1f) - ) - } - OutlinedTextField( - value = land, - onValueChange = { land = it }, - label = { Text("Land") }, - singleLine = true, - modifier = Modifier.fillMaxWidth() - ) - OutlinedTextField( - value = mapsLink, - onValueChange = { mapsLink = it }, - label = { Text("Google Maps / OpenStreetMap Link") }, - placeholder = { Text("https://maps.google.com/...") }, - singleLine = true, - modifier = Modifier.fillMaxWidth() - ) - } - } - - // --- Ansprechpersonen --- - Card(modifier = Modifier.fillMaxWidth()) { - Column(modifier = Modifier.padding(20.dp), verticalArrangement = Arrangement.spacedBy(16.dp)) { - Text("Ansprechpersonen", style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold) - - Text("Hauptkontakt", style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.primary) - OutlinedTextField( - value = kontakt1Name, - onValueChange = { kontakt1Name = it }, - label = { Text("Name") }, - singleLine = true, - modifier = Modifier.fillMaxWidth() - ) - OutlinedTextField( - value = kontakt1Email, - onValueChange = { kontakt1Email = it }, - label = { Text("E-Mail") }, - singleLine = true, - modifier = Modifier.fillMaxWidth() - ) - OutlinedTextField( - value = kontakt1Telefon, - onValueChange = { kontakt1Telefon = it }, - label = { Text("Telefon / Mobil") }, - singleLine = true, - modifier = Modifier.fillMaxWidth() - ) - - HorizontalDivider() - - Text( - "Weiterer Kontakt (optional)", - style = MaterialTheme.typography.titleMedium, - color = MaterialTheme.colorScheme.secondary - ) - OutlinedTextField( - value = kontakt2Name, - onValueChange = { kontakt2Name = it }, - label = { Text("Name") }, - singleLine = true, - modifier = Modifier.fillMaxWidth() - ) - OutlinedTextField( - value = kontakt2Email, - onValueChange = { kontakt2Email = it }, - label = { Text("E-Mail") }, - singleLine = true, - modifier = Modifier.fillMaxWidth() - ) - OutlinedTextField( - value = kontakt2Telefon, - onValueChange = { kontakt2Telefon = it }, - label = { Text("Telefon / Mobil") }, - singleLine = true, - modifier = Modifier.fillMaxWidth() - ) - } - } - - // --- Social Media & Links --- - Card(modifier = Modifier.fillMaxWidth()) { - Column(modifier = Modifier.padding(20.dp), verticalArrangement = Arrangement.spacedBy(16.dp)) { - Text("Links & Social Media", style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold) - - OutlinedTextField( - value = webseite, - onValueChange = { webseite = it }, - label = { Text("Webseite") }, - placeholder = { Text("https://www.meinverein.at") }, - singleLine = true, - modifier = Modifier.fillMaxWidth() - ) - OutlinedTextField( - value = facebook, - onValueChange = { facebook = it }, - label = { Text("Facebook") }, - placeholder = { Text("https://facebook.com/...") }, - singleLine = true, - modifier = Modifier.fillMaxWidth() - ) - OutlinedTextField( - value = instagram, - onValueChange = { instagram = it }, - label = { Text("Instagram") }, - placeholder = { Text("https://instagram.com/...") }, - singleLine = true, - modifier = Modifier.fillMaxWidth() - ) - OutlinedTextField( - value = youtube, - onValueChange = { youtube = it }, - label = { Text("YouTube") }, - placeholder = { Text("https://youtube.com/...") }, - singleLine = true, - modifier = Modifier.fillMaxWidth() - ) - } - } - - // --- Weitere Vereinsdaten --- - Card(modifier = Modifier.fillMaxWidth()) { - Column(modifier = Modifier.padding(20.dp), verticalArrangement = Arrangement.spacedBy(16.dp)) { - Text("Weitere Informationen", style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold) - - OutlinedTextField( - value = bankverbindung, - onValueChange = { bankverbindung = it }, - label = { Text("IBAN / Bankverbindung") }, - placeholder = { Text("AT12 3456 7890 1234 5678") }, - singleLine = true, - modifier = Modifier.fillMaxWidth() - ) - OutlinedTextField( - value = uid, - onValueChange = { uid = it }, - label = { Text("UID-Nummer / ZVR-Zahl") }, - singleLine = true, - modifier = Modifier.fillMaxWidth() - ) - } - } - - // --- Speichern --- - if (saveSuccess) { - Card( - colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.primaryContainer), - modifier = Modifier.fillMaxWidth() - ) { - Text( - "✓ Profil erfolgreich gespeichert!", - modifier = Modifier.padding(16.dp), - color = MaterialTheme.colorScheme.onPrimaryContainer, - style = MaterialTheme.typography.bodyLarge - ) - } - } - - Button( - onClick = { - // TODO: Backend-Anbindung (PUT /api/organizer/profile) - saveSuccess = true - }, - modifier = Modifier.fillMaxWidth().height(52.dp) - ) { - Text("Profil speichern", style = MaterialTheme.typography.titleMedium) - } - - OutlinedButton( - onClick = onNavigateToDashboard, - modifier = Modifier.fillMaxWidth() - ) { - Text("Zum Dashboard") - } - - Spacer(modifier = Modifier.height(24.dp)) - } - } -} - diff --git a/frontend/shells/meldestelle-portal/src/commonTest/kotlin/ComposeAppCommonTest.kt b/frontend/shells/meldestelle-portal/src/commonTest/kotlin/ComposeAppCommonTest.kt deleted file mode 100644 index b671cfd1..00000000 --- a/frontend/shells/meldestelle-portal/src/commonTest/kotlin/ComposeAppCommonTest.kt +++ /dev/null @@ -1,10 +0,0 @@ -import kotlin.test.Test -import kotlin.test.assertEquals - -class ComposeAppCommonTest { - - @Test - fun example() { - assertEquals(3, 1 + 2) - } -} diff --git a/frontend/shells/meldestelle-portal/src/jsMain/kotlin/Config.kt b/frontend/shells/meldestelle-portal/src/jsMain/kotlin/Config.kt deleted file mode 100644 index 3047e86c..00000000 --- a/frontend/shells/meldestelle-portal/src/jsMain/kotlin/Config.kt +++ /dev/null @@ -1,40 +0,0 @@ -import kotlinx.browser.window -import kotlinx.coroutines.await -import kotlinx.serialization.Serializable -import kotlinx.serialization.json.Json -import org.w3c.fetch.RequestInit - -private val AppJson = Json { ignoreUnknownKeys = true } - -@Serializable -data class AppConfig( - val apiBaseUrl: String, - val keycloakUrl: String? = null // Optional, da nicht immer vorhanden -) - -suspend fun loadAppConfig(): AppConfig { - return try { - // ?_nocache erzwingt einen SW-bypassing Request (SW sieht andere URL → Cache-Miss → Netzwerk) - val response = window.fetch("/config.json?_nocache=1", RequestInit()).await() - if (!response.ok) { - console.warn("[Config] Failed to load config.json, falling back to globalThis") - return fallbackFromGlobal() - } - val text = response.text().await() - AppJson.decodeFromString(AppConfig.serializer(), text) - } catch (e: dynamic) { - console.error("[Config] Error loading config:", e) - // Fallback: Caddy-injizierte Werte aus index.html (globalThis.API_BASE_URL / KEYCLOAK_URL) - fallbackFromGlobal() - } -} - -private fun fallbackFromGlobal(): AppConfig { - val apiBase = (js("globalThis.API_BASE_URL") as? String) - ?.takeIf { it.isNotBlank() && !it.startsWith($$"${") } - ?: window.location.origin - val kcUrl = (js("globalThis.KEYCLOAK_URL") as? String) - ?.takeIf { it.isNotBlank() && !it.startsWith($$"${") } - console.log("[Config] Fallback: apiBaseUrl=$apiBase, keycloakUrl=$kcUrl") - return AppConfig(apiBaseUrl = apiBase, keycloakUrl = kcUrl) -} diff --git a/frontend/shells/meldestelle-portal/src/jsMain/kotlin/DevelopmentMode.js.kt b/frontend/shells/meldestelle-portal/src/jsMain/kotlin/DevelopmentMode.js.kt deleted file mode 100644 index 87df6fe7..00000000 --- a/frontend/shells/meldestelle-portal/src/jsMain/kotlin/DevelopmentMode.js.kt +++ /dev/null @@ -1,2 +0,0 @@ -actual fun isDevelopmentMode(): Boolean = - kotlinx.browser.window.location.hostname == "localhost" diff --git a/frontend/shells/meldestelle-portal/src/jsMain/kotlin/NennungScreenContent.kt b/frontend/shells/meldestelle-portal/src/jsMain/kotlin/NennungScreenContent.kt deleted file mode 100644 index 4cc2129d..00000000 --- a/frontend/shells/meldestelle-portal/src/jsMain/kotlin/NennungScreenContent.kt +++ /dev/null @@ -1,8 +0,0 @@ -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable - -@Composable -actual fun NennungScreenContent() { - // Nennungs-Maske ist nur für Desktop (JVM) verfügbar - Text("Nennungs-Maske ist nur in der Desktop-App verfügbar.") -} diff --git a/frontend/shells/meldestelle-portal/src/jsMain/kotlin/main.kt b/frontend/shells/meldestelle-portal/src/jsMain/kotlin/main.kt deleted file mode 100644 index 00e1204f..00000000 --- a/frontend/shells/meldestelle-portal/src/jsMain/kotlin/main.kt +++ /dev/null @@ -1,113 +0,0 @@ -import androidx.compose.ui.ExperimentalComposeUiApi -import androidx.compose.ui.window.ComposeViewport -import at.mocode.frontend.core.auth.di.authModule -import at.mocode.frontend.core.localdb.AppDatabase -import at.mocode.frontend.core.localdb.DatabaseProvider -import at.mocode.frontend.core.localdb.localDbModule -import at.mocode.frontend.core.network.networkModule -import at.mocode.frontend.core.sync.di.syncModule -import at.mocode.ping.feature.di.pingFeatureModule -import kotlinx.browser.document -import kotlinx.coroutines.MainScope -import kotlinx.coroutines.launch -import navigation.navigationModule -import org.koin.core.context.GlobalContext -import org.koin.core.context.loadKoinModules -import org.koin.core.context.startKoin -import org.koin.dsl.module -import org.w3c.dom.HTMLElement - -@OptIn(ExperimentalComposeUiApi::class) -fun main() { - console.log("[WebApp] main() entered") - - MainScope().launch { - try { - // 1. Load Runtime Configuration (Async) - console.log("[WebApp] Loading configuration...") - val config = loadAppConfig() - console.log("[WebApp] Configuration loaded: apiBaseUrl=${config.apiBaseUrl}, keycloakUrl=${config.keycloakUrl}") - - // Inject config into the global JS scope for PlatformConfig to find - val global = js("typeof globalThis !== 'undefined' ? globalThis : window") - global.API_BASE_URL = config.apiBaseUrl - config.keycloakUrl?.let { global.KEYCLOAK_URL = it } - - // 2. Initialize DI (Koin) - // We register the config immediately so other modules can use it - startKoin { - modules( - module { single { config } }, // Make AppConfig available for injection - networkModule, - localDbModule, - syncModule, - pingFeatureModule, - authModule, - navigationModule - ) - } - console.log("[WebApp] Koin initialized") - - // 3. Initialize Database (Async) - val provider = GlobalContext.get().get() - console.log("[WebApp] Initializing Database...") - val db = provider.createDatabase() - - // Register the created DB instance into Koin - loadKoinModules( - module { - single { db } - } - ) - console.log("[WebApp] Local DB created and registered in Koin") - - // 4. Start UI - startAppWhenDomReady() - - } catch (e: dynamic) { - console.error("[WebApp] CRITICAL: Initialization failed:", e) - renderFatalError("Initialization failed: ${e?.message ?: e}") - } - } -} - -@OptIn(ExperimentalComposeUiApi::class) -fun startAppWhenDomReady() { - val state = document.asDynamic().readyState as String? - if (state == "interactive" || state == "complete") { - mountComposeApp() - } else { - document.addEventListener("DOMContentLoaded", { mountComposeApp() }) - } -} - -@OptIn(ExperimentalComposeUiApi::class) -fun mountComposeApp() { - try { - console.log("[WebApp] Mounting Compose App...") - val root = document.getElementById("ComposeTarget") as HTMLElement - - ComposeViewport(root) { - MainApp() - } - - // Remove loading spinner - (document.querySelector(".loading") as? HTMLElement)?.let { it.parentElement?.removeChild(it) } - console.log("[WebApp] App mounted successfully") - - } catch (e: Exception) { - console.error("Failed to start Compose Web app", e) - renderFatalError("UI Mount failed: ${e.message}") - } -} - -fun renderFatalError(message: String) { - val fallbackTarget = (document.getElementById("ComposeTarget") ?: document.body) as HTMLElement - fallbackTarget.innerHTML = """ -
-

System Error

-

The application could not be started.

-
$message
-
- """.trimIndent() -} diff --git a/frontend/shells/meldestelle-portal/src/jsMain/resources/icons/icon-192.png b/frontend/shells/meldestelle-portal/src/jsMain/resources/icons/icon-192.png deleted file mode 100644 index 03fc0c72..00000000 Binary files a/frontend/shells/meldestelle-portal/src/jsMain/resources/icons/icon-192.png and /dev/null differ diff --git a/frontend/shells/meldestelle-portal/src/jsMain/resources/icons/icon-512.png b/frontend/shells/meldestelle-portal/src/jsMain/resources/icons/icon-512.png deleted file mode 100644 index 16994d9c..00000000 Binary files a/frontend/shells/meldestelle-portal/src/jsMain/resources/icons/icon-512.png and /dev/null differ diff --git a/frontend/shells/meldestelle-portal/src/jsMain/resources/index.html b/frontend/shells/meldestelle-portal/src/jsMain/resources/index.html deleted file mode 100644 index cd3d2048..00000000 --- a/frontend/shells/meldestelle-portal/src/jsMain/resources/index.html +++ /dev/null @@ -1,73 +0,0 @@ - - - - - - Meldestelle - Web - - - - - -
-
Loading...
-
- - - - - - - - - - diff --git a/frontend/shells/meldestelle-portal/src/jsMain/resources/manifest.webmanifest b/frontend/shells/meldestelle-portal/src/jsMain/resources/manifest.webmanifest deleted file mode 100644 index 3e89d363..00000000 --- a/frontend/shells/meldestelle-portal/src/jsMain/resources/manifest.webmanifest +++ /dev/null @@ -1,13 +0,0 @@ -{ - "name": "Meldestelle", - "short_name": "Melde", - "start_url": "/", - "scope": "/", - "display": "standalone", - "background_color": "#ffffff", - "theme_color": "#0f172a", - "icons": [ - { "src": "icons/icon-192.png", "sizes": "192x192", "type": "image/png" }, - { "src": "icons/icon-512.png", "sizes": "512x512", "type": "image/png" } - ] -} diff --git a/frontend/shells/meldestelle-portal/src/jsMain/resources/styles.css b/frontend/shells/meldestelle-portal/src/jsMain/resources/styles.css deleted file mode 100644 index 2c25ff81..00000000 --- a/frontend/shells/meldestelle-portal/src/jsMain/resources/styles.css +++ /dev/null @@ -1,22 +0,0 @@ -html, body { - margin: 0; - padding: 0; - font-family: system-ui, -apple-system, sans-serif; - background: #fafafa; - overflow: hidden; /* Verhindert Scrollbalken durch die Canvas */ -} - -#ComposeTarget { - height: 100vh; - display: flex; - flex-direction: column; -} - -.loading { - display: flex; - justify-content: center; - align-items: center; - height: 100vh; - font-size: 18px; - color: #666; -} diff --git a/frontend/shells/meldestelle-portal/src/jsMain/resources/sw.js b/frontend/shells/meldestelle-portal/src/jsMain/resources/sw.js deleted file mode 100644 index d6868704..00000000 --- a/frontend/shells/meldestelle-portal/src/jsMain/resources/sw.js +++ /dev/null @@ -1,114 +0,0 @@ -const IS_DEV = self.location.hostname === 'localhost' || self.location.hostname === '127.0.0.1' || self.location.hostname === '::1'; - -const CACHE_NAME = 'meldestelle-cache-v4'; -const PRECACHE_URLS = [ - '/', - '/index.html', - '/styles.css' -]; - -self.addEventListener('install', (event) => { - if (IS_DEV) { - // In dev, don't precache. Just activate the SW immediately. - self.skipWaiting().then(_ => { - }); - return; - } - event.waitUntil( - caches.open(CACHE_NAME) - .then((cache) => cache.addAll(PRECACHE_URLS)) - .then(() => self.skipWaiting()) - ); -}); - -self.addEventListener('activate', (event) => { - if (IS_DEV) { - event.waitUntil(self.clients.claim()); - return; - } - event.waitUntil( - caches.keys().then((keys) => Promise.all( - keys.filter((k) => k !== CACHE_NAME).map((k) => caches.delete(k)) - )).then(() => self.clients.claim()) - ); -}); - -self.addEventListener('fetch', (event) => { - if (IS_DEV) { - return; // don't interfere with dev server/HMR - } - - const req = event.request; - const url = new URL(req.url); - - const isHttp = url.protocol === 'http:' || url.protocol === 'https:'; - const sameOrigin = url.origin === self.location.origin; - const isExtension = url.protocol === 'chrome-extension:'; - const isHotUpdate = url.pathname.includes('hot-update'); - const isWebSocketUpgrade = req.headers.get('upgrade') === 'websocket'; - - // Ignore non-GET, cross-origin, browser extensions, HMR, and WebSocket upgrade requests - if (req.method !== 'GET' || !isHttp || !sameOrigin || isExtension || isHotUpdate || isWebSocketUpgrade) { - return; // Let the browser handle it - } - - if (req.mode === 'navigate') { - // Network-first for navigation - event.respondWith( - fetch(req) - .then((resp) => { - if (resp && resp.status === 200 && resp.type === 'basic') { - const copy = resp.clone(); - caches.open(CACHE_NAME).then((cache) => cache.put('/index.html', copy)).catch(() => { - }); - } - return resp; - }) - .catch(() => caches.match('/index.html')) - ); - return; - } - - // Laufzeit-Config immer direkt vom Netzwerk – niemals aus dem Cache - if (url.pathname === '/config.json') { - event.respondWith(fetch(req).catch(() => new Response('{}', { status: 200, headers: { 'Content-Type': 'application/json' } }))); - return; - } - - // API-Requests immer direkt vom Netzwerk – niemals aus dem Cache (Auth-Header müssen erhalten bleiben) - if (url.pathname.startsWith('/api/')) { - event.respondWith(fetch(req)); - return; - } - - // App-Bundle immer vom Netzwerk – niemals aus dem Cache (verhindert veraltete JS-Versionen) - if (url.pathname.endsWith('web-app.js') || url.pathname.endsWith('web-app.js.map')) { - event.respondWith(fetch(req)); - return; - } - - // Avoid noisy errors for favicon during dev/prod when missing - if (url.pathname === '/favicon.ico') { - event.respondWith( - fetch(req).catch(() => caches.match(req)) - ); - return; - } - - // Cache-first for static assets - event.respondWith( - caches.match(req).then((cached) => { - if (cached) return cached; - return fetch(req) - .then((resp) => { - if (resp && resp.status === 200 && resp.type === 'basic') { - const copy = resp.clone(); - caches.open(CACHE_NAME).then((cache) => cache.put(req, copy)).catch(() => { - }); - } - return resp; - }) - .catch(() => caches.match(req)); - }) - ); -}); diff --git a/frontend/shells/meldestelle-portal/src/jvmMain/kotlin/AppPreview.kt b/frontend/shells/meldestelle-portal/src/jvmMain/kotlin/AppPreview.kt deleted file mode 100644 index a1d8f4ee..00000000 --- a/frontend/shells/meldestelle-portal/src/jvmMain/kotlin/AppPreview.kt +++ /dev/null @@ -1,52 +0,0 @@ -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.padding -import androidx.compose.material3.Button -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Surface -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.ui.Modifier -import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.window.Window -import androidx.compose.ui.window.application -import at.mocode.frontend.core.designsystem.theme.AppTheme -import at.mocode.frontend.core.designsystem.theme.Dimens - -@Preview -@Composable -fun LandingPagePreview() { - AppTheme { - Surface( - modifier = Modifier.fillMaxSize(), - color = MaterialTheme.colorScheme.background - ) { - Column( - modifier = Modifier.padding(Dimens.SpacingM), - verticalArrangement = Arrangement.spacedBy(Dimens.SpacingM) - ) { - Text( - text = "Landing Page Preview", - style = MaterialTheme.typography.titleLarge, - color = MaterialTheme.colorScheme.primary - ) - Text( - text = "Dies ist eine Live-Vorschau. Du kannst hier deine UI-Komponenten isoliert bauen und sofort sehen, wie sie aussehen. Ändere Text, Farben oder Layouts im Code – die Preview aktualisiert sich automatisch in IntelliJ (Hot-Reload).", - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onBackground - ) - Button(onClick = {}) { - Text("Ein Button im Primary-Design") - } - } - } - } -} - -// Fallback für IntelliJ, falls das Compose Plugin die Preview nicht erkennt -fun main() = application { - Window(onCloseRequest = ::exitApplication, title = "Design Preview Sandbox") { - LandingPagePreview() - } -} diff --git a/frontend/shells/meldestelle-portal/src/jvmMain/kotlin/DevelopmentMode.jvm.kt b/frontend/shells/meldestelle-portal/src/jvmMain/kotlin/DevelopmentMode.jvm.kt deleted file mode 100644 index e48d0561..00000000 --- a/frontend/shells/meldestelle-portal/src/jvmMain/kotlin/DevelopmentMode.jvm.kt +++ /dev/null @@ -1,2 +0,0 @@ -actual fun isDevelopmentMode(): Boolean = - System.getProperty("development.mode", "false").toBoolean() diff --git a/frontend/shells/meldestelle-portal/src/jvmMain/kotlin/NennungScreenContent.kt b/frontend/shells/meldestelle-portal/src/jvmMain/kotlin/NennungScreenContent.kt deleted file mode 100644 index c9d0b22f..00000000 --- a/frontend/shells/meldestelle-portal/src/jvmMain/kotlin/NennungScreenContent.kt +++ /dev/null @@ -1,10 +0,0 @@ -import androidx.compose.runtime.Composable -import at.mocode.nennung.feature.presentation.NennungsMaske -import at.mocode.nennung.feature.presentation.NennungViewModel -import org.koin.compose.viewmodel.koinViewModel - -@Composable -actual fun NennungScreenContent() { - val viewModel: NennungViewModel = koinViewModel() - NennungsMaske(viewModel = viewModel) -} diff --git a/frontend/shells/meldestelle-portal/src/jvmMain/kotlin/main.kt b/frontend/shells/meldestelle-portal/src/jvmMain/kotlin/main.kt deleted file mode 100644 index 81969e64..00000000 --- a/frontend/shells/meldestelle-portal/src/jvmMain/kotlin/main.kt +++ /dev/null @@ -1,59 +0,0 @@ -import androidx.compose.ui.unit.dp -import androidx.compose.ui.window.Window -import androidx.compose.ui.window.WindowState -import androidx.compose.ui.window.application -import at.mocode.frontend.core.auth.di.authModule -import at.mocode.frontend.core.localdb.AppDatabase -import at.mocode.frontend.core.localdb.DatabaseProvider -import at.mocode.frontend.core.localdb.localDbModule -import at.mocode.frontend.core.network.networkModule -import at.mocode.frontend.core.sync.di.syncModule -import at.mocode.ping.feature.di.pingFeatureModule -import at.mocode.nennung.feature.di.nennungFeatureModule -import kotlinx.coroutines.runBlocking -import navigation.navigationModule -import org.koin.core.context.loadKoinModules -import org.koin.core.context.startKoin -import org.koin.dsl.module - -fun main() = application { - // Initialize DI (Koin) with shared modules + network module - try { - // Updated: Only load the consolidated pingFeatureModule from at.mocode.ping.feature.di - startKoin { - modules( - networkModule, - syncModule, - pingFeatureModule, - nennungFeatureModule, - authModule, - navigationModule, - localDbModule - ) - } - println("[DesktopApp] Koin initialized with networkModule + authModule + navigationModule + pingFeatureModule + localDbModule") - } catch (e: Exception) { - println("[DesktopApp] Koin initialization warning: ${e.message}") - } - - // Create the local DB once and register it into Koin so feature repositories can resolve it. - try { - val provider = org.koin.core.context.GlobalContext.get().get() - val db = runBlocking { provider.createDatabase() } - loadKoinModules( - module { - single { db } - } - ) - println("[DesktopApp] Local DB created and registered in Koin") - } catch (e: Exception) { - println("[DesktopApp] Local DB init warning: ${e.message}") - } - Window( - onCloseRequest = ::exitApplication, - title = "Master Desktop", - state = WindowState(width = 1200.dp, height = 800.dp) - ) { - MainApp() - } -} diff --git a/frontend/shells/meldestelle-portal/src/wasmJsMain/kotlin/main.kt b/frontend/shells/meldestelle-portal/src/wasmJsMain/kotlin/main.kt deleted file mode 100644 index d9bc8010..00000000 --- a/frontend/shells/meldestelle-portal/src/wasmJsMain/kotlin/main.kt +++ /dev/null @@ -1,54 +0,0 @@ -import androidx.compose.ui.ExperimentalComposeUiApi -import androidx.compose.ui.window.ComposeViewport -import kotlinx.browser.document -import org.w3c.dom.HTMLElement - -import at.mocode.shared.di.initKoin -import at.mocode.frontend.core.network.networkModule -import at.mocode.clients.authfeature.di.authFeatureModule -import at.mocode.frontend.core.sync.di.syncModule -import at.mocode.ping.feature.di.pingFeatureModule -import at.mocode.frontend.core.localdb.AppDatabase -import at.mocode.frontend.core.localdb.DatabaseProvider -import navigation.navigationModule -import kotlinx.coroutines.MainScope -import kotlinx.coroutines.launch -import org.koin.core.context.GlobalContext -import org.koin.core.context.loadKoinModules -import org.koin.dsl.module - -@OptIn(ExperimentalComposeUiApi::class) -fun main() { - // Initialize DI - try { - initKoin { modules(networkModule, syncModule, pingFeatureModule, authFeatureModule, navigationModule) } - println("[WasmApp] Koin initialized (with navigationModule)") - } catch (e: Exception) { - println("[WasmApp] Koin init failed: ${e.message}") - } - - // Create the local DB asynchronously and register it into Koin. - try { - val provider = GlobalContext.get().get() - MainScope().launch { - try { - val db = provider.createDatabase() - loadKoinModules( - module { - single { db } - } - ) - println("[WasmApp] Local DB created and registered in Koin") - } catch (e: dynamic) { - println("[WasmApp] Local DB init warning: ${e?.message ?: e}") - } - } - } catch (e: Exception) { - println("[WasmApp] Local DB init warning: ${e.message}") - } - - val root = document.getElementById("ComposeTarget") as HTMLElement - ComposeViewport(root) { - MainApp() - } -} diff --git a/frontend/shells/meldestelle-portal/webpack.config.d/opfs-headers.js b/frontend/shells/meldestelle-portal/webpack.config.d/opfs-headers.js deleted file mode 100644 index 0ef314b9..00000000 --- a/frontend/shells/meldestelle-portal/webpack.config.d/opfs-headers.js +++ /dev/null @@ -1,8 +0,0 @@ -// Essenzielle Header für OPFS Support (SharedArrayBuffer) -// Siehe: https://sqlite.org/wasm/doc/trunk/persistence.html#opfs -config.devServer = config.devServer || {}; -config.devServer.headers = { - ...config.devServer.headers, - "Cross-Origin-Opener-Policy": "same-origin", - "Cross-Origin-Embedder-Policy": "require-corp" -}; diff --git a/frontend/shells/meldestelle-portal/webpack.config.d/sqlite-config.js b/frontend/shells/meldestelle-portal/webpack.config.d/sqlite-config.js deleted file mode 100644 index 8f5b6f2f..00000000 --- a/frontend/shells/meldestelle-portal/webpack.config.d/sqlite-config.js +++ /dev/null @@ -1,173 +0,0 @@ -// Webpack configuration for SQLite WASM support AND Skiko fixes -const CopyWebpackPlugin = require('copy-webpack-plugin'); -const webpack = require('webpack'); -const path = require('path'); -const fs = require('fs'); - -console.log("SQLite Config: Current working directory (cwd):", process.cwd()); -console.log("SQLite Config: __dirname:", __dirname); - -config.resolve = config.resolve || {}; -config.resolve.fallback = config.resolve.fallback || {}; -config.resolve.alias = config.resolve.alias || {}; - -// 1. Fallbacks for Node.js core modules -config.resolve.fallback.fs = false; -config.resolve.fallback.path = false; -config.resolve.fallback.crypto = false; - -// 2. Resolve sqlite3 paths -let sqliteBaseDir; -try { - const packagePath = path.dirname(require.resolve('@sqlite.org/sqlite-wasm/package.json')); - sqliteBaseDir = path.join(packagePath, 'sqlite-wasm/jswasm'); -} catch (e) { - console.warn("Could not resolve @sqlite.org/sqlite-wasm path automatically. Using fallback path."); - sqliteBaseDir = path.resolve(__dirname, '../../../../../../node_modules/@sqlite.org/sqlite-wasm/sqlite-wasm/jswasm'); -} - -// 3. Copy ALL sqlite3 assets (wasm, js, and auxiliary workers) -const copyPatterns = []; - -if (fs.existsSync(sqliteBaseDir)) { - console.log("Copying sqlite3 assets from:", sqliteBaseDir); - copyPatterns.push({ - from: sqliteBaseDir, - to: '.', // Copy to root of dist - globOptions: { - ignore: ['**/package.json'] - }, - noErrorOnMissing: true - }); -} else { - console.error("ERROR: sqlite3 base directory does not exist:", sqliteBaseDir); -} - -// 4. Copy sqlite.worker.js from source -// Try multiple strategies to find the file - -// Strategy A: Relative to __dirname (webpack.config.d) -// ../../../core/local-db/src/jsMain/resources/sqlite.worker.js -const pathA = path.resolve(__dirname, '../../../core/local-db/src/jsMain/resources/sqlite.worker.js'); - -// Strategy B: Relative to process.cwd() (project root usually) -// ../../core/local-db/src/jsMain/resources/sqlite.worker.js (assuming cwd is meldestelle-portal) -const pathB = path.resolve(process.cwd(), '../../core/local-db/src/jsMain/resources/sqlite.worker.js'); - -// Strategy C: Hardcoded fallback based on typical structure -const pathC = path.resolve(__dirname, '../../../../core/local-db/src/jsMain/resources/sqlite.worker.js'); -// Strategy D: From processedResources of local-db module (Kotlin/JS build output) -const pathD = path.resolve(__dirname, '../../../../core/local-db/build/processedResources/js/main/sqlite.worker.js'); -// Strategy E: Via process.cwd() 4 levels up = project root (works when Webpack runs from build/js/packages//) -const pathE = path.resolve(process.cwd(), '../../../../frontend/core/local-db/src/jsMain/resources/sqlite.worker.js'); -// Strategy F: processedResources via process.cwd() 4 levels up -const pathF = path.resolve(process.cwd(), '../../../../frontend/core/local-db/build/processedResources/js/main/sqlite.worker.js'); -// Strategy G: Via process.cwd() when Gradle runs from project root -const pathG = path.resolve(process.cwd(), 'frontend/core/local-db/src/jsMain/resources/sqlite.worker.js'); -// Strategy H: processedResources via process.cwd() from project root -const pathH = path.resolve(process.cwd(), 'frontend/core/local-db/build/processedResources/js/main/sqlite.worker.js'); - -let workerSourcePath = null; - -if (fs.existsSync(pathA)) { - workerSourcePath = pathA; - console.log("Found sqlite.worker.js at (Strategy A):", pathA); -} else if (fs.existsSync(pathB)) { - workerSourcePath = pathB; - console.log("Found sqlite.worker.js at (Strategy B):", pathB); -} else if (fs.existsSync(pathC)) { - workerSourcePath = pathC; - console.log("Found sqlite.worker.js at (Strategy C):", pathC); -} else if (fs.existsSync(pathD)) { - workerSourcePath = pathD; - console.log("Found sqlite.worker.js at (Strategy D - processedResources):", pathD); -} else if (fs.existsSync(pathE)) { - workerSourcePath = pathE; - console.log("Found sqlite.worker.js at (Strategy E - build dir relative):", pathE); -} else if (fs.existsSync(pathF)) { - workerSourcePath = pathF; - console.log("Found sqlite.worker.js at (Strategy F - build dir processedResources):", pathF); -} else if (fs.existsSync(pathG)) { - workerSourcePath = pathG; - console.log("Found sqlite.worker.js at (Strategy G - cwd project root):", pathG); -} else if (fs.existsSync(pathH)) { - workerSourcePath = pathH; - console.log("Found sqlite.worker.js at (Strategy H - cwd project root processedResources):", pathH); -} else { - console.error("ERROR: Could not find sqlite.worker.js in any expected location!"); - console.error("Checked A:", pathA); - console.error("Checked B:", pathB); - console.error("Checked C:", pathC); - console.error("Checked D:", pathD); - console.error("Checked E:", pathE); - console.error("Checked F:", pathF); - console.error("Checked G:", pathG); - console.error("Checked H:", pathH); -} - -if (workerSourcePath) { - copyPatterns.push({ - from: workerSourcePath, - to: 'sqlite.worker.js', - noErrorOnMissing: true - }); -} - -config.plugins.push( - new CopyWebpackPlugin({ - patterns: copyPatterns - }) -); - -// 5. Alias sqlite3.wasm (still needed for some internal checks maybe) -const sqliteWasmPath = path.join(sqliteBaseDir, 'sqlite3.wasm'); -config.resolve.alias['sqlite3.wasm'] = sqliteWasmPath; -config.resolve.alias['./sqlite3.wasm'] = sqliteWasmPath; - -// 6. Handle .wasm files -config.experiments = config.experiments || {}; -config.experiments.asyncWebAssembly = true; - -config.module = config.module || {}; -config.module.rules = config.module.rules || []; - -// Treat Skiko WASM as resource to avoid parsing errors -config.module.rules.push({ - test: /skiko\.wasm$/, - type: 'asset/resource' -}); - -// Treat other WASM as async (default) -config.module.rules.push({ - test: /\.wasm$/, - exclude: /skiko\.wasm$/, - type: 'webassembly/async' -}); - -// 7. Ignore warnings -config.ignoreWarnings = config.ignoreWarnings || []; -config.ignoreWarnings.push(/Critical dependency: the request of a dependency is an expression/); - -// 8. Fix for "webpackEmptyContext" in sqlite3.mjs -config.plugins.push( - new webpack.ContextReplacementPlugin( - /@sqlite\.org\/sqlite-wasm/, - (data) => { - delete data.dependencies; - return data; - } - ) -); - -// 9. MIME types -config.devServer = config.devServer || {}; -config.devServer.devMiddleware = config.devServer.devMiddleware || {}; -config.devServer.devMiddleware.mimeTypes = { - 'application/wasm': ['wasm'], - 'application/javascript': ['js'] -}; - -// 10. OPTIMIZATION: Handled by z_disable-minification.js -// Minification is disabled in the alphabetically-last config file to ensure -// it cannot be overridden by any subsequent Kotlin/JS or plugin step. -// See: webpack.config.d/z_disable-minification.js diff --git a/frontend/shells/meldestelle-portal/webpack.config.d/webpack.config.js b/frontend/shells/meldestelle-portal/webpack.config.d/webpack.config.js deleted file mode 100644 index 2cd6bdd8..00000000 --- a/frontend/shells/meldestelle-portal/webpack.config.d/webpack.config.js +++ /dev/null @@ -1,67 +0,0 @@ -// HTML template will be handled by Kotlin/JS build system -// No need for custom HtmlWebpackPlugin configuration - -// Bundle-Analyze für Development (optional, only if package is available) -if (process.env.ANALYZE_BUNDLE === 'true') { - try { - const BundleAnalyzerPlugin = require('webpack-bundle-analyzer').BundleAnalyzerPlugin; - config.plugins.push(new BundleAnalyzerPlugin({ - analyzerMode: 'static', - openAnalyzer: false, - reportFilename: 'bundle-report.html' - })); - console.log('Bundle analyzer enabled'); - } catch (e) { - console.log('Bundle analyzer not available (webpack-bundle-analyzer not installed)'); - } -} - -// Hinweis: Wir liefern eine statische index.html aus src/jsMain/resources aus. -// Diese Datei enthält nur ein Script-Tag zu "web-app.js" und wird NICHT -// vom HtmlWebpackPlugin generiert. Zusätzliche Chunks (z. B. vendor/runtime) -// würden dann nicht automatisch injiziert und führen dazu, dass die App nicht startet -// (Bildschirm bleibt auf "Loading ..."). -// -// Daher überschreiben wir config.optimization NICHT mehr mit splitChunks. -// Wenn später Chunking gewünscht ist, muss die index.html durch das generierte -// HTML ersetzt oder die zusätzlichen Chunks manuell eingebunden werden. -// -// (Frühere splitChunks-Konfiguration wurde bewusst entfernt.) - -// Development Server Konfiguration erweitern -if (config.devServer) { - config.devServer = { - ...config.devServer, - historyApiFallback: true, - hot: true, - // API Proxy für Backend-Anfragen (Array-Format für modernen Webpack) - proxy: [ - { - context: ['/api'], - target: 'http://localhost:8081', - changeOrigin: true, - secure: false, - // WICHTIG: pathRewrite entfernt /api, wenn das Backend unter /api lauscht, - // ist das falsch. Wenn das Backend unter / lauscht, ist es richtig. - // Das API Gateway lauscht unter http://localhost:8081/api/... - // Wenn wir also /api/ping aufrufen, soll es zu http://localhost:8081/api/ping gehen. - // Daher KEIN pathRewrite, wenn das Gateway selbst /api erwartet. - // Wenn das Gateway aber die Routen ohne /api mappt (z.B. /ping), dann brauchen wir Rewrite. - // - // Analyse: - // Gateway Routes sind oft: /api/ping -> Ping Service /api/ping oder /ping - // Wenn Gateway Routes definiert sind als: - // - id: ping-service - // uri: lb://ping-service - // predicates: - // - Path=/api/ping/** - // - // Dann leitet das Gateway /api/ping weiter. - // Wenn wir pathRewrite machen, kommt beim Gateway nur /ping an. - // Das Gateway matcht aber auf /api/ping. - // Also: pathRewrite entfernen! - // pathRewrite: {'^/api': ''} - } - ] - } -} diff --git a/frontend/shells/meldestelle-portal/webpack.config.d/z_disable-minification.js b/frontend/shells/meldestelle-portal/webpack.config.d/z_disable-minification.js deleted file mode 100644 index 612142ad..00000000 --- a/frontend/shells/meldestelle-portal/webpack.config.d/z_disable-minification.js +++ /dev/null @@ -1,27 +0,0 @@ -// ============================================================ -// FINAL GUARD: Disable ALL Minification for SQLite-WASM stability -// ============================================================ -// WHY THIS FILE EXISTS: -// Kotlin/JS loads webpack.config.d files in alphabetical order. -// This file is prefixed with "z_" to ensure it runs LAST — -// AFTER Kotlin/ JS, and all other plugins have had their chance -// to set config.optimization. This guarantees that no Terser -// or another minimizer plugin can re-enable itself after this point. -// -// ROOT CAUSE: -// sqlite3-worker1.mjs contains a top-level `return` statement -// that is valid for browser-worker scripts but crashes Terser. -// Disabling minification for the entire shell module is the -// most stable fix in the context of WASM-heavy KMP projects. -// (Performance trade-off is negligible for WASM bundles.) -// -// REFERENCE: docs/99_Journal for build history. -// ============================================================ - -console.log("[z_disable-minification] Enforcing minimize=false as FINAL webpack config step."); - -config.optimization = config.optimization || {}; -config.optimization.minimize = false; -config.optimization.minimizer = []; - -console.log("[z_disable-minification] Terser and all minimizers are now DISABLED."); diff --git a/platform/architecture-tests/build.gradle.kts b/platform/architecture-tests/build.gradle.kts index 0e790148..15f144df 100644 --- a/platform/architecture-tests/build.gradle.kts +++ b/platform/architecture-tests/build.gradle.kts @@ -35,5 +35,6 @@ dependencies { implementation(project(":frontend:core:network")) implementation(project(":frontend:core:local-db")) implementation(project(":frontend:core:sync")) - implementation(project(":frontend:shells:meldestelle-portal")) + implementation(project(":frontend:shells:meldestelle-desktop")) + implementation(project(":frontend:features:zns-import-feature")) } diff --git a/settings.gradle.kts b/settings.gradle.kts index dc98acc4..a472beff 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -134,9 +134,9 @@ include(":frontend:core:sync") // include(":frontend:features:members-feature") include(":frontend:features:ping-feature") include(":frontend:features:nennung-feature") +include(":frontend:features:zns-import-feature") // --- SHELLS --- -include(":frontend:shells:meldestelle-portal") include(":frontend:shells:meldestelle-desktop") // ==========================================================================