feat(ClubSettings): add country and state code fields for regional calendar data
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:
Torsten Schulz (local)
2026-05-12 23:46:07 +02:00
parent 1e23171370
commit bea5facb7d
46 changed files with 4286 additions and 12 deletions

View 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' });
}
}
};

View File

@@ -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);

View 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 });
}
};

View 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;

View 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;

View 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
);

View File

@@ -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,

View 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;

View File

@@ -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,

View 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;

View 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;

View File

@@ -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

View 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();

View File

@@ -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,

View 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();

View File

@@ -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') }}

View File

@@ -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 },

View File

@@ -79,6 +79,7 @@ const NOINDEX_PREFIXES = [
'/showclub',
'/members',
'/diary',
'/calendar',
'/pending-approvals',
'/schedule',
'/tournaments',

View 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>

View File

@@ -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>

View File

@@ -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
---

View File

@@ -41,6 +41,7 @@ kotlin {
implementation(libs.koin.android)
implementation(libs.coil.compose)
implementation(libs.yalantis.ucrop)
implementation(libs.ktor.serialization.kotlinx.json)
}
}
}

View File

@@ -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)

View File

@@ -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")
}

View File

@@ -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")) }
},
)
}
}

View File

@@ -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))
}
}
}
}
}

View File

@@ -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()
}

View File

@@ -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
}
}

View File

@@ -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) }
}
}

View File

@@ -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()
}
}

View File

@@ -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)
}
}
}

View File

@@ -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()
}
}

View File

@@ -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))
}
}
}

View File

@@ -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
}

View File

@@ -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 }
}

View File

@@ -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()
}
}

View File

@@ -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,
)

View File

@@ -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")
}

View File

@@ -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,
}

View File

@@ -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(),
)

View File

@@ -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
}
}

View File

@@ -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"),
)
}
}
}
}

View File

@@ -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"),
)
}
}
}
}

View File

@@ -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)
}
}

View File

@@ -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)
}
}

View File

@@ -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)
}
}