From bea5facb7d884cc03c65b0cc7ff1dcfb41111444 Mon Sep 17 00:00:00 2001 From: "Torsten Schulz (local)" Date: Tue, 12 May 2026 23:46:07 +0200 Subject: [PATCH] feat(ClubSettings): add country and state code fields for regional calendar data - Introduced `countryCode` and `stateCode` fields in the Club model to support regional calendar data. - Updated ClubSettings component to allow users to select their country and state, enhancing the configuration options for clubs. - Enhanced the ClubService to handle normalization of country and state codes during updates. - Added new routes and middleware to support the training cancellation feature and calendar integration in the backend. - Updated frontend navigation to include a calendar link, improving user access to scheduling features. --- backend/controllers/calendarController.js | 20 + backend/controllers/clubsController.js | 4 + .../trainingCancellationController.js | 49 + .../add_calendar_region_to_clubs.sql | 3 + .../add_range_to_training_cancellations.sql | 13 + .../create_training_cancellations_table.sql | 14 + backend/models/Club.js | 13 + backend/models/TrainingCancellation.js | 51 + backend/models/index.js | 4 + backend/routes/calendarRoutes.js | 9 + backend/routes/trainingCancellationRoutes.js | 16 + backend/server.js | 8 +- backend/services/calendarHolidayService.js | 134 +++ backend/services/clubService.js | 14 + .../services/trainingCancellationService.js | 76 ++ frontend/src/App.vue | 4 + frontend/src/router.js | 2 + frontend/src/utils/seo.js | 1 + frontend/src/views/CalendarView.vue | 945 ++++++++++++++++++ frontend/src/views/ClubSettings.vue | 60 +- mobile-app/TODO.md | 19 +- mobile-app/composeApp/build.gradle.kts | 1 + .../de/tt_tagebuch/app/AppDependencies.kt | 27 +- .../kotlin/de/tt_tagebuch/app/ui/AppRoot.kt | 93 +- .../de/tt_tagebuch/app/ui/ClubAdminScreens.kt | 569 +++++++++++ .../de/tt_tagebuch/app/ui/ScheduleScreen.kt | 502 ++++++++++ .../tt_tagebuch/app/ui/TournamentsScreen.kt | 295 ++++++ .../de/tt_tagebuch/shared/api/ApiLogsApi.kt | 38 + .../shared/api/ClubApprovalsApi.kt | 25 + .../de/tt_tagebuch/shared/api/ClubTeamsApi.kt | 17 + .../de/tt_tagebuch/shared/api/MatchesApi.kt | 37 + .../shared/api/OfficialTournamentsApi.kt | 19 + .../tt_tagebuch/shared/api/PermissionsApi.kt | 41 + .../shared/api/RolePermissionMatrix.kt | 122 +++ .../tt_tagebuch/shared/api/ScheduleLogic.kt | 77 ++ .../tt_tagebuch/shared/api/TournamentsApi.kt | 23 + .../shared/api/models/ClubAdmin.kt | 121 +++ .../api/models/ClubPermissionHelpers.kt | 51 + .../tt_tagebuch/shared/api/models/Schedule.kt | 107 ++ .../shared/api/models/TournamentDtos.kt | 59 ++ .../shared/state/ApiLogsManager.kt | 98 ++ .../state/ClubInternalTournamentsManager.kt | 85 ++ .../state/OfficialTournamentsReadManager.kt | 50 + .../shared/state/PendingApprovalsManager.kt | 48 + .../shared/state/PermissionsAdminManager.kt | 76 ++ .../shared/state/ScheduleManager.kt | 258 +++++ 46 files changed, 4286 insertions(+), 12 deletions(-) create mode 100644 backend/controllers/calendarController.js create mode 100644 backend/controllers/trainingCancellationController.js create mode 100644 backend/migrations/add_calendar_region_to_clubs.sql create mode 100644 backend/migrations/add_range_to_training_cancellations.sql create mode 100644 backend/migrations/create_training_cancellations_table.sql create mode 100644 backend/models/TrainingCancellation.js create mode 100644 backend/routes/calendarRoutes.js create mode 100644 backend/routes/trainingCancellationRoutes.js create mode 100644 backend/services/calendarHolidayService.js create mode 100644 backend/services/trainingCancellationService.js create mode 100644 frontend/src/views/CalendarView.vue create mode 100644 mobile-app/composeApp/src/androidMain/kotlin/de/tt_tagebuch/app/ui/ClubAdminScreens.kt create mode 100644 mobile-app/composeApp/src/androidMain/kotlin/de/tt_tagebuch/app/ui/ScheduleScreen.kt create mode 100644 mobile-app/composeApp/src/androidMain/kotlin/de/tt_tagebuch/app/ui/TournamentsScreen.kt create mode 100644 mobile-app/shared/src/commonMain/kotlin/de/tt_tagebuch/shared/api/ApiLogsApi.kt create mode 100644 mobile-app/shared/src/commonMain/kotlin/de/tt_tagebuch/shared/api/ClubApprovalsApi.kt create mode 100644 mobile-app/shared/src/commonMain/kotlin/de/tt_tagebuch/shared/api/ClubTeamsApi.kt create mode 100644 mobile-app/shared/src/commonMain/kotlin/de/tt_tagebuch/shared/api/MatchesApi.kt create mode 100644 mobile-app/shared/src/commonMain/kotlin/de/tt_tagebuch/shared/api/OfficialTournamentsApi.kt create mode 100644 mobile-app/shared/src/commonMain/kotlin/de/tt_tagebuch/shared/api/RolePermissionMatrix.kt create mode 100644 mobile-app/shared/src/commonMain/kotlin/de/tt_tagebuch/shared/api/ScheduleLogic.kt create mode 100644 mobile-app/shared/src/commonMain/kotlin/de/tt_tagebuch/shared/api/TournamentsApi.kt create mode 100644 mobile-app/shared/src/commonMain/kotlin/de/tt_tagebuch/shared/api/models/ClubAdmin.kt create mode 100644 mobile-app/shared/src/commonMain/kotlin/de/tt_tagebuch/shared/api/models/Schedule.kt create mode 100644 mobile-app/shared/src/commonMain/kotlin/de/tt_tagebuch/shared/api/models/TournamentDtos.kt create mode 100644 mobile-app/shared/src/commonMain/kotlin/de/tt_tagebuch/shared/state/ApiLogsManager.kt create mode 100644 mobile-app/shared/src/commonMain/kotlin/de/tt_tagebuch/shared/state/ClubInternalTournamentsManager.kt create mode 100644 mobile-app/shared/src/commonMain/kotlin/de/tt_tagebuch/shared/state/OfficialTournamentsReadManager.kt create mode 100644 mobile-app/shared/src/commonMain/kotlin/de/tt_tagebuch/shared/state/PendingApprovalsManager.kt create mode 100644 mobile-app/shared/src/commonMain/kotlin/de/tt_tagebuch/shared/state/PermissionsAdminManager.kt create mode 100644 mobile-app/shared/src/commonMain/kotlin/de/tt_tagebuch/shared/state/ScheduleManager.kt diff --git a/backend/controllers/calendarController.js b/backend/controllers/calendarController.js new file mode 100644 index 00000000..c1db7a73 --- /dev/null +++ b/backend/controllers/calendarController.js @@ -0,0 +1,20 @@ +import calendarHolidayService from '../services/calendarHolidayService.js'; + +export const getClubCalendarDays = async (req, res) => { + try { + const { authcode: token } = req.headers; + const { clubId } = req.params; + const { year } = req.query; + const result = await calendarHolidayService.getClubCalendarDays(token, clubId, year); + res.status(200).json(result); + } catch (error) { + if (error.message === 'clubnotfound') { + res.status(404).json({ error: 'clubnotfound' }); + } else if (error.message === 'noaccess') { + res.status(403).json({ error: 'noaccess' }); + } else { + console.error('[getClubCalendarDays] - error:', error); + res.status(502).json({ error: 'calendarproviderfailed' }); + } + } +}; diff --git a/backend/controllers/clubsController.js b/backend/controllers/clubsController.js index 8b721fd4..6009cf05 100644 --- a/backend/controllers/clubsController.js +++ b/backend/controllers/clubsController.js @@ -65,6 +65,8 @@ export const updateClubSettings = async (req, res) => { associationMemberNumber, myTischtennisFedNickname, autoFetchRankings, + countryCode, + stateCode, memberDataQualityRequirements } = req.body; const updated = await ClubService.updateClubSettings(token, clubid, { @@ -72,6 +74,8 @@ export const updateClubSettings = async (req, res) => { associationMemberNumber, myTischtennisFedNickname, autoFetchRankings, + countryCode, + stateCode, memberDataQualityRequirements }); res.status(200).json(updated); diff --git a/backend/controllers/trainingCancellationController.js b/backend/controllers/trainingCancellationController.js new file mode 100644 index 00000000..b154b116 --- /dev/null +++ b/backend/controllers/trainingCancellationController.js @@ -0,0 +1,49 @@ +import trainingCancellationService from '../services/trainingCancellationService.js'; +import { getSafeErrorMessage } from '../utils/errorUtils.js'; + +export const getTrainingCancellations = async (req, res) => { + try { + const { authcode: userToken } = req.headers; + const { clubId } = req.params; + const { year } = req.query; + const result = await trainingCancellationService.getTrainingCancellations(userToken, clubId, year); + res.status(200).json(result); + } catch (error) { + console.error('[getTrainingCancellations] - Error:', error); + const message = getSafeErrorMessage(error, 'Fehler beim Laden der Trainingsausfälle'); + res.status(error.statusCode || 500).json({ error: message }); + } +}; + +export const upsertTrainingCancellation = async (req, res) => { + try { + const { authcode: userToken } = req.headers; + const { clubId } = req.params; + const { date, startDate, endDate, reason } = req.body; + const result = await trainingCancellationService.upsertTrainingCancellation( + userToken, + clubId, + startDate || date, + reason, + endDate || date || startDate + ); + res.status(200).json(result); + } catch (error) { + console.error('[upsertTrainingCancellation] - Error:', error); + const message = getSafeErrorMessage(error, 'Fehler beim Speichern des Trainingsausfalls'); + res.status(error.statusCode || 500).json({ error: message }); + } +}; + +export const deleteTrainingCancellation = async (req, res) => { + try { + const { authcode: userToken } = req.headers; + const { clubId, cancellationId } = req.params; + const result = await trainingCancellationService.deleteTrainingCancellation(userToken, clubId, cancellationId); + res.status(200).json(result); + } catch (error) { + console.error('[deleteTrainingCancellation] - Error:', error); + const message = getSafeErrorMessage(error, 'Fehler beim Löschen des Trainingsausfalls'); + res.status(error.statusCode || 500).json({ error: message }); + } +}; diff --git a/backend/migrations/add_calendar_region_to_clubs.sql b/backend/migrations/add_calendar_region_to_clubs.sql new file mode 100644 index 00000000..01647525 --- /dev/null +++ b/backend/migrations/add_calendar_region_to_clubs.sql @@ -0,0 +1,3 @@ +ALTER TABLE clubs + ADD COLUMN IF NOT EXISTS country_code VARCHAR(2) NOT NULL DEFAULT 'DE', + ADD COLUMN IF NOT EXISTS state_code VARCHAR(16) NULL; diff --git a/backend/migrations/add_range_to_training_cancellations.sql b/backend/migrations/add_range_to_training_cancellations.sql new file mode 100644 index 00000000..a4e45d73 --- /dev/null +++ b/backend/migrations/add_range_to_training_cancellations.sql @@ -0,0 +1,13 @@ +ALTER TABLE training_cancellations + ADD COLUMN IF NOT EXISTS start_date DATE NULL, + ADD COLUMN IF NOT EXISTS end_date DATE NULL; + +UPDATE training_cancellations +SET + start_date = COALESCE(start_date, date), + end_date = COALESCE(end_date, date) +WHERE start_date IS NULL OR end_date IS NULL; + +ALTER TABLE training_cancellations + MODIFY start_date DATE NOT NULL, + MODIFY end_date DATE NOT NULL; diff --git a/backend/migrations/create_training_cancellations_table.sql b/backend/migrations/create_training_cancellations_table.sql new file mode 100644 index 00000000..bfb90e4e --- /dev/null +++ b/backend/migrations/create_training_cancellations_table.sql @@ -0,0 +1,14 @@ +CREATE TABLE IF NOT EXISTS training_cancellations ( + id INT AUTO_INCREMENT PRIMARY KEY, + club_id INT NOT NULL, + start_date DATE NOT NULL, + end_date DATE NOT NULL, + date DATE NULL, + reason VARCHAR(255) NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + UNIQUE KEY uniq_training_cancellation_club_range (club_id, start_date, end_date), + CONSTRAINT fk_training_cancellations_club + FOREIGN KEY (club_id) REFERENCES clubs(id) + ON DELETE CASCADE +); diff --git a/backend/models/Club.js b/backend/models/Club.js index ad9654a8..3b182ae7 100644 --- a/backend/models/Club.js +++ b/backend/models/Club.js @@ -30,6 +30,19 @@ const Club = sequelize.define('Club', { field: 'auto_fetch_rankings', comment: 'Enable automatic TTR/QTTR rankings fetch for this club' }, + countryCode: { + type: DataTypes.STRING(2), + allowNull: false, + defaultValue: 'DE', + field: 'country_code', + comment: 'ISO 3166-1 alpha-2 country code for regional calendar data' + }, + stateCode: { + type: DataTypes.STRING(16), + allowNull: true, + field: 'state_code', + comment: 'ISO 3166-2 subdivision code for regional calendar data, e.g. DE-NW' + }, memberDataQualityRequirements: { type: DataTypes.JSON, allowNull: true, diff --git a/backend/models/TrainingCancellation.js b/backend/models/TrainingCancellation.js new file mode 100644 index 00000000..1cc9b89c --- /dev/null +++ b/backend/models/TrainingCancellation.js @@ -0,0 +1,51 @@ +import { DataTypes } from 'sequelize'; +import sequelize from '../database.js'; +import Club from './Club.js'; + +const TrainingCancellation = sequelize.define('TrainingCancellation', { + id: { + type: DataTypes.INTEGER, + primaryKey: true, + autoIncrement: true, + allowNull: false, + }, + clubId: { + type: DataTypes.INTEGER, + allowNull: false, + references: { + model: Club, + key: 'id', + }, + onDelete: 'CASCADE', + }, + startDate: { + type: DataTypes.DATEONLY, + allowNull: false, + field: 'start_date', + }, + endDate: { + type: DataTypes.DATEONLY, + allowNull: false, + field: 'end_date', + }, + date: { + type: DataTypes.DATEONLY, + allowNull: true, + }, + reason: { + type: DataTypes.STRING(255), + allowNull: true, + }, +}, { + tableName: 'training_cancellations', + underscored: true, + timestamps: true, + indexes: [ + { + unique: true, + fields: ['club_id', 'start_date', 'end_date'], + }, + ], +}); + +export default TrainingCancellation; diff --git a/backend/models/index.js b/backend/models/index.js index 1723e7d1..44c28dcf 100644 --- a/backend/models/index.js +++ b/backend/models/index.js @@ -58,6 +58,7 @@ import TrainingGroup from './TrainingGroup.js'; import MemberTrainingGroup from './MemberTrainingGroup.js'; import ClubDisabledPresetGroup from './ClubDisabledPresetGroup.js'; import TrainingTime from './TrainingTime.js'; +import TrainingCancellation from './TrainingCancellation.js'; import BillingTemplate from './BillingTemplate.js'; import BillingTemplateField from './BillingTemplateField.js'; import BillingRun from './BillingRun.js'; @@ -407,6 +408,8 @@ ClubDisabledPresetGroup.belongsTo(Club, { foreignKey: 'clubId', as: 'club' }); // Training Times TrainingGroup.hasMany(TrainingTime, { foreignKey: 'trainingGroupId', as: 'trainingTimes' }); TrainingTime.belongsTo(TrainingGroup, { foreignKey: 'trainingGroupId', as: 'trainingGroup' }); +Club.hasMany(TrainingCancellation, { foreignKey: 'clubId', as: 'trainingCancellations' }); +TrainingCancellation.belongsTo(Club, { foreignKey: 'clubId', as: 'club' }); // Billing Club.hasMany(BillingTemplate, { foreignKey: 'clubId', as: 'billingTemplates' }); @@ -484,6 +487,7 @@ export { MemberTrainingGroup, ClubDisabledPresetGroup, TrainingTime, + TrainingCancellation, BillingTemplate, BillingTemplateField, BillingRun, diff --git a/backend/routes/calendarRoutes.js b/backend/routes/calendarRoutes.js new file mode 100644 index 00000000..d95049bb --- /dev/null +++ b/backend/routes/calendarRoutes.js @@ -0,0 +1,9 @@ +import express from 'express'; +import { authenticate } from '../middleware/authMiddleware.js'; +import { getClubCalendarDays } from '../controllers/calendarController.js'; + +const router = express.Router(); + +router.get('/club/:clubId/holidays', authenticate, getClubCalendarDays); + +export default router; diff --git a/backend/routes/trainingCancellationRoutes.js b/backend/routes/trainingCancellationRoutes.js new file mode 100644 index 00000000..66b361a3 --- /dev/null +++ b/backend/routes/trainingCancellationRoutes.js @@ -0,0 +1,16 @@ +import express from 'express'; +import { authenticate } from '../middleware/authMiddleware.js'; +import { + deleteTrainingCancellation, + getTrainingCancellations, + upsertTrainingCancellation, +} from '../controllers/trainingCancellationController.js'; + +const router = express.Router(); +router.use(authenticate); + +router.get('/:clubId', getTrainingCancellations); +router.post('/:clubId', upsertTrainingCancellation); +router.delete('/:clubId/:cancellationId', deleteTrainingCancellation); + +export default router; diff --git a/backend/server.js b/backend/server.js index 629107d9..aa1965e0 100644 --- a/backend/server.js +++ b/backend/server.js @@ -14,7 +14,7 @@ import { PredefinedActivity, PredefinedActivityImage, DiaryDateActivity, DiaryMemberActivity, Match, League, Team, ClubTeam, ClubTeamMember, TeamDocument, Group, GroupActivity, Tournament, TournamentGroup, TournamentMatch, TournamentResult, TournamentMember, Accident, UserToken, OfficialTournament, OfficialCompetition, OfficialCompetitionMember, MyTischtennis, ClickTtAccount, MyTischtennisUpdateHistory, MyTischtennisFetchLog, ApiLog, MemberTransferConfig, MemberContact, MemberTtrHistory, MemberPlayInterest, - MemberOrder, MemberOrderHistory, MemberGroupPhoto, BillingTemplate, BillingTemplateField, BillingRun, BillingDocument, BillingDocumentValue, BillingUserSetting + MemberOrder, MemberOrderHistory, MemberGroupPhoto, BillingTemplate, BillingTemplateField, BillingRun, BillingDocument, BillingDocumentValue, BillingUserSetting, TrainingCancellation } from './models/index.js'; import authRoutes from './routes/authRoutes.js'; import clubRoutes from './routes/clubRoutes.js'; @@ -55,9 +55,11 @@ import clickTtHttpPageRoutes from './routes/clickTtHttpPageRoutes.js'; import memberTransferConfigRoutes from './routes/memberTransferConfigRoutes.js'; import trainingGroupRoutes from './routes/trainingGroupRoutes.js'; import trainingTimeRoutes from './routes/trainingTimeRoutes.js'; +import trainingCancellationRoutes from './routes/trainingCancellationRoutes.js'; import memberOrderRoutes from './routes/memberOrderRoutes.js'; import memberGroupPhotoRoutes from './routes/memberGroupPhotoRoutes.js'; import billingRoutes from './routes/billingRoutes.js'; +import calendarRoutes from './routes/calendarRoutes.js'; import schedulerService from './services/schedulerService.js'; import { requestLoggingMiddleware } from './middleware/requestLoggingMiddleware.js'; import HttpError from './exceptions/HttpError.js'; @@ -151,6 +153,7 @@ const SEO_NOINDEX_PREFIXES = [ '/showclub', '/members', '/diary', + '/calendar', '/pending-approvals', '/schedule', '/tournaments', @@ -302,9 +305,11 @@ app.use('/api/clicktt', clickTtHttpPageRoutes); app.use('/api/member-transfer-config', memberTransferConfigRoutes); app.use('/api/training-groups', trainingGroupRoutes); app.use('/api/training-times', trainingTimeRoutes); +app.use('/api/training-cancellations', trainingCancellationRoutes); app.use('/api/member-orders', memberOrderRoutes); app.use('/api/member-group-photos', memberGroupPhotoRoutes); app.use('/api/billing', billingRoutes); +app.use('/api/calendar', calendarRoutes); // Middleware für dynamischen kanonischen Tag (vor express.static) const setCanonicalTag = (req, res, next) => { @@ -559,6 +564,7 @@ app.use((err, req, res, next) => { await safeSync(BillingDocumentValue); await safeSync(BillingUserSetting); await safeSync(ClubTeam); + await safeSync(TrainingCancellation); await safeSync(TeamDocument); // Foreign Keys wieder aktivieren diff --git a/backend/services/calendarHolidayService.js b/backend/services/calendarHolidayService.js new file mode 100644 index 00000000..7dca2b67 --- /dev/null +++ b/backend/services/calendarHolidayService.js @@ -0,0 +1,134 @@ +import Club from '../models/Club.js'; +import { checkAccess } from '../utils/userUtils.js'; + +const OPEN_HOLIDAYS_BASE_URL = 'https://openholidaysapi.org'; +const CACHE_TTL_MS = 1000 * 60 * 60 * 24 * 14; +const REQUEST_TIMEOUT_MS = 8000; +const cache = new Map(); + +class CalendarHolidayService { + async getClubCalendarDays(userToken, clubId, year) { + await checkAccess(userToken, clubId); + const club = await Club.findByPk(clubId); + if (!club) { + throw new Error('clubnotfound'); + } + + const normalizedYear = this.normalizeYear(year); + const countryCode = this.normalizeCountryCode(club.countryCode); + const stateCode = this.normalizeStateCode(club.stateCode); + + if (!countryCode || !stateCode) { + return { + countryCode, + stateCode, + year: normalizedYear, + holidays: [], + schoolHolidays: [] + }; + } + + const cacheKey = `${countryCode}:${stateCode}:${normalizedYear}`; + const cached = cache.get(cacheKey); + if (cached && cached.expiresAt > Date.now()) { + return cached.data; + } + + const validFrom = `${normalizedYear}-01-01`; + const validTo = `${normalizedYear}-12-31`; + const [holidays, schoolHolidays] = await Promise.all([ + this.fetchOpenHolidays('PublicHolidays', { countryCode, stateCode, validFrom, validTo }), + this.fetchOpenHolidays('SchoolHolidays', { countryCode, stateCode, validFrom, validTo }) + ]); + + const data = { + countryCode, + stateCode, + year: normalizedYear, + holidays: holidays + .filter(item => this.isRelevantForSubdivision(item, stateCode)) + .map(item => this.normalizeOpenHoliday(item, 'holiday')) + .filter(Boolean), + schoolHolidays: schoolHolidays.map(item => this.normalizeOpenHoliday(item, 'schoolHoliday')).filter(Boolean) + }; + cache.set(cacheKey, { data, expiresAt: Date.now() + CACHE_TTL_MS }); + return data; + } + + async fetchOpenHolidays(endpoint, { countryCode, stateCode, validFrom, validTo }) { + const params = new URLSearchParams({ + countryIsoCode: countryCode, + languageIsoCode: 'DE', + validFrom, + validTo + }); + if (endpoint === 'SchoolHolidays') { + params.set('subdivisionCode', stateCode); + } + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS); + + try { + const response = await fetch(`${OPEN_HOLIDAYS_BASE_URL}/${endpoint}?${params.toString()}`, { + headers: { accept: 'text/json' }, + signal: controller.signal + }); + if (!response.ok) { + throw new Error(`OpenHolidays ${endpoint} HTTP ${response.status}`); + } + const data = await response.json(); + return Array.isArray(data) ? data : []; + } finally { + clearTimeout(timeout); + } + } + + normalizeOpenHoliday(item, type) { + const startDate = item?.startDate || item?.date || item?.validFrom; + const endDate = item?.endDate || item?.date || item?.validTo || startDate; + if (!startDate || !endDate) return null; + + return { + id: item.id || `${type}-${startDate}-${this.getName(item)}`, + type, + startDate: String(startDate).slice(0, 10), + endDate: String(endDate).slice(0, 10), + name: this.getName(item) + }; + } + + getName(item) { + const names = Array.isArray(item?.name) ? item.name : []; + const germanName = names.find(name => String(name.language || name.languageIsoCode || '').toUpperCase() === 'DE'); + const firstName = germanName || names[0]; + if (firstName?.text) return firstName.text; + if (typeof item?.name === 'string') return item.name; + if (typeof item?.localName === 'string') return item.localName; + return 'Kalendereintrag'; + } + + isRelevantForSubdivision(item, stateCode) { + const subdivisions = Array.isArray(item?.subdivisions) ? item.subdivisions : []; + if (subdivisions.length === 0) return true; + return subdivisions.some(subdivision => String(subdivision?.code || '').toUpperCase() === stateCode); + } + + normalizeYear(year) { + const parsed = Number.parseInt(year, 10); + if (Number.isInteger(parsed) && parsed >= 2020 && parsed <= 2100) { + return parsed; + } + return new Date().getFullYear(); + } + + normalizeCountryCode(countryCode) { + const normalized = String(countryCode || 'DE').trim().toUpperCase(); + return /^[A-Z]{2}$/.test(normalized) ? normalized : 'DE'; + } + + normalizeStateCode(stateCode) { + return String(stateCode || '').trim().toUpperCase(); + } +} + +export default new CalendarHolidayService(); diff --git a/backend/services/clubService.js b/backend/services/clubService.js index a5239df0..bd0bf553 100644 --- a/backend/services/clubService.js +++ b/backend/services/clubService.js @@ -72,6 +72,8 @@ class ClubService { associationMemberNumber, myTischtennisFedNickname, autoFetchRankings, + countryCode, + stateCode, memberDataQualityRequirements }) { await checkAccess(userToken, clubId); @@ -82,12 +84,24 @@ class ClubService { const updates = { greetingText, associationMemberNumber }; if (myTischtennisFedNickname !== undefined) updates.myTischtennisFedNickname = myTischtennisFedNickname || null; if (autoFetchRankings !== undefined) updates.autoFetchRankings = !!autoFetchRankings; + if (countryCode !== undefined) updates.countryCode = this.normalizeCountryCode(countryCode); + if (stateCode !== undefined) updates.stateCode = this.normalizeStateCode(stateCode); if (memberDataQualityRequirements !== undefined) { updates.memberDataQualityRequirements = this.normalizeMemberDataQualityRequirements(memberDataQualityRequirements); } return await club.update(updates); } + normalizeCountryCode(countryCode) { + const normalized = String(countryCode || 'DE').trim().toUpperCase(); + return /^[A-Z]{2}$/.test(normalized) ? normalized : 'DE'; + } + + normalizeStateCode(stateCode) { + const normalized = String(stateCode || '').trim().toUpperCase(); + return normalized || null; + } + normalizeMemberDataQualityRequirements(settings) { const defaults = { requireStreet: true, diff --git a/backend/services/trainingCancellationService.js b/backend/services/trainingCancellationService.js new file mode 100644 index 00000000..e1cf3dba --- /dev/null +++ b/backend/services/trainingCancellationService.js @@ -0,0 +1,76 @@ +import { Op } from 'sequelize'; +import TrainingCancellation from '../models/TrainingCancellation.js'; +import { checkAccess } from '../utils/userUtils.js'; +import HttpError from '../exceptions/HttpError.js'; + +class TrainingCancellationService { + async getTrainingCancellations(userToken, clubId, year) { + await checkAccess(userToken, clubId); + const normalizedYear = this.normalizeYear(year); + return await TrainingCancellation.findAll({ + where: { + clubId, + [Op.or]: [ + { + startDate: { [Op.lte]: `${normalizedYear}-12-31` }, + endDate: { [Op.gte]: `${normalizedYear}-01-01` }, + }, + { + date: { [Op.between]: [`${normalizedYear}-01-01`, `${normalizedYear}-12-31`] }, + }, + ], + }, + order: [['startDate', 'ASC'], ['date', 'ASC']], + }); + } + + async upsertTrainingCancellation(userToken, clubId, date, reason, endDate = null) { + await checkAccess(userToken, clubId); + const normalizedStartDate = this.normalizeDate(date); + const normalizedEndDate = this.normalizeDate(endDate || date); + if (!normalizedStartDate || !normalizedEndDate) { + throw new HttpError('Ungültiges Datum', 400); + } + if (normalizedStartDate > normalizedEndDate) { + throw new HttpError('Enddatum darf nicht vor dem Startdatum liegen', 400); + } + + const [cancellation] = await TrainingCancellation.upsert({ + clubId, + startDate: normalizedStartDate, + endDate: normalizedEndDate, + date: normalizedStartDate, + reason: String(reason || '').trim() || null, + }); + return cancellation || await TrainingCancellation.findOne({ + where: { clubId, startDate: normalizedStartDate, endDate: normalizedEndDate }, + }); + } + + async deleteTrainingCancellation(userToken, clubId, cancellationId) { + await checkAccess(userToken, clubId); + const cancellation = await TrainingCancellation.findOne({ + where: { id: cancellationId, clubId }, + }); + if (!cancellation) { + throw new HttpError('Trainingsausfall nicht gefunden', 404); + } + await cancellation.destroy(); + return { success: true }; + } + + normalizeYear(year) { + const parsed = Number.parseInt(year, 10); + if (Number.isInteger(parsed) && parsed >= 2020 && parsed <= 2100) { + return parsed; + } + return new Date().getFullYear(); + } + + normalizeDate(date) { + const text = String(date || '').slice(0, 10); + return /^\d{4}-\d{2}-\d{2}$/.test(text) ? text : null; + } +} + +export default new TrainingCancellationService(); diff --git a/frontend/src/App.vue b/frontend/src/App.vue index fdccacf2..586d1a09 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -85,6 +85,10 @@ 📝 {{ $t('navigation.diary') }} + + 📆 + Kalender + {{ $t('navigation.approvals') }} diff --git a/frontend/src/router.js b/frontend/src/router.js index 6982d23a..537eaa0c 100644 --- a/frontend/src/router.js +++ b/frontend/src/router.js @@ -16,6 +16,7 @@ const CreateClub = () => import('./views/CreateClub.vue'); const ClubView = () => import('./views/ClubView.vue'); const MembersView = () => import('./views/MembersView.vue'); const DiaryView = () => import('./views/DiaryView.vue'); +const CalendarView = () => import('./views/CalendarView.vue'); const PendingApprovalsView = () => import('./views/PendingApprovalsView.vue'); const ScheduleView = () => import('./views/ScheduleView.vue'); const TournamentsView = () => import('./views/TournamentsView.vue'); @@ -51,6 +52,7 @@ const routes = [ { path: '/showclub/:clubId', name: 'show-club', component: ClubView }, { path: '/members', name: 'members', component: MembersView }, { path: '/diary', name: 'diary', component: DiaryView }, + { path: '/calendar', name: 'calendar', component: CalendarView }, { path: '/pending-approvals', name: 'pending-approvals', component: PendingApprovalsView}, { path: '/schedule', name: 'schedule', component: ScheduleView}, { path: '/tournaments', name: 'tournaments', component: TournamentsView }, diff --git a/frontend/src/utils/seo.js b/frontend/src/utils/seo.js index 68fe476a..65199ae3 100644 --- a/frontend/src/utils/seo.js +++ b/frontend/src/utils/seo.js @@ -79,6 +79,7 @@ const NOINDEX_PREFIXES = [ '/showclub', '/members', '/diary', + '/calendar', '/pending-approvals', '/schedule', '/tournaments', diff --git a/frontend/src/views/CalendarView.vue b/frontend/src/views/CalendarView.vue new file mode 100644 index 00000000..a4d6848c --- /dev/null +++ b/frontend/src/views/CalendarView.vue @@ -0,0 +1,945 @@ + + + + + diff --git a/frontend/src/views/ClubSettings.vue b/frontend/src/views/ClubSettings.vue index 79c7424d..c17a88f4 100644 --- a/frontend/src/views/ClubSettings.vue +++ b/frontend/src/views/ClubSettings.vue @@ -51,6 +51,28 @@ +
+

Kalenderregion

+

Die Region wird für Schulferien und gesetzliche Feiertage im Kalender genutzt.

+
+
+ + +
+
+ + +
+
+
+

{{ $t('clubSettings.myTischtennisRankings') }}

{{ $t('clubSettings.myTischtennisRankingsHint') }}

@@ -133,6 +155,25 @@ const defaultMemberDataQualityRequirements = () => ({ requireEmail: true, }); +const GERMAN_STATES = [ + { code: 'DE-BW', name: 'Baden-Württemberg' }, + { code: 'DE-BY', name: 'Bayern' }, + { code: 'DE-BE', name: 'Berlin' }, + { code: 'DE-BB', name: 'Brandenburg' }, + { code: 'DE-HB', name: 'Bremen' }, + { code: 'DE-HH', name: 'Hamburg' }, + { code: 'DE-HE', name: 'Hessen' }, + { code: 'DE-MV', name: 'Mecklenburg-Vorpommern' }, + { code: 'DE-NI', name: 'Niedersachsen' }, + { code: 'DE-NW', name: 'Nordrhein-Westfalen' }, + { code: 'DE-RP', name: 'Rheinland-Pfalz' }, + { code: 'DE-SL', name: 'Saarland' }, + { code: 'DE-SN', name: 'Sachsen' }, + { code: 'DE-ST', name: 'Sachsen-Anhalt' }, + { code: 'DE-SH', name: 'Schleswig-Holstein' }, + { code: 'DE-TH', name: 'Thüringen' }, +]; + export default { name: 'ClubSettings', components: { @@ -144,6 +185,8 @@ export default { activeTab: 'settings', greeting: '', associationMemberNumber: '', + countryCode: 'DE', + stateCode: '', myTischtennisFedNickname: '', autoFetchRankings: false, memberDataQualityRequirements: defaultMemberDataQualityRequirements(), @@ -154,6 +197,9 @@ export default { }, computed: { ...mapGetters(['currentClub']), + germanStates() { + return GERMAN_STATES; + }, }, watch: { currentClub: { @@ -168,6 +214,8 @@ export default { if (!this.currentClub) { this.greeting = ''; this.associationMemberNumber = ''; + this.countryCode = 'DE'; + this.stateCode = ''; this.myTischtennisFedNickname = ''; this.autoFetchRankings = false; this.memberDataQualityRequirements = defaultMemberDataQualityRequirements(); @@ -181,6 +229,8 @@ export default { const club = response.data; this.greeting = club?.greetingText ?? ''; this.associationMemberNumber = club?.associationMemberNumber ?? ''; + this.countryCode = club?.countryCode ?? 'DE'; + this.stateCode = club?.stateCode ?? ''; this.myTischtennisFedNickname = club?.myTischtennisFedNickname ?? ''; this.autoFetchRankings = !!club?.autoFetchRankings; this.memberDataQualityRequirements = this.normalizeMemberDataQualityRequirements(club?.memberDataQualityRequirements); @@ -188,6 +238,8 @@ export default { this.loadError = this.$t('clubSettings.loadFailed'); this.greeting = ''; this.associationMemberNumber = ''; + this.countryCode = 'DE'; + this.stateCode = ''; this.myTischtennisFedNickname = ''; this.autoFetchRankings = false; this.memberDataQualityRequirements = defaultMemberDataQualityRequirements(); @@ -224,6 +276,8 @@ export default { await apiClient.put(`/clubs/${this.currentClub}/settings`, { greetingText: this.greeting, associationMemberNumber: this.associationMemberNumber, + countryCode: this.countryCode, + stateCode: this.stateCode || null, myTischtennisFedNickname: this.myTischtennisFedNickname || null, autoFetchRankings: this.autoFetchRankings, memberDataQualityRequirements: this.normalizeMemberDataQualityRequirements(this.memberDataQualityRequirements), @@ -264,6 +318,7 @@ export default { .text-input { width: 100%; border: 1px solid #ddd; border-radius: 6px; padding: 8px; font-size: 14px; } .rankings-row { margin-bottom: 12px; } .rankings-fields { margin-top: 12px; } +.field-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px; } .quality-options { display: grid; gap: 10px; margin-top: 12px; } .field-group label { display: block; margin-bottom: 4px; font-weight: 500; color: #333; } .checkbox-label { display: flex; align-items: center; gap: 8px; cursor: pointer; } @@ -305,5 +360,8 @@ export default { color: #28a745; border-bottom-color: #28a745; } - +@media (max-width: 720px) { + .field-grid { grid-template-columns: 1fr; } +} + diff --git a/mobile-app/TODO.md b/mobile-app/TODO.md index bf5c27f1..3b699aff 100644 --- a/mobile-app/TODO.md +++ b/mobile-app/TODO.md @@ -140,10 +140,15 @@ Web: `DiaryView.vue` (sehr groß). API-Cluster (Auszug – in Web nach `apiClien - [x] **i18n-Local:** `LanguageLocals.kt` (`LocalLanguageCode`) aus `AppRoot.kt` ausgelagert - [x] **Hinweis:** Web hat keinen CSV-Export für diese Statistik; mobil zusätzlich **CSV exportieren** (gefilterte/sortierte Mitgliederliste) -## Phase 6 – Terminplan (ScheduleView) +## Phase 6 – Terminplan (ScheduleView) — erledigt -- [ ] Kalender-/Listenansicht, CRUD oder Sync wie Web -- [ ] API-Endpunkte aus `ScheduleView.vue` ins `shared` übernehmen +- [x] **DTOs:** `Schedule.kt` – `ClubTeamDto`, `ScheduleMatchDto`, `LeagueTableRowDto`, `UpdateMatchPlayersBody`, `ScheduleMatchScope`, `ScheduleViewMode` +- [x] **APIs:** `ClubTeamsApi` (`GET /api/club-teams/club/:clubId`), `MatchesApi` (`/api/matches/leagues/...` matches + Tabelle, `PATCH /api/matches/:matchId/players`) +- [x] **Logik:** `ScheduleLogic.kt` – Sortierung, Merge, Filter „Erwachsene“, Mannschafts-Scope wie Web +- [x] **State:** `ScheduleManager.kt` – Mannschaften laden, Mannschafts-/Gesamt-/Erwachsenen-Ansicht, Tabelle, Spieler-Patch + Refresh +- [x] **Berechtigungen:** `canReadSchedule` / `canWriteSchedule` in `ClubPermissionHelpers.kt` +- [x] **UI:** `ScheduleScreen.kt` – Tab **Terminplan** (`MainTab.Schedule`), Home-Kachel bei Lese-Recht, Liste + Detail, Aufstellung (R/P/S) bei Schreib-Recht +- [x] **Noch nicht mobil:** CSV-Import (`POST /api/matches/import`), MyTT-Tabellen-Fetch (`POST .../table/.../fetch`), Galerie/Lineup wie Web – bei Bedarf spätere Phase --- @@ -158,10 +163,10 @@ Web: `DiaryView.vue` (sehr groß). API-Cluster (Auszug – in Web nach `apiClien ## Phase 8 – Freigaben & Verwaltung -- [ ] **Ausstehende Freigaben** (`PendingApprovalsView.vue`) -- [ ] **Team-Management** (`TeamManagementView.vue`) -- [ ] **Berechtigungen** (`PermissionsView.vue`) – rollenbasiert -- [ ] **Logs** (`LogsView.vue`) – eher Admin; nur wenn nötig mobil +- [x] **Ausstehende Freigaben** – `ClubApprovalsApi`, `PendingApprovalsManager`, Screen unter „Mehr“ → Club-Verwaltung (`ClubAdminScreens.kt`) +- [x] **Team-Management** – Deep-Link `openBackendPath("/team-management")` bei `canReadTeams()` (volle Parität zur Web-`TeamManagementView` bewusst nicht mobil) +- [x] **Berechtigungen** – erweiterte `PermissionsApi`, `PermissionsAdminManager`, UI Rolle/Status/Anpassen mit `RolePermissionMatrix` (`ClubAdminScreens.kt`) +- [x] **Logs** – `ApiLogsApi`, `ApiLogsManager`, Liste + Pagination + Detail (`ClubAdminScreens.kt`); `AppDependencies` / Logout / 401 räumen Manager auf --- diff --git a/mobile-app/composeApp/build.gradle.kts b/mobile-app/composeApp/build.gradle.kts index 6893ad6b..c971703c 100644 --- a/mobile-app/composeApp/build.gradle.kts +++ b/mobile-app/composeApp/build.gradle.kts @@ -41,6 +41,7 @@ kotlin { implementation(libs.koin.android) implementation(libs.coil.compose) implementation(libs.yalantis.ucrop) + implementation(libs.ktor.serialization.kotlinx.json) } } } diff --git a/mobile-app/composeApp/src/androidMain/kotlin/de/tt_tagebuch/app/AppDependencies.kt b/mobile-app/composeApp/src/androidMain/kotlin/de/tt_tagebuch/app/AppDependencies.kt index 5d01a5d2..0fbc0f93 100644 --- a/mobile-app/composeApp/src/androidMain/kotlin/de/tt_tagebuch/app/AppDependencies.kt +++ b/mobile-app/composeApp/src/androidMain/kotlin/de/tt_tagebuch/app/AppDependencies.kt @@ -4,9 +4,12 @@ import android.content.Context import android.content.Intent import android.net.Uri import de.tt_tagebuch.shared.api.AccidentApi +import de.tt_tagebuch.shared.api.ApiLogsApi +import de.tt_tagebuch.shared.api.ClubApprovalsApi import de.tt_tagebuch.shared.api.ApiConfig import de.tt_tagebuch.shared.api.AuthApi import de.tt_tagebuch.shared.api.PublicAuthApi +import de.tt_tagebuch.shared.api.ClubTeamsApi import de.tt_tagebuch.shared.api.ClubsApi import de.tt_tagebuch.shared.api.DiaryApi import de.tt_tagebuch.shared.api.DiaryMemberActivitiesApi @@ -14,26 +17,36 @@ import de.tt_tagebuch.shared.api.DiaryMemberApi import de.tt_tagebuch.shared.api.GroupApi import de.tt_tagebuch.shared.api.ParticipantsApi import de.tt_tagebuch.shared.api.PredefinedActivitiesApi +import de.tt_tagebuch.shared.api.MatchesApi import de.tt_tagebuch.shared.api.MemberActivitiesApi import de.tt_tagebuch.shared.api.MemberGroupPhotosApi import de.tt_tagebuch.shared.api.MembersApi +import de.tt_tagebuch.shared.api.OfficialTournamentsApi import de.tt_tagebuch.shared.api.PermissionsApi import de.tt_tagebuch.shared.api.SessionApi import de.tt_tagebuch.shared.api.TrainingGroupsApi import de.tt_tagebuch.shared.api.TrainingStatsApi import de.tt_tagebuch.shared.api.TrainingTimesApi +import de.tt_tagebuch.shared.api.TournamentsApi import de.tt_tagebuch.shared.api.http.AndroidHttpClientEngineFactory import de.tt_tagebuch.shared.api.http.AuthedHttpClient import de.tt_tagebuch.shared.api.http.PublicHttpClient import de.tt_tagebuch.shared.state.AndroidClubStorage import de.tt_tagebuch.shared.state.AndroidLanguageStorage import de.tt_tagebuch.shared.state.AndroidTokenStorage +import de.tt_tagebuch.shared.state.ApiLogsManager import de.tt_tagebuch.shared.state.AuthManager +import de.tt_tagebuch.shared.state.ClubInternalTournamentsManager import de.tt_tagebuch.shared.state.ClubManager import de.tt_tagebuch.shared.state.DiaryManager import de.tt_tagebuch.shared.state.LanguageManager import de.tt_tagebuch.shared.state.MembersManager import de.tt_tagebuch.shared.state.MutableTokenProvider +import de.tt_tagebuch.shared.state.OfficialTournamentsReadManager +import de.tt_tagebuch.shared.state.PendingApprovalsManager +import de.tt_tagebuch.shared.state.PermissionsAdminManager +import de.tt_tagebuch.shared.state.MutableTokenProvider +import de.tt_tagebuch.shared.state.ScheduleManager import de.tt_tagebuch.shared.state.TrainingStatsManager import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -73,12 +86,20 @@ class AppDependencies(context: Context) { sessionApi = SessionApi(client), ) + private val permissionsApi = PermissionsApi(client) + val clubManager = ClubManager( clubStorage = AndroidClubStorage(context.applicationContext), clubsApi = ClubsApi(client), - permissionsApi = PermissionsApi(client), + permissionsApi = permissionsApi, ) + val pendingApprovalsManager = PendingApprovalsManager(ClubApprovalsApi(client)) + val permissionsAdminManager = PermissionsAdminManager(permissionsApi) + val apiLogsManager = ApiLogsManager(ApiLogsApi(client)) + val clubInternalTournamentsManager = ClubInternalTournamentsManager(TournamentsApi(client)) + val officialTournamentsReadManager = OfficialTournamentsReadManager(OfficialTournamentsApi(client)) + val diaryManager = DiaryManager( DiaryApi(client), ParticipantsApi(client), @@ -96,6 +117,10 @@ class AppDependencies(context: Context) { TrainingTimesApi(client), ) val trainingStatsManager = TrainingStatsManager(TrainingStatsApi(client)) + val scheduleManager = ScheduleManager( + ClubTeamsApi(client), + MatchesApi(client), + ) val languageManager = LanguageManager(AndroidLanguageStorage(context.applicationContext)) val sessionApi = SessionApi(client) diff --git a/mobile-app/composeApp/src/androidMain/kotlin/de/tt_tagebuch/app/ui/AppRoot.kt b/mobile-app/composeApp/src/androidMain/kotlin/de/tt_tagebuch/app/ui/AppRoot.kt index 43a6e843..533dbfeb 100644 --- a/mobile-app/composeApp/src/androidMain/kotlin/de/tt_tagebuch/app/ui/AppRoot.kt +++ b/mobile-app/composeApp/src/androidMain/kotlin/de/tt_tagebuch/app/ui/AppRoot.kt @@ -47,8 +47,10 @@ import androidx.compose.material.Text import androidx.compose.material.TextButton import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.automirrored.filled.List import androidx.compose.material.icons.filled.BarChart import androidx.compose.material.icons.filled.DateRange +import androidx.compose.material.icons.filled.EmojiEvents import androidx.compose.material.icons.filled.ExpandLess import androidx.compose.material.icons.filled.ExpandMore import androidx.compose.material.icons.filled.Home @@ -81,8 +83,13 @@ import de.tt_tagebuch.app.pdf.writeTrainingPlanPdf import de.tt_tagebuch.shared.api.memberProfileImagePath import de.tt_tagebuch.shared.api.toAbsoluteUrl import de.tt_tagebuch.shared.api.models.MemberGroupPhotoDto +import de.tt_tagebuch.shared.api.models.canReadApprovals +import de.tt_tagebuch.shared.api.models.canReadClubPermissions import de.tt_tagebuch.shared.api.models.canReadDiary +import de.tt_tagebuch.shared.api.models.canReadTeams import de.tt_tagebuch.shared.api.models.canReadMembers +import de.tt_tagebuch.shared.api.models.canReadSchedule +import de.tt_tagebuch.shared.api.models.canReadTournaments import de.tt_tagebuch.shared.api.models.canWriteDiary import de.tt_tagebuch.shared.api.models.canWriteMembers import de.tt_tagebuch.shared.api.models.mainActivityImagePath @@ -115,6 +122,7 @@ import de.tt_tagebuch.shared.api.models.TrainingGroupDto import de.tt_tagebuch.shared.api.models.TrainingTimeDto import de.tt_tagebuch.shared.api.models.toSetBody import de.tt_tagebuch.shared.api.models.Member +import de.tt_tagebuch.shared.api.models.UserClubPermissions import de.tt_tagebuch.shared.i18n.MobileStrings import kotlinx.coroutines.async import kotlinx.coroutines.coroutineScope @@ -129,6 +137,14 @@ private const val MAIN_NAV_RAIL_MIN_WIDTH_DP = 600 private val ScreenHorizontalPadding = 20.dp private val TouchMinHeight = 48.dp +private fun visibleMainTabs(perms: UserClubPermissions?): List = + MainTab.entries.filter { tab -> + when (tab) { + MainTab.Tournaments -> perms?.canReadTournaments() == true + else -> true + } + } + private const val AUTH_ROUTE_LOGIN = "login" private const val AUTH_ROUTE_REGISTER = "register" private const val AUTH_ROUTE_FORGOT = "forgot" @@ -139,7 +155,9 @@ private enum class MainTab { Home, Diary, Members, + Schedule, Stats, + Tournaments, Settings, } @@ -162,6 +180,12 @@ fun AppRoot(dependencies: AppDependencies) { dependencies.diaryManager.clear() dependencies.membersManager.clear() dependencies.trainingStatsManager.clear() + dependencies.scheduleManager.clear() + dependencies.pendingApprovalsManager.clear() + dependencies.permissionsAdminManager.clear() + dependencies.apiLogsManager.clear() + dependencies.clubInternalTournamentsManager.clear() + dependencies.officialTournamentsReadManager.clear() } } } @@ -184,6 +208,14 @@ private fun MainTabs(dependencies: AppDependencies) { var diarySelectedEntryId by remember { mutableStateOf(null) } var membersNestedOpen by remember { mutableStateOf(false) } val useWideMainNav = LocalConfiguration.current.screenWidthDp >= MAIN_NAV_RAIL_MIN_WIDTH_DP + val clubState by dependencies.clubManager.state.collectAsState() + val visibleTabs = visibleMainTabs(clubState.currentPermissions) + + LaunchedEffect(visibleTabs, selectedTab) { + if (!visibleTabs.contains(selectedTab)) { + selectedTab = MainTab.Home + } + } val isNestedDetail = when (selectedTab) { MainTab.Diary -> diarySelectedEntryId != null @@ -205,6 +237,7 @@ private fun MainTabs(dependencies: AppDependencies) { MainNavigationRail( selectedTab = selectedTab, onTabSelected = { selectMainTab(it) }, + visibleTabs = visibleTabs, ) Divider( color = MaterialTheme.colors.onSurface.copy(alpha = 0.12f), @@ -240,7 +273,7 @@ private fun MainTabs(dependencies: AppDependencies) { contentColor = MaterialTheme.colors.onSurface, elevation = 8.dp, ) { - MainTab.values().forEach { tab -> + visibleTabs.forEach { tab -> BottomNavigationItem( icon = { Icon(mainTabIcon(tab), contentDescription = tabTitle(tab)) }, label = { Text(tabTitle(tab)) }, @@ -280,6 +313,8 @@ private fun MainTabContent( dependencies = dependencies, onNestedOpenChange = onMembersNestedOpenChange, ) + MainTab.Schedule -> ScheduleScreen(dependencies) + MainTab.Tournaments -> TournamentsScreen(dependencies) MainTab.Stats -> TrainingStatsScreen(dependencies) MainTab.Settings -> SettingsScreen(dependencies) } @@ -337,6 +372,15 @@ private fun HomeScreen( subtitle = tr("home.tileMembers", "Liste und Profile"), onClick = { onOpenTab(MainTab.Members) }, ) + clubState.currentPermissions?.let { p -> + if (p.canReadSchedule()) { + HomeHubTile( + title = tr("navigation.schedule", "Terminplan"), + subtitle = tr("home.tileSchedule", "Mannschaften, Spiele, Tabelle"), + onClick = { onOpenTab(MainTab.Schedule) }, + ) + } + } HomeHubTile( title = tr("navigation.statistics", "Statistik"), subtitle = tr("home.tileStats", "Kennzahlen und Teilnahmen"), @@ -397,6 +441,7 @@ private fun HomeHubTile(title: String, subtitle: String, onClick: () -> Unit) { private fun MainNavigationRail( selectedTab: MainTab, onTabSelected: (MainTab) -> Unit, + visibleTabs: List, modifier: Modifier = Modifier, ) { Column( @@ -407,7 +452,7 @@ private fun MainNavigationRail( .padding(vertical = 12.dp, horizontal = 8.dp), verticalArrangement = Arrangement.spacedBy(6.dp), ) { - MainTab.values().forEach { tab -> + visibleTabs.forEach { tab -> val selected = selectedTab == tab Surface( modifier = Modifier @@ -455,6 +500,8 @@ private fun mainTabIcon(tab: MainTab): ImageVector = when (tab) { MainTab.Home -> Icons.Filled.Home MainTab.Diary -> Icons.Filled.DateRange MainTab.Members -> Icons.Filled.People + MainTab.Schedule -> Icons.AutoMirrored.Filled.List + MainTab.Tournaments -> Icons.Filled.EmojiEvents MainTab.Stats -> Icons.Filled.BarChart MainTab.Settings -> Icons.Filled.Settings } @@ -3913,6 +3960,15 @@ private fun RowSwitch(label: String, checked: Boolean, onChecked: (Boolean) -> U @Composable private fun SettingsScreen(dependencies: AppDependencies) { + var clubAdminSection by remember { mutableStateOf(null) } + if (clubAdminSection != null) { + ClubAdminFlowScreen( + destination = clubAdminSection!!, + dependencies = dependencies, + onBack = { clubAdminSection = null }, + ) + return + } val authState by dependencies.authManager.state.collectAsState() val clubState by dependencies.clubManager.state.collectAsState() var sessionStatus by rememberSaveable { mutableStateOf(null) } @@ -3936,6 +3992,33 @@ private fun SettingsScreen(dependencies: AppDependencies) { clubState.currentPermissions?.let { permissions -> DetailLine(tr("mobile.role", "Rolle"), permissions.role) } + val clubId = clubState.currentClubId + val perms = clubState.currentPermissions + if (clubId != null && perms != null) { + SectionTitle(tr("mobile.clubAdmin", "Club-Verwaltung")) + if (perms.canReadApprovals()) { + TextButton( + onClick = { clubAdminSection = ClubAdminSettingsSection.Pending }, + modifier = Modifier.fillMaxWidth(), + ) { Text(tr("mobile.pendingApprovals", "Ausstehende Freigaben")) } + } + if (perms.canReadClubPermissions()) { + TextButton( + onClick = { clubAdminSection = ClubAdminSettingsSection.Permissions }, + modifier = Modifier.fillMaxWidth(), + ) { Text(tr("mobile.permissionsAdmin", "Berechtigungen")) } + } + TextButton( + onClick = { clubAdminSection = ClubAdminSettingsSection.Logs }, + modifier = Modifier.fillMaxWidth(), + ) { Text(tr("mobile.apiLogs", "API-Logs")) } + if (perms.canReadTeams()) { + TextButton( + onClick = { dependencies.openBackendPath("/team-management") }, + modifier = Modifier.fillMaxWidth(), + ) { Text(tr("mobile.teamManagementWeb", "Team-Verwaltung (Web)")) } + } + } SectionTitle(tr("mobile.language", "Sprache")) MobileStrings.supportedLanguages.forEach { language -> TextButton( @@ -3992,6 +4075,10 @@ private fun SettingsScreen(dependencies: AppDependencies) { dependencies.diaryManager.clear() dependencies.membersManager.clear() dependencies.trainingStatsManager.clear() + dependencies.scheduleManager.clear() + dependencies.pendingApprovalsManager.clear() + dependencies.permissionsAdminManager.clear() + dependencies.apiLogsManager.clear() } }, modifier = Modifier @@ -4411,6 +4498,8 @@ private fun tabTitle(tab: MainTab): String = when (tab) { MainTab.Home -> tr("navigation.home", "Start") MainTab.Diary -> tr("navigation.diary", "Tagebuch") MainTab.Members -> tr("navigation.members", "Mitglieder") + MainTab.Schedule -> tr("navigation.schedule", "Terminplan") + MainTab.Tournaments -> tr("navigation.clubTournaments", "Turniere") MainTab.Stats -> tr("navigation.statistics", "Statistik") MainTab.Settings -> tr("mobile.more", "Mehr") } diff --git a/mobile-app/composeApp/src/androidMain/kotlin/de/tt_tagebuch/app/ui/ClubAdminScreens.kt b/mobile-app/composeApp/src/androidMain/kotlin/de/tt_tagebuch/app/ui/ClubAdminScreens.kt new file mode 100644 index 00000000..8b212bc9 --- /dev/null +++ b/mobile-app/composeApp/src/androidMain/kotlin/de/tt_tagebuch/app/ui/ClubAdminScreens.kt @@ -0,0 +1,569 @@ +package de.tt_tagebuch.app.ui + +import androidx.activity.compose.BackHandler +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.AlertDialog +import androidx.compose.material.Button +import androidx.compose.material.Card +import androidx.compose.material.CircularProgressIndicator +import androidx.compose.material.Divider +import androidx.compose.material.DropdownMenu +import androidx.compose.material.DropdownMenuItem +import androidx.compose.material.Icon +import androidx.compose.material.IconButton +import androidx.compose.material.MaterialTheme +import androidx.compose.material.OutlinedButton +import androidx.compose.material.OutlinedTextField +import androidx.compose.material.Switch +import androidx.compose.material.Text +import androidx.compose.material.TextButton +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateMapOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import de.tt_tagebuch.app.AppDependencies +import de.tt_tagebuch.shared.api.RolePermissionMatrix +import de.tt_tagebuch.shared.api.models.ApiLogDetailDto +import de.tt_tagebuch.shared.api.models.ClubPermissionMemberDto +import de.tt_tagebuch.shared.api.models.PermissionResourceDto +import de.tt_tagebuch.shared.api.models.canReadApprovals +import de.tt_tagebuch.shared.api.models.canReadClubPermissions +import de.tt_tagebuch.shared.api.models.canWriteApprovals +import de.tt_tagebuch.shared.api.models.canWriteClubPermissions +import de.tt_tagebuch.shared.i18n.MobileStrings +import kotlinx.coroutines.launch +import kotlinx.serialization.json.JsonNull +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.booleanOrNull +import kotlinx.serialization.json.buildJsonObject + +private val ClubAdminPad = 20.dp +private val ClubAdminTouchMin = 48.dp + +internal enum class ClubAdminSettingsSection { + Pending, + Permissions, + Logs, +} + +@Composable +internal fun ClubAdminFlowScreen( + destination: ClubAdminSettingsSection, + dependencies: AppDependencies, + onBack: () -> Unit, +) { + BackHandler(onBack = onBack) + when (destination) { + ClubAdminSettingsSection.Pending -> + ClubAdminPendingScreen(dependencies = dependencies, onBack = onBack) + ClubAdminSettingsSection.Permissions -> + ClubAdminPermissionsScreen(dependencies = dependencies, onBack = onBack) + ClubAdminSettingsSection.Logs -> + ClubAdminLogsScreen(dependencies = dependencies, onBack = onBack) + } +} + +@Composable +private fun ClubAdminTopBar(title: String, onBack: () -> Unit) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + ) { + IconButton(onClick = onBack) { + Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = null) + } + Text(title, style = MaterialTheme.typography.h6, fontWeight = FontWeight.SemiBold) + } + Spacer(modifier = Modifier.height(8.dp)) +} + +private fun readTri(root: JsonObject?, resource: String, action: String): Boolean? { + val mod = root?.get(resource) ?: return null + val obj = mod as? JsonObject ?: return null + if (!obj.containsKey(action)) return null + val el = obj[action] ?: return null + if (el is JsonNull) return null + return (el as? JsonPrimitive)?.booleanOrNull +} + +private fun overridesToJson( + structure: Map, + overrides: Map, +): JsonObject = buildJsonObject { + structure.forEach { (res, spec) -> + val inner = buildJsonObject { + spec.actions.forEach { a -> + val k = permKey(res, a) + when (val v = overrides[k]) { + true -> put(a, JsonPrimitive(true)) + false -> put(a, JsonPrimitive(false)) + null -> {} + } + } + } + if (inner.isNotEmpty()) put(res, inner) + } +} + +private fun permKey(resource: String, action: String) = "$resource#$action" + +@Composable +private fun ClubAdminPendingScreen(dependencies: AppDependencies, onBack: () -> Unit) { + val languageCode = LocalLanguageCode.current + fun tr(key: String, fb: String) = MobileStrings.get(languageCode, key, fb) + val clubState by dependencies.clubManager.state.collectAsState() + val pendingState by dependencies.pendingApprovalsManager.state.collectAsState() + val clubId = clubState.currentClubId ?: return + val perms = clubState.currentPermissions + val canWrite = perms?.canWriteApprovals() == true + val scope = rememberCoroutineScope() + var actionError by remember { mutableStateOf(null) } + + LaunchedEffect(clubId) { + dependencies.pendingApprovalsManager.load(clubId) + } + + Column( + modifier = Modifier + .fillMaxSize() + .padding(horizontal = ClubAdminPad, vertical = 16.dp), + ) { + ClubAdminTopBar(tr("mobile.pendingApprovals", "Ausstehende Freigaben"), onBack) + actionError?.let { + Text(it, color = MaterialTheme.colors.error, modifier = Modifier.padding(bottom = 8.dp)) + } + if (perms != null && !perms.canReadApprovals()) { + Text(tr("mobile.noAccess", "Keine Berechtigung."), modifier = Modifier.padding(top = 8.dp)) + return + } + if (pendingState.isLoading && pendingState.pending.isEmpty()) { + CircularProgressIndicator(modifier = Modifier.padding(top = 24.dp)) + return + } + pendingState.error?.let { Text(it, color = MaterialTheme.colors.error) } + if (pendingState.pending.isEmpty()) { + Text(tr("mobile.pendingEmpty", "Keine ausstehenden Anträge."), modifier = Modifier.padding(top = 16.dp)) + } else { + LazyColumn(verticalArrangement = Arrangement.spacedBy(8.dp)) { + items(pendingState.pending, key = { it.userId }) { row -> + val u = row.user + val label = listOfNotNull(u?.firstName, u?.lastName).joinToString(" ").trim() + .ifEmpty { u?.email ?: "User ${row.userId}" } + Card(modifier = Modifier.fillMaxWidth(), elevation = 1.dp) { + Column(modifier = Modifier.padding(12.dp)) { + Text(label, fontWeight = FontWeight.SemiBold) + u?.email?.let { Text(it, style = MaterialTheme.typography.caption) } + Row( + modifier = Modifier + .fillMaxWidth() + .padding(top = 8.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + Button( + onClick = { + actionError = null + scope.launch { + try { + dependencies.pendingApprovalsManager.approve(clubId, row.userId) + } catch (t: Throwable) { + actionError = t.message + } + } + }, + enabled = canWrite, + modifier = Modifier.weight(1f).heightIn(min = ClubAdminTouchMin), + ) { Text(tr("mobile.approve", "Freigeben")) } + OutlinedButton( + onClick = { + actionError = null + scope.launch { + try { + dependencies.pendingApprovalsManager.reject(clubId, row.userId) + } catch (t: Throwable) { + actionError = t.message + } + } + }, + enabled = canWrite, + modifier = Modifier.weight(1f).heightIn(min = ClubAdminTouchMin), + ) { Text(tr("mobile.reject", "Ablehnen")) } + } + } + } + } + } + } + } +} + +@Composable +private fun ClubAdminPermissionsScreen(dependencies: AppDependencies, onBack: () -> Unit) { + val languageCode = LocalLanguageCode.current + fun tr(key: String, fb: String) = MobileStrings.get(languageCode, key, fb) + val clubState by dependencies.clubManager.state.collectAsState() + val adminState by dependencies.permissionsAdminManager.state.collectAsState() + val clubId = clubState.currentClubId ?: return + val perms = clubState.currentPermissions + val canWrite = perms?.canWriteClubPermissions() == true + val scope = rememberCoroutineScope() + var roleMenuFor by remember { mutableStateOf(null) } + var customizeFor by remember { mutableStateOf(null) } + val overrides = remember { mutableStateMapOf() } + var customizeSaving by remember { mutableStateOf(false) } + var opError by remember { mutableStateOf(null) } + + LaunchedEffect(clubId) { + dependencies.permissionsAdminManager.load(clubId) + } + + LaunchedEffect(customizeFor, adminState.permissionStructure) { + val m = customizeFor ?: return@LaunchedEffect + if (adminState.permissionStructure.isEmpty()) return@LaunchedEffect + overrides.clear() + adminState.permissionStructure.forEach { (res, spec) -> + spec.actions.forEach { a -> + overrides[permKey(res, a)] = readTri(m.permissions, res, a) + } + } + } + + Column( + modifier = Modifier + .fillMaxSize() + .padding(horizontal = ClubAdminPad, vertical = 16.dp), + ) { + ClubAdminTopBar(tr("mobile.permissionsAdmin", "Berechtigungen"), onBack) + opError?.let { Text(it, color = MaterialTheme.colors.error, modifier = Modifier.padding(bottom = 8.dp)) } + if (perms != null && !perms.canReadClubPermissions()) { + Text(tr("mobile.noAccess", "Keine Berechtigung.")) + return + } + if (adminState.isLoading && adminState.members.isEmpty()) { + CircularProgressIndicator(modifier = Modifier.padding(top = 24.dp)) + return + } + adminState.error?.let { Text(it, color = MaterialTheme.colors.error) } + LazyColumn(verticalArrangement = Arrangement.spacedBy(8.dp)) { + items(adminState.members, key = { it.userId }) { m -> + val email = m.user?.email ?: "User ${m.userId}" + Card(modifier = Modifier.fillMaxWidth(), elevation = 1.dp) { + Column(modifier = Modifier.padding(12.dp)) { + Text(email, fontWeight = FontWeight.SemiBold) + if (m.isOwner) { + Text(tr("mobile.clubOwner", "Vereinsbesitzer"), style = MaterialTheme.typography.caption) + } + Row( + modifier = Modifier.fillMaxWidth().padding(top = 8.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Text(tr("mobile.role", "Rolle")) + Box { + TextButton( + enabled = canWrite && !m.isOwner, + onClick = { roleMenuFor = m.userId }, + ) { + Text(adminState.availableRoles.firstOrNull { it.value == m.role }?.label ?: m.role) + } + DropdownMenu( + expanded = roleMenuFor == m.userId, + onDismissRequest = { roleMenuFor = null }, + ) { + adminState.availableRoles.forEach { role -> + DropdownMenuItem( + onClick = { + roleMenuFor = null + opError = null + scope.launch { + try { + dependencies.permissionsAdminManager.updateRole(clubId, m.userId, role.value) + } catch (t: Throwable) { + opError = t.message + } + } + }, + ) { Text(role.label) } + } + } + } + } + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Text(tr("mobile.memberApproved", "Aktiv")) + Switch( + checked = m.approved != false, + enabled = canWrite && !m.isOwner, + onCheckedChange = { on -> + opError = null + scope.launch { + try { + dependencies.permissionsAdminManager.updateApproved(clubId, m.userId, on) + } catch (t: Throwable) { + opError = t.message + } + } + }, + ) + } + TextButton( + enabled = canWrite && !m.isOwner, + onClick = { customizeFor = m }, + modifier = Modifier.fillMaxWidth().heightIn(min = ClubAdminTouchMin), + ) { Text(tr("mobile.customizePermissions", "Berechtigungen anpassen")) } + } + } + } + } + } + + val dialogMember = customizeFor + if (dialogMember != null) { + AlertDialog( + onDismissRequest = { if (!customizeSaving) customizeFor = null }, + title = { Text(dialogMember.user?.email ?: "User ${dialogMember.userId}") }, + text = { + Column( + modifier = Modifier + .verticalScroll(rememberScrollState()) + .heightIn(max = 420.dp), + ) { + adminState.permissionStructure.forEach { (res, spec) -> + Text( + spec.label.ifEmpty { res }, + fontWeight = FontWeight.SemiBold, + modifier = Modifier.padding(top = 8.dp, bottom = 4.dp), + ) + spec.actions.forEach { action -> + val k = permKey(res, action) + val tri = overrides[k] + val inherited = RolePermissionMatrix.defaultAction(dialogMember.role, res, action) + val label = when (tri) { + null -> tr("mobile.permInherit", "Standard") + " (${if (inherited) tr("mobile.yes", "Ja") else tr("mobile.no", "Nein")})" + true -> tr("mobile.permAllow", "Erlaubt") + false -> tr("mobile.permDeny", "Verweigert") + } + TextButton( + onClick = { + overrides[k] = when (tri) { + null -> true + true -> false + false -> null + } + }, + modifier = Modifier.fillMaxWidth(), + ) { + Column(modifier = Modifier.fillMaxWidth()) { + Text(action, style = MaterialTheme.typography.caption) + Text(label) + } + } + Divider() + } + } + } + }, + confirmButton = { + TextButton( + enabled = !customizeSaving, + onClick = { + scope.launch { + customizeSaving = true + opError = null + try { + val json = overridesToJson(adminState.permissionStructure, overrides) + dependencies.permissionsAdminManager.saveCustomPermissions( + clubId, + dialogMember.userId, + json, + ) + customizeFor = null + } catch (t: Throwable) { + opError = t.message + } finally { + customizeSaving = false + } + } + }, + ) { Text(tr("common.save", "Speichern")) } + }, + dismissButton = { + TextButton(enabled = !customizeSaving, onClick = { customizeFor = null }) { + Text(tr("common.cancel", "Abbrechen")) + } + }, + ) + } +} + +@Composable +private fun ClubAdminLogsScreen(dependencies: AppDependencies, onBack: () -> Unit) { + val languageCode = LocalLanguageCode.current + fun tr(key: String, fb: String) = MobileStrings.get(languageCode, key, fb) + val logsState by dependencies.apiLogsManager.state.collectAsState() + val scope = rememberCoroutineScope() + var pathFilter by remember { mutableStateOf("") } + var detailId by remember { mutableStateOf(null) } + var detail by remember { mutableStateOf(null) } + var detailLoading by remember { mutableStateOf(false) } + + LaunchedEffect(Unit) { + dependencies.apiLogsManager.load(resetOffset = true) + } + + Column( + modifier = Modifier + .fillMaxSize() + .padding(horizontal = ClubAdminPad, vertical = 16.dp), + ) { + ClubAdminTopBar(tr("mobile.apiLogs", "API-Logs"), onBack) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + OutlinedTextField( + value = pathFilter, + onValueChange = { pathFilter = it }, + label = { Text(tr("mobile.logPathFilter", "Pfad enthält")) }, + singleLine = true, + modifier = Modifier.weight(1f), + ) + Button( + onClick = { + scope.launch { + dependencies.apiLogsManager.load( + pathContains = pathFilter.takeIf { it.isNotBlank() }, + resetOffset = true, + ) + } + }, + modifier = Modifier.heightIn(min = ClubAdminTouchMin), + ) { Text(tr("mobile.apply", "Anwenden")) } + } + logsState.error?.let { Text(it, color = MaterialTheme.colors.error, modifier = Modifier.padding(top = 8.dp)) } + Row( + modifier = Modifier + .fillMaxWidth() + .padding(top = 8.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + OutlinedButton( + enabled = !logsState.isLoading && logsState.offset > 0, + onClick = { + scope.launch { + dependencies.apiLogsManager.previousPage(pathContains = pathFilter.takeIf { it.isNotBlank() }) + } + }, + modifier = Modifier.heightIn(min = ClubAdminTouchMin), + ) { Text(tr("mobile.prevPage", "Zurück")) } + Text( + "${logsState.offset + 1}–${(logsState.offset + logsState.logs.size).coerceAtLeast(logsState.offset)} / ${logsState.total}", + style = MaterialTheme.typography.caption, + ) + OutlinedButton( + enabled = !logsState.isLoading && logsState.offset + logsState.logs.size < logsState.total, + onClick = { + scope.launch { + dependencies.apiLogsManager.nextPage(pathContains = pathFilter.takeIf { it.isNotBlank() }) + } + }, + modifier = Modifier.heightIn(min = ClubAdminTouchMin), + ) { Text(tr("mobile.nextPage", "Weiter")) } + } + if (logsState.isLoading && logsState.logs.isEmpty()) { + CircularProgressIndicator(modifier = Modifier.padding(top = 24.dp)) + } else { + LazyColumn(modifier = Modifier.padding(top = 8.dp), verticalArrangement = Arrangement.spacedBy(4.dp)) { + items(logsState.logs, key = { it.id }) { row -> + Card( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 2.dp), + elevation = 1.dp, + ) { + TextButton( + onClick = { + scope.launch { + detailId = row.id + detail = null + detailLoading = true + detail = dependencies.apiLogsManager.fetchDetail(row.id) + detailLoading = false + } + }, + modifier = Modifier.fillMaxWidth(), + ) { + Column(modifier = Modifier.fillMaxWidth().padding(4.dp)) { + Text( + "${row.method ?: "?"} ${row.statusCode ?: "-"}", + fontWeight = FontWeight.SemiBold, + ) + Text(row.path ?: "-", style = MaterialTheme.typography.caption) + row.createdAt?.let { Text(it, style = MaterialTheme.typography.caption) } + } + } + } + } + } + } + } + + if (detailId != null) { + AlertDialog( + onDismissRequest = { detailId = null }, + title = { Text(tr("mobile.logDetail", "Log-Detail")) }, + text = { + when { + detailLoading -> CircularProgressIndicator() + detail != null -> { + val d = detail!! + Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { + Text("id=${d.id}") + d.method?.let { Text("method=$it") } + d.path?.let { Text("path=$it") } + d.statusCode?.let { Text("status=$it") } + d.executionTime?.let { Text("ms=$it") } + d.createdAt?.let { Text(it) } + d.errorMessage?.takeIf { it.isNotBlank() }?.let { Text(it, color = MaterialTheme.colors.error) } + d.ipAddress?.let { Text("ip=$it") } + d.userAgent?.let { Text(it, style = MaterialTheme.typography.caption) } + } + } + else -> Text(tr("mobile.logDetailFailed", "Konnte nicht geladen werden.")) + } + }, + confirmButton = { + TextButton(onClick = { detailId = null }) { Text(tr("common.close", "Schließen")) } + }, + ) + } +} diff --git a/mobile-app/composeApp/src/androidMain/kotlin/de/tt_tagebuch/app/ui/ScheduleScreen.kt b/mobile-app/composeApp/src/androidMain/kotlin/de/tt_tagebuch/app/ui/ScheduleScreen.kt new file mode 100644 index 00000000..a041d27b --- /dev/null +++ b/mobile-app/composeApp/src/androidMain/kotlin/de/tt_tagebuch/app/ui/ScheduleScreen.kt @@ -0,0 +1,502 @@ +package de.tt_tagebuch.app.ui + +import android.content.Intent +import android.net.Uri +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.AlertDialog +import androidx.compose.material.Card +import androidx.compose.material.Checkbox +import androidx.compose.material.CircularProgressIndicator +import androidx.compose.material.Divider +import androidx.compose.material.DropdownMenu +import androidx.compose.material.DropdownMenuItem +import androidx.compose.material.MaterialTheme +import androidx.compose.material.OutlinedButton +import androidx.compose.material.Text +import androidx.compose.material.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalClipboardManager +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import de.tt_tagebuch.app.AppDependencies +import de.tt_tagebuch.app.stats.TrainingStatsDerived +import de.tt_tagebuch.shared.api.models.ScheduleMatchDto +import de.tt_tagebuch.shared.api.models.ScheduleMatchScope +import de.tt_tagebuch.shared.api.models.ScheduleViewMode +import de.tt_tagebuch.shared.api.models.canReadSchedule +import de.tt_tagebuch.shared.api.models.canWriteSchedule +import de.tt_tagebuch.shared.i18n.MobileStrings +import kotlinx.coroutines.launch + +private val SchedulePad = 20.dp +private val ScheduleTouchMin = 48.dp + +@Composable +internal fun ScheduleScreen(dependencies: AppDependencies) { + val clubState by dependencies.clubManager.state.collectAsState() + val scheduleState by dependencies.scheduleManager.state.collectAsState() + val membersState by dependencies.membersManager.state.collectAsState() + val clubId = clubState.currentClubId ?: return + val permissions = clubState.currentPermissions + val languageCode = LocalLanguageCode.current + fun tr(key: String, fb: String) = MobileStrings.get(languageCode, key, fb) + val scope = rememberCoroutineScope() + val clipboard = LocalClipboardManager.current + val context = LocalContext.current + + var teamMenu by remember { mutableStateOf(false) } + var otherTeamMenu by remember { mutableStateOf(false) } + var detailMatch by remember { mutableStateOf(null) } + var playerMatch by remember { mutableStateOf(null) } + var playerError by remember { mutableStateOf(null) } + var playerSaving by remember { mutableStateOf(false) } + + var readyIds by remember { mutableStateOf(emptyList()) } + var plannedIds by remember { mutableStateOf(emptyList()) } + var playedIds by remember { mutableStateOf(emptyList()) } + + LaunchedEffect(playerMatch?.id) { + val pm = playerMatch ?: return@LaunchedEffect + readyIds = pm.playersReady + plannedIds = pm.playersPlanned + playedIds = pm.playersPlayed + } + + LaunchedEffect(clubId) { + dependencies.scheduleManager.clear() + dependencies.scheduleManager.loadClubTeams(clubId) + } + + if (permissions != null && !permissions.canReadSchedule()) { + Column( + modifier = Modifier + .fillMaxSize() + .padding(SchedulePad) + .imePadding() + .navigationBarsPadding(), + ) { + Text(tr("schedule.noPermission", "Keine Berechtigung für den Spielplan."), style = MaterialTheme.typography.body1) + } + return + } + + Column( + modifier = Modifier + .fillMaxSize() + .imePadding() + .navigationBarsPadding() + .padding(horizontal = SchedulePad, vertical = 12.dp), + ) { + Text(tr("navigation.schedule", "Terminplan"), style = MaterialTheme.typography.h5, fontWeight = FontWeight.SemiBold) + Spacer(modifier = Modifier.height(8.dp)) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + OutlinedButton( + onClick = { + scope.launch { + dependencies.scheduleManager.loadOverallSchedule(clubId) + } + }, + modifier = Modifier.weight(1f).heightIn(min = ScheduleTouchMin), + ) { + Text(tr("schedule.overallSchedule", "Gesamtplan"), maxLines = 2) + } + OutlinedButton( + onClick = { + scope.launch { + dependencies.scheduleManager.loadAdultSchedule(clubId) + } + }, + modifier = Modifier.weight(1f).heightIn(min = ScheduleTouchMin), + ) { + Text(tr("schedule.adultSchedule", "Erwachsene"), maxLines = 2) + } + } + Spacer(modifier = Modifier.height(8.dp)) + Box { + OutlinedButton( + onClick = { teamMenu = true }, + modifier = Modifier.fillMaxWidth().heightIn(min = ScheduleTouchMin), + ) { + val label = scheduleState.selectedTeam?.let { t -> + val lg = t.league?.name?.takeIf { it.isNotBlank() } + if (lg != null) "${t.name} ($lg)" else t.name + } ?: tr("schedule.selectTeam", "Mannschaft wählen") + Text(label, maxLines = 2) + } + DropdownMenu(expanded = teamMenu, onDismissRequest = { teamMenu = false }) { + scheduleState.teams.forEach { team -> + DropdownMenuItem( + onClick = { + teamMenu = false + scope.launch { dependencies.scheduleManager.selectTeam(clubId, team.id) } + }, + ) { + val lg = team.league?.name?.takeIf { it.isNotBlank() } + Text(if (lg != null) "${team.name} ($lg)" else team.name) + } + } + } + } + + if (scheduleState.viewMode == ScheduleViewMode.Team && scheduleState.selectedTeam != null) { + Spacer(modifier = Modifier.height(8.dp)) + Text(tr("schedule.matchScope", "Spiele anzeigen"), style = MaterialTheme.typography.caption) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(6.dp), + ) { + ScheduleScopeChip( + label = tr("schedule.ownTeamMatches", "Eigene"), + selected = scheduleState.matchScope == ScheduleMatchScope.Own, + onClick = { dependencies.scheduleManager.setMatchScope(ScheduleMatchScope.Own) }, + ) + ScheduleScopeChip( + label = tr("schedule.allLeagueMatches", "Alle"), + selected = scheduleState.matchScope == ScheduleMatchScope.All, + onClick = { dependencies.scheduleManager.setMatchScope(ScheduleMatchScope.All) }, + ) + ScheduleScopeChip( + label = tr("schedule.otherTeamMatches", "Andere"), + selected = scheduleState.matchScope == ScheduleMatchScope.Other, + onClick = { dependencies.scheduleManager.setMatchScope(ScheduleMatchScope.Other) }, + ) + } + if (scheduleState.matchScope == ScheduleMatchScope.Other) { + Box(modifier = Modifier.fillMaxWidth().padding(top = 6.dp)) { + OutlinedButton( + onClick = { otherTeamMenu = true }, + modifier = Modifier.fillMaxWidth().heightIn(min = ScheduleTouchMin), + ) { + Text( + scheduleState.otherTeamName.ifBlank { tr("schedule.selectOtherTeam", "Mannschaft wählen") }, + maxLines = 2, + ) + } + DropdownMenu(expanded = otherTeamMenu, onDismissRequest = { otherTeamMenu = false }) { + scheduleState.leagueTeamOptions.forEach { name -> + DropdownMenuItem( + onClick = { + otherTeamMenu = false + dependencies.scheduleManager.setOtherTeamName(name) + }, + ) { Text(name) } + } + } + } + } + } + + Spacer(modifier = Modifier.height(8.dp)) + OutlinedButton( + onClick = { scope.launch { dependencies.scheduleManager.refresh(clubId) } }, + modifier = Modifier.fillMaxWidth().heightIn(min = ScheduleTouchMin), + ) { Text(tr("mobile.refresh", "Aktualisieren")) } + + if (scheduleState.isLoading) { + Row( + Modifier + .fillMaxWidth() + .padding(12.dp), + horizontalArrangement = Arrangement.Center, + ) { + CircularProgressIndicator() + } + } + scheduleState.error?.let { + Text(it, color = MaterialTheme.colors.error, modifier = Modifier.padding(vertical = 8.dp)) + } + + if (scheduleState.viewMode == ScheduleViewMode.Team && scheduleState.leagueTable.isNotEmpty()) { + Text(tr("schedule.leagueTable", "Tabelle"), style = MaterialTheme.typography.subtitle1, fontWeight = FontWeight.SemiBold, modifier = Modifier.padding(top = 12.dp)) + Card(modifier = Modifier.fillMaxWidth().padding(vertical = 6.dp), elevation = 1.dp) { + Column(modifier = Modifier.padding(8.dp)) { + Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) { + Text("#", fontWeight = FontWeight.Bold, modifier = Modifier.widthIn(28.dp)) + Text(tr("schedule.team", "Team"), fontWeight = FontWeight.Bold, modifier = Modifier.weight(1f)) + Text(tr("schedule.points", "Pkt"), fontWeight = FontWeight.Bold, modifier = Modifier.widthIn(40.dp)) + } + Divider() + scheduleState.leagueTable.forEachIndexed { idx, row -> + Row( + Modifier + .fillMaxWidth() + .padding(vertical = 4.dp), + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Text("${idx + 1}", modifier = Modifier.widthIn(28.dp)) + Text(row.teamName, modifier = Modifier.weight(1f), maxLines = 2) + Text(row.tablePoints, modifier = Modifier.widthIn(40.dp)) + } + } + } + } + } + + Text(tr("schedule.games", "Spiele"), style = MaterialTheme.typography.subtitle1, fontWeight = FontWeight.SemiBold, modifier = Modifier.padding(top = 12.dp)) + + val matches = scheduleState.displayedMatches + if (matches.isEmpty() && !scheduleState.isLoading) { + Text(tr("schedule.noGames", "Keine Spiele"), modifier = Modifier.padding(top = 8.dp)) + } else { + LazyColumn( + verticalArrangement = Arrangement.spacedBy(8.dp), + modifier = Modifier.weight(1f), + ) { + items(matches, key = { it.id }) { m -> + ScheduleMatchCard( + match = m, + highlightClubName = clubState.clubs.find { it.id == clubId }?.name.orEmpty(), + showLeagueColumn = scheduleState.viewMode != ScheduleViewMode.Team, + onClick = { detailMatch = m }, + ) + } + } + } + } + + detailMatch?.let { m -> + AlertDialog( + onDismissRequest = { detailMatch = null }, + title = { Text("${m.homeTeam?.name ?: "?"} : ${m.guestTeam?.name ?: "?"}") }, + text = { + val scroll = rememberScrollState() + Column(Modifier.verticalScroll(scroll)) { + Text("${TrainingStatsDerived.formatDateGerman(m.date)} · ${TrainingStatsDerived.weekdayGerman(m.date ?: "")}") + Text( + tr("schedule.time", "Zeit") + ": " + (m.time?.take(5)?.takeIf { it.isNotBlank() } ?: "—"), + ) + if (m.isCompleted) { + Text( + tr("schedule.result", "Ergebnis") + ": ${m.homeMatchPoints}:${m.guestMatchPoints}", + fontWeight = FontWeight.SemiBold, + ) + } + val loc = m.location + loc?.name?.takeIf { it.isNotBlank() && it != "Unbekannt" }?.let { hallName -> + Text(tr("schedule.location", "Halle") + ": $hallName") + val addr = listOfNotNull( + loc.address.takeIf { a -> a.isNotBlank() }, + listOfNotNull(loc.zip.takeIf { z -> z.isNotBlank() }, loc.city.takeIf { c -> c.isNotBlank() }) + .joinToString(" ") + .takeIf { s -> s.isNotBlank() }, + ).joinToString(", ") + if (addr.isNotBlank()) Text(addr, style = MaterialTheme.typography.caption) + } + m.leagueDetails?.name?.takeIf { it.isNotBlank() }?.let { + Text(tr("schedule.ageClass", "Liga") + ": $it", style = MaterialTheme.typography.caption) + } + m.code?.takeIf { it.isNotBlank() }?.let { code -> + TextButton(onClick = { clipboard.setText(AnnotatedString(code)) }) { + Text(tr("schedule.code", "Code") + ": $code") + } + } + Row { + m.homePin?.takeIf { it.isNotBlank() }?.let { pin -> + TextButton(onClick = { clipboard.setText(AnnotatedString(pin)) }) { + Text(tr("schedule.homePin", "Heim-PIN") + ": $pin") + } + } + m.guestPin?.takeIf { it.isNotBlank() }?.let { pin -> + TextButton(onClick = { clipboard.setText(AnnotatedString(pin)) }) { + Text(tr("schedule.guestPin", "Gast-PIN") + ": $pin") + } + } + } + m.pdfUrl?.takeIf { it.startsWith("http") }?.let { url -> + TextButton( + onClick = { + runCatching { + context.startActivity( + Intent(Intent.ACTION_VIEW, Uri.parse(url)).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK), + ) + } + }, + ) { Text(tr("schedule.openMatchReport", "Bericht öffnen")) } + } + if (permissions?.canWriteSchedule() == true) { + TextButton( + onClick = { + playerError = null + playerMatch = m + detailMatch = null + }, + ) { + Text(tr("schedule.players", "Aufstellung / Spieler")) + } + } + } + }, + confirmButton = { + TextButton(onClick = { detailMatch = null }) { Text(tr("common.close", "Schließen")) } + }, + ) + } + + playerMatch?.let { m -> + LaunchedEffect(m.id, clubId) { + dependencies.membersManager.loadMembers(clubId) + } + AlertDialog( + onDismissRequest = { if (!playerSaving) playerMatch = null }, + title = { Text(tr("schedule.playerSelectionTitle", "Spieler")) }, + text = { + Column(modifier = Modifier.heightIn(max = 400.dp)) { + playerError?.let { Text(it, color = MaterialTheme.colors.error) } + val memberList = membersState.members.filter { it.active } + if (membersState.isLoading) { + CircularProgressIndicator(modifier = Modifier.padding(16.dp)) + } else { + val scroll = rememberScrollState() + Column(Modifier.verticalScroll(scroll)) { + memberList.forEach { mem -> + val id = mem.id + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth(), + ) { + Column(Modifier.weight(1f)) { + Text("${mem.firstName} ${mem.lastName}".trim(), maxLines = 1) + } + Text("R", style = MaterialTheme.typography.caption) + Checkbox( + checked = id in readyIds, + onCheckedChange = { c -> + readyIds = if (c) (readyIds + id).distinct() else readyIds.filter { it != id } + }, + ) + Text("P", style = MaterialTheme.typography.caption) + Checkbox( + checked = id in plannedIds, + onCheckedChange = { c -> + plannedIds = if (c) (plannedIds + id).distinct() else plannedIds.filter { it != id } + }, + ) + Text("S", style = MaterialTheme.typography.caption) + Checkbox( + checked = id in playedIds, + onCheckedChange = { c -> + playedIds = if (c) (playedIds + id).distinct() else playedIds.filter { it != id } + }, + ) + } + Divider() + } + } + } + } + }, + confirmButton = { + TextButton( + enabled = !playerSaving, + onClick = { + scope.launch { + playerSaving = true + playerError = null + runCatching { + dependencies.scheduleManager.updateMatchPlayers( + clubId = clubId, + matchId = m.id, + ready = readyIds, + planned = plannedIds, + played = playedIds, + ) + playerMatch = null + }.onFailure { playerError = it.message ?: tr("schedule.errorSavingPlayerSelection", "Speichern fehlgeschlagen") } + playerSaving = false + } + }, + ) { Text(tr("common.save", "Speichern")) } + }, + dismissButton = { + TextButton(enabled = !playerSaving, onClick = { playerMatch = null }) { + Text(tr("common.cancel", "Abbrechen")) + } + }, + ) + } +} + +@Composable +private fun ScheduleScopeChip(label: String, selected: Boolean, onClick: () -> Unit) { + OutlinedButton( + onClick = onClick, + modifier = Modifier.heightIn(min = 40.dp), + ) { + Text(label, fontWeight = if (selected) FontWeight.Bold else FontWeight.Normal, maxLines = 1) + } +} + +@Composable +private fun ScheduleMatchCard( + match: ScheduleMatchDto, + highlightClubName: String, + showLeagueColumn: Boolean, + onClick: () -> Unit, +) { + val homeH = highlightClubName.isNotBlank() && match.homeTeam?.name?.contains(highlightClubName) == true + val guestH = highlightClubName.isNotBlank() && match.guestTeam?.name?.contains(highlightClubName) == true + Card( + modifier = Modifier + .fillMaxWidth() + .clickable(onClick = onClick), + elevation = 1.dp, + ) { + Column(modifier = Modifier.padding(12.dp)) { + Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) { + Text( + TrainingStatsDerived.formatDateGerman(match.date), + fontWeight = FontWeight.SemiBold, + ) + Text(match.time?.take(5) ?: "—", style = MaterialTheme.typography.caption) + } + Text( + "${match.homeTeam?.name ?: "?"} : ${match.guestTeam?.name ?: "?"}", + color = when { + homeH || guestH -> MaterialTheme.colors.primary + else -> MaterialTheme.colors.onSurface + }, + ) + if (showLeagueColumn) { + Text(match.leagueDetails?.name ?: "", style = MaterialTheme.typography.caption) + } + Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) { + if (match.isCompleted) { + Text("${match.homeMatchPoints}:${match.guestMatchPoints}", fontWeight = FontWeight.Bold) + } else { + Text("—", color = MaterialTheme.colors.onSurface.copy(alpha = 0.5f)) + } + } + } + } +} diff --git a/mobile-app/composeApp/src/androidMain/kotlin/de/tt_tagebuch/app/ui/TournamentsScreen.kt b/mobile-app/composeApp/src/androidMain/kotlin/de/tt_tagebuch/app/ui/TournamentsScreen.kt new file mode 100644 index 00000000..aa564dcc --- /dev/null +++ b/mobile-app/composeApp/src/androidMain/kotlin/de/tt_tagebuch/app/ui/TournamentsScreen.kt @@ -0,0 +1,295 @@ +package de.tt_tagebuch.app.ui + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material.Card +import androidx.compose.material.CircularProgressIndicator +import androidx.compose.material.Divider +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Text +import androidx.compose.material.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import de.tt_tagebuch.app.AppDependencies +import de.tt_tagebuch.shared.api.models.OfficialParticipationEntryDto +import de.tt_tagebuch.shared.api.models.canReadTournaments +import de.tt_tagebuch.shared.i18n.MobileStrings +import de.tt_tagebuch.shared.state.ClubTournamentDisplayFilter + +private data class ParticipationFlatRow( + val tournamentId: String?, + val tournamentTitle: String?, + val entry: OfficialParticipationEntryDto, +) + +private val TournamentsPad = 20.dp +private val TournamentsTouchMin = 48.dp + +@Composable +internal fun TournamentsScreen(dependencies: AppDependencies) { + val languageCode = LocalLanguageCode.current + fun tr(key: String, fb: String) = MobileStrings.get(languageCode, key, fb) + val clubState by dependencies.clubManager.state.collectAsState() + val clubId = clubState.currentClubId ?: return + val perms = clubState.currentPermissions + val internalState by dependencies.clubInternalTournamentsManager.state.collectAsState() + val officialState by dependencies.officialTournamentsReadManager.state.collectAsState() + + if (perms?.canReadTournaments() != true) { + Column( + modifier = Modifier + .fillMaxSize() + .padding(TournamentsPad), + ) { + Text(tr("mobile.noTournamentAccess", "Keine Berechtigung für Turniere.")) + } + return + } + + LaunchedEffect(clubId, internalState.filter) { + dependencies.clubInternalTournamentsManager.loadList(clubId) + } + + LaunchedEffect(clubId, internalState.selectedId) { + val id = internalState.selectedId ?: return@LaunchedEffect + dependencies.clubInternalTournamentsManager.loadDetail(clubId, id) + } + + LaunchedEffect(clubId) { + dependencies.officialTournamentsReadManager.load(clubId) + } + + val participationFlatRows = remember(officialState.participationBuckets) { + officialState.participationBuckets.flatMap { bucket -> + bucket.entries.map { entry -> + ParticipationFlatRow(bucket.tournamentId, bucket.title, entry) + } + } + } + + LazyColumn( + modifier = Modifier + .fillMaxSize() + .padding(horizontal = TournamentsPad, vertical = 16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + item { + Text( + tr("navigation.clubTournaments", "Turniere"), + style = MaterialTheme.typography.h5, + fontWeight = FontWeight.SemiBold, + ) + Text( + tr("mobile.tournamentsHubHint", "Vereins-Turniere und offizielle Meldelisten. Verwaltung im Browser."), + style = MaterialTheme.typography.caption, + color = MaterialTheme.colors.onSurface.copy(alpha = 0.72f), + modifier = Modifier.padding(top = 4.dp, bottom = 8.dp), + ) + TextButton( + onClick = { dependencies.openBackendPath("/tournaments") }, + modifier = Modifier.fillMaxWidth().heightIn(min = TournamentsTouchMin), + ) { + Text(tr("mobile.openTournamentsInWeb", "Turniere im Browser öffnen")) + } + } + + item { + Text(tr("tournaments.internalTournaments", "Vereins-Turniere"), fontWeight = FontWeight.SemiBold) + Column(modifier = Modifier.fillMaxWidth().padding(top = 8.dp)) { + ModeFilterChip( + label = tr("mobile.tournamentFilterInternal", "Intern"), + selected = internalState.filter == ClubTournamentDisplayFilter.Internal, + onClick = { dependencies.clubInternalTournamentsManager.setFilter(ClubTournamentDisplayFilter.Internal) }, + ) + ModeFilterChip( + label = tr("tournaments.openTournaments", "Offen"), + selected = internalState.filter == ClubTournamentDisplayFilter.External, + onClick = { dependencies.clubInternalTournamentsManager.setFilter(ClubTournamentDisplayFilter.External) }, + ) + ModeFilterChip( + label = tr("tournaments.miniChampionships", "Mini"), + selected = internalState.filter == ClubTournamentDisplayFilter.Mini, + onClick = { dependencies.clubInternalTournamentsManager.setFilter(ClubTournamentDisplayFilter.Mini) }, + ) + } + } + + if (internalState.isLoadingList) { + item { CircularProgressIndicator(modifier = Modifier.padding(vertical = 16.dp)) } + } else { + internalState.error?.let { err -> + item { + Text(err, color = MaterialTheme.colors.error) + } + } + if (internalState.tournaments.isEmpty()) { + item { + Text( + tr("mobile.noClubTournaments", "Keine Turniere in dieser Ansicht."), + style = MaterialTheme.typography.body2, + modifier = Modifier.padding(vertical = 8.dp), + ) + } + } else { + items(internalState.tournaments, key = { it.id }) { t -> + val selected = internalState.selectedId == t.id + Card( + modifier = Modifier.fillMaxWidth(), + elevation = if (selected) 2.dp else 1.dp, + backgroundColor = if (selected) { + MaterialTheme.colors.primary.copy(alpha = 0.08f) + } else { + MaterialTheme.colors.surface + }, + ) { + TextButton( + onClick = { + dependencies.clubInternalTournamentsManager.selectTournament( + if (selected) null else t.id, + ) + }, + modifier = Modifier.fillMaxWidth().heightIn(min = TournamentsTouchMin), + ) { + Column(modifier = Modifier.fillMaxWidth(), horizontalAlignment = Alignment.Start) { + Text(t.name ?: "Turnier #${t.id}", fontWeight = FontWeight.SemiBold) + t.date?.let { d -> Text(d, style = MaterialTheme.typography.caption) } + } + } + } + } + } + } + + if (internalState.selectedId != null) { + item { + Divider(modifier = Modifier.padding(vertical = 8.dp)) + Text(tr("mobile.tournamentDetails", "Details"), fontWeight = FontWeight.SemiBold) + when { + internalState.isLoadingDetail -> CircularProgressIndicator(modifier = Modifier.padding(8.dp)) + internalState.detail != null -> { + val d = internalState.detail!! + Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { + d.name?.let { Text(it, fontWeight = FontWeight.Medium) } + d.date?.let { Text("${tr("tournaments.date", "Datum")}: $it") } + d.type?.takeIf { it.isNotBlank() }?.let { Text("${tr("mobile.mode", "Modus")}: $it") } + d.winningSets?.let { Text("${tr("tournaments.winningSets", "Gewinnsätze")}: $it") } + d.numberOfGroups?.let { Text("${tr("mobile.groups", "Gruppen")}: $it") } + d.numberOfTables?.let { Text("${tr("mobile.tables", "Tische")}: $it") } + if (d.miniChampionshipYear != null) { + Text("${tr("tournaments.miniChampionshipYear", "Minimeisterschaft-Jahr")}: ${d.miniChampionshipYear}") + } + if (d.allowsExternal == true) { + Text(tr("tournaments.openTournaments", "Offenes Turnier")) + } + if (d.isDoublesTournament == true) { + Text(tr("mobile.doublesTournament", "Doppel-Turnier")) + } + } + } + else -> Text(tr("mobile.tournamentDetailPending", "Details werden geladen …")) + } + } + } + + item { + Spacer(modifier = Modifier.height(16.dp)) + Text(tr("officialTournaments.savedEvents", "Offizielle Turniere (Import)"), fontWeight = FontWeight.SemiBold) + } + + if (officialState.isLoading) { + item { CircularProgressIndicator(modifier = Modifier.padding(vertical = 8.dp)) } + } else { + officialState.error?.let { err -> + item { Text(err, color = MaterialTheme.colors.error) } + } + if (officialState.tournaments.isEmpty()) { + item { + Text( + tr("officialTournaments.noEvents", "Keine importierten Turniere."), + style = MaterialTheme.typography.body2, + ) + } + } else { + items(officialState.tournaments, key = { it.id }) { ot -> + Column(modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp)) { + Text(ot.title ?: "Turnier #${ot.id}", fontWeight = FontWeight.Medium) + ot.eventDate?.takeIf { it.isNotBlank() }?.let { + Text(it, style = MaterialTheme.typography.caption) + } + } + Divider() + } + } + } + + item { + Spacer(modifier = Modifier.height(12.dp)) + Text(tr("officialTournaments.participations", "Teilnahmen (übersicht)"), fontWeight = FontWeight.SemiBold) + } + + if (participationFlatRows.isEmpty() && !officialState.isLoading) { + item { + Text( + tr("mobile.noOfficialParticipations", "Keine erfassten Teilnahmen."), + style = MaterialTheme.typography.body2, + ) + } + } else { + items( + participationFlatRows, + key = { r -> "${r.tournamentId}_${r.entry.memberId}_${r.entry.competitionId}_${r.entry.date}_${r.entry.competitionName}" }, + ) { r -> + ParticipationRow(tournamentTitle = r.tournamentTitle, entry = r.entry) + } + } + } +} + +@Composable +@Composable +private fun ModeFilterChip(label: String, selected: Boolean, onClick: () -> Unit) { + TextButton( + onClick = onClick, + modifier = Modifier.heightIn(min = TournamentsTouchMin), + ) { + Text( + label, + fontWeight = if (selected) FontWeight.Bold else FontWeight.Normal, + color = if (selected) MaterialTheme.colors.primary else MaterialTheme.colors.onSurface, + ) + } +} + +@Composable +private fun ParticipationRow(tournamentTitle: String?, entry: OfficialParticipationEntryDto) { + Column(modifier = Modifier.fillMaxWidth().padding(vertical = 6.dp)) { + Text( + listOfNotNull(entry.memberName, tournamentTitle).joinToString(" · "), + style = MaterialTheme.typography.body2, + fontWeight = FontWeight.Medium, + ) + Text( + listOfNotNull(entry.competitionName, entry.date, entry.placement?.let { p -> "Pl. $p" }) + .filter { it.isNotBlank() } + .joinToString(" · "), + style = MaterialTheme.typography.caption, + color = MaterialTheme.colors.onSurface.copy(alpha = 0.75f), + ) + } + Divider() +} diff --git a/mobile-app/shared/src/commonMain/kotlin/de/tt_tagebuch/shared/api/ApiLogsApi.kt b/mobile-app/shared/src/commonMain/kotlin/de/tt_tagebuch/shared/api/ApiLogsApi.kt new file mode 100644 index 00000000..72bd555b --- /dev/null +++ b/mobile-app/shared/src/commonMain/kotlin/de/tt_tagebuch/shared/api/ApiLogsApi.kt @@ -0,0 +1,38 @@ +package de.tt_tagebuch.shared.api + +import de.tt_tagebuch.shared.api.http.AuthedHttpClient +import de.tt_tagebuch.shared.api.models.ApiLogDetailDto +import de.tt_tagebuch.shared.api.models.ApiLogDetailEnvelopeDto +import de.tt_tagebuch.shared.api.models.ApiLogsListEnvelopeDto +import de.tt_tagebuch.shared.api.models.ApiLogsListPageDto +import io.ktor.client.call.body +import io.ktor.client.request.get +import io.ktor.client.request.parameter + +class ApiLogsApi( + private val client: AuthedHttpClient, +) { + suspend fun listLogs( + limit: Int = 50, + offset: Int = 0, + logType: String? = null, + method: String? = null, + statusCode: Int? = null, + pathContains: String? = null, + ): ApiLogsListPageDto { + val env = client.http.get("/api/logs") { + parameter("limit", limit) + parameter("offset", offset) + logType?.takeIf { it.isNotBlank() }?.let { parameter("logType", it) } + method?.takeIf { it.isNotBlank() }?.let { parameter("method", it) } + statusCode?.let { parameter("statusCode", it) } + pathContains?.takeIf { it.isNotBlank() }?.let { parameter("path", it) } + }.body() + return env.data ?: ApiLogsListPageDto() + } + + suspend fun getLog(id: Int): ApiLogDetailDto? { + val env = client.http.get("/api/logs/$id").body() + return env.data + } +} diff --git a/mobile-app/shared/src/commonMain/kotlin/de/tt_tagebuch/shared/api/ClubApprovalsApi.kt b/mobile-app/shared/src/commonMain/kotlin/de/tt_tagebuch/shared/api/ClubApprovalsApi.kt new file mode 100644 index 00000000..1aeacba3 --- /dev/null +++ b/mobile-app/shared/src/commonMain/kotlin/de/tt_tagebuch/shared/api/ClubApprovalsApi.kt @@ -0,0 +1,25 @@ +package de.tt_tagebuch.shared.api + +import de.tt_tagebuch.shared.api.http.AuthedHttpClient +import de.tt_tagebuch.shared.api.models.ClubAccessDecisionBody +import de.tt_tagebuch.shared.api.models.PendingUserClubJoinDto +import io.ktor.client.call.body +import io.ktor.client.request.get +import io.ktor.client.request.post +import io.ktor.client.request.setBody + +class ClubApprovalsApi( + private val client: AuthedHttpClient, +) { + suspend fun listPending(clubId: Int): List { + return client.http.get("/api/clubs/pending/$clubId").body() + } + + suspend fun approve(body: ClubAccessDecisionBody) { + client.http.post("/api/clubs/approve") { setBody(body) } + } + + suspend fun reject(body: ClubAccessDecisionBody) { + client.http.post("/api/clubs/reject") { setBody(body) } + } +} diff --git a/mobile-app/shared/src/commonMain/kotlin/de/tt_tagebuch/shared/api/ClubTeamsApi.kt b/mobile-app/shared/src/commonMain/kotlin/de/tt_tagebuch/shared/api/ClubTeamsApi.kt new file mode 100644 index 00000000..8f4e470e --- /dev/null +++ b/mobile-app/shared/src/commonMain/kotlin/de/tt_tagebuch/shared/api/ClubTeamsApi.kt @@ -0,0 +1,17 @@ +package de.tt_tagebuch.shared.api + +import de.tt_tagebuch.shared.api.http.AuthedHttpClient +import de.tt_tagebuch.shared.api.models.ClubTeamDto +import io.ktor.client.call.body +import io.ktor.client.request.get +import io.ktor.client.request.parameter + +class ClubTeamsApi( + private val client: AuthedHttpClient, +) { + suspend fun listClubTeams(clubId: Int, seasonId: Int? = null): List { + return client.http.get("/api/club-teams/club/$clubId") { + seasonId?.let { parameter("seasonid", it) } + }.body() + } +} diff --git a/mobile-app/shared/src/commonMain/kotlin/de/tt_tagebuch/shared/api/MatchesApi.kt b/mobile-app/shared/src/commonMain/kotlin/de/tt_tagebuch/shared/api/MatchesApi.kt new file mode 100644 index 00000000..9668f724 --- /dev/null +++ b/mobile-app/shared/src/commonMain/kotlin/de/tt_tagebuch/shared/api/MatchesApi.kt @@ -0,0 +1,37 @@ +package de.tt_tagebuch.shared.api + +import de.tt_tagebuch.shared.api.http.AuthedHttpClient +import de.tt_tagebuch.shared.api.models.LeagueTableRowDto +import de.tt_tagebuch.shared.api.models.ScheduleMatchDto +import de.tt_tagebuch.shared.api.models.UpdateMatchPlayersBody +import io.ktor.client.call.body +import io.ktor.client.request.get +import io.ktor.client.request.parameter +import io.ktor.client.request.patch +import io.ktor.client.request.setBody + +class MatchesApi( + private val client: AuthedHttpClient, +) { + suspend fun listMatchesForLeagues(clubId: Int, seasonId: Int? = null): List { + return client.http.get("/api/matches/leagues/$clubId/matches") { + seasonId?.let { parameter("seasonid", it) } + }.body() + } + + suspend fun listMatchesForLeague(clubId: Int, leagueId: Int, scope: String = "own"): List { + return client.http.get("/api/matches/leagues/$clubId/matches/$leagueId") { + parameter("scope", scope) + }.body() + } + + suspend fun leagueTable(clubId: Int, leagueId: Int): List { + return client.http.get("/api/matches/leagues/$clubId/table/$leagueId").body() + } + + suspend fun updateMatchPlayers(matchId: Int, body: UpdateMatchPlayersBody) { + client.http.patch("/api/matches/$matchId/players") { + setBody(body) + } + } +} diff --git a/mobile-app/shared/src/commonMain/kotlin/de/tt_tagebuch/shared/api/OfficialTournamentsApi.kt b/mobile-app/shared/src/commonMain/kotlin/de/tt_tagebuch/shared/api/OfficialTournamentsApi.kt new file mode 100644 index 00000000..461187e8 --- /dev/null +++ b/mobile-app/shared/src/commonMain/kotlin/de/tt_tagebuch/shared/api/OfficialTournamentsApi.kt @@ -0,0 +1,19 @@ +package de.tt_tagebuch.shared.api + +import de.tt_tagebuch.shared.api.http.AuthedHttpClient +import de.tt_tagebuch.shared.api.models.OfficialParticipationBucketDto +import de.tt_tagebuch.shared.api.models.OfficialTournamentListRowDto +import io.ktor.client.call.body +import io.ktor.client.request.get + +class OfficialTournamentsApi( + private val client: AuthedHttpClient, +) { + suspend fun listForClub(clubId: Int): List { + return client.http.get("/api/official-tournaments/$clubId").body() + } + + suspend fun listParticipationSummary(clubId: Int): List { + return client.http.get("/api/official-tournaments/$clubId/participations/summary").body() + } +} diff --git a/mobile-app/shared/src/commonMain/kotlin/de/tt_tagebuch/shared/api/PermissionsApi.kt b/mobile-app/shared/src/commonMain/kotlin/de/tt_tagebuch/shared/api/PermissionsApi.kt index 519a4db1..4db4331e 100644 --- a/mobile-app/shared/src/commonMain/kotlin/de/tt_tagebuch/shared/api/PermissionsApi.kt +++ b/mobile-app/shared/src/commonMain/kotlin/de/tt_tagebuch/shared/api/PermissionsApi.kt @@ -1,9 +1,18 @@ package de.tt_tagebuch.shared.api import de.tt_tagebuch.shared.api.http.AuthedHttpClient +import de.tt_tagebuch.shared.api.models.AvailableRoleDto +import de.tt_tagebuch.shared.api.models.ClubPermissionMemberDto +import de.tt_tagebuch.shared.api.models.PermissionResourceDto +import de.tt_tagebuch.shared.api.models.UpdateUserApprovedBody +import de.tt_tagebuch.shared.api.models.UpdateUserCustomPermissionsBody +import de.tt_tagebuch.shared.api.models.UpdateUserRoleBody import de.tt_tagebuch.shared.api.models.UserClubPermissions import io.ktor.client.call.body import io.ktor.client.request.get +import io.ktor.client.request.parameter +import io.ktor.client.request.put +import io.ktor.client.request.setBody class PermissionsApi( private val client: AuthedHttpClient, @@ -11,5 +20,37 @@ class PermissionsApi( suspend fun getUserPermissions(clubId: Int): UserClubPermissions { return client.http.get("/api/permissions/$clubId").body() } + + suspend fun listAvailableRoles(): List { + return client.http.get("/api/permissions/roles/available").body() + } + + suspend fun getPermissionStructure(): Map { + return client.http.get("/api/permissions/structure/all").body() + } + + suspend fun listClubMembersWithPermissions(clubId: Int, cacheBust: Boolean = false): List { + return client.http.get("/api/permissions/$clubId/members") { + if (cacheBust) parameter("t", kotlin.random.Random.nextLong().toString()) + }.body() + } + + suspend fun updateUserRole(clubId: Int, userId: Int, role: String) { + client.http.put("/api/permissions/$clubId/user/$userId/role") { + setBody(UpdateUserRoleBody(role = role)) + } + } + + suspend fun updateUserStatus(clubId: Int, userId: Int, approved: Boolean) { + client.http.put("/api/permissions/$clubId/user/$userId/status") { + setBody(UpdateUserApprovedBody(approved = approved)) + } + } + + suspend fun updateUserCustomPermissions(clubId: Int, userId: Int, permissions: kotlinx.serialization.json.JsonObject) { + client.http.put("/api/permissions/$clubId/user/$userId/permissions") { + setBody(UpdateUserCustomPermissionsBody(permissions = permissions)) + } + } } diff --git a/mobile-app/shared/src/commonMain/kotlin/de/tt_tagebuch/shared/api/RolePermissionMatrix.kt b/mobile-app/shared/src/commonMain/kotlin/de/tt_tagebuch/shared/api/RolePermissionMatrix.kt new file mode 100644 index 00000000..7252f708 --- /dev/null +++ b/mobile-app/shared/src/commonMain/kotlin/de/tt_tagebuch/shared/api/RolePermissionMatrix.kt @@ -0,0 +1,122 @@ +package de.tt_tagebuch.shared.api + +/** + * Rollen-Standardrechte (analog [backend/services/permissionService.js] ROLE_PERMISSIONS) + * für die mobile Berechtigungs-UI (Erbe / explizit erlauben / explizit verbieten). + */ +object RolePermissionMatrix { + + private val admin = resourceMap( + diary = triple(true, true, true), + members = triple(true, true, true), + teams = triple(true, true, true), + schedule = triple(true, true, true), + tournaments = triple(true, true, true), + statistics = pair(true, true), + settings = pair(true, true), + permissions = pair(true, true), + approvals = pair(true, true), + mytischtennis_admin = pair(true, true), + predefined_activities = triple(true, true, true), + ) + + private val trainer = resourceMap( + diary = triple(true, true, true), + members = triple(true, true, false), + teams = triple(true, true, false), + schedule = triple(true, false, false), + tournaments = triple(true, true, false), + statistics = pair(true, false), + settings = pair(false, false), + permissions = pair(false, false), + approvals = pair(false, false), + mytischtennis_admin = pair(false, false), + predefined_activities = triple(true, true, true), + ) + + private val teamManager = resourceMap( + diary = triple(false, false, false), + members = triple(true, false, false), + teams = triple(true, true, false), + schedule = triple(true, true, false), + tournaments = triple(true, false, false), + statistics = pair(true, false), + settings = pair(false, false), + permissions = pair(false, false), + approvals = pair(false, false), + mytischtennis_admin = pair(false, false), + predefined_activities = triple(false, false, false), + ) + + private val tournamentManager = resourceMap( + diary = triple(false, false, false), + members = triple(true, false, false), + teams = triple(false, false, false), + schedule = triple(false, false, false), + tournaments = triple(true, true, false), + statistics = pair(true, false), + settings = pair(false, false), + permissions = pair(false, false), + approvals = pair(false, false), + mytischtennis_admin = pair(false, false), + predefined_activities = triple(false, false, false), + ) + + private val member = resourceMap( + diary = triple(false, false, false), + members = triple(false, false, false), + teams = triple(false, false, false), + schedule = triple(false, false, false), + tournaments = triple(false, false, false), + statistics = pair(true, false), + settings = pair(false, false), + permissions = pair(false, false), + approvals = pair(false, false), + mytischtennis_admin = pair(false, false), + predefined_activities = triple(false, false, false), + ) + + private fun triple(r: Boolean, w: Boolean, d: Boolean): Map = + mapOf("read" to r, "write" to w, "delete" to d) + + private fun pair(r: Boolean, w: Boolean): Map = + mapOf("read" to r, "write" to w) + + private fun resourceMap( + diary: Map, + members: Map, + teams: Map, + schedule: Map, + tournaments: Map, + statistics: Map, + settings: Map, + permissions: Map, + approvals: Map, + mytischtennis_admin: Map, + predefined_activities: Map, + ): Map> = mapOf( + "diary" to diary, + "members" to members, + "teams" to teams, + "schedule" to schedule, + "tournaments" to tournaments, + "statistics" to statistics, + "settings" to settings, + "permissions" to permissions, + "approvals" to approvals, + "mytischtennis_admin" to mytischtennis_admin, + "predefined_activities" to predefined_activities, + ) + + fun defaultsForRole(role: String): Map> = + when (role) { + "admin" -> admin + "trainer" -> trainer + "team_manager" -> teamManager + "tournament_manager" -> tournamentManager + else -> member + } + + fun defaultAction(role: String, resource: String, action: String): Boolean = + defaultsForRole(role)[resource]?.get(action) ?: false +} diff --git a/mobile-app/shared/src/commonMain/kotlin/de/tt_tagebuch/shared/api/ScheduleLogic.kt b/mobile-app/shared/src/commonMain/kotlin/de/tt_tagebuch/shared/api/ScheduleLogic.kt new file mode 100644 index 00000000..c99bfae9 --- /dev/null +++ b/mobile-app/shared/src/commonMain/kotlin/de/tt_tagebuch/shared/api/ScheduleLogic.kt @@ -0,0 +1,77 @@ +package de.tt_tagebuch.shared.api + +import de.tt_tagebuch.shared.api.models.ClubTeamDto +import de.tt_tagebuch.shared.api.models.ScheduleMatchDto +import de.tt_tagebuch.shared.api.models.ScheduleMatchScope + +object ScheduleLogic { + + fun sortClubTeams(teams: List): List = + teams.sortedWith(compareBy({ it.league?.name ?: "" }, { it.name })) + + fun sortMatches(matches: List): List = + matches.sortedWith( + compareBy( + { it.date ?: "" }, + { it.time ?: "" }, + { it.homeTeam?.name ?: "" }, + { it.guestTeam?.name ?: "" }, + ), + ) + + fun mergeUniqueMatches(a: List, b: List): List { + val seen = LinkedHashSet() + val out = ArrayList(a.size + b.size) + for (m in a + b) { + if (seen.add(m.id)) out.add(m) + } + return out + } + + fun leagueTeamNames(matches: List): List { + val names = LinkedHashSet() + for (m in matches) { + m.homeTeam?.name?.takeIf { it.isNotBlank() }?.let(names::add) + m.guestTeam?.name?.takeIf { it.isNotBlank() }?.let(names::add) + } + return names.sorted() + } + + fun filterAdultLeagues(matches: List): List { + val youth = Regex("""[JM]\d|jugend""", RegexOption.IGNORE_CASE) + return matches.filter { m -> + val leagueName = m.leagueDetails?.name ?: "" + !youth.containsMatchIn(leagueName) + } + } + + /** + * @param ownTeamName Name des gewählten Vereins-Teams (ClubTeam), wie im Web `selectedTeam.name`. + */ + fun applyTeamMatchScope( + ownMatches: List, + allMatches: List, + scope: ScheduleMatchScope, + ownTeamName: String, + otherTeamName: String, + ): List { + val combined = sortMatches(mergeUniqueMatches(allMatches, ownMatches)) + return when (scope) { + ScheduleMatchScope.All -> combined + ScheduleMatchScope.Other -> { + if (otherTeamName.isBlank()) emptyList() + else combined.filter { m -> + m.homeTeam?.name == otherTeamName || m.guestTeam?.name == otherTeamName + } + } + ScheduleMatchScope.Own -> + if (ownMatches.isNotEmpty()) sortMatches(ownMatches) + else combined.filter { m -> + m.homeTeam?.name == ownTeamName || m.guestTeam?.name == ownTeamName + } + } + } + + fun teamsWithLeague(teams: List): List = + teams.filter { it.league != null && (it.league?.id ?: 0) > 0 } +} diff --git a/mobile-app/shared/src/commonMain/kotlin/de/tt_tagebuch/shared/api/TournamentsApi.kt b/mobile-app/shared/src/commonMain/kotlin/de/tt_tagebuch/shared/api/TournamentsApi.kt new file mode 100644 index 00000000..6888c82b --- /dev/null +++ b/mobile-app/shared/src/commonMain/kotlin/de/tt_tagebuch/shared/api/TournamentsApi.kt @@ -0,0 +1,23 @@ +package de.tt_tagebuch.shared.api + +import de.tt_tagebuch.shared.api.http.AuthedHttpClient +import de.tt_tagebuch.shared.api.models.InternalTournamentDetailDto +import de.tt_tagebuch.shared.api.models.InternalTournamentSummaryDto +import io.ktor.client.call.body +import io.ktor.client.request.get +import io.ktor.client.request.parameter + +class TournamentsApi( + private val client: AuthedHttpClient, +) { + /** Query [type]: `mini` nur Minimeisterschaften; sonst alle (clientseitig nach intern/offen filtern). */ + suspend fun listTournaments(clubId: Int, type: String? = null): List { + return client.http.get("/api/tournament/$clubId") { + type?.takeIf { it.isNotBlank() }?.let { parameter("type", it) } + }.body() + } + + suspend fun getTournament(clubId: Int, tournamentId: Int): InternalTournamentDetailDto { + return client.http.get("/api/tournament/$clubId/$tournamentId").body() + } +} diff --git a/mobile-app/shared/src/commonMain/kotlin/de/tt_tagebuch/shared/api/models/ClubAdmin.kt b/mobile-app/shared/src/commonMain/kotlin/de/tt_tagebuch/shared/api/models/ClubAdmin.kt new file mode 100644 index 00000000..965f0b74 --- /dev/null +++ b/mobile-app/shared/src/commonMain/kotlin/de/tt_tagebuch/shared/api/models/ClubAdmin.kt @@ -0,0 +1,121 @@ +package de.tt_tagebuch.shared.api.models + +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.JsonObject + +@Serializable +data class PendingUserDto( + val id: Int = 0, + val email: String = "", + val firstName: String? = null, + val lastName: String? = null, +) + +@Serializable +data class PendingUserClubJoinDto( + val id: Int? = null, + val userId: Int = 0, + val clubId: Int? = null, + val user: PendingUserDto? = null, +) + +@Serializable +data class ClubAccessDecisionBody( + val clubid: Int, + val userid: Int, +) + +@Serializable +data class AvailableRoleDto( + val value: String, + val label: String, + val description: String = "", +) + +@Serializable +data class PermissionResourceDto( + val label: String = "", + val actions: List = emptyList(), +) + +@Serializable +data class PermissionUserRefDto( + val id: Int = 0, + val email: String = "", +) + +@Serializable +data class ClubPermissionMemberDto( + val userId: Int, + val user: PermissionUserRefDto? = null, + val role: String = "", + val isOwner: Boolean = false, + val approved: Boolean? = true, + val permissions: JsonObject? = null, + val effectivePermissions: JsonObject? = null, +) + +@Serializable +data class UpdateUserRoleBody( + val role: String, +) + +@Serializable +data class UpdateUserApprovedBody( + val approved: Boolean, +) + +@Serializable +data class UpdateUserCustomPermissionsBody( + val permissions: JsonObject, +) + +@Serializable +data class ApiLogListRowDto( + val id: Int, + val userId: Int? = null, + val method: String? = null, + val path: String? = null, + val statusCode: Int? = null, + val executionTime: Int? = null, + val errorMessage: String? = null, + val logType: String? = null, + val schedulerJobType: String? = null, + val createdAt: String? = null, +) + +@Serializable +data class ApiLogsListPageDto( + val logs: List = emptyList(), + val total: Int = 0, + val limit: Int = 0, + val offset: Int = 0, +) + +@Serializable +data class ApiLogsListEnvelopeDto( + val success: Boolean = false, + val data: ApiLogsListPageDto? = null, +) + +@Serializable +data class ApiLogDetailEnvelopeDto( + val success: Boolean = false, + val data: ApiLogDetailDto? = null, +) + +@Serializable +data class ApiLogDetailDto( + val id: Int = 0, + val userId: Int? = null, + val method: String? = null, + val path: String? = null, + val statusCode: Int? = null, + val executionTime: Int? = null, + val errorMessage: String? = null, + val logType: String? = null, + val schedulerJobType: String? = null, + val createdAt: String? = null, + val ipAddress: String? = null, + val userAgent: String? = null, +) diff --git a/mobile-app/shared/src/commonMain/kotlin/de/tt_tagebuch/shared/api/models/ClubPermissionHelpers.kt b/mobile-app/shared/src/commonMain/kotlin/de/tt_tagebuch/shared/api/models/ClubPermissionHelpers.kt index bd71e7af..ddc8d394 100644 --- a/mobile-app/shared/src/commonMain/kotlin/de/tt_tagebuch/shared/api/models/ClubPermissionHelpers.kt +++ b/mobile-app/shared/src/commonMain/kotlin/de/tt_tagebuch/shared/api/models/ClubPermissionHelpers.kt @@ -30,3 +30,54 @@ fun UserClubPermissions.canWriteMembers(): Boolean { if (isOwner) return true return permissions.boolAt("members", "write") } + +fun UserClubPermissions.canReadSchedule(): Boolean { + if (isOwner) return true + return permissions.boolAt("schedule", "read") +} + +fun UserClubPermissions.canWriteSchedule(): Boolean { + if (isOwner) return true + return permissions.boolAt("schedule", "write") +} + +fun UserClubPermissions.canReadApprovals(): Boolean { + if (isOwner) return true + return permissions.boolAt("approvals", "read") +} + +fun UserClubPermissions.canWriteApprovals(): Boolean { + if (isOwner) return true + return permissions.boolAt("approvals", "write") +} + +/** Lesen der Berechtigungsverwaltung (Modul `permissions` im Backend). */ +fun UserClubPermissions.canReadClubPermissions(): Boolean { + if (isOwner) return true + return permissions.boolAt("permissions", "read") +} + +fun UserClubPermissions.canWriteClubPermissions(): Boolean { + if (isOwner) return true + return permissions.boolAt("permissions", "write") +} + +fun UserClubPermissions.canReadTeams(): Boolean { + if (isOwner) return true + return permissions.boolAt("teams", "read") +} + +fun UserClubPermissions.canWriteTeams(): Boolean { + if (isOwner) return true + return permissions.boolAt("teams", "write") +} + +fun UserClubPermissions.canReadTournaments(): Boolean { + if (isOwner) return true + return permissions.boolAt("tournaments", "read") +} + +fun UserClubPermissions.canWriteTournaments(): Boolean { + if (isOwner) return true + return permissions.boolAt("tournaments", "write") +} diff --git a/mobile-app/shared/src/commonMain/kotlin/de/tt_tagebuch/shared/api/models/Schedule.kt b/mobile-app/shared/src/commonMain/kotlin/de/tt_tagebuch/shared/api/models/Schedule.kt new file mode 100644 index 00000000..321e741e --- /dev/null +++ b/mobile-app/shared/src/commonMain/kotlin/de/tt_tagebuch/shared/api/models/Schedule.kt @@ -0,0 +1,107 @@ +package de.tt_tagebuch.shared.api.models + +import kotlinx.serialization.Serializable + +@Serializable +data class ClubTeamLeagueDto( + val id: Int = 0, + val name: String = "", + val myTischtennisGroupId: String? = null, + val association: String? = null, + val groupname: String? = null, +) + +@Serializable +data class ClubTeamSeasonDto( + val season: String = "", +) + +@Serializable +data class ClubTeamDto( + val id: Int, + val name: String = "", + val clubId: Int = 0, + val leagueId: Int? = null, + val seasonId: Int? = null, + val myTischtennisTeamId: String? = null, + val teamGender: String? = null, + val teamAgeGroup: String? = null, + val plannedLeagueName: String? = null, + val league: ClubTeamLeagueDto? = null, + val season: ClubTeamSeasonDto? = null, +) + +@Serializable +data class ScheduleTeamNameDto( + val name: String = "", +) + +@Serializable +data class ScheduleLocationDto( + val name: String = "", + val address: String = "", + val city: String = "", + val zip: String = "", +) + +@Serializable +data class ScheduleLeagueDetailsDto( + val name: String = "", +) + +@Serializable +data class ScheduleMatchDto( + val id: Int, + val date: String? = null, + val time: String? = null, + val homeTeamId: Int? = null, + val guestTeamId: Int? = null, + val locationId: Int? = null, + val leagueId: Int? = null, + val code: String? = null, + val homePin: String? = null, + val guestPin: String? = null, + val homeMatchPoints: Int = 0, + val guestMatchPoints: Int = 0, + val isCompleted: Boolean = false, + val pdfUrl: String? = null, + val playersReady: List = emptyList(), + val playersPlanned: List = emptyList(), + val playersPlayed: List = emptyList(), + val homeTeam: ScheduleTeamNameDto? = null, + val guestTeam: ScheduleTeamNameDto? = null, + val location: ScheduleLocationDto? = null, + val leagueDetails: ScheduleLeagueDetailsDto? = null, +) + +@Serializable +data class LeagueTableRowDto( + val teamId: Int, + val teamName: String = "", + val setsWon: Int = 0, + val setsLost: Int = 0, + /** z. B. \"3:1\" */ + val matchPoints: String = "", + val tablePoints: String = "", + val pointRatio: String = "", +) + +@Serializable +data class UpdateMatchPlayersBody( + val clubId: Int, + val playersReady: List = emptyList(), + val playersPlanned: List = emptyList(), + val playersPlayed: List = emptyList(), +) + +enum class ScheduleMatchScope { + Own, + All, + Other, +} + +enum class ScheduleViewMode { + Team, + Overall, + Adult, +} diff --git a/mobile-app/shared/src/commonMain/kotlin/de/tt_tagebuch/shared/api/models/TournamentDtos.kt b/mobile-app/shared/src/commonMain/kotlin/de/tt_tagebuch/shared/api/models/TournamentDtos.kt new file mode 100644 index 00000000..12877e8d --- /dev/null +++ b/mobile-app/shared/src/commonMain/kotlin/de/tt_tagebuch/shared/api/models/TournamentDtos.kt @@ -0,0 +1,59 @@ +package de.tt_tagebuch.shared.api.models + +import kotlinx.serialization.Serializable + +@Serializable +data class InternalTournamentSummaryDto( + val id: Int = 0, + val name: String? = null, + val date: String? = null, + val allowsExternal: Boolean? = null, + val miniChampionshipYear: Int? = null, + val isDoublesTournament: Boolean? = null, +) + +@Serializable +data class InternalTournamentDetailDto( + val id: Int = 0, + val name: String? = null, + val date: String? = null, + val type: String? = null, + val clubId: Int? = null, + val winningSets: Int? = null, + val allowsExternal: Boolean? = null, + val miniChampionshipYear: Int? = null, + val numberOfTables: Int? = null, + val numberOfGroups: Int? = null, + val advancingPerGroup: Int? = null, + val isDoublesTournament: Boolean? = null, + val bestOfEndroundSize: Int? = null, +) + +@Serializable +data class OfficialTournamentListRowDto( + val id: Int = 0, + val clubId: Int? = null, + val title: String? = null, + val eventDate: String? = null, + val organizer: String? = null, + val host: String? = null, +) + +@Serializable +data class OfficialParticipationEntryDto( + val memberId: Int? = null, + val memberName: String? = null, + val competitionId: Int? = null, + val competitionName: String? = null, + val placement: String? = null, + val date: String? = null, +) + +@Serializable +data class OfficialParticipationBucketDto( + val tournamentId: String? = null, + val title: String? = null, + val startDate: String? = null, + val endDate: String? = null, + val entries: List = emptyList(), +) diff --git a/mobile-app/shared/src/commonMain/kotlin/de/tt_tagebuch/shared/state/ApiLogsManager.kt b/mobile-app/shared/src/commonMain/kotlin/de/tt_tagebuch/shared/state/ApiLogsManager.kt new file mode 100644 index 00000000..e509ab49 --- /dev/null +++ b/mobile-app/shared/src/commonMain/kotlin/de/tt_tagebuch/shared/state/ApiLogsManager.kt @@ -0,0 +1,98 @@ +package de.tt_tagebuch.shared.state + +import de.tt_tagebuch.shared.api.ApiLogsApi +import de.tt_tagebuch.shared.api.models.ApiLogDetailDto +import de.tt_tagebuch.shared.api.models.ApiLogListRowDto +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update + +data class ApiLogsState( + val logs: List = emptyList(), + val total: Int = 0, + val offset: Int = 0, + val limit: Int = 50, + val isLoading: Boolean = false, + val error: String? = null, +) + +class ApiLogsManager( + private val apiLogsApi: ApiLogsApi, +) { + private val _state = MutableStateFlow(ApiLogsState()) + val state: StateFlow = _state.asStateFlow() + + fun clear() { + _state.value = ApiLogsState() + } + + suspend fun load( + logType: String? = null, + method: String? = null, + statusCode: Int? = null, + pathContains: String? = null, + resetOffset: Boolean = false, + offsetOverride: Int? = null, + ) { + val offset = offsetOverride ?: if (resetOffset) 0 else _state.value.offset + val limit = _state.value.limit + _state.update { it.copy(isLoading = true, error = null, offset = offset) } + try { + val page = apiLogsApi.listLogs( + limit = limit, + offset = offset, + logType = logType, + method = method, + statusCode = statusCode, + pathContains = pathContains, + ) + _state.update { + it.copy( + logs = page.logs, + total = page.total, + limit = page.limit.takeIf { l -> l > 0 } ?: limit, + offset = page.offset, + isLoading = false, + error = null, + ) + } + } catch (t: Throwable) { + _state.update { + it.copy( + isLoading = false, + error = t.toUserMessage("Logs konnten nicht geladen werden"), + ) + } + } + } + + suspend fun nextPage( + logType: String? = null, + method: String? = null, + statusCode: Int? = null, + pathContains: String? = null, + ) { + val s = _state.value + if (s.offset + s.logs.size >= s.total) return + load(logType, method, statusCode, pathContains, offsetOverride = s.offset + s.limit) + } + + suspend fun previousPage( + logType: String? = null, + method: String? = null, + statusCode: Int? = null, + pathContains: String? = null, + ) { + val s = _state.value + if (s.offset <= 0) return + load(logType, method, statusCode, pathContains, offsetOverride = (s.offset - s.limit).coerceAtLeast(0)) + } + + suspend fun fetchDetail(id: Int): ApiLogDetailDto? = + try { + apiLogsApi.getLog(id) + } catch (_: Throwable) { + null + } +} diff --git a/mobile-app/shared/src/commonMain/kotlin/de/tt_tagebuch/shared/state/ClubInternalTournamentsManager.kt b/mobile-app/shared/src/commonMain/kotlin/de/tt_tagebuch/shared/state/ClubInternalTournamentsManager.kt new file mode 100644 index 00000000..40bbf558 --- /dev/null +++ b/mobile-app/shared/src/commonMain/kotlin/de/tt_tagebuch/shared/state/ClubInternalTournamentsManager.kt @@ -0,0 +1,85 @@ +package de.tt_tagebuch.shared.state + +import de.tt_tagebuch.shared.api.TournamentsApi +import de.tt_tagebuch.shared.api.models.InternalTournamentDetailDto +import de.tt_tagebuch.shared.api.models.InternalTournamentSummaryDto +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update + +enum class ClubTournamentDisplayFilter { + Internal, + External, + Mini, +} + +data class ClubInternalTournamentsState( + val filter: ClubTournamentDisplayFilter = ClubTournamentDisplayFilter.Internal, + val tournaments: List = emptyList(), + val selectedId: Int? = null, + val detail: InternalTournamentDetailDto? = null, + val isLoadingList: Boolean = false, + val isLoadingDetail: Boolean = false, + val error: String? = null, +) + +class ClubInternalTournamentsManager( + private val tournamentsApi: TournamentsApi, +) { + private val _state = MutableStateFlow(ClubInternalTournamentsState()) + val state: StateFlow = _state.asStateFlow() + + fun clear() { + _state.value = ClubInternalTournamentsState() + } + + fun setFilter(filter: ClubTournamentDisplayFilter) { + _state.update { it.copy(filter = filter, selectedId = null, detail = null, error = null) } + } + + fun selectTournament(id: Int?) { + _state.update { it.copy(selectedId = id, detail = null) } + } + + suspend fun loadList(clubId: Int) { + val filter = _state.value.filter + _state.update { it.copy(isLoadingList = true, error = null) } + try { + val raw = when (filter) { + ClubTournamentDisplayFilter.Mini -> tournamentsApi.listTournaments(clubId, type = "mini") + else -> tournamentsApi.listTournaments(clubId, type = null) + } + val list = when (filter) { + ClubTournamentDisplayFilter.Mini -> raw + ClubTournamentDisplayFilter.Internal -> + raw.filter { it.miniChampionshipYear == null && it.allowsExternal != true } + ClubTournamentDisplayFilter.External -> + raw.filter { it.miniChampionshipYear == null && it.allowsExternal == true } + } + _state.update { it.copy(tournaments = list, isLoadingList = false, error = null) } + } catch (t: Throwable) { + _state.update { + it.copy( + isLoadingList = false, + error = t.toUserMessage("Vereins-Turniere konnten nicht geladen werden"), + ) + } + } + } + + suspend fun loadDetail(clubId: Int, tournamentId: Int) { + _state.update { it.copy(isLoadingDetail = true, error = null) } + try { + val d = tournamentsApi.getTournament(clubId, tournamentId) + _state.update { it.copy(detail = d, isLoadingDetail = false, error = null) } + } catch (t: Throwable) { + _state.update { + it.copy( + isLoadingDetail = false, + error = t.toUserMessage("Turnierdetails konnten nicht geladen werden"), + ) + } + } + } +} diff --git a/mobile-app/shared/src/commonMain/kotlin/de/tt_tagebuch/shared/state/OfficialTournamentsReadManager.kt b/mobile-app/shared/src/commonMain/kotlin/de/tt_tagebuch/shared/state/OfficialTournamentsReadManager.kt new file mode 100644 index 00000000..86577254 --- /dev/null +++ b/mobile-app/shared/src/commonMain/kotlin/de/tt_tagebuch/shared/state/OfficialTournamentsReadManager.kt @@ -0,0 +1,50 @@ +package de.tt_tagebuch.shared.state + +import de.tt_tagebuch.shared.api.OfficialTournamentsApi +import de.tt_tagebuch.shared.api.models.OfficialParticipationBucketDto +import de.tt_tagebuch.shared.api.models.OfficialTournamentListRowDto +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update + +data class OfficialTournamentsReadState( + val tournaments: List = emptyList(), + val participationBuckets: List = emptyList(), + val isLoading: Boolean = false, + val error: String? = null, +) + +class OfficialTournamentsReadManager( + private val api: OfficialTournamentsApi, +) { + private val _state = MutableStateFlow(OfficialTournamentsReadState()) + val state: StateFlow = _state.asStateFlow() + + fun clear() { + _state.value = OfficialTournamentsReadState() + } + + suspend fun load(clubId: Int) { + _state.update { it.copy(isLoading = true, error = null) } + try { + val list = api.listForClub(clubId) + val summary = api.listParticipationSummary(clubId) + _state.update { + it.copy( + tournaments = list, + participationBuckets = summary, + isLoading = false, + error = null, + ) + } + } catch (t: Throwable) { + _state.update { + it.copy( + isLoading = false, + error = t.toUserMessage("Offizielle Turniere konnten nicht geladen werden"), + ) + } + } + } +} diff --git a/mobile-app/shared/src/commonMain/kotlin/de/tt_tagebuch/shared/state/PendingApprovalsManager.kt b/mobile-app/shared/src/commonMain/kotlin/de/tt_tagebuch/shared/state/PendingApprovalsManager.kt new file mode 100644 index 00000000..bd9634b3 --- /dev/null +++ b/mobile-app/shared/src/commonMain/kotlin/de/tt_tagebuch/shared/state/PendingApprovalsManager.kt @@ -0,0 +1,48 @@ +package de.tt_tagebuch.shared.state + +import de.tt_tagebuch.shared.api.ClubApprovalsApi +import de.tt_tagebuch.shared.api.models.ClubAccessDecisionBody +import de.tt_tagebuch.shared.api.models.PendingUserClubJoinDto +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update + +data class PendingApprovalsState( + val pending: List = emptyList(), + val isLoading: Boolean = false, + val error: String? = null, +) + +class PendingApprovalsManager( + private val clubApprovalsApi: ClubApprovalsApi, +) { + private val _state = MutableStateFlow(PendingApprovalsState()) + val state: StateFlow = _state.asStateFlow() + + fun clear() { + _state.value = PendingApprovalsState() + } + + suspend fun load(clubId: Int) { + _state.update { it.copy(isLoading = true, error = null) } + try { + val list = clubApprovalsApi.listPending(clubId) + _state.update { it.copy(pending = list, isLoading = false, error = null) } + } catch (t: Throwable) { + _state.update { + it.copy(isLoading = false, error = t.toUserMessage("Freigaben konnten nicht geladen werden")) + } + } + } + + suspend fun approve(clubId: Int, userId: Int) { + clubApprovalsApi.approve(ClubAccessDecisionBody(clubid = clubId, userid = userId)) + load(clubId) + } + + suspend fun reject(clubId: Int, userId: Int) { + clubApprovalsApi.reject(ClubAccessDecisionBody(clubid = clubId, userid = userId)) + load(clubId) + } +} diff --git a/mobile-app/shared/src/commonMain/kotlin/de/tt_tagebuch/shared/state/PermissionsAdminManager.kt b/mobile-app/shared/src/commonMain/kotlin/de/tt_tagebuch/shared/state/PermissionsAdminManager.kt new file mode 100644 index 00000000..0bf14400 --- /dev/null +++ b/mobile-app/shared/src/commonMain/kotlin/de/tt_tagebuch/shared/state/PermissionsAdminManager.kt @@ -0,0 +1,76 @@ +package de.tt_tagebuch.shared.state + +import de.tt_tagebuch.shared.api.PermissionsApi +import de.tt_tagebuch.shared.api.models.AvailableRoleDto +import de.tt_tagebuch.shared.api.models.ClubPermissionMemberDto +import de.tt_tagebuch.shared.api.models.PermissionResourceDto +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update + +data class PermissionsAdminState( + val members: List = emptyList(), + val availableRoles: List = emptyList(), + val permissionStructure: Map = emptyMap(), + val isLoading: Boolean = false, + val error: String? = null, +) + +class PermissionsAdminManager( + private val permissionsApi: PermissionsApi, +) { + private val _state = MutableStateFlow(PermissionsAdminState()) + val state: StateFlow = _state.asStateFlow() + + fun clear() { + _state.value = PermissionsAdminState() + } + + suspend fun load(clubId: Int) { + _state.update { it.copy(isLoading = true, error = null) } + try { + val roles = permissionsApi.listAvailableRoles() + val structure = permissionsApi.getPermissionStructure() + val members = permissionsApi.listClubMembersWithPermissions(clubId) + _state.update { + it.copy( + availableRoles = roles, + permissionStructure = structure, + members = members, + isLoading = false, + error = null, + ) + } + } catch (t: Throwable) { + _state.update { + it.copy( + isLoading = false, + error = t.toUserMessage("Berechtigungen konnten nicht geladen werden"), + ) + } + } + } + + suspend fun reloadMembers(clubId: Int) { + try { + val members = permissionsApi.listClubMembersWithPermissions(clubId, cacheBust = true) + _state.update { it.copy(members = members) } + } catch (_: Throwable) { } + } + + suspend fun updateRole(clubId: Int, userId: Int, role: String) { + permissionsApi.updateUserRole(clubId, userId, role) + load(clubId) + } + + suspend fun updateApproved(clubId: Int, userId: Int, approved: Boolean) { + permissionsApi.updateUserStatus(clubId, userId, approved) + load(clubId) + } + + suspend fun saveCustomPermissions(clubId: Int, userId: Int, permissions: kotlinx.serialization.json.JsonObject) { + permissionsApi.updateUserCustomPermissions(clubId, userId, permissions) + load(clubId) + } +} diff --git a/mobile-app/shared/src/commonMain/kotlin/de/tt_tagebuch/shared/state/ScheduleManager.kt b/mobile-app/shared/src/commonMain/kotlin/de/tt_tagebuch/shared/state/ScheduleManager.kt new file mode 100644 index 00000000..d9d7479a --- /dev/null +++ b/mobile-app/shared/src/commonMain/kotlin/de/tt_tagebuch/shared/state/ScheduleManager.kt @@ -0,0 +1,258 @@ +package de.tt_tagebuch.shared.state + +import de.tt_tagebuch.shared.api.ClubTeamsApi +import de.tt_tagebuch.shared.api.MatchesApi +import de.tt_tagebuch.shared.api.ScheduleLogic +import de.tt_tagebuch.shared.api.models.ClubTeamDto +import de.tt_tagebuch.shared.api.models.LeagueTableRowDto +import de.tt_tagebuch.shared.api.models.ScheduleMatchDto +import de.tt_tagebuch.shared.api.models.ScheduleMatchScope +import de.tt_tagebuch.shared.api.models.ScheduleViewMode +import de.tt_tagebuch.shared.api.models.UpdateMatchPlayersBody +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update + +data class ScheduleState( + val viewMode: ScheduleViewMode = ScheduleViewMode.Team, + val teams: List = emptyList(), + val selectedTeamId: Int? = null, + val ownMatches: List = emptyList(), + val allMatches: List = emptyList(), + val overallMatches: List = emptyList(), + val leagueTable: List = emptyList(), + val matchScope: ScheduleMatchScope = ScheduleMatchScope.Own, + val otherTeamName: String = "", + val isLoading: Boolean = false, + val error: String? = null, + val seasonId: Int? = null, +) { + val selectedTeam: ClubTeamDto? + get() = teams.find { it.id == selectedTeamId } + + val displayedMatches: List + get() = when (viewMode) { + ScheduleViewMode.Overall -> ScheduleLogic.sortMatches(overallMatches) + ScheduleViewMode.Adult -> + ScheduleLogic.sortMatches(ScheduleLogic.filterAdultLeagues(overallMatches)) + ScheduleViewMode.Team -> { + val t = selectedTeam ?: return emptyList() + ScheduleLogic.applyTeamMatchScope( + ownMatches = ownMatches, + allMatches = allMatches, + scope = matchScope, + ownTeamName = t.name, + otherTeamName = otherTeamName, + ) + } + } + + val leagueTeamOptions: List + get() = ScheduleLogic.leagueTeamNames(ScheduleLogic.mergeUniqueMatches(allMatches, ownMatches)) +} + +class ScheduleManager( + private val clubTeamsApi: ClubTeamsApi, + private val matchesApi: MatchesApi, +) { + private val _state = MutableStateFlow(ScheduleState()) + val state: StateFlow = _state.asStateFlow() + + fun clear() { + _state.value = ScheduleState() + } + + suspend fun refresh(clubId: Int) { + when (_state.value.viewMode) { + ScheduleViewMode.Team -> { + val team = _state.value.selectedTeam + if (team != null && (team.league?.id ?: 0) > 0) { + loadMatchesForTeam(clubId, team) + } else { + loadClubTeams(clubId) + } + } + ScheduleViewMode.Overall -> loadOverallSchedule(clubId) + ScheduleViewMode.Adult -> loadAdultSchedule(clubId) + } + } + + suspend fun loadClubTeams(clubId: Int) { + _state.update { it.copy(isLoading = true, error = null) } + try { + val raw = clubTeamsApi.listClubTeams(clubId, _state.value.seasonId) + val sorted = ScheduleLogic.sortClubTeams(raw) + val withLeague = ScheduleLogic.teamsWithLeague(sorted) + val pick = withLeague.firstOrNull() ?: sorted.firstOrNull() + _state.update { + it.copy( + teams = sorted, + selectedTeamId = pick?.id, + isLoading = false, + error = null, + ) + } + if (pick != null && (pick.league?.id ?: 0) > 0) { + loadMatchesForTeam(clubId, pick) + } else { + _state.update { + it.copy( + viewMode = ScheduleViewMode.Team, + ownMatches = emptyList(), + allMatches = emptyList(), + leagueTable = emptyList(), + overallMatches = emptyList(), + ) + } + } + } catch (t: Throwable) { + _state.update { + it.copy( + isLoading = false, + error = t.toUserMessage("Mannschaften konnten nicht geladen werden"), + ) + } + } + } + + suspend fun selectTeam(clubId: Int, teamId: Int) { + val team = _state.value.teams.find { it.id == teamId } ?: return + _state.update { + it.copy( + selectedTeamId = teamId, + matchScope = ScheduleMatchScope.Own, + otherTeamName = "", + ) + } + if ((team.league?.id ?: 0) > 0) { + loadMatchesForTeam(clubId, team) + } else { + _state.update { + it.copy( + ownMatches = emptyList(), + allMatches = emptyList(), + leagueTable = emptyList(), + overallMatches = emptyList(), + viewMode = ScheduleViewMode.Team, + ) + } + } + } + + private suspend fun loadMatchesForTeam(clubId: Int, team: ClubTeamDto) { + val leagueId = team.league?.id ?: return + _state.update { it.copy(isLoading = true, error = null, viewMode = ScheduleViewMode.Team) } + try { + val own = matchesApi.listMatchesForLeague(clubId, leagueId, "own") + val all = matchesApi.listMatchesForLeague(clubId, leagueId, "all") + val table = runCatching { matchesApi.leagueTable(clubId, leagueId) }.getOrElse { emptyList() } + _state.update { + it.copy( + selectedTeamId = team.id, + ownMatches = own, + allMatches = all, + overallMatches = emptyList(), + leagueTable = table, + isLoading = false, + error = null, + ) + } + ensureOtherTeamDefault() + } catch (t: Throwable) { + _state.update { + it.copy( + isLoading = false, + ownMatches = emptyList(), + allMatches = emptyList(), + leagueTable = emptyList(), + error = t.toUserMessage("Spiele konnten nicht geladen werden"), + ) + } + } + } + + suspend fun loadOverallSchedule(clubId: Int) { + _state.update { + it.copy( + isLoading = true, + error = null, + viewMode = ScheduleViewMode.Overall, + matchScope = ScheduleMatchScope.Own, + otherTeamName = "", + selectedTeamId = null, + ownMatches = emptyList(), + allMatches = emptyList(), + leagueTable = emptyList(), + ) + } + try { + val matches = matchesApi.listMatchesForLeagues(clubId, _state.value.seasonId) + _state.update { it.copy(overallMatches = matches, isLoading = false, error = null) } + } catch (t: Throwable) { + _state.update { + it.copy( + isLoading = false, + overallMatches = emptyList(), + error = t.toUserMessage("Gesamtspielplan konnte nicht geladen werden"), + ) + } + } + } + + suspend fun loadAdultSchedule(clubId: Int) { + _state.update { + it.copy( + isLoading = true, + error = null, + viewMode = ScheduleViewMode.Adult, + matchScope = ScheduleMatchScope.Own, + otherTeamName = "", + selectedTeamId = null, + ownMatches = emptyList(), + allMatches = emptyList(), + leagueTable = emptyList(), + ) + } + try { + val matches = matchesApi.listMatchesForLeagues(clubId, _state.value.seasonId) + _state.update { it.copy(overallMatches = matches, isLoading = false, error = null) } + } catch (t: Throwable) { + _state.update { + it.copy( + isLoading = false, + overallMatches = emptyList(), + error = t.toUserMessage("Erwachsenen-Spielplan konnte nicht geladen werden"), + ) + } + } + } + + fun setMatchScope(scope: ScheduleMatchScope) { + _state.update { it.copy(matchScope = scope) } + if (scope == ScheduleMatchScope.Other) { + ensureOtherTeamDefault() + } else { + _state.update { it.copy(otherTeamName = "") } + } + } + + fun setOtherTeamName(name: String) { + _state.update { it.copy(otherTeamName = name) } + } + + private fun ensureOtherTeamDefault() { + val options = _state.value.leagueTeamOptions + if (_state.value.otherTeamName.isBlank() && options.isNotEmpty()) { + _state.update { it.copy(otherTeamName = options.first()) } + } + } + + suspend fun updateMatchPlayers(clubId: Int, matchId: Int, ready: List, planned: List, played: List) { + matchesApi.updateMatchPlayers( + matchId, + UpdateMatchPlayersBody(clubId = clubId, playersReady = ready, playersPlanned = planned, playersPlayed = played), + ) + refresh(clubId) + } +}