feat(Calendar): integrate CalendarEvent model and enhance calendar functionality
All checks were successful
Deploy tt-tagebuch / deploy (push) Successful in 43s
All checks were successful
Deploy tt-tagebuch / deploy (push) Successful in 43s
- Added CalendarEvent model to the backend, establishing relationships with the Club model for better event management. - Updated server.js to include calendarEventRoutes, enabling API access for calendar events. - Enhanced CalendarView.vue to support custom event creation and management, improving user interaction with the calendar. - Refactored various components to streamline event handling and improve overall user experience in the calendar interface. - Updated TODO and DEVELOPMENT documentation to reflect new calendar features and architectural decisions.
This commit is contained in:
@@ -0,0 +1,16 @@
|
||||
package de.tt_tagebuch.shared.api
|
||||
|
||||
import de.tt_tagebuch.shared.api.http.AuthedHttpClient
|
||||
import de.tt_tagebuch.shared.api.models.ClubCalendarHolidaysEnvelope
|
||||
import io.ktor.client.call.body
|
||||
import io.ktor.client.request.get
|
||||
import io.ktor.client.request.parameter
|
||||
|
||||
class CalendarHolidayApi(
|
||||
private val client: AuthedHttpClient,
|
||||
) {
|
||||
suspend fun getClubHolidays(clubId: Int, year: Int): ClubCalendarHolidaysEnvelope =
|
||||
client.http.get("/api/calendar/club/$clubId/holidays") {
|
||||
parameter("year", year)
|
||||
}.body()
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
package de.tt_tagebuch.shared.api
|
||||
|
||||
import de.tt_tagebuch.shared.api.http.AuthedHttpClient
|
||||
import de.tt_tagebuch.shared.api.models.TrainingCancellationDto
|
||||
import de.tt_tagebuch.shared.api.models.TrainingCancellationUpsertBody
|
||||
import io.ktor.client.call.body
|
||||
import io.ktor.client.request.delete
|
||||
import io.ktor.client.request.get
|
||||
import io.ktor.client.request.parameter
|
||||
import io.ktor.client.request.post
|
||||
import io.ktor.client.request.setBody
|
||||
import io.ktor.http.ContentType
|
||||
import io.ktor.http.contentType
|
||||
|
||||
class TrainingCancellationApi(
|
||||
private val client: AuthedHttpClient,
|
||||
) {
|
||||
suspend fun list(clubId: Int, year: Int): List<TrainingCancellationDto> =
|
||||
client.http.get("/api/training-cancellations/$clubId") {
|
||||
parameter("year", year)
|
||||
}.body()
|
||||
|
||||
suspend fun upsert(clubId: Int, body: TrainingCancellationUpsertBody): TrainingCancellationDto =
|
||||
client.http.post("/api/training-cancellations/$clubId") {
|
||||
contentType(ContentType.Application.Json)
|
||||
setBody(body)
|
||||
}.body()
|
||||
|
||||
suspend fun delete(clubId: Int, cancellationId: Int) {
|
||||
client.http.delete("/api/training-cancellations/$clubId/$cancellationId")
|
||||
}
|
||||
}
|
||||
@@ -25,6 +25,7 @@ class AuthedHttpClient(
|
||||
json(
|
||||
Json {
|
||||
ignoreUnknownKeys = true
|
||||
coerceInputValues = true
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
package de.tt_tagebuch.shared.api.models
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
data class TrainingCancellationDto(
|
||||
val id: Int = 0,
|
||||
val clubId: Int? = null,
|
||||
val startDate: String? = null,
|
||||
val endDate: String? = null,
|
||||
val date: String? = null,
|
||||
val reason: String? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class TrainingCancellationUpsertBody(
|
||||
val startDate: String,
|
||||
val endDate: String,
|
||||
val reason: String? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class CalendarHolidayRowDto(
|
||||
val id: String? = null,
|
||||
val startDate: String? = null,
|
||||
val endDate: String? = null,
|
||||
val name: String? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class ClubCalendarHolidaysEnvelope(
|
||||
val holidays: List<CalendarHolidayRowDto> = emptyList(),
|
||||
val schoolHolidays: List<CalendarHolidayRowDto> = emptyList(),
|
||||
)
|
||||
@@ -1,6 +1,25 @@
|
||||
package de.tt_tagebuch.shared.api.models
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonElement
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
import kotlinx.serialization.json.JsonTransformingSerializer
|
||||
|
||||
object MemberDataQualityRequirementsSerializer :
|
||||
JsonTransformingSerializer<MemberDataQualityRequirements>(MemberDataQualityRequirements.serializer()) {
|
||||
override fun transformDeserialize(element: JsonElement): JsonElement {
|
||||
return if (element is JsonPrimitive && element.isString) {
|
||||
try {
|
||||
Json.parseToJsonElement(element.content)
|
||||
} catch (e: Exception) {
|
||||
element
|
||||
}
|
||||
} else {
|
||||
element
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Serializable
|
||||
data class MemberDataQualityRequirements(
|
||||
@@ -19,6 +38,7 @@ data class UpdateClubSettingsBody(
|
||||
val stateCode: String? = null,
|
||||
val myTischtennisFedNickname: String? = null,
|
||||
val autoFetchRankings: Boolean? = null,
|
||||
@Serializable(with = MemberDataQualityRequirementsSerializer::class)
|
||||
val memberDataQualityRequirements: MemberDataQualityRequirements? = null,
|
||||
)
|
||||
|
||||
@@ -32,6 +52,7 @@ data class Club(
|
||||
val autoFetchRankings: Boolean? = null,
|
||||
val countryCode: String? = null,
|
||||
val stateCode: String? = null,
|
||||
@Serializable(with = MemberDataQualityRequirementsSerializer::class)
|
||||
val memberDataQualityRequirements: MemberDataQualityRequirements? = null,
|
||||
)
|
||||
|
||||
|
||||
@@ -43,6 +43,7 @@ fun UserClubPermissions.canWriteSchedule(): Boolean {
|
||||
|
||||
fun UserClubPermissions.canReadApprovals(): Boolean {
|
||||
if (isOwner) return true
|
||||
if (role.equals("admin", ignoreCase = true)) return true
|
||||
return permissions.boolAt("approvals", "read")
|
||||
}
|
||||
|
||||
@@ -101,3 +102,8 @@ fun UserClubPermissions.canWritePredefinedActivities(): Boolean {
|
||||
if (isOwner) return true
|
||||
return permissions.boolAt("predefined_activities", "write")
|
||||
}
|
||||
|
||||
fun UserClubPermissions.canReadStatistics(): Boolean {
|
||||
if (isOwner) return true
|
||||
return permissions.boolAt("statistics", "read")
|
||||
}
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
package de.tt_tagebuch.shared.api.models
|
||||
|
||||
import kotlinx.serialization.KSerializer
|
||||
import kotlinx.serialization.descriptors.PrimitiveKind
|
||||
import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor
|
||||
import kotlinx.serialization.descriptors.SerialDescriptor
|
||||
import kotlinx.serialization.encoding.Decoder
|
||||
import kotlinx.serialization.encoding.Encoder
|
||||
import kotlinx.serialization.json.JsonDecoder
|
||||
import kotlinx.serialization.json.JsonElement
|
||||
import kotlinx.serialization.json.JsonEncoder
|
||||
import kotlinx.serialization.json.JsonNull
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
import kotlinx.serialization.json.booleanOrNull
|
||||
import kotlinx.serialization.json.contentOrNull
|
||||
import kotlinx.serialization.json.intOrNull
|
||||
import kotlinx.serialization.json.longOrNull
|
||||
|
||||
/**
|
||||
* Akzeptiert `true`/`false`, Zahlen 0/1 und einige String-Formen – vermeidet Deserialisierungsfehler,
|
||||
* wenn die API (oder ein Proxy) Booleans als Zahl liefert.
|
||||
*/
|
||||
object FlexibleNullableBooleanSerializer : KSerializer<Boolean?> {
|
||||
override val descriptor: SerialDescriptor =
|
||||
PrimitiveSerialDescriptor("FlexibleNullableBoolean", PrimitiveKind.BOOLEAN)
|
||||
|
||||
override fun deserialize(decoder: Decoder): Boolean? {
|
||||
val input = decoder as? JsonDecoder
|
||||
?: error("FlexibleNullableBooleanSerializer requires JsonDecoder")
|
||||
return parseBooleanElement(input.decodeJsonElement())
|
||||
}
|
||||
|
||||
private fun parseBooleanElement(element: JsonElement): Boolean? {
|
||||
if (element is JsonNull) return null
|
||||
if (element !is JsonPrimitive) return null
|
||||
element.booleanOrNull?.let { return it }
|
||||
element.intOrNull?.let { return it != 0 }
|
||||
element.longOrNull?.let { return it != 0L }
|
||||
val s = element.contentOrNull ?: return null
|
||||
return when (s.lowercase()) {
|
||||
"true", "1", "yes" -> true
|
||||
"false", "0", "no" -> false
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
|
||||
override fun serialize(encoder: Encoder, value: Boolean?) {
|
||||
when (encoder) {
|
||||
is JsonEncoder -> encoder.encodeJsonElement(
|
||||
if (value == null) JsonNull else JsonPrimitive(value),
|
||||
)
|
||||
else -> if (value != null) encoder.encodeBoolean(value)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
package de.tt_tagebuch.shared.api.models
|
||||
|
||||
import de.tt_tagebuch.shared.api.serialization.LenientIntListSerializer
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
@@ -65,8 +66,11 @@ data class ScheduleMatchDto(
|
||||
val guestMatchPoints: Int = 0,
|
||||
val isCompleted: Boolean = false,
|
||||
val pdfUrl: String? = null,
|
||||
@Serializable(with = LenientIntListSerializer::class)
|
||||
val playersReady: List<Int> = emptyList(),
|
||||
@Serializable(with = LenientIntListSerializer::class)
|
||||
val playersPlanned: List<Int> = emptyList(),
|
||||
@Serializable(with = LenientIntListSerializer::class)
|
||||
val playersPlayed: List<Int> = emptyList(),
|
||||
val homeTeam: ScheduleTeamNameDto? = null,
|
||||
val guestTeam: ScheduleTeamNameDto? = null,
|
||||
|
||||
@@ -7,8 +7,10 @@ data class InternalTournamentSummaryDto(
|
||||
val id: Int = 0,
|
||||
val name: String? = null,
|
||||
val date: String? = null,
|
||||
@Serializable(with = FlexibleNullableBooleanSerializer::class)
|
||||
val allowsExternal: Boolean? = null,
|
||||
val miniChampionshipYear: Int? = null,
|
||||
@Serializable(with = FlexibleNullableBooleanSerializer::class)
|
||||
val isDoublesTournament: Boolean? = null,
|
||||
)
|
||||
|
||||
@@ -20,11 +22,13 @@ data class InternalTournamentDetailDto(
|
||||
val type: String? = null,
|
||||
val clubId: Int? = null,
|
||||
val winningSets: Int? = null,
|
||||
@Serializable(with = FlexibleNullableBooleanSerializer::class)
|
||||
val allowsExternal: Boolean? = null,
|
||||
val miniChampionshipYear: Int? = null,
|
||||
val numberOfTables: Int? = null,
|
||||
val numberOfGroups: Int? = null,
|
||||
val advancingPerGroup: Int? = null,
|
||||
@Serializable(with = FlexibleNullableBooleanSerializer::class)
|
||||
val isDoublesTournament: Boolean? = null,
|
||||
val bestOfEndroundSize: Int? = null,
|
||||
)
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
package de.tt_tagebuch.shared.api.serialization
|
||||
|
||||
import kotlinx.serialization.KSerializer
|
||||
import kotlinx.serialization.builtins.ListSerializer
|
||||
import kotlinx.serialization.builtins.serializer
|
||||
import kotlinx.serialization.descriptors.SerialDescriptor
|
||||
import kotlinx.serialization.encoding.Decoder
|
||||
import kotlinx.serialization.encoding.Encoder
|
||||
import kotlinx.serialization.json.JsonArray
|
||||
import kotlinx.serialization.json.JsonDecoder
|
||||
import kotlinx.serialization.json.JsonElement
|
||||
import kotlinx.serialization.json.JsonNull
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
|
||||
/**
|
||||
* Backend liefert `playersReady` / `playersPlanned` / `playersPlayed` als JSON-Spalte: mal `null`,
|
||||
* mal leeres Objekt statt Array. Die Web-UI parst das nicht strikt; kotlinx.serialization würde sonst
|
||||
* bei HTTP 200 trotzdem abbrechen.
|
||||
*/
|
||||
object LenientIntListSerializer : KSerializer<List<Int>> {
|
||||
private val delegate = ListSerializer(Int.serializer())
|
||||
override val descriptor: SerialDescriptor = delegate.descriptor
|
||||
|
||||
override fun serialize(encoder: Encoder, value: List<Int>) {
|
||||
delegate.serialize(encoder, value)
|
||||
}
|
||||
|
||||
override fun deserialize(decoder: Decoder): List<Int> {
|
||||
if (decoder !is JsonDecoder) {
|
||||
return delegate.deserialize(decoder)
|
||||
}
|
||||
return when (val element = decoder.decodeJsonElement()) {
|
||||
JsonNull -> emptyList()
|
||||
is JsonArray -> element.mapNotNull { parseIntJsonElement(it) }
|
||||
else -> emptyList()
|
||||
}
|
||||
}
|
||||
|
||||
private fun parseIntJsonElement(el: JsonElement): Int? {
|
||||
val p = el as? JsonPrimitive ?: return null
|
||||
val c = p.content
|
||||
if (c.isBlank()) return null
|
||||
c.toIntOrNull()?.let { return it }
|
||||
c.toDoubleOrNull()?.let { return it.toInt() }
|
||||
return null
|
||||
}
|
||||
}
|
||||
@@ -12,15 +12,25 @@ import org.koin.dsl.module
|
||||
import org.koin.core.module.Module
|
||||
import org.koin.dsl.module
|
||||
|
||||
fun initKoin(baseUrl: String, additionalModules: List<Module> = emptyList(), appDeclaration: KoinAppDeclaration = {}) =
|
||||
fun initKoin(
|
||||
baseUrl: String,
|
||||
socketBaseUrl: String? = null,
|
||||
additionalModules: List<Module> = emptyList(),
|
||||
appDeclaration: KoinAppDeclaration = {},
|
||||
) =
|
||||
startKoin {
|
||||
appDeclaration()
|
||||
modules(commonModule(baseUrl) + additionalModules)
|
||||
modules(commonModule(baseUrl, socketBaseUrl) + additionalModules)
|
||||
}
|
||||
|
||||
fun commonModule(baseUrl: String) = module {
|
||||
fun commonModule(baseUrl: String, socketBaseUrl: String? = null) = module {
|
||||
single { ApiClient(baseUrl) }
|
||||
single { SocketService(baseUrl.replace("https://", "wss://").replace("http://", "ws://")) } // Simplified
|
||||
single {
|
||||
SocketService(
|
||||
socketBaseUrl
|
||||
?: baseUrl.replace("https://", "wss://").replace("http://", "ws://"),
|
||||
)
|
||||
}
|
||||
single { AuthRepository(get()) }
|
||||
single { DiaryRepository(get(), get()) }
|
||||
single { MemberRepository(get()) }
|
||||
|
||||
@@ -19,7 +19,39 @@ class ClubManager(
|
||||
|
||||
suspend fun hydrate() {
|
||||
val stored = clubStorage.loadCurrentClubId()
|
||||
_state.value = _state.value.copy(currentClubId = stored)
|
||||
if (stored == null) {
|
||||
_state.value = _state.value.copy(
|
||||
currentClubId = null,
|
||||
currentPermissions = null,
|
||||
isLoading = false,
|
||||
error = null,
|
||||
)
|
||||
return
|
||||
}
|
||||
_state.value = _state.value.copy(
|
||||
currentClubId = stored,
|
||||
isLoading = true,
|
||||
error = null,
|
||||
)
|
||||
try {
|
||||
val permissions = permissionsApi.getUserPermissions(stored)
|
||||
clubStorage.saveCurrentClubId(stored)
|
||||
_state.value = _state.value.copy(
|
||||
currentClubId = stored,
|
||||
currentPermissions = permissions,
|
||||
isLoading = false,
|
||||
error = null,
|
||||
)
|
||||
} catch (t: Throwable) {
|
||||
if (t is CancellationException) throw t
|
||||
_state.value = _state.value.copy(
|
||||
isLoading = false,
|
||||
error = t.toUserMessage("Keine Berechtigung oder Fehler beim Laden der Permissions"),
|
||||
currentPermissions = null,
|
||||
currentClubId = null,
|
||||
)
|
||||
clubStorage.saveCurrentClubId(null)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun loadClubs() {
|
||||
|
||||
@@ -21,6 +21,7 @@ import de.tt_tagebuch.shared.api.models.CreateDiaryPlanActivityRequest
|
||||
import de.tt_tagebuch.shared.api.models.CreateTrainingGroupBody
|
||||
import de.tt_tagebuch.shared.api.models.DeleteTrainingGroupBody
|
||||
import de.tt_tagebuch.shared.api.models.DiaryDateActivityItem
|
||||
import de.tt_tagebuch.shared.api.models.DiaryDate
|
||||
import de.tt_tagebuch.shared.api.models.DiaryFreeformActivity
|
||||
import de.tt_tagebuch.shared.api.models.DiaryMemberActivityLink
|
||||
import de.tt_tagebuch.shared.api.models.MemberGroupPhotoDto
|
||||
@@ -46,6 +47,8 @@ class DiaryManager(
|
||||
private val _state = MutableStateFlow(DiaryState())
|
||||
val state: StateFlow<DiaryState> = _state.asStateFlow()
|
||||
|
||||
suspend fun listDates(clubId: Int): List<DiaryDate> = diaryApi.listDates(clubId)
|
||||
|
||||
suspend fun fetchDateActivities(clubId: Int, diaryDateId: Int): List<DiaryDateActivityItem> {
|
||||
return diaryApi.listDateActivities(clubId, diaryDateId)
|
||||
}
|
||||
|
||||
@@ -47,4 +47,7 @@ class OfficialTournamentsReadManager(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun fetchParticipationSummary(clubId: Int): List<OfficialParticipationBucketDto> =
|
||||
api.listParticipationSummary(clubId)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user