diff --git a/backend/controllers/diaryController.js b/backend/controllers/diaryController.js index e2f0587e..768f772c 100644 --- a/backend/controllers/diaryController.js +++ b/backend/controllers/diaryController.js @@ -18,14 +18,14 @@ const createDateForClub = async (req, res) => { try { const { clubId } = req.params; const { authcode: userToken } = req.headers; - const { date, trainingStart, trainingEnd } = req.body; + const { date, trainingStart, trainingEnd, excludeFromBilling } = req.body; if (!date) { throw new HttpError('The date field is required', 400); } if (isNaN(new Date(date).getTime())) { throw new HttpError('Invalid date format', 400); } - const newDate = await diaryService.createDateForClub(userToken, clubId, date, trainingStart, trainingEnd); + const newDate = await diaryService.createDateForClub(userToken, clubId, date, trainingStart, trainingEnd, excludeFromBilling); res.status(201).json(newDate); } catch (error) { console.error('[createDateForClub] - Error:', error); @@ -37,15 +37,22 @@ const updateTrainingTimes = async (req, res) => { try { const { clubId } = req.params; const { authcode: userToken } = req.headers; - const { dateId, trainingStart, trainingEnd } = req.body; - if (!dateId || !trainingStart) { - devLog(dateId, trainingStart, trainingEnd); + const { dateId, trainingStart, trainingEnd, excludeFromBilling } = req.body; + if (!dateId) { + devLog(dateId, trainingStart, trainingEnd, excludeFromBilling); throw new HttpError('notallfieldsfilled', 400); } - const updatedDate = await diaryService.updateTrainingTimes(userToken, clubId, dateId, trainingStart, trainingEnd); + const updatedDate = await diaryService.updateTrainingTimes( + userToken, + clubId, + dateId, + trainingStart, + trainingEnd, + excludeFromBilling, + ); // Emit Socket-Event - emitDiaryDateUpdated(clubId, dateId, { trainingStart, trainingEnd }); + emitDiaryDateUpdated(clubId, dateId, { trainingStart, trainingEnd, excludeFromBilling }); res.status(200).json(updatedDate); } catch (error) { diff --git a/backend/models/DiaryDates.js b/backend/models/DiaryDates.js index 86082f5c..ef845f7f 100644 --- a/backend/models/DiaryDates.js +++ b/backend/models/DiaryDates.js @@ -23,6 +23,12 @@ const DiaryDate = sequelize.define('DiaryDate', { trainingEnd: { type: DataTypes.TIME, allowNull: true, + }, + excludeFromBilling: { + type: DataTypes.BOOLEAN, + allowNull: false, + defaultValue: false, + field: 'exclude_from_billing', } }, { tableName: 'diary_dates', diff --git a/backend/services/billingService.js b/backend/services/billingService.js index 4d233cde..fee3dfa7 100644 --- a/backend/services/billingService.js +++ b/backend/services/billingService.js @@ -321,7 +321,8 @@ class BillingService { clubId, date: { [Op.between]: [periodStart, periodEnd] }, trainingStart: { [Op.ne]: null }, - trainingEnd: { [Op.ne]: null } + trainingEnd: { [Op.ne]: null }, + excludeFromBilling: false, }, attributes: ['date', 'trainingStart', 'trainingEnd'], order: [['date', 'ASC'], ['trainingStart', 'ASC']] diff --git a/backend/services/diaryService.js b/backend/services/diaryService.js index 1e8a64b9..2d0e9f3f 100644 --- a/backend/services/diaryService.js +++ b/backend/services/diaryService.js @@ -26,7 +26,7 @@ class DiaryService { return dates; } - async createDateForClub(userToken, clubId, date, trainingStart, trainingEnd) { + async createDateForClub(userToken, clubId, date, trainingStart, trainingEnd, excludeFromBilling = false) { await checkAccess(userToken, clubId); const club = await Club.findByPk(clubId); if (!club) { @@ -44,12 +44,13 @@ class DiaryService { clubId, trainingStart: trainingStart || null, trainingEnd: trainingEnd || null, + excludeFromBilling: Boolean(excludeFromBilling), }); return newDate; } - async updateTrainingTimes(userToken, clubId, dateId, trainingStart, trainingEnd) { + async updateTrainingTimes(userToken, clubId, dateId, trainingStart, trainingEnd, excludeFromBilling) { await checkAccess(userToken, clubId); const diaryDate = await DiaryDate.findOne({ where: { clubId, id: dateId } }); if (!diaryDate) { @@ -60,6 +61,9 @@ class DiaryService { } diaryDate.trainingStart = trainingStart || null; diaryDate.trainingEnd = trainingEnd || null; + if (excludeFromBilling !== undefined) { + diaryDate.excludeFromBilling = Boolean(excludeFromBilling); + } await diaryDate.save(); return diaryDate; } diff --git a/frontend/src/components/diary/DiaryOverviewPanels.vue b/frontend/src/components/diary/DiaryOverviewPanels.vue index 38391382..b1ba7d52 100644 --- a/frontend/src/components/diary/DiaryOverviewPanels.vue +++ b/frontend/src/components/diary/DiaryOverviewPanels.vue @@ -51,6 +51,17 @@ +
+ +
@@ -120,11 +131,12 @@ export default { activitiesCount: { type: Number, default: 0 }, trainingStart: { type: String, default: '' }, trainingEnd: { type: String, default: '' }, + excludeFromBilling: { type: Boolean, default: false }, groups: { type: Array, required: true }, editingGroupId: { type: [Number, String, null], default: null }, newGroupCount: { type: Number, default: 2 } }, - emits: ['toggle-panel', 'update-training-times', 'update:training-start', 'update:training-end', 'edit-group', 'update-group-field', 'save-group', 'cancel-edit-group', 'delete-group', 'update:new-group-count', 'create-groups'] + emits: ['toggle-panel', 'update-training-times', 'update:training-start', 'update:training-end', 'update:exclude-from-billing', 'edit-group', 'update-group-field', 'save-group', 'cancel-edit-group', 'delete-group', 'update:new-group-count', 'create-groups'] }; @@ -243,6 +255,20 @@ export default { margin-bottom: 0.25rem; } +.diary-billing-toggle-wrap { + display: flex; + align-items: center; + min-height: 2.5rem; +} + +.diary-billing-toggle { + display: inline-flex; + align-items: center; + gap: 0.45rem; + font-weight: 600; + color: #173042; +} + .diary-groups-grid { display: grid; grid-template-columns: 1.4fr 1fr; diff --git a/frontend/src/views/DiaryView.vue b/frontend/src/views/DiaryView.vue index 065775ef..bc4a9e2e 100644 --- a/frontend/src/views/DiaryView.vue +++ b/frontend/src/views/DiaryView.vue @@ -68,6 +68,12 @@ +
+ +
@@ -83,6 +89,7 @@ :activities-count="activities.length" :training-start="trainingStart" :training-end="trainingEnd" + :exclude-from-billing="excludeFromBilling" :groups="groups" :editing-group-id="editingGroupId" :new-group-count="newGroupCount" @@ -90,6 +97,7 @@ @update-training-times="updateTrainingTimes" @update:training-start="trainingStart = $event" @update:training-end="trainingEnd = $event" + @update:exclude-from-billing="excludeFromBilling = $event" @edit-group="editGroup" @update-group-field="updateGroupField" @save-group="saveGroup" @@ -977,6 +985,7 @@ export default { newDate: '', trainingStart: '', trainingEnd: '', + excludeFromBilling: false, members: [], participants: [], showTrainingGroupDialog: false, @@ -1780,6 +1789,7 @@ export default { date: slot.date, trainingStart: slot.startTime || null, trainingEnd: slot.endTime || null, + excludeFromBilling: false, }); await this.refreshDates(post.data.id); await this.handleDateChange(); @@ -1799,6 +1809,7 @@ export default { const dateData = response.data.find(entry => entry.id === dateId); this.trainingStart = dateData.trainingStart; this.trainingEnd = dateData.trainingEnd; + this.excludeFromBilling = Boolean(dateData.excludeFromBilling); this.selectedActivityTags = dateData.diaryTags.map(tag => ({ id: tag.id, name: tag.name @@ -1816,6 +1827,7 @@ export default { this.newDate = ''; this.trainingStart = ''; this.trainingEnd = ''; + this.excludeFromBilling = false; this.participants = []; } }, @@ -1836,6 +1848,7 @@ export default { date: this.newDate, trainingStart: this.trainingStart || null, trainingEnd: this.trainingEnd || null, + excludeFromBilling: this.excludeFromBilling, }); this.dates.push({ id: response.data.id, date: response.data.date }); // Liste nach Datum sortieren (neueste zuerst) @@ -1844,6 +1857,7 @@ export default { this.newDate = ''; this.trainingStart = response.data.trainingStart; this.trainingEnd = response.data.trainingEnd; + this.excludeFromBilling = Boolean(response.data.excludeFromBilling); // Direkt auf das leere Tagebuch des neuen Datums wechseln await this.handleDateChange(); } catch (error) { @@ -1858,6 +1872,7 @@ export default { dateId, trainingStart: this.trainingStart || null, trainingEnd: this.trainingEnd || null, + excludeFromBilling: this.excludeFromBilling, }); this.showInfo(this.$t('messages.success'), this.$t('diary.trainingTimesUpdated'), '', 'success'); } catch (error) { @@ -4332,6 +4347,9 @@ export default { if (data.updates.trainingEnd !== undefined) { this.trainingEnd = data.updates.trainingEnd; } + if (data.updates.excludeFromBilling !== undefined) { + this.excludeFromBilling = Boolean(data.updates.excludeFromBilling); + } } }, diff --git a/mobile-app/composeApp/src/androidMain/kotlin/de/tsschulz/tt_tagebuch/app/ui/AppRoot.kt b/mobile-app/composeApp/src/androidMain/kotlin/de/tsschulz/tt_tagebuch/app/ui/AppRoot.kt index c7b1ffb4..02cdf60b 100644 --- a/mobile-app/composeApp/src/androidMain/kotlin/de/tsschulz/tt_tagebuch/app/ui/AppRoot.kt +++ b/mobile-app/composeApp/src/androidMain/kotlin/de/tsschulz/tt_tagebuch/app/ui/AppRoot.kt @@ -1588,6 +1588,7 @@ private fun DiaryListScreen( var newDiaryDateStr by rememberSaveable { mutableStateOf("") } var newDiaryStart by rememberSaveable { mutableStateOf("") } var newDiaryEnd by rememberSaveable { mutableStateOf("") } + var newDiaryExcludeFromBilling by rememberSaveable { mutableStateOf(false) } val newDateScope = rememberCoroutineScope() var newDateScheduleGroups by remember { mutableStateOf>(emptyList()) } var newDateScheduleLoading by remember { mutableStateOf(false) } @@ -1735,6 +1736,7 @@ private fun DiaryListScreen( newDiaryDateStr = kotlin.runCatching { java.time.LocalDate.now().toString() }.getOrElse { "" } newDiaryStart = diaryTimeForFormField(tmpl?.trainingStart).ifBlank { "17:30" } newDiaryEnd = diaryTimeForFormField(tmpl?.trainingEnd).ifBlank { "19:30" } + newDiaryExcludeFromBilling = false showNewDateDialog = true }, modifier = Modifier.heightIn(min = TouchMinHeight), @@ -1774,6 +1776,7 @@ private fun DiaryListScreen( slot.date, diaryTimeFieldToApi(slot.trainingStart), diaryTimeFieldToApi(slot.trainingEnd), + excludeFromBilling = false, ) quickCreateBusy = false if (id != null) { @@ -1922,6 +1925,20 @@ private fun DiaryListScreen( enabled = !diaryState.isLoading, modifier = Modifier.fillMaxWidth(), ) + Row( + modifier = Modifier + .fillMaxWidth() + .padding(top = 8.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Text("Nicht abrechnen") + Switch( + checked = newDiaryExcludeFromBilling, + onCheckedChange = { newDiaryExcludeFromBilling = it }, + enabled = !diaryState.isLoading, + ) + } diaryState.error?.let { err -> Text( err, @@ -1941,6 +1958,7 @@ private fun DiaryListScreen( newDiaryDateStr.trim(), diaryTimeFieldToApi(newDiaryStart), diaryTimeFieldToApi(newDiaryEnd), + newDiaryExcludeFromBilling, ) if (id != null) { showNewDateDialog = false @@ -2775,10 +2793,11 @@ private fun DiaryDetailScreen( initialDate = entry.date.take(10), initialStart = entry.trainingStart.orEmpty(), initialEnd = entry.trainingEnd.orEmpty(), + initialExcludeFromBilling = entry.excludeFromBilling, submitLabel = tr("common.save", "Speichern"), - onSubmit = { _, start, end -> + onSubmit = { _, start, end, excludeFromBilling -> dependencies.applicationScope.launch { - dependencies.diaryManager.updateTimes(clubId, entry.id, start, end) + dependencies.diaryManager.updateTimes(clubId, entry.id, start, end, excludeFromBilling) showEdit = false } }, @@ -6878,14 +6897,16 @@ private fun DeleteAccountDialog( @Composable private fun DiaryEditForm( submitLabel: String, - onSubmit: (date: String, trainingStart: String?, trainingEnd: String?) -> Unit, + onSubmit: (date: String, trainingStart: String?, trainingEnd: String?, excludeFromBilling: Boolean) -> Unit, initialDate: String = "", initialStart: String = "", initialEnd: String = "", + initialExcludeFromBilling: Boolean = false, ) { var date by rememberSaveable { mutableStateOf(initialDate) } var start by rememberSaveable { mutableStateOf(initialStart) } var end by rememberSaveable { mutableStateOf(initialEnd) } + var excludeFromBilling by rememberSaveable { mutableStateOf(initialExcludeFromBilling) } var error by rememberSaveable { mutableStateOf(null) } Card(modifier = Modifier.fillMaxWidth().padding(vertical = 12.dp), elevation = 2.dp) { @@ -6895,6 +6916,17 @@ private fun DiaryEditForm( OutlinedTextField(value = start, onValueChange = { start = it }, label = { Text("Start") }, modifier = Modifier.weight(1f), singleLine = true) OutlinedTextField(value = end, onValueChange = { end = it }, label = { Text("Ende") }, modifier = Modifier.weight(1f), singleLine = true) } + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Text("Nicht abrechnen") + Switch( + checked = excludeFromBilling, + onCheckedChange = { excludeFromBilling = it }, + ) + } ErrorText(error) Button(onClick = { if (!date.matches(Regex("\\d{4}-\\d{2}-\\d{2}"))) { @@ -6906,7 +6938,7 @@ private fun DiaryEditForm( return@Button } error = null - onSubmit(date, start.takeIf { it.isNotBlank() }, end.takeIf { it.isNotBlank() }) + onSubmit(date, start.takeIf { it.isNotBlank() }, end.takeIf { it.isNotBlank() }, excludeFromBilling) }) { Text(submitLabel) } diff --git a/mobile-app/composeApp/src/androidMain/kotlin/de/tsschulz/tt_tagebuch/app/ui/BillingOrdersScreens.kt b/mobile-app/composeApp/src/androidMain/kotlin/de/tsschulz/tt_tagebuch/app/ui/BillingOrdersScreens.kt index 77897401..177f15ee 100644 --- a/mobile-app/composeApp/src/androidMain/kotlin/de/tsschulz/tt_tagebuch/app/ui/BillingOrdersScreens.kt +++ b/mobile-app/composeApp/src/androidMain/kotlin/de/tsschulz/tt_tagebuch/app/ui/BillingOrdersScreens.kt @@ -197,6 +197,65 @@ private fun GlobalOrdersScreen(dependencies: AppDependencies, onBack: () -> Unit } } + fun quickSaveFlags(orderId: Int, nextStatus: String? = null, nextPaidConfirmed: Boolean? = null) { + val snapshot = rows.firstOrNull { it.order.id == orderId } ?: return + val patchedStatus = nextStatus ?: snapshot.draftStatus + val patchedPaidConfirmed = nextPaidConfirmed ?: snapshot.draftPaidConfirmed + val memberId = snapshot.order.memberId ?: return + val orderClubId = snapshot.order.clubId ?: return + + rows = rows.map { + if (it.order.id == orderId) { + it.copy( + draftStatus = patchedStatus, + draftPaidConfirmed = patchedPaidConfirmed, + ) + } else { + it + } + } + + scope.launch { + savingIds = savingIds + orderId + runCatching { + dependencies.memberOrdersApi.update( + clubId = orderClubId, + memberId = memberId, + orderId = orderId, + body = MemberOrderPatchBody( + // Nur Status-Flags sofort speichern; restliche Felder bleiben Draft bis "Speichern". + item = snapshot.order.item, + status = patchedStatus, + cost = normalizeAmount(snapshot.order.cost), + paidAmount = normalizeAmount(snapshot.order.paidAmount), + budget = normalizeAmount(snapshot.order.budget), + paidConfirmed = patchedPaidConfirmed, + ), + ).order + }.onSuccess { updated -> + if (updated != null) { + rows = rows.map { row -> + if (row.order.id == orderId) { + row.copy( + order = updated, + draftStatus = updated.status, + draftPaidConfirmed = updated.paidConfirmed, + ) + } else { + row + } + } + } + }.onFailure { e -> + rows = rows.map { + if (it.order.id == orderId) snapshot else it + } + err = e.message + } + savingIds = savingIds - orderId + } + } + Column( modifier = Modifier .fillMaxSize() @@ -338,8 +397,9 @@ private fun GlobalOrdersScreen(dependencies: AppDependencies, onBack: () -> Unit orderStatuses.forEach { (v, k) -> TextButton( onClick = { - rows = rows.map { if (it.order.id == o.id) it.copy(draftStatus = v) else it } + quickSaveFlags(orderId = o.id, nextStatus = v) }, + enabled = !savingIds.contains(o.id), ) { Text( tr(k, v), @@ -373,8 +433,9 @@ private fun GlobalOrdersScreen(dependencies: AppDependencies, onBack: () -> Unit Switch( checked = row.draftPaidConfirmed, onCheckedChange = { v -> - rows = rows.map { if (it.order.id == o.id) it.copy(draftPaidConfirmed = v) else it } + quickSaveFlags(orderId = o.id, nextPaidConfirmed = v) }, + enabled = !savingIds.contains(o.id), ) Text(tr("orders.paidConfirmed", "Bezahlt bestÃĪtigt"), modifier = Modifier.padding(start = 8.dp)) } diff --git a/mobile-app/shared/src/commonMain/kotlin/de/tsschulz/tt_tagebuch/shared/api/DiaryApi.kt b/mobile-app/shared/src/commonMain/kotlin/de/tsschulz/tt_tagebuch/shared/api/DiaryApi.kt index b51a5587..4124691c 100644 --- a/mobile-app/shared/src/commonMain/kotlin/de/tsschulz/tt_tagebuch/shared/api/DiaryApi.kt +++ b/mobile-app/shared/src/commonMain/kotlin/de/tsschulz/tt_tagebuch/shared/api/DiaryApi.kt @@ -83,15 +83,27 @@ class DiaryApi( client.http.delete("/api/diary-date-activities/group/$clubId/$groupActivityId") } - suspend fun createDate(clubId: Int, date: String, trainingStart: String?, trainingEnd: String?): DiaryDate { + suspend fun createDate( + clubId: Int, + date: String, + trainingStart: String?, + trainingEnd: String?, + excludeFromBilling: Boolean = false, + ): DiaryDate { return client.http.post("/api/diary/$clubId") { - setBody(CreateDiaryDateRequest(date, trainingStart, trainingEnd)) + setBody(CreateDiaryDateRequest(date, trainingStart, trainingEnd, excludeFromBilling)) }.body() } - suspend fun updateTimes(clubId: Int, dateId: Int, trainingStart: String?, trainingEnd: String?): DiaryDate { + suspend fun updateTimes( + clubId: Int, + dateId: Int, + trainingStart: String?, + trainingEnd: String?, + excludeFromBilling: Boolean? = null, + ): DiaryDate { return client.http.put("/api/diary/$clubId") { - setBody(UpdateDiaryTimesRequest(dateId, trainingStart, trainingEnd)) + setBody(UpdateDiaryTimesRequest(dateId, trainingStart, trainingEnd, excludeFromBilling)) }.body() } diff --git a/mobile-app/shared/src/commonMain/kotlin/de/tsschulz/tt_tagebuch/shared/api/models/DiaryDate.kt b/mobile-app/shared/src/commonMain/kotlin/de/tsschulz/tt_tagebuch/shared/api/models/DiaryDate.kt index 29898155..f84dc7a5 100644 --- a/mobile-app/shared/src/commonMain/kotlin/de/tsschulz/tt_tagebuch/shared/api/models/DiaryDate.kt +++ b/mobile-app/shared/src/commonMain/kotlin/de/tsschulz/tt_tagebuch/shared/api/models/DiaryDate.kt @@ -9,6 +9,7 @@ data class DiaryDate( val date: String, val trainingStart: String? = null, val trainingEnd: String? = null, + val excludeFromBilling: Boolean = false, val diaryNotes: List = emptyList(), val diaryTags: List = emptyList(), ) @@ -31,6 +32,7 @@ data class CreateDiaryDateRequest( val date: String, val trainingStart: String? = null, val trainingEnd: String? = null, + val excludeFromBilling: Boolean = false, ) @Serializable @@ -38,6 +40,7 @@ data class UpdateDiaryTimesRequest( val dateId: Int, val trainingStart: String? = null, val trainingEnd: String? = null, + val excludeFromBilling: Boolean? = null, ) @Serializable diff --git a/mobile-app/shared/src/commonMain/kotlin/de/tsschulz/tt_tagebuch/shared/models/Models.kt b/mobile-app/shared/src/commonMain/kotlin/de/tsschulz/tt_tagebuch/shared/models/Models.kt index b7859e0d..e6d7e2fc 100644 --- a/mobile-app/shared/src/commonMain/kotlin/de/tsschulz/tt_tagebuch/shared/models/Models.kt +++ b/mobile-app/shared/src/commonMain/kotlin/de/tsschulz/tt_tagebuch/shared/models/Models.kt @@ -31,6 +31,7 @@ data class DiaryDate( val clubId: Int, val trainingStart: String? = null, val trainingEnd: String? = null, + val excludeFromBilling: Boolean = false, val activities: List = emptyList(), val participants: List = emptyList() ) diff --git a/mobile-app/shared/src/commonMain/kotlin/de/tsschulz/tt_tagebuch/shared/state/DiaryManager.kt b/mobile-app/shared/src/commonMain/kotlin/de/tsschulz/tt_tagebuch/shared/state/DiaryManager.kt index a84daf43..180f024e 100644 --- a/mobile-app/shared/src/commonMain/kotlin/de/tsschulz/tt_tagebuch/shared/state/DiaryManager.kt +++ b/mobile-app/shared/src/commonMain/kotlin/de/tsschulz/tt_tagebuch/shared/state/DiaryManager.kt @@ -263,10 +263,16 @@ class DiaryManager( } /** @return neue `diaryDateId` bei Erfolg, sonst `null` */ - suspend fun createDate(clubId: Int, date: String, trainingStart: String?, trainingEnd: String?): Int? { + suspend fun createDate( + clubId: Int, + date: String, + trainingStart: String?, + trainingEnd: String?, + excludeFromBilling: Boolean = false, + ): Int? { _state.value = _state.value.copy(isLoading = true, error = null) return try { - val created = diaryApi.createDate(clubId, date, trainingStart, trainingEnd) + val created = diaryApi.createDate(clubId, date, trainingStart, trainingEnd, excludeFromBilling) loadDates(clubId) created.id } catch (t: Throwable) { @@ -275,10 +281,16 @@ class DiaryManager( } } - suspend fun updateTimes(clubId: Int, dateId: Int, trainingStart: String?, trainingEnd: String?) { + suspend fun updateTimes( + clubId: Int, + dateId: Int, + trainingStart: String?, + trainingEnd: String?, + excludeFromBilling: Boolean? = null, + ) { _state.value = _state.value.copy(isLoading = true, error = null) try { - diaryApi.updateTimes(clubId, dateId, trainingStart, trainingEnd) + diaryApi.updateTimes(clubId, dateId, trainingStart, trainingEnd, excludeFromBilling) loadDates(clubId) } catch (t: Throwable) { _state.value = _state.value.copy(isLoading = false, error = t.toUserMessage("Zeiten konnten nicht gespeichert werden"))