feat(ClubSettings): add country and state code fields for regional calendar data
All checks were successful
Deploy tt-tagebuch / deploy (push) Successful in 43s
All checks were successful
Deploy tt-tagebuch / deploy (push) Successful in 43s
- 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.
This commit is contained in:
20
backend/controllers/calendarController.js
Normal file
20
backend/controllers/calendarController.js
Normal file
@@ -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' });
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -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);
|
||||
|
||||
49
backend/controllers/trainingCancellationController.js
Normal file
49
backend/controllers/trainingCancellationController.js
Normal file
@@ -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 });
|
||||
}
|
||||
};
|
||||
3
backend/migrations/add_calendar_region_to_clubs.sql
Normal file
3
backend/migrations/add_calendar_region_to_clubs.sql
Normal file
@@ -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;
|
||||
13
backend/migrations/add_range_to_training_cancellations.sql
Normal file
13
backend/migrations/add_range_to_training_cancellations.sql
Normal file
@@ -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;
|
||||
14
backend/migrations/create_training_cancellations_table.sql
Normal file
14
backend/migrations/create_training_cancellations_table.sql
Normal file
@@ -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
|
||||
);
|
||||
@@ -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,
|
||||
|
||||
51
backend/models/TrainingCancellation.js
Normal file
51
backend/models/TrainingCancellation.js
Normal file
@@ -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;
|
||||
@@ -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,
|
||||
|
||||
9
backend/routes/calendarRoutes.js
Normal file
9
backend/routes/calendarRoutes.js
Normal file
@@ -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;
|
||||
16
backend/routes/trainingCancellationRoutes.js
Normal file
16
backend/routes/trainingCancellationRoutes.js
Normal file
@@ -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;
|
||||
@@ -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
|
||||
|
||||
134
backend/services/calendarHolidayService.js
Normal file
134
backend/services/calendarHolidayService.js
Normal file
@@ -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();
|
||||
@@ -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,
|
||||
|
||||
76
backend/services/trainingCancellationService.js
Normal file
76
backend/services/trainingCancellationService.js
Normal file
@@ -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();
|
||||
@@ -85,6 +85,10 @@
|
||||
<span class="nav-icon">📝</span>
|
||||
{{ $t('navigation.diary') }}
|
||||
</router-link>
|
||||
<router-link v-if="hasPermission('diary', 'read') || hasPermission('schedule', 'read') || hasPermission('tournaments', 'read')" to="/calendar" class="nav-link" title="Kalender">
|
||||
<span class="nav-icon">📆</span>
|
||||
Kalender
|
||||
</router-link>
|
||||
<router-link v-if="canManageApprovals" to="/pending-approvals" class="nav-link" title="Freigaben">
|
||||
<span class="nav-icon">⏳</span>
|
||||
{{ $t('navigation.approvals') }}
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -79,6 +79,7 @@ const NOINDEX_PREFIXES = [
|
||||
'/showclub',
|
||||
'/members',
|
||||
'/diary',
|
||||
'/calendar',
|
||||
'/pending-approvals',
|
||||
'/schedule',
|
||||
'/tournaments',
|
||||
|
||||
945
frontend/src/views/CalendarView.vue
Normal file
945
frontend/src/views/CalendarView.vue
Normal file
@@ -0,0 +1,945 @@
|
||||
<template>
|
||||
<div class="calendar-page">
|
||||
<header class="calendar-header">
|
||||
<div>
|
||||
<span class="calendar-eyebrow">Vereinskalender</span>
|
||||
<h2>Kalender</h2>
|
||||
<p>Trainingstage, Vereinsturniere und Punktspiele in einer Monatsansicht.</p>
|
||||
</div>
|
||||
<div class="calendar-actions">
|
||||
<button type="button" class="calendar-nav-button" @click="goToPreviousMonth">‹</button>
|
||||
<button type="button" class="calendar-today-button" @click="goToToday">Heute</button>
|
||||
<button type="button" class="calendar-nav-button" @click="goToNextMonth">›</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section class="calendar-toolbar">
|
||||
<strong>{{ monthLabel }}</strong>
|
||||
<div class="calendar-legend">
|
||||
<button
|
||||
v-for="type in eventTypes"
|
||||
:key="type.key"
|
||||
type="button"
|
||||
class="legend-item"
|
||||
:class="[`legend-${type.key}`, { inactive: !activeTypes[type.key] }]"
|
||||
@click="toggleType(type.key)"
|
||||
>
|
||||
{{ type.label }} <span>{{ eventCounts[type.key] }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section v-if="currentClub" class="training-cancellation-panel">
|
||||
<div>
|
||||
<h3>Training fällt aus</h3>
|
||||
<p>Hier eingetragene Tage blenden die regelmäßigen Trainingszeiten aus.</p>
|
||||
</div>
|
||||
<form class="training-cancellation-form" @submit.prevent="saveTrainingCancellation">
|
||||
<label>
|
||||
<span>Datum</span>
|
||||
<input v-model="cancellationForm.startDate" type="date" required />
|
||||
</label>
|
||||
<label>
|
||||
<span>Bis optional</span>
|
||||
<input v-model="cancellationForm.endDate" type="date" />
|
||||
</label>
|
||||
<input v-model="cancellationForm.reason" type="text" placeholder="Grund, z.B. Halle gesperrt" />
|
||||
<button type="submit" :disabled="cancellationSaving">
|
||||
{{ cancellationSaving ? 'Speichern...' : 'Eintragen' }}
|
||||
</button>
|
||||
</form>
|
||||
<div v-if="visibleTrainingCancellations.length" class="training-cancellation-list">
|
||||
<button
|
||||
v-for="cancellation in visibleTrainingCancellations"
|
||||
:key="`cancel-${cancellation.cancellationId}`"
|
||||
type="button"
|
||||
class="training-cancellation-item"
|
||||
@click="deleteTrainingCancellation(cancellation)"
|
||||
>
|
||||
<strong>{{ formatShortDate(cancellation.date) }}</strong>
|
||||
<span>{{ cancellation.title }}</span>
|
||||
<small>Löschen</small>
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section v-if="sourceWarnings.length" class="calendar-state calendar-state-warning">
|
||||
{{ sourceWarnings.join(' · ') }}
|
||||
</section>
|
||||
<section v-if="loading" class="calendar-state">Kalenderdaten werden geladen...</section>
|
||||
<section v-else-if="error" class="calendar-state calendar-state-error">{{ error }}</section>
|
||||
|
||||
<section v-if="!currentClub" class="calendar-state">Bitte zuerst einen Verein auswählen.</section>
|
||||
|
||||
<section v-else class="calendar-grid" aria-label="Kalender">
|
||||
<div v-for="day in weekdays" :key="day" class="calendar-weekday">{{ day }}</div>
|
||||
<article
|
||||
v-for="day in calendarDays"
|
||||
:key="day.key"
|
||||
class="calendar-day"
|
||||
:class="{ 'outside-month': !day.isCurrentMonth, today: day.isToday }"
|
||||
>
|
||||
<div class="calendar-day-number">{{ day.dayNumber }}</div>
|
||||
<div class="calendar-events">
|
||||
<button
|
||||
v-for="event in day.events"
|
||||
:key="event.id"
|
||||
type="button"
|
||||
class="calendar-event"
|
||||
:class="`event-${event.type}`"
|
||||
@click="openEvent(event)"
|
||||
>
|
||||
<span class="event-time" v-if="event.time">{{ event.time }}</span>
|
||||
<span class="event-title">{{ event.title }}</span>
|
||||
<span class="event-subtitle" v-if="event.subtitle">{{ event.subtitle }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</article>
|
||||
</section>
|
||||
|
||||
<section class="calendar-agenda">
|
||||
<h3>Termine im Monat</h3>
|
||||
<p v-if="visibleEvents.length === 0" class="agenda-empty">Keine Termine in diesem Monat.</p>
|
||||
<button
|
||||
v-for="event in visibleEvents"
|
||||
:key="`agenda-${event.id}`"
|
||||
type="button"
|
||||
class="agenda-item"
|
||||
:class="`agenda-${event.type}`"
|
||||
@click="openEvent(event)"
|
||||
>
|
||||
<span class="agenda-date">{{ formatEventDate(event) }}</span>
|
||||
<span class="agenda-main">
|
||||
<strong>{{ event.title }}</strong>
|
||||
<small>{{ event.subtitle }}</small>
|
||||
</span>
|
||||
<span class="agenda-time">{{ event.time }}</span>
|
||||
</button>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { mapGetters } from 'vuex';
|
||||
import apiClient from '../apiClient';
|
||||
|
||||
const WEEKDAYS = ['Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa', 'So'];
|
||||
|
||||
export default {
|
||||
name: 'CalendarView',
|
||||
data() {
|
||||
const today = new Date();
|
||||
return {
|
||||
cursor: new Date(today.getFullYear(), today.getMonth(), 1),
|
||||
events: [],
|
||||
loading: false,
|
||||
error: '',
|
||||
sourceErrors: [],
|
||||
activeTypes: {
|
||||
training: true,
|
||||
tournament: true,
|
||||
match: true,
|
||||
holiday: true,
|
||||
schoolHoliday: true,
|
||||
trainingCancellation: true
|
||||
},
|
||||
cancellationSaving: false,
|
||||
cancellationForm: {
|
||||
startDate: '',
|
||||
endDate: '',
|
||||
reason: ''
|
||||
}
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
...mapGetters(['currentClub']),
|
||||
weekdays() {
|
||||
return WEEKDAYS;
|
||||
},
|
||||
eventTypes() {
|
||||
return [
|
||||
{ key: 'training', label: 'Training' },
|
||||
{ key: 'tournament', label: 'Turnier' },
|
||||
{ key: 'match', label: 'Punktspiel' },
|
||||
{ key: 'holiday', label: 'Feiertag' },
|
||||
{ key: 'schoolHoliday', label: 'Ferien' },
|
||||
{ key: 'trainingCancellation', label: 'Ausfall' }
|
||||
];
|
||||
},
|
||||
monthLabel() {
|
||||
return this.cursor.toLocaleDateString('de-DE', { month: 'long', year: 'numeric' });
|
||||
},
|
||||
displayedYear() {
|
||||
return this.cursor.getFullYear();
|
||||
},
|
||||
visibleEventPool() {
|
||||
return this.events.filter(event => this.activeTypes[event.type] !== false);
|
||||
},
|
||||
visibleEvents() {
|
||||
const month = this.cursor.getMonth();
|
||||
const year = this.cursor.getFullYear();
|
||||
const monthStart = new Date(year, month, 1);
|
||||
const monthEnd = new Date(year, month + 1, 0);
|
||||
return this.visibleEventPool
|
||||
.filter(event => this.isEventInRange(event, monthStart, monthEnd))
|
||||
.sort((a, b) => a.startsAt - b.startsAt);
|
||||
},
|
||||
eventCounts() {
|
||||
return this.events.reduce((counts, event) => {
|
||||
counts[event.type] = (counts[event.type] || 0) + 1;
|
||||
return counts;
|
||||
}, { training: 0, tournament: 0, match: 0, holiday: 0, schoolHoliday: 0, trainingCancellation: 0 });
|
||||
},
|
||||
visibleTrainingCancellations() {
|
||||
const month = this.cursor.getMonth();
|
||||
const year = this.cursor.getFullYear();
|
||||
return this.events
|
||||
.filter(event => event.type === 'trainingCancellation')
|
||||
.filter(event => event.date.getFullYear() === year && event.date.getMonth() === month)
|
||||
.sort((a, b) => a.startsAt - b.startsAt);
|
||||
},
|
||||
sourceWarnings() {
|
||||
return this.sourceErrors.map(source => `${source} konnte nicht geladen werden`);
|
||||
},
|
||||
calendarDays() {
|
||||
const year = this.cursor.getFullYear();
|
||||
const month = this.cursor.getMonth();
|
||||
const firstOfMonth = new Date(year, month, 1);
|
||||
const firstWeekday = (firstOfMonth.getDay() + 6) % 7;
|
||||
const gridStart = new Date(year, month, 1 - firstWeekday);
|
||||
const todayKey = this.toDateKey(new Date());
|
||||
|
||||
return Array.from({ length: 42 }, (_, index) => {
|
||||
const date = new Date(gridStart);
|
||||
date.setDate(gridStart.getDate() + index);
|
||||
const key = this.toDateKey(date);
|
||||
return {
|
||||
key,
|
||||
date,
|
||||
dayNumber: date.getDate(),
|
||||
isCurrentMonth: date.getMonth() === month,
|
||||
isToday: key === todayKey,
|
||||
events: this.visibleEventPool
|
||||
.filter(event => this.isEventOnDate(event, date))
|
||||
.sort((a, b) => a.startsAt - b.startsAt)
|
||||
};
|
||||
});
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
currentClub: {
|
||||
immediate: true,
|
||||
handler() {
|
||||
this.loadCalendarEvents();
|
||||
}
|
||||
},
|
||||
displayedYear() {
|
||||
this.loadCalendarEvents();
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
async loadCalendarEvents() {
|
||||
if (!this.currentClub) {
|
||||
this.events = [];
|
||||
return;
|
||||
}
|
||||
|
||||
this.loading = true;
|
||||
this.error = '';
|
||||
this.sourceErrors = [];
|
||||
|
||||
const sources = await Promise.allSettled([
|
||||
this.loadSource('Trainingstage', () => this.loadTrainingEvents()),
|
||||
this.loadSource('Trainingszeiten', () => this.loadRecurringTrainingEvents()),
|
||||
this.loadSource('Trainingsausfälle', () => this.loadTrainingCancellationEvents()),
|
||||
this.loadSource('Turniere', () => this.loadTournamentEvents()),
|
||||
this.loadSource('Punktspiele', () => this.loadMatchEvents()),
|
||||
this.loadSource('Ferien/Feiertage', () => this.loadHolidayEvents())
|
||||
]);
|
||||
|
||||
const loadedEvents = sources
|
||||
.filter(result => result.status === 'fulfilled')
|
||||
.flatMap(result => result.value.events)
|
||||
.filter(event => event.date && !Number.isNaN(event.date.getTime()));
|
||||
const cancellationDates = new Set(
|
||||
loadedEvents
|
||||
.filter(event => event.type === 'trainingCancellation')
|
||||
.flatMap(event => this.getDateKeysForRange(event.date, event.endDate || event.date))
|
||||
);
|
||||
this.events = loadedEvents.filter(event => (
|
||||
!event.isRecurringTraining || !cancellationDates.has(this.toDateKey(event.date))
|
||||
));
|
||||
this.sourceErrors = sources
|
||||
.filter(result => result.status === 'rejected')
|
||||
.map(result => result.reason?.source)
|
||||
.filter(Boolean);
|
||||
|
||||
if (sources.every(result => result.status === 'rejected')) {
|
||||
this.error = 'Kalenderdaten konnten nicht geladen werden.';
|
||||
}
|
||||
|
||||
this.loading = false;
|
||||
},
|
||||
async loadSource(source, loader) {
|
||||
try {
|
||||
const events = await loader();
|
||||
return { source, events };
|
||||
} catch (error) {
|
||||
error.source = error.source || source;
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
async loadTrainingEvents() {
|
||||
const response = await apiClient.get(`/diary/${this.currentClub}`);
|
||||
this.ensureSuccess(response, 'Trainingstage');
|
||||
return (response.data || []).map(entry => {
|
||||
const date = this.parseDate(entry.date);
|
||||
return {
|
||||
id: `training-${entry.id}`,
|
||||
type: 'training',
|
||||
date,
|
||||
startsAt: this.combineDateTime(date, entry.trainingStart),
|
||||
time: this.formatTimeRange(entry.trainingStart, entry.trainingEnd),
|
||||
title: 'Training',
|
||||
subtitle: entry.diaryTags?.map(tag => tag.name).join(', ') || '',
|
||||
route: '/diary'
|
||||
};
|
||||
});
|
||||
},
|
||||
async loadRecurringTrainingEvents() {
|
||||
const response = await apiClient.get(`/training-times/${this.currentClub}`);
|
||||
this.ensureSuccess(response, 'Trainingszeiten');
|
||||
const groups = Array.isArray(response.data) ? response.data : [];
|
||||
return groups.flatMap(group => this.mapGroupTrainingTimesToEvents(group));
|
||||
},
|
||||
mapGroupTrainingTimesToEvents(group) {
|
||||
const trainingTimes = Array.isArray(group?.trainingTimes) ? group.trainingTimes : [];
|
||||
return trainingTimes.flatMap(time => this.createRecurringTrainingEvents(group, time));
|
||||
},
|
||||
createRecurringTrainingEvents(group, time) {
|
||||
const weekday = Number(time?.weekday);
|
||||
if (!Number.isInteger(weekday) || weekday < 0 || weekday > 6 || !time?.startTime) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const events = [];
|
||||
const date = new Date(this.displayedYear, 0, 1);
|
||||
const daysUntilWeekday = (weekday - date.getDay() + 7) % 7;
|
||||
date.setDate(date.getDate() + daysUntilWeekday);
|
||||
|
||||
while (date.getFullYear() === this.displayedYear) {
|
||||
const eventDate = new Date(date.getFullYear(), date.getMonth(), date.getDate());
|
||||
const dateKey = this.toDateKey(eventDate);
|
||||
events.push({
|
||||
id: `training-time-${group.id}-${time.id}-${dateKey}`,
|
||||
type: 'training',
|
||||
date: eventDate,
|
||||
startsAt: this.combineDateTime(eventDate, time.startTime),
|
||||
time: this.formatTimeRange(time.startTime, time.endTime),
|
||||
title: group?.name || 'Training',
|
||||
subtitle: 'Regelmäßige Trainingszeit',
|
||||
route: '/diary',
|
||||
isRecurringTraining: true
|
||||
});
|
||||
date.setDate(date.getDate() + 7);
|
||||
}
|
||||
|
||||
return events;
|
||||
},
|
||||
async loadTrainingCancellationEvents() {
|
||||
const response = await apiClient.get(`/training-cancellations/${this.currentClub}`, {
|
||||
params: { year: this.displayedYear }
|
||||
});
|
||||
this.ensureSuccess(response, 'Trainingsausfälle');
|
||||
return (response.data || []).map(cancellation => {
|
||||
const date = this.parseDate(cancellation.startDate || cancellation.date);
|
||||
const endDate = this.parseDate(cancellation.endDate || cancellation.startDate || cancellation.date);
|
||||
return {
|
||||
id: `training-cancellation-${cancellation.id}`,
|
||||
cancellationId: cancellation.id,
|
||||
type: 'trainingCancellation',
|
||||
date,
|
||||
endDate,
|
||||
startsAt: this.combineDateTime(date),
|
||||
time: '',
|
||||
title: cancellation.reason || 'Training fällt aus',
|
||||
subtitle: 'Trainingsausfall'
|
||||
};
|
||||
});
|
||||
},
|
||||
async saveTrainingCancellation() {
|
||||
if (!this.currentClub || !this.cancellationForm.startDate) return;
|
||||
this.cancellationSaving = true;
|
||||
try {
|
||||
const response = await apiClient.post(`/training-cancellations/${this.currentClub}`, {
|
||||
startDate: this.cancellationForm.startDate,
|
||||
endDate: this.cancellationForm.endDate || this.cancellationForm.startDate,
|
||||
reason: this.cancellationForm.reason
|
||||
});
|
||||
this.ensureSuccess(response, 'Trainingsausfälle');
|
||||
this.cancellationForm = { startDate: '', endDate: '', reason: '' };
|
||||
await this.loadCalendarEvents();
|
||||
} finally {
|
||||
this.cancellationSaving = false;
|
||||
}
|
||||
},
|
||||
getDateKeysForRange(startDate, endDate) {
|
||||
const keys = [];
|
||||
const cursor = this.startOfDay(startDate);
|
||||
const rangeEnd = this.startOfDay(endDate);
|
||||
while (cursor <= rangeEnd) {
|
||||
keys.push(this.toDateKey(cursor));
|
||||
cursor.setDate(cursor.getDate() + 1);
|
||||
}
|
||||
return keys;
|
||||
},
|
||||
async deleteTrainingCancellation(cancellation) {
|
||||
if (!this.currentClub || !cancellation?.cancellationId) return;
|
||||
const response = await apiClient.delete(`/training-cancellations/${this.currentClub}/${cancellation.cancellationId}`);
|
||||
this.ensureSuccess(response, 'Trainingsausfälle');
|
||||
await this.loadCalendarEvents();
|
||||
},
|
||||
async loadTournamentEvents() {
|
||||
const response = await apiClient.get(`/tournament/${this.currentClub}`);
|
||||
this.ensureSuccess(response, 'Turniere');
|
||||
return (response.data || []).map(tournament => {
|
||||
const date = this.parseDate(tournament.date);
|
||||
return {
|
||||
id: `tournament-${tournament.id}`,
|
||||
type: 'tournament',
|
||||
date,
|
||||
startsAt: this.combineDateTime(date),
|
||||
time: '',
|
||||
title: tournament.name || tournament.tournamentName || 'Turnier',
|
||||
subtitle: tournament.allowsExternal ? 'Offenes Turnier' : 'Vereinsturnier',
|
||||
route: '/tournaments'
|
||||
};
|
||||
});
|
||||
},
|
||||
async loadMatchEvents() {
|
||||
const response = await apiClient.get(`/matches/leagues/${this.currentClub}/matches`);
|
||||
this.ensureSuccess(response, 'Punktspiele');
|
||||
return (response.data || []).map(match => {
|
||||
const date = this.parseDate(match.date);
|
||||
const home = match.homeTeam?.name || 'Heim';
|
||||
const guest = match.guestTeam?.name || 'Gast';
|
||||
return {
|
||||
id: `match-${match.id}`,
|
||||
type: 'match',
|
||||
date,
|
||||
startsAt: this.combineDateTime(date, match.time),
|
||||
time: this.formatTime(match.time),
|
||||
title: `${home} - ${guest}`,
|
||||
subtitle: match.leagueDetails?.name || match.league?.name || 'Punktspiel',
|
||||
route: '/schedule'
|
||||
};
|
||||
});
|
||||
},
|
||||
async loadHolidayEvents() {
|
||||
const response = await apiClient.get(`/calendar/club/${this.currentClub}/holidays`, {
|
||||
params: { year: this.displayedYear }
|
||||
});
|
||||
this.ensureSuccess(response, 'Ferien/Feiertage');
|
||||
const holidays = response.data?.holidays || [];
|
||||
const schoolHolidays = response.data?.schoolHolidays || [];
|
||||
return [
|
||||
...holidays.map(entry => this.mapCalendarDayEvent(entry, 'holiday', 'Feiertag')),
|
||||
...schoolHolidays.map(entry => this.mapCalendarDayEvent(entry, 'schoolHoliday', 'Schulferien'))
|
||||
].filter(Boolean);
|
||||
},
|
||||
mapCalendarDayEvent(entry, type, fallbackTitle) {
|
||||
const date = this.parseDate(entry.startDate);
|
||||
const endDate = this.parseDate(entry.endDate || entry.startDate);
|
||||
if (!date || !endDate) return null;
|
||||
return {
|
||||
id: `${type}-${entry.id || entry.startDate}`,
|
||||
type,
|
||||
date,
|
||||
endDate,
|
||||
startsAt: this.combineDateTime(date),
|
||||
time: '',
|
||||
title: entry.name || fallbackTitle,
|
||||
subtitle: fallbackTitle
|
||||
};
|
||||
},
|
||||
ensureSuccess(response, source) {
|
||||
if (!response || response.status >= 400) {
|
||||
const error = new Error(`HTTP ${response?.status || 0}`);
|
||||
error.source = source;
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
toggleType(type) {
|
||||
this.activeTypes = {
|
||||
...this.activeTypes,
|
||||
[type]: !this.activeTypes[type]
|
||||
};
|
||||
},
|
||||
parseDate(value) {
|
||||
if (!value) return null;
|
||||
if (value instanceof Date) return new Date(value.getFullYear(), value.getMonth(), value.getDate());
|
||||
const text = String(value).slice(0, 10);
|
||||
const [year, month, day] = text.split('-').map(Number);
|
||||
if (!year || !month || !day) return new Date(value);
|
||||
return new Date(year, month - 1, day);
|
||||
},
|
||||
combineDateTime(date, time) {
|
||||
if (!date) return Number.POSITIVE_INFINITY;
|
||||
const result = new Date(date);
|
||||
const [hours, minutes] = String(time || '').split(':').map(Number);
|
||||
result.setHours(Number.isFinite(hours) ? hours : 0, Number.isFinite(minutes) ? minutes : 0, 0, 0);
|
||||
return result.getTime();
|
||||
},
|
||||
isEventOnDate(event, date) {
|
||||
const eventStart = this.startOfDay(event.date);
|
||||
const eventEnd = this.startOfDay(event.endDate || event.date);
|
||||
const candidate = this.startOfDay(date);
|
||||
return candidate >= eventStart && candidate <= eventEnd;
|
||||
},
|
||||
isEventInRange(event, rangeStart, rangeEnd) {
|
||||
const eventStart = this.startOfDay(event.date);
|
||||
const eventEnd = this.startOfDay(event.endDate || event.date);
|
||||
return eventStart <= this.startOfDay(rangeEnd) && eventEnd >= this.startOfDay(rangeStart);
|
||||
},
|
||||
startOfDay(date) {
|
||||
const result = new Date(date);
|
||||
result.setHours(0, 0, 0, 0);
|
||||
return result;
|
||||
},
|
||||
toDateKey(date) {
|
||||
const year = date.getFullYear();
|
||||
const month = String(date.getMonth() + 1).padStart(2, '0');
|
||||
const day = String(date.getDate()).padStart(2, '0');
|
||||
return `${year}-${month}-${day}`;
|
||||
},
|
||||
formatTime(value) {
|
||||
if (!value) return '';
|
||||
return String(value).slice(0, 5);
|
||||
},
|
||||
formatTimeRange(start, end) {
|
||||
const startTime = this.formatTime(start);
|
||||
const endTime = this.formatTime(end);
|
||||
if (startTime && endTime) return `${startTime}-${endTime}`;
|
||||
return startTime || endTime || '';
|
||||
},
|
||||
formatShortDate(date) {
|
||||
return date.toLocaleDateString('de-DE', { weekday: 'short', day: '2-digit', month: '2-digit' });
|
||||
},
|
||||
formatEventDate(event) {
|
||||
if (!event.endDate || this.toDateKey(event.date) === this.toDateKey(event.endDate)) {
|
||||
return this.formatShortDate(event.date);
|
||||
}
|
||||
return `${this.formatShortDate(event.date)} - ${this.formatShortDate(event.endDate)}`;
|
||||
},
|
||||
goToPreviousMonth() {
|
||||
this.cursor = new Date(this.cursor.getFullYear(), this.cursor.getMonth() - 1, 1);
|
||||
},
|
||||
goToNextMonth() {
|
||||
this.cursor = new Date(this.cursor.getFullYear(), this.cursor.getMonth() + 1, 1);
|
||||
},
|
||||
goToToday() {
|
||||
const today = new Date();
|
||||
this.cursor = new Date(today.getFullYear(), today.getMonth(), 1);
|
||||
},
|
||||
openEvent(event) {
|
||||
if (event.route) {
|
||||
this.$router.push(event.route);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.calendar-page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.calendar-header,
|
||||
.calendar-toolbar,
|
||||
.training-cancellation-panel,
|
||||
.calendar-agenda {
|
||||
border: 1px solid #dfe7e2;
|
||||
border-radius: 10px;
|
||||
background: #fff;
|
||||
box-shadow: 0 6px 18px rgba(15, 23, 42, 0.05);
|
||||
}
|
||||
|
||||
.calendar-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
align-items: flex-start;
|
||||
padding: 1.2rem;
|
||||
}
|
||||
|
||||
.calendar-eyebrow {
|
||||
display: block;
|
||||
margin-bottom: 0.25rem;
|
||||
color: #2f7a5f;
|
||||
font-size: 0.78rem;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.calendar-header h2 {
|
||||
margin: 0;
|
||||
color: #14251f;
|
||||
}
|
||||
|
||||
.calendar-header p {
|
||||
margin: 0.35rem 0 0;
|
||||
color: #607169;
|
||||
}
|
||||
|
||||
.calendar-actions,
|
||||
.calendar-legend {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.calendar-nav-button,
|
||||
.calendar-today-button {
|
||||
border: 1px solid #cfdad4;
|
||||
border-radius: 8px;
|
||||
background: #f8fbf9;
|
||||
color: #173d31;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.calendar-nav-button {
|
||||
width: 2.5rem;
|
||||
height: 2.5rem;
|
||||
font-size: 1.35rem;
|
||||
}
|
||||
|
||||
.calendar-today-button {
|
||||
min-height: 2.5rem;
|
||||
padding: 0 0.85rem;
|
||||
}
|
||||
|
||||
.calendar-toolbar {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
align-items: center;
|
||||
padding: 0.85rem 1rem;
|
||||
}
|
||||
|
||||
.legend-item {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
border: 1px solid #d6e2dc;
|
||||
border-radius: 999px;
|
||||
background: #f8fbf9;
|
||||
font-size: 0.84rem;
|
||||
font-weight: 700;
|
||||
color: #40524b;
|
||||
cursor: pointer;
|
||||
padding: 0.35rem 0.65rem;
|
||||
}
|
||||
|
||||
.legend-item.inactive {
|
||||
opacity: 0.45;
|
||||
}
|
||||
|
||||
.legend-item::before {
|
||||
content: '';
|
||||
width: 0.7rem;
|
||||
height: 0.7rem;
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
.legend-training::before { background: #2f7a5f; }
|
||||
.legend-tournament::before { background: #b7791f; }
|
||||
.legend-match::before { background: #2563eb; }
|
||||
.legend-holiday::before { background: #dc2626; }
|
||||
.legend-schoolHoliday::before { background: #7c3aed; }
|
||||
.legend-trainingCancellation::before { background: #64748b; }
|
||||
|
||||
.training-cancellation-panel {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(14rem, 1fr) minmax(18rem, 2fr);
|
||||
gap: 1rem;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.training-cancellation-panel h3 {
|
||||
margin: 0;
|
||||
color: #14251f;
|
||||
}
|
||||
|
||||
.training-cancellation-panel p {
|
||||
margin: 0.25rem 0 0;
|
||||
color: #607169;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.training-cancellation-form {
|
||||
display: grid;
|
||||
grid-template-columns: 10rem 10rem minmax(12rem, 1fr) auto;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.training-cancellation-form label {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.2rem;
|
||||
}
|
||||
|
||||
.training-cancellation-form label span {
|
||||
color: #607169;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.training-cancellation-form input,
|
||||
.training-cancellation-form button {
|
||||
border: 1px solid #cfdad4;
|
||||
border-radius: 8px;
|
||||
min-height: 2.4rem;
|
||||
padding: 0 0.75rem;
|
||||
}
|
||||
|
||||
.training-cancellation-form button {
|
||||
background: #2f7a5f;
|
||||
color: #fff;
|
||||
cursor: pointer;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.training-cancellation-list {
|
||||
display: flex;
|
||||
grid-column: 1 / -1;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.training-cancellation-item {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.45rem;
|
||||
border: 1px solid #cbd5e1;
|
||||
border-radius: 999px;
|
||||
background: #f8fafc;
|
||||
color: #334155;
|
||||
cursor: pointer;
|
||||
padding: 0.35rem 0.65rem;
|
||||
}
|
||||
|
||||
.training-cancellation-item small {
|
||||
color: #991b1b;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.calendar-state {
|
||||
padding: 0.9rem 1rem;
|
||||
border-radius: 8px;
|
||||
background: #eef6f2;
|
||||
color: #255545;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.calendar-state-error {
|
||||
background: #fee2e2;
|
||||
color: #991b1b;
|
||||
}
|
||||
|
||||
.calendar-state-warning {
|
||||
background: #fff7e6;
|
||||
color: #8a4b11;
|
||||
}
|
||||
|
||||
.calendar-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(7, minmax(0, 1fr));
|
||||
overflow: hidden;
|
||||
border: 1px solid #dfe7e2;
|
||||
border-radius: 10px;
|
||||
background: #dfe7e2;
|
||||
gap: 1px;
|
||||
}
|
||||
|
||||
.calendar-weekday,
|
||||
.calendar-day {
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.calendar-weekday {
|
||||
padding: 0.7rem;
|
||||
color: #607169;
|
||||
font-size: 0.78rem;
|
||||
font-weight: 800;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.calendar-day {
|
||||
min-height: 9.5rem;
|
||||
padding: 0.55rem;
|
||||
}
|
||||
|
||||
.calendar-day.outside-month {
|
||||
background: #f6f8f7;
|
||||
color: #9aa8a1;
|
||||
}
|
||||
|
||||
.calendar-day.today {
|
||||
box-shadow: inset 0 0 0 2px #2f7a5f;
|
||||
}
|
||||
|
||||
.calendar-day-number {
|
||||
margin-bottom: 0.45rem;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.calendar-events {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
|
||||
.calendar-event,
|
||||
.agenda-item {
|
||||
border: 0;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.calendar-event {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.05rem;
|
||||
padding: 0.4rem 0.45rem;
|
||||
border-radius: 7px;
|
||||
color: #0f172a;
|
||||
}
|
||||
|
||||
.event-training { background: #e8f4ef; border-left: 4px solid #2f7a5f; }
|
||||
.event-tournament { background: #fff7e6; border-left: 4px solid #b7791f; }
|
||||
.event-match { background: #eaf1ff; border-left: 4px solid #2563eb; }
|
||||
.event-holiday { background: #fee2e2; border-left: 4px solid #dc2626; }
|
||||
.event-schoolHoliday { background: #f3e8ff; border-left: 4px solid #7c3aed; }
|
||||
.event-trainingCancellation { background: #f1f5f9; border-left: 4px solid #64748b; }
|
||||
|
||||
.event-time {
|
||||
font-size: 0.72rem;
|
||||
font-weight: 800;
|
||||
color: #4b5d55;
|
||||
}
|
||||
|
||||
.event-title {
|
||||
overflow: hidden;
|
||||
font-size: 0.82rem;
|
||||
font-weight: 800;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.event-subtitle {
|
||||
overflow: hidden;
|
||||
color: #5b6b64;
|
||||
font-size: 0.74rem;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.calendar-agenda {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.calendar-agenda h3 {
|
||||
margin: 0 0 0.75rem;
|
||||
}
|
||||
|
||||
.agenda-empty {
|
||||
margin: 0;
|
||||
color: #607169;
|
||||
}
|
||||
|
||||
.agenda-item {
|
||||
display: grid;
|
||||
grid-template-columns: 12rem 1fr auto;
|
||||
gap: 0.85rem;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
padding: 0.7rem 0;
|
||||
border-top: 1px solid #edf1ee;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.agenda-date {
|
||||
color: #607169;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.agenda-main {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.agenda-main strong,
|
||||
.agenda-main small {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.agenda-main small {
|
||||
color: #607169;
|
||||
}
|
||||
|
||||
.agenda-time {
|
||||
color: #40524b;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.calendar-header,
|
||||
.calendar-toolbar,
|
||||
.training-cancellation-panel {
|
||||
align-items: stretch;
|
||||
grid-template-columns: 1fr;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.training-cancellation-form {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.calendar-grid {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.calendar-weekday,
|
||||
.calendar-day.outside-month {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.calendar-day {
|
||||
min-height: auto;
|
||||
border: 1px solid #dfe7e2;
|
||||
border-radius: 9px;
|
||||
}
|
||||
|
||||
.calendar-day:not(:has(.calendar-event)) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.agenda-item {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -51,6 +51,28 @@
|
||||
<input v-model="associationMemberNumber" class="text-input" :placeholder="$t('clubSettings.associationMemberNumberPlaceholder')" />
|
||||
</section>
|
||||
|
||||
<section v-if="currentClub && !loading" class="card">
|
||||
<h2>Kalenderregion</h2>
|
||||
<p class="hint">Die Region wird für Schulferien und gesetzliche Feiertage im Kalender genutzt.</p>
|
||||
<div class="field-grid">
|
||||
<div class="field-group">
|
||||
<label>Land</label>
|
||||
<select v-model="countryCode" class="text-input">
|
||||
<option value="DE">Deutschland</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="field-group">
|
||||
<label>Bundesland</label>
|
||||
<select v-model="stateCode" class="text-input">
|
||||
<option value="">Nicht gesetzt</option>
|
||||
<option v-for="state in germanStates" :key="state.code" :value="state.code">
|
||||
{{ state.name }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section v-if="currentClub && !loading" class="card">
|
||||
<h2>{{ $t('clubSettings.myTischtennisRankings') }}</h2>
|
||||
<p class="hint">{{ $t('clubSettings.myTischtennisRankingsHint') }}</p>
|
||||
@@ -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;
|
||||
}
|
||||
</style>
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.field-grid { grid-template-columns: 1fr; }
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -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
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -41,6 +41,7 @@ kotlin {
|
||||
implementation(libs.koin.android)
|
||||
implementation(libs.coil.compose)
|
||||
implementation(libs.yalantis.ucrop)
|
||||
implementation(libs.ktor.serialization.kotlinx.json)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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> =
|
||||
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<Int?>(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<MainTab>,
|
||||
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<ClubAdminSettingsSection?>(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<String?>(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")
|
||||
}
|
||||
|
||||
@@ -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<String, PermissionResourceDto>,
|
||||
overrides: Map<String, Boolean?>,
|
||||
): 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<String?>(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<Int?>(null) }
|
||||
var customizeFor by remember { mutableStateOf<ClubPermissionMemberDto?>(null) }
|
||||
val overrides = remember { mutableStateMapOf<String, Boolean?>() }
|
||||
var customizeSaving by remember { mutableStateOf(false) }
|
||||
var opError by remember { mutableStateOf<String?>(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<Int?>(null) }
|
||||
var detail by remember { mutableStateOf<ApiLogDetailDto?>(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")) }
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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<ScheduleMatchDto?>(null) }
|
||||
var playerMatch by remember { mutableStateOf<ScheduleMatchDto?>(null) }
|
||||
var playerError by remember { mutableStateOf<String?>(null) }
|
||||
var playerSaving by remember { mutableStateOf(false) }
|
||||
|
||||
var readyIds by remember { mutableStateOf(emptyList<Int>()) }
|
||||
var plannedIds by remember { mutableStateOf(emptyList<Int>()) }
|
||||
var playedIds by remember { mutableStateOf(emptyList<Int>()) }
|
||||
|
||||
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))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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<ApiLogsListEnvelopeDto>()
|
||||
return env.data ?: ApiLogsListPageDto()
|
||||
}
|
||||
|
||||
suspend fun getLog(id: Int): ApiLogDetailDto? {
|
||||
val env = client.http.get("/api/logs/$id").body<ApiLogDetailEnvelopeDto>()
|
||||
return env.data
|
||||
}
|
||||
}
|
||||
@@ -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<PendingUserClubJoinDto> {
|
||||
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) }
|
||||
}
|
||||
}
|
||||
@@ -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<ClubTeamDto> {
|
||||
return client.http.get("/api/club-teams/club/$clubId") {
|
||||
seasonId?.let { parameter("seasonid", it) }
|
||||
}.body()
|
||||
}
|
||||
}
|
||||
@@ -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<ScheduleMatchDto> {
|
||||
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<ScheduleMatchDto> {
|
||||
return client.http.get("/api/matches/leagues/$clubId/matches/$leagueId") {
|
||||
parameter("scope", scope)
|
||||
}.body()
|
||||
}
|
||||
|
||||
suspend fun leagueTable(clubId: Int, leagueId: Int): List<LeagueTableRowDto> {
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<OfficialTournamentListRowDto> {
|
||||
return client.http.get("/api/official-tournaments/$clubId").body()
|
||||
}
|
||||
|
||||
suspend fun listParticipationSummary(clubId: Int): List<OfficialParticipationBucketDto> {
|
||||
return client.http.get("/api/official-tournaments/$clubId/participations/summary").body()
|
||||
}
|
||||
}
|
||||
@@ -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<AvailableRoleDto> {
|
||||
return client.http.get("/api/permissions/roles/available").body()
|
||||
}
|
||||
|
||||
suspend fun getPermissionStructure(): Map<String, PermissionResourceDto> {
|
||||
return client.http.get("/api/permissions/structure/all").body()
|
||||
}
|
||||
|
||||
suspend fun listClubMembersWithPermissions(clubId: Int, cacheBust: Boolean = false): List<ClubPermissionMemberDto> {
|
||||
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))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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<String, Boolean> =
|
||||
mapOf("read" to r, "write" to w, "delete" to d)
|
||||
|
||||
private fun pair(r: Boolean, w: Boolean): Map<String, Boolean> =
|
||||
mapOf("read" to r, "write" to w)
|
||||
|
||||
private fun resourceMap(
|
||||
diary: Map<String, Boolean>,
|
||||
members: Map<String, Boolean>,
|
||||
teams: Map<String, Boolean>,
|
||||
schedule: Map<String, Boolean>,
|
||||
tournaments: Map<String, Boolean>,
|
||||
statistics: Map<String, Boolean>,
|
||||
settings: Map<String, Boolean>,
|
||||
permissions: Map<String, Boolean>,
|
||||
approvals: Map<String, Boolean>,
|
||||
mytischtennis_admin: Map<String, Boolean>,
|
||||
predefined_activities: Map<String, Boolean>,
|
||||
): Map<String, Map<String, Boolean>> = 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<String, Map<String, Boolean>> =
|
||||
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
|
||||
}
|
||||
@@ -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<ClubTeamDto>): List<ClubTeamDto> =
|
||||
teams.sortedWith(compareBy({ it.league?.name ?: "" }, { it.name }))
|
||||
|
||||
fun sortMatches(matches: List<ScheduleMatchDto>): List<ScheduleMatchDto> =
|
||||
matches.sortedWith(
|
||||
compareBy(
|
||||
{ it.date ?: "" },
|
||||
{ it.time ?: "" },
|
||||
{ it.homeTeam?.name ?: "" },
|
||||
{ it.guestTeam?.name ?: "" },
|
||||
),
|
||||
)
|
||||
|
||||
fun mergeUniqueMatches(a: List<ScheduleMatchDto>, b: List<ScheduleMatchDto>): List<ScheduleMatchDto> {
|
||||
val seen = LinkedHashSet<Int>()
|
||||
val out = ArrayList<ScheduleMatchDto>(a.size + b.size)
|
||||
for (m in a + b) {
|
||||
if (seen.add(m.id)) out.add(m)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
fun leagueTeamNames(matches: List<ScheduleMatchDto>): List<String> {
|
||||
val names = LinkedHashSet<String>()
|
||||
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<ScheduleMatchDto>): List<ScheduleMatchDto> {
|
||||
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<ScheduleMatchDto>,
|
||||
allMatches: List<ScheduleMatchDto>,
|
||||
scope: ScheduleMatchScope,
|
||||
ownTeamName: String,
|
||||
otherTeamName: String,
|
||||
): List<ScheduleMatchDto> {
|
||||
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<ClubTeamDto>): List<ClubTeamDto> =
|
||||
teams.filter { it.league != null && (it.league?.id ?: 0) > 0 }
|
||||
}
|
||||
@@ -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<InternalTournamentSummaryDto> {
|
||||
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()
|
||||
}
|
||||
}
|
||||
@@ -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<String> = 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<ApiLogListRowDto> = 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,
|
||||
)
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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<Int> = emptyList(),
|
||||
val playersPlanned: List<Int> = emptyList(),
|
||||
val playersPlayed: List<Int> = 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<Int> = emptyList(),
|
||||
val playersPlanned: List<Int> = emptyList(),
|
||||
val playersPlayed: List<Int> = emptyList(),
|
||||
)
|
||||
|
||||
enum class ScheduleMatchScope {
|
||||
Own,
|
||||
All,
|
||||
Other,
|
||||
}
|
||||
|
||||
enum class ScheduleViewMode {
|
||||
Team,
|
||||
Overall,
|
||||
Adult,
|
||||
}
|
||||
@@ -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<OfficialParticipationEntryDto> = emptyList(),
|
||||
)
|
||||
@@ -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<ApiLogListRowDto> = 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<ApiLogsState> = _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
|
||||
}
|
||||
}
|
||||
@@ -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<InternalTournamentSummaryDto> = 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<ClubInternalTournamentsState> = _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"),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<OfficialTournamentListRowDto> = emptyList(),
|
||||
val participationBuckets: List<OfficialParticipationBucketDto> = emptyList(),
|
||||
val isLoading: Boolean = false,
|
||||
val error: String? = null,
|
||||
)
|
||||
|
||||
class OfficialTournamentsReadManager(
|
||||
private val api: OfficialTournamentsApi,
|
||||
) {
|
||||
private val _state = MutableStateFlow(OfficialTournamentsReadState())
|
||||
val state: StateFlow<OfficialTournamentsReadState> = _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"),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<PendingUserClubJoinDto> = emptyList(),
|
||||
val isLoading: Boolean = false,
|
||||
val error: String? = null,
|
||||
)
|
||||
|
||||
class PendingApprovalsManager(
|
||||
private val clubApprovalsApi: ClubApprovalsApi,
|
||||
) {
|
||||
private val _state = MutableStateFlow(PendingApprovalsState())
|
||||
val state: StateFlow<PendingApprovalsState> = _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)
|
||||
}
|
||||
}
|
||||
@@ -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<ClubPermissionMemberDto> = emptyList(),
|
||||
val availableRoles: List<AvailableRoleDto> = emptyList(),
|
||||
val permissionStructure: Map<String, PermissionResourceDto> = emptyMap(),
|
||||
val isLoading: Boolean = false,
|
||||
val error: String? = null,
|
||||
)
|
||||
|
||||
class PermissionsAdminManager(
|
||||
private val permissionsApi: PermissionsApi,
|
||||
) {
|
||||
private val _state = MutableStateFlow(PermissionsAdminState())
|
||||
val state: StateFlow<PermissionsAdminState> = _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)
|
||||
}
|
||||
}
|
||||
@@ -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<ClubTeamDto> = emptyList(),
|
||||
val selectedTeamId: Int? = null,
|
||||
val ownMatches: List<ScheduleMatchDto> = emptyList(),
|
||||
val allMatches: List<ScheduleMatchDto> = emptyList(),
|
||||
val overallMatches: List<ScheduleMatchDto> = emptyList(),
|
||||
val leagueTable: List<LeagueTableRowDto> = 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<ScheduleMatchDto>
|
||||
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<String>
|
||||
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<ScheduleState> = _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<Int>, planned: List<Int>, played: List<Int>) {
|
||||
matchesApi.updateMatchPlayers(
|
||||
matchId,
|
||||
UpdateMatchPlayersBody(clubId = clubId, playersReady = ready, playersPlanned = planned, playersPlayed = played),
|
||||
)
|
||||
refresh(clubId)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user