feat(Calendar): integrate CalendarEvent model and enhance calendar functionality
All checks were successful
Deploy tt-tagebuch / deploy (push) Successful in 43s

- Added CalendarEvent model to the backend, establishing relationships with the Club model for better event management.
- Updated server.js to include calendarEventRoutes, enabling API access for calendar events.
- Enhanced CalendarView.vue to support custom event creation and management, improving user interaction with the calendar.
- Refactored various components to streamline event handling and improve overall user experience in the calendar interface.
- Updated TODO and DEVELOPMENT documentation to reflect new calendar features and architectural decisions.
This commit is contained in:
Torsten Schulz (local)
2026-05-13 10:21:30 +02:00
parent 9be5f50ede
commit 004801b1a6
33 changed files with 2715 additions and 632 deletions

View File

@@ -0,0 +1,42 @@
import calendarEventService from '../services/calendarEventService.js';
import { getSafeErrorMessage } from '../utils/errorUtils.js';
export const listClubCalendarEvents = async (req, res) => {
try {
const { authcode: userToken } = req.headers;
const { clubId } = req.params;
const { year } = req.query;
const events = await calendarEventService.listClubEvents(userToken, clubId, year);
res.status(200).json(events);
} catch (error) {
console.error('[listClubCalendarEvents] - Error:', error);
const msg = getSafeErrorMessage(error, 'Fehler beim Laden der Kalender-Events');
res.status(error.statusCode || 500).json({ error: msg });
}
};
export const createClubCalendarEvent = async (req, res) => {
try {
const { authcode: userToken } = req.headers;
const { clubId } = req.params;
const event = await calendarEventService.createClubEvent(userToken, clubId, req.body);
res.status(201).json(event);
} catch (error) {
console.error('[createClubCalendarEvent] - Error:', error);
const msg = getSafeErrorMessage(error, 'Fehler beim Speichern des Kalender-Events');
res.status(error.statusCode || 500).json({ error: msg });
}
};
export const deleteClubCalendarEvent = async (req, res) => {
try {
const { authcode: userToken } = req.headers;
const { clubId, eventId } = req.params;
const result = await calendarEventService.deleteClubEvent(userToken, clubId, eventId);
res.status(200).json(result);
} catch (error) {
console.error('[deleteClubCalendarEvent] - Error:', error);
const msg = getSafeErrorMessage(error, 'Fehler beim Löschen des Kalender-Events');
res.status(error.statusCode || 500).json({ error: msg });
}
};

View File

@@ -0,0 +1,15 @@
CREATE TABLE IF NOT EXISTS calendar_events (
id INT AUTO_INCREMENT PRIMARY KEY,
club_id INT NOT NULL,
title VARCHAR(255) NOT NULL,
start_date DATE NOT NULL,
end_date DATE NOT NULL,
category VARCHAR(64) NULL,
notes TEXT NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
KEY idx_calendar_events_club_start (club_id, start_date),
CONSTRAINT fk_calendar_events_club
FOREIGN KEY (club_id) REFERENCES clubs(id)
ON DELETE CASCADE
);

View File

@@ -0,0 +1,25 @@
import { DataTypes } from 'sequelize';
import sequelize from '../database.js';
import Club from './Club.js';
const CalendarEvent = sequelize.define('CalendarEvent', {
id: { type: DataTypes.INTEGER, primaryKey: true, autoIncrement: true },
clubId: {
type: DataTypes.INTEGER,
allowNull: false,
references: { model: Club, key: 'id' },
onDelete: 'CASCADE',
},
title: { type: DataTypes.STRING(255), allowNull: false },
startDate: { type: DataTypes.DATEONLY, allowNull: false, field: 'start_date' },
endDate: { type: DataTypes.DATEONLY, allowNull: false, field: 'end_date' },
category: { type: DataTypes.STRING(64), allowNull: true },
notes: { type: DataTypes.TEXT, allowNull: true },
}, {
tableName: 'calendar_events',
underscored: true,
timestamps: true,
indexes: [{ fields: ['club_id', 'start_date'] }],
});
export default CalendarEvent;

View File

@@ -59,6 +59,7 @@ import MemberTrainingGroup from './MemberTrainingGroup.js';
import ClubDisabledPresetGroup from './ClubDisabledPresetGroup.js';
import TrainingTime from './TrainingTime.js';
import TrainingCancellation from './TrainingCancellation.js';
import CalendarEvent from './CalendarEvent.js';
import BillingTemplate from './BillingTemplate.js';
import BillingTemplateField from './BillingTemplateField.js';
import BillingRun from './BillingRun.js';
@@ -410,6 +411,8 @@ TrainingGroup.hasMany(TrainingTime, { foreignKey: 'trainingGroupId', as: 'traini
TrainingTime.belongsTo(TrainingGroup, { foreignKey: 'trainingGroupId', as: 'trainingGroup' });
Club.hasMany(TrainingCancellation, { foreignKey: 'clubId', as: 'trainingCancellations' });
TrainingCancellation.belongsTo(Club, { foreignKey: 'clubId', as: 'club' });
Club.hasMany(CalendarEvent, { foreignKey: 'clubId', as: 'calendarEvents' });
CalendarEvent.belongsTo(Club, { foreignKey: 'clubId', as: 'club' });
// Billing
Club.hasMany(BillingTemplate, { foreignKey: 'clubId', as: 'billingTemplates' });
@@ -488,6 +491,7 @@ export {
ClubDisabledPresetGroup,
TrainingTime,
TrainingCancellation,
CalendarEvent,
BillingTemplate,
BillingTemplateField,
BillingRun,

View File

@@ -0,0 +1,16 @@
import express from 'express';
import { authenticate } from '../middleware/authMiddleware.js';
import {
createClubCalendarEvent,
deleteClubCalendarEvent,
listClubCalendarEvents,
} from '../controllers/calendarEventController.js';
const router = express.Router();
router.use(authenticate);
router.get('/:clubId', listClubCalendarEvents);
router.post('/:clubId', createClubCalendarEvent);
router.delete('/:clubId/:eventId', deleteClubCalendarEvent);
export default router;

View File

@@ -15,6 +15,7 @@ import {
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, TrainingCancellation
, CalendarEvent
} from './models/index.js';
import authRoutes from './routes/authRoutes.js';
import clubRoutes from './routes/clubRoutes.js';
@@ -60,6 +61,7 @@ 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 calendarEventRoutes from './routes/calendarEventRoutes.js';
import schedulerService from './services/schedulerService.js';
import { requestLoggingMiddleware } from './middleware/requestLoggingMiddleware.js';
import HttpError from './exceptions/HttpError.js';
@@ -310,6 +312,7 @@ app.use('/api/member-orders', memberOrderRoutes);
app.use('/api/member-group-photos', memberGroupPhotoRoutes);
app.use('/api/billing', billingRoutes);
app.use('/api/calendar', calendarRoutes);
app.use('/api/calendar-events', calendarEventRoutes);
// Middleware für dynamischen kanonischen Tag (vor express.static)
const setCanonicalTag = (req, res, next) => {
@@ -565,6 +568,7 @@ app.use((err, req, res, next) => {
await safeSync(BillingUserSetting);
await safeSync(ClubTeam);
await safeSync(TrainingCancellation);
await safeSync(CalendarEvent);
await safeSync(TeamDocument);
// Foreign Keys wieder aktivieren

View File

@@ -0,0 +1,59 @@
import { Op } from 'sequelize';
import CalendarEvent from '../models/CalendarEvent.js';
import { checkAccess } from '../utils/userUtils.js';
import HttpError from '../exceptions/HttpError.js';
class CalendarEventService {
async listClubEvents(userToken, clubId, year) {
await checkAccess(userToken, clubId);
const normalizedYear = this.normalizeYear(year);
return await CalendarEvent.findAll({
where: {
clubId,
startDate: { [Op.lte]: `${normalizedYear}-12-31` },
endDate: { [Op.gte]: `${normalizedYear}-01-01` },
},
order: [['startDate', 'ASC'], ['title', 'ASC']],
});
}
async createClubEvent(userToken, clubId, payload) {
await checkAccess(userToken, clubId);
const title = String(payload?.title || '').trim();
if (!title) throw new HttpError('Titel fehlt', 400);
const startDate = this.normalizeDate(payload?.startDate);
const endDate = this.normalizeDate(payload?.endDate || payload?.startDate);
if (!startDate || !endDate) throw new HttpError('Ungültiges Datum', 400);
if (startDate > endDate) throw new HttpError('Enddatum darf nicht vor dem Startdatum liegen', 400);
return await CalendarEvent.create({
clubId,
title,
startDate,
endDate,
category: payload?.category ? String(payload.category).trim().slice(0, 64) : null,
notes: payload?.notes ? String(payload.notes).trim() : null,
});
}
async deleteClubEvent(userToken, clubId, eventId) {
await checkAccess(userToken, clubId);
const event = await CalendarEvent.findOne({ where: { id: eventId, clubId } });
if (!event) throw new HttpError('Event nicht gefunden', 404);
await event.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 CalendarEventService();

View File

@@ -29,39 +29,78 @@
</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>
<details v-if="currentClub" class="calendar-options" :open="optionsOpen">
<summary class="calendar-options-summary" @click.prevent="optionsOpen = !optionsOpen">
<span>Optionen</span>
<small>Ausfälle & eigene Termine</small>
</summary>
<div class="calendar-options-body">
<section class="training-cancellation-panel">
<div>
<h3>Training fällt aus</h3>
<p>Blendet regelmäßige 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 (optional)" />
<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)"
title="Löschen"
>
<strong>{{ formatShortDate(cancellation.date) }}</strong>
<span>{{ cancellation.title }}</span>
<small>Löschen</small>
</button>
</div>
</section>
<section class="custom-event-panel">
<div>
<h3>Eigene Termine</h3>
<p>Kreistage, Sitzungen, interne Meetings, ...</p>
</div>
<form class="custom-event-form" @submit.prevent="saveCustomEvent">
<input v-model="customEventForm.title" type="text" placeholder="Titel" required />
<input v-model="customEventForm.startDate" type="date" required />
<input v-model="customEventForm.endDate" type="date" />
<input v-model="customEventForm.category" type="text" placeholder="Kategorie (optional)" />
<button type="submit" :disabled="customEventSaving">
{{ customEventSaving ? 'Speichern...' : 'Anlegen' }}
</button>
</form>
<div v-if="visibleCustomEvents.length" class="custom-event-list">
<button
v-for="event in visibleCustomEvents"
:key="`ce-${event.customEventId}`"
type="button"
class="custom-event-item"
@click="deleteCustomEvent(event)"
title="Löschen"
>
<strong>{{ formatEventDate(event) }}</strong>
<span>{{ event.title }}</span>
<small>Löschen</small>
</button>
</div>
</section>
</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>
</details>
<section v-if="sourceWarnings.length" class="calendar-state calendar-state-warning">
{{ sourceWarnings.join(' · ') }}
@@ -116,17 +155,34 @@
<span class="agenda-time">{{ event.time }}</span>
</button>
</section>
<ConfirmDialog
v-model="confirmDialog.isOpen"
:title="confirmDialog.title"
:message="confirmDialog.message"
:details="confirmDialog.details"
:type="confirmDialog.type"
:confirm-text="confirmDialog.confirmText"
:cancel-text="confirmDialog.cancelText"
:show-cancel="confirmDialog.showCancel"
@confirm="handleConfirmResult(true)"
@cancel="handleConfirmResult(false)"
/>
</div>
</template>
<script>
import { mapGetters } from 'vuex';
import apiClient from '../apiClient';
import ConfirmDialog from '../components/ConfirmDialog.vue';
const WEEKDAYS = ['Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa', 'So'];
export default {
name: 'CalendarView',
components: {
ConfirmDialog,
},
data() {
const today = new Date();
return {
@@ -142,18 +198,39 @@ export default {
match: true,
holiday: true,
schoolHoliday: true,
trainingCancellation: true
trainingCancellation: true,
customEvent: true
},
cancellationSaving: false,
cancellationForm: {
startDate: '',
endDate: '',
reason: ''
}
},
customEventSaving: false,
customEventForm: {
title: '',
startDate: '',
endDate: '',
category: ''
},
optionsOpen: false,
confirmDialog: {
isOpen: false,
title: '',
message: '',
details: '',
type: 'info',
confirmText: '',
cancelText: '',
showCancel: true,
resolveCallback: null,
},
plannedTrainingByDateKey: {},
};
},
computed: {
...mapGetters(['currentClub']),
...mapGetters(['currentClub', 'currentClubName']),
weekdays() {
return WEEKDAYS;
},
@@ -165,7 +242,8 @@ export default {
{ key: 'match', label: 'Punktspiel' },
{ key: 'holiday', label: 'Feiertag' },
{ key: 'schoolHoliday', label: 'Ferien' },
{ key: 'trainingCancellation', label: 'Ausfall' }
{ key: 'trainingCancellation', label: 'Ausfall' },
{ key: 'customEvent', label: 'Termin' }
];
},
monthLabel() {
@@ -184,13 +262,14 @@ export default {
const monthEnd = new Date(year, month + 1, 0);
return this.visibleEventPool
.filter(event => this.isEventInRange(event, monthStart, monthEnd))
.filter(event => this.shouldShowEventOnDate(event, this.toDateKey(event.date)))
.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, officialTournament: 0, match: 0, holiday: 0, schoolHoliday: 0, trainingCancellation: 0 });
}, { training: 0, tournament: 0, officialTournament: 0, match: 0, holiday: 0, schoolHoliday: 0, trainingCancellation: 0, customEvent: 0 });
},
visibleTrainingCancellations() {
const month = this.cursor.getMonth();
@@ -200,6 +279,14 @@ export default {
.filter(event => event.date.getFullYear() === year && event.date.getMonth() === month)
.sort((a, b) => a.startsAt - b.startsAt);
},
visibleCustomEvents() {
const month = this.cursor.getMonth();
const year = this.cursor.getFullYear();
return this.events
.filter(event => event.type === 'customEvent')
.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`);
},
@@ -223,6 +310,7 @@ export default {
isToday: key === todayKey,
events: this.visibleEventPool
.filter(event => this.isEventOnDate(event, date))
.filter(event => this.shouldShowEventOnDate(event, key))
.sort((a, b) => a.startsAt - b.startsAt)
};
});
@@ -240,6 +328,32 @@ export default {
}
},
methods: {
async showConfirm(title, message, details = '', type = 'info', options = {}) {
return new Promise((resolve) => {
this.confirmDialog = {
isOpen: true,
title,
message,
details,
type,
confirmText: options.confirmText || '',
cancelText: options.cancelText || '',
showCancel: options.showCancel !== undefined ? !!options.showCancel : true,
resolveCallback: resolve,
};
});
},
handleConfirmResult(confirmed) {
if (this.confirmDialog.resolveCallback) {
this.confirmDialog.resolveCallback(confirmed);
}
this.confirmDialog.isOpen = false;
this.confirmDialog.resolveCallback = null;
},
isOurTeam(teamName) {
if (!teamName || !this.currentClubName) return false;
return String(teamName).startsWith(this.currentClubName);
},
async loadCalendarEvents() {
if (!this.currentClub) {
this.events = [];
@@ -254,6 +368,7 @@ export default {
this.loadSource('Trainingstage', () => this.loadTrainingEvents()),
this.loadSource('Trainingszeiten', () => this.loadRecurringTrainingEvents()),
this.loadSource('Trainingsausfälle', () => this.loadTrainingCancellationEvents()),
this.loadSource('Eigene Termine', () => this.loadCustomEvents()),
this.loadSource('Turniere', () => this.loadTournamentEvents()),
this.loadSource('Turnierteilnahmen', () => this.loadOfficialTournamentEvents()),
this.loadSource('Punktspiele', () => this.loadMatchEvents()),
@@ -264,6 +379,14 @@ export default {
.filter(result => result.status === 'fulfilled')
.flatMap(result => result.value.events)
.filter(event => event.date && !Number.isNaN(event.date.getTime()));
this.plannedTrainingByDateKey = loadedEvents
.filter(event => event.type === 'training')
.reduce((map, event) => {
map[this.toDateKey(event.date)] = true;
return map;
}, {});
const cancellationDates = new Set(
loadedEvents
.filter(event => event.type === 'trainingCancellation')
@@ -470,6 +593,50 @@ export default {
this.ensureSuccess(response, 'Trainingsausfälle');
await this.loadCalendarEvents();
},
async loadCustomEvents() {
const response = await apiClient.get(`/calendar-events/${this.currentClub}`, {
params: { year: this.displayedYear },
});
this.ensureSuccess(response, 'Eigene Termine');
return (response.data || []).map(event => {
const date = this.parseDate(event.startDate);
const endDate = this.parseDate(event.endDate || event.startDate);
return {
id: `custom-event-${event.id}`,
customEventId: event.id,
type: 'customEvent',
date,
endDate,
startsAt: this.combineDateTime(date),
time: '',
title: event.title,
subtitle: event.category || 'Eigener Termin',
};
});
},
async saveCustomEvent() {
if (!this.currentClub || !this.customEventForm.title || !this.customEventForm.startDate) return;
this.customEventSaving = true;
try {
const response = await apiClient.post(`/calendar-events/${this.currentClub}`, {
title: this.customEventForm.title,
startDate: this.customEventForm.startDate,
endDate: this.customEventForm.endDate || this.customEventForm.startDate,
category: this.customEventForm.category || null,
});
this.ensureSuccess(response, 'Eigene Termine');
this.customEventForm = { title: '', startDate: '', endDate: '', category: '' };
await this.loadCalendarEvents();
} finally {
this.customEventSaving = false;
}
},
async deleteCustomEvent(event) {
if (!this.currentClub || !event?.customEventId) return;
const response = await apiClient.delete(`/calendar-events/${this.currentClub}/${event.customEventId}`);
this.ensureSuccess(response, 'Eigene Termine');
await this.loadCalendarEvents();
},
async loadTournamentEvents() {
const response = await apiClient.get(`/tournament/${this.currentClub}`);
this.ensureSuccess(response, 'Turniere');
@@ -490,7 +657,9 @@ export default {
async loadMatchEvents() {
const response = await apiClient.get(`/matches/leagues/${this.currentClub}/matches`);
this.ensureSuccess(response, 'Punktspiele');
return (response.data || []).map(match => {
return (response.data || [])
.filter(match => this.isOurTeam(match.homeTeam?.name) || this.isOurTeam(match.guestTeam?.name))
.map(match => {
const date = this.parseDate(match.date);
const home = match.homeTeam?.name || 'Heim';
const guest = match.guestTeam?.name || 'Gast';
@@ -603,6 +772,10 @@ export default {
const eventEnd = this.startOfDay(event.endDate || event.date);
return eventStart <= this.startOfDay(rangeEnd) && eventEnd >= this.startOfDay(rangeStart);
},
shouldShowEventOnDate(event, dateKey) {
if (event.type !== 'trainingCancellation') return true;
return Boolean(this.plannedTrainingByDateKey[dateKey]);
},
startOfDay(date) {
const result = new Date(date);
result.setHours(0, 0, 0, 0);
@@ -644,9 +817,51 @@ export default {
this.cursor = new Date(today.getFullYear(), today.getMonth(), 1);
},
openEvent(event) {
if (event?.type === 'holiday' || event?.type === 'schoolHoliday') {
this.offerTrainingCancellationForHoliday(event);
return;
}
if (event.route) {
this.$router.push(event.route);
}
},
async offerTrainingCancellationForHoliday(event) {
if (!this.currentClub) return;
const startDateKey = this.toDateKey(event.date);
const endDateKey = this.toDateKey(event.endDate || event.date);
const isRange = startDateKey !== endDateKey;
const shouldCreate = await this.showConfirm(
'Trainingsausfall',
'Training als Ausfall markieren?',
'',
'warning',
{ confirmText: 'Ja', cancelText: 'Nein' }
);
if (!shouldCreate) return;
let useRange = false;
if (isRange) {
useRange = await this.showConfirm(
'Zeitraum',
`Gilt der Ausfall für den gesamten Zeitraum (${startDateKey} bis ${endDateKey})?`,
'',
'info',
{ confirmText: 'Zeitraum', cancelText: 'Nur Tag' }
);
}
const reason = `${event.title}${event.subtitle ? ` (${event.subtitle})` : ''}`.trim();
await this.createTrainingCancellation(startDateKey, useRange ? endDateKey : startDateKey, reason);
await this.loadCalendarEvents();
},
async createTrainingCancellation(startDate, endDate, reason) {
const response = await apiClient.post(`/training-cancellations/${this.currentClub}`, {
startDate,
endDate,
reason
});
this.ensureSuccess(response, 'Trainingsausfälle');
}
}
};
@@ -662,6 +877,7 @@ export default {
.calendar-header,
.calendar-toolbar,
.training-cancellation-panel,
.custom-event-panel,
.calendar-agenda {
border: 1px solid #dfe7e2;
border-radius: 10px;
@@ -731,7 +947,7 @@ export default {
justify-content: space-between;
gap: 1rem;
align-items: center;
padding: 0.85rem 1rem;
padding: 0.65rem 1rem;
}
.legend-item {
@@ -741,13 +957,50 @@ export default {
border: 1px solid #d6e2dc;
border-radius: 999px;
background: #f8fbf9;
font-size: 0.84rem;
font-size: 0.8rem;
font-weight: 700;
color: #40524b;
cursor: pointer;
padding: 0.35rem 0.65rem;
}
.calendar-options {
border: 1px solid #dfe7e2;
border-radius: 10px;
background: #fff;
box-shadow: 0 6px 18px rgba(15, 23, 42, 0.05);
overflow: hidden;
}
.calendar-options-summary {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 0.75rem;
cursor: pointer;
padding: 0.75rem 1rem;
background: #f8fbf9;
border-bottom: 1px solid #edf1ee;
user-select: none;
}
.calendar-options-summary span {
font-weight: 900;
color: #14251f;
}
.calendar-options-summary small {
color: #607169;
font-weight: 700;
}
.calendar-options-body {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1rem;
padding: 1rem;
}
.legend-item.inactive {
opacity: 0.45;
}
@@ -766,12 +1019,76 @@ export default {
.legend-holiday::before { background: #dc2626; }
.legend-schoolHoliday::before { background: #7c3aed; }
.legend-trainingCancellation::before { background: #64748b; }
.legend-customEvent::before { background: #0ea5e9; }
.custom-event-panel {
display: grid;
grid-template-columns: 1fr;
gap: 1rem;
padding: 0;
}
.custom-event-panel h3 {
margin: 0;
color: #14251f;
}
.custom-event-panel p {
margin: 0.25rem 0 0;
color: #607169;
font-size: 0.9rem;
}
.custom-event-form {
display: grid;
grid-template-columns: minmax(14rem, 1fr) 10rem 10rem 12rem auto;
gap: 0.5rem;
}
.custom-event-form input,
.custom-event-form button {
border: 1px solid #cfdad4;
border-radius: 8px;
min-height: 2.4rem;
padding: 0 0.75rem;
}
.custom-event-form button {
background: #0ea5e9;
color: #fff;
cursor: pointer;
font-weight: 800;
}
.custom-event-list {
display: flex;
grid-column: 1 / -1;
flex-wrap: wrap;
gap: 0.5rem;
}
.custom-event-item {
display: inline-flex;
align-items: center;
gap: 0.45rem;
border: 1px solid #bae6fd;
border-radius: 999px;
background: #f0f9ff;
color: #0c4a6e;
cursor: pointer;
padding: 0.35rem 0.65rem;
}
.custom-event-item small {
color: #991b1b;
font-weight: 800;
}
.training-cancellation-panel {
display: grid;
grid-template-columns: minmax(14rem, 1fr) minmax(18rem, 2fr);
grid-template-columns: 1fr;
gap: 1rem;
padding: 1rem;
padding: 0;
}
.training-cancellation-panel h3 {
@@ -931,6 +1248,7 @@ export default {
.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-customEvent { background: #f0f9ff; border-left: 4px solid #0ea5e9; }
.event-time {
font-size: 0.72rem;
@@ -1008,16 +1326,25 @@ export default {
@media (max-width: 900px) {
.calendar-header,
.calendar-toolbar,
.training-cancellation-panel {
.training-cancellation-panel,
.custom-event-panel {
align-items: stretch;
grid-template-columns: 1fr;
flex-direction: column;
}
.calendar-options-body {
grid-template-columns: 1fr;
}
.training-cancellation-form {
grid-template-columns: 1fr;
}
.custom-event-form {
grid-template-columns: 1fr;
}
.calendar-grid {
display: flex;
flex-direction: column;

View File

@@ -1,5 +1,47 @@
# Development
## Architektur Phase 0 (abgeschlossen)
Die folgenden Punkte entsprechen **Phase 0** in `TODO.md`: festgelegte Entscheidungen und **Ist-Zustand** der Codebasis, ohne dass die App komplett neu strukturiert wird.
### Navigation
- **Single Activity**, Haupt-UI in `AppRoot.kt`: eingeloggt → `MainTabs` mit **`MainTab`** (Start, Tagebuch, Mitglieder, …).
- **Breit / schmal:** ab ca. 600dp `MainNavigationRail`, sonst `BottomNavigation` (siehe `MAIN_NAV_RAIL_MIN_WIDTH_DP`).
- **Unter-Navigation / Tiefe:** kein zentraler **Navigation-Compose**-`NavHost` für die Shell; stattdessen **expliziter Zustand** (`rememberSaveable` / `remember`), z.B. `diarySelectedEntryId`, `membersNestedOpen`, `billingOrdersSection`. **Zurück** über `BackHandler` und Tab-Wechsel, der Detailzustand zurücksetzt (`selectMainTab`).
- **Auth:** eigener Flow (`AuthFlowHost`) vor Club-Auswahl ebenfalls ohne separates Nav-Graph-Modul.
- **Begründung (kein zentraler NavHost):** geringere Migrationskosten, vorhandenes Verhalten beibehalten. Ein späterer Umstieg auf `NavHost` (z.B. pro Tab oder für Deep Links) ist möglich.
- **Start-Hub (`HomeScreen`):** Nur **Willkommen** + Vereinsinfos (Karte); keine Navigations-Kacheln mehr (vermeidet leere Abschnitte und Doppelung mit der Rail).
- **Navigation Rail (Tablet / breit):** Drei **aufklappbare Blöcke** wie die Web-Sidebar **Tagesgeschäft**, **Wettbewerb**, **Einstellungen** mit allen Einträgen inkl. Freigaben, Turnierteilnahmen (Browser), Vereinseinstellungen, Vordefinierte Aktivitäten, Team (Browser), Abrechnung, Bestellungen; oben **Start**, unten **Mehr**. Zustand der Aufklappung per `rememberSaveable`.
- **Smartphone:** Weiterhin **untere Tab-Leiste** (`visibleMainTabs`) + vollständige Einstellungen unter **Mehr**; Kurzhinweis auf der Startseite.
- **Tab-Sichtbarkeit:** Entspricht grob den Web-`v-if`-Bedingungen (`visibleMainTabs` in `AppRoot.kt`, u.a. Kalender nur bei Tagebuch-, Terminplan- oder Turnier-Leserecht).
### Feature-Paketierung (Richtlinie + Ist)
- **`shared`:** HTTP-APIs (`shared/.../api`), DTOs (`api/models`), zustandsführende **Manager** (`shared/.../state`), i18n.
- **`composeApp`:** Android-UI unter `composeApp/.../ui/` mit **dateiweise** Feature-Namen (`Diary*`, `Members*`, `Schedule*`, `CalendarScreen`, …) sowie `AppRoot.kt`, `AppDependencies.kt`.
- **Zielbild:** Neue Features als eigene Dateien/ Unterpakete `ui/<bereich>/` anlegen; keine weiteren riesigen Monolithen neben `AppRoot` anhäufen, sondern schrittweise auslagern (laufende Arbeit).
### Use-Cases / Geschäftslogik
- **Primär `shared`:** `*Manager`-Klassen kapseln API + Caching/Flow (z.B. `DiaryManager`, `ClubManager`).
- **Plattformnah Android:** reine UI-Hilfen oder `java.time`-lastige Aggregation bleiben in `composeApp` (z.B. `calendar/CalendarAggregator.kt`), angebunden über `AppDependencies`.
- **Composables:** möglichst nur Zustand, `LaunchedEffect`, Aufrufe in Manager/APIs; komplexe Ableitungen in wiederverwendbaren Funktionen/Klassen statt inline riesiger Blöcke.
### Echtzeit (optional / Web-Parität)
- **Web** nutzt u.a. Socket-Updates; **Android v1:** bewusst **ohne** parallelen Socket-Client.
- **Aktualisierung:** `LaunchedEffect` bei Screen-Fokus / `clubId` / relevanten Keys, nach Schreiboperationen (`dataGeneration++` o. ä.) oder Tabwechsel. **Polling** nur dort, wo bereits im Code nachvollziehbar (kein globales Intervall-Polling).
- **Später:** optional `socket.io`-Client im `shared` (Dependency existiert bereits) anbinden oder dokumentiertes Polling siehe `TODO.md` Backlog.
### Medien (Bilder / PDF)
- **Bilder:** **Coil** (`coil-compose`), authentifizierte Requests wo nötig (`diaryAuthHeaders` / `AuthenticatedAsyncImage`).
- **PDF / Teilen:** Generierung im App-Code, Ausgabe über **FileProvider** und System-**Intent** (`sharePdfFile` u.a. im Tagebuch-Flow).
- **Cache:** Standard Coil-/Systemverhalten; kein separates Offline-Framework in Phase 0.
---
## Backend-URL
**Standard:** `https://tt-tagebuch.de` gesetzt in `gradle.properties` (`backendBaseUrl=…`) und als Fallback in `composeApp/build.gradle.kts`.

View File

@@ -26,12 +26,12 @@ Dieses Dokument ist die **Arbeitsliste**, um die **funktionale Abdeckung der Web
## Phase 0 Architektur & Grundlagen für Vollausbau
- [ ] **Navigation:** Zentraler Nav-Graph (z. B. Navigation Compose) mit Back-Stack pro Tab oder einheitlichem Stack; Tiefe: Club → Modul → Unterseiten
- [ ] **Feature-Paketierung:** UI/API pro Bereich trennen (`diary`, `members`, `schedule`, …), Composables entschlacken
- [ ] **Use-Cases:** Geschäftslogik aus Composables in testbare Funktionen/Klassen im `shared`
- [x] **Navigation:** Single-Activity-Modell mit `MainTab` + zustandsbasierter Tiefe (Tagebuch-Tag, Mitglieder-Details, Einstellungen-Unterseiten); Rail/Bottom-Nav; **kein** zentraler Navigation-Compose-`NavHost` für die Shell (bewusste Entscheidung, später migrierbar) siehe `DEVELOPMENT.md` § Architektur Phase 0, Umsetzung `AppRoot.kt`
- [x] **Feature-Paketierung:** Richtlinie und Ist: APIs/DTOs/Manager im `shared`, UI in `composeApp/.../ui/` mit featurebezogenen Dateien; schrittweise Entschlackung großer Dateien `DEVELOPMENT.md` § Architektur Phase 0
- [x] **Use-Cases:** Geschäftslogik überwiegend in `shared/state/*Manager` und gezielt in plattformnahen Helfern (`CalendarAggregator` o. ä.); Composables orchestrieren `DEVELOPMENT.md` § Architektur Phase 0
- [x] **Fehlerbild (Basis):** JSON-Fehlerbody (`error` / `message`) aus API-Antworten → [ApiException]-Text (`ApiErrorMessage.kt`, authed + public Client); bekannte Tokens (`alreadyexists`, …) → Deutsch; Retries bewusst noch offen
- [ ] **Echtzeit (optional):** Socket-Events wie Web (`socketService`) oder dokumentiertes Polling nach Schreiboperationen
- [ ] **Medien:** Entscheidung Bilder/PDF (Coil, Cache, Download, Intents)
- [x] **Echtzeit (optional):** v1 ohne Web-Socket; Aktualisierung über `LaunchedEffect` / manuelles Neuladen dokumentiert; optional Socket/Polling später `DEVELOPMENT.md` § Architektur Phase 0
- [x] **Medien:** Coil für Bilder, FileProvider + Share-Intents für PDF; dokumentiert in `DEVELOPMENT.md` § Architektur Phase 0
- [x] **Öffentlicher HTTP-Client** ohne Auth-Header (`PublicHttpClient`, `PublicAuthApi` im `shared`)
---
@@ -199,15 +199,24 @@ Web: `DiaryView.vue` (sehr groß). API-Cluster (Auszug in Web nach `apiClien
Web-Route: `/calendar` · Referenz: `CalendarView.vue` (Aggregation mehrerer Datenquellen in einer Monatsansicht).
- [ ] **Navigation & Shell:** Tab oder Hub-Eintrag (Berechtigungen klären), Monat vor/zurück, „Heute“, Monatsüberschrift
- [ ] **Monatsgitter:** 7×6 wie Web, Kennzeichnung „außerhalb Monat / heute, Wochentagskopf
- [ ] **Legende / Filter:** Eventtypen einblendbar (Training, Turnier, Teilnahme offiziell, Punktspiel, Feiertag, Ferien, Trainingsausfall) inkl. Zähler
- [ ] **Daten laden (parallel, teilfehlertolerant):** gleiche Quellen wie Web u. a. Tagebuch-Trainingstage, `GET /api/training-times/:clubId`, Trainingsausfälle, Vereins-Turniere, offizielle Teilnahmen, Punktspiele (`MatchesApi`/League-Endpoints), Feiertage/Ferien (Kalenderregion Verein); Fehler pro Quelle wie `sourceWarnings` optional anzeigen
- [ ] **Logik:** wiederkehrende Trainingszeiten expandieren, Zusammenführen von Slots (`mergeRecurringTrainingSlots`), Ausfälle blenden wiederkehrende Slots aus Verhalten an Web angleichen
- [ ] **Trainingsausfall:** Formular (Datum, optional Bis, Grund), Liste im Monat, Speichern/Löschen API wie Web (`CalendarView` / Backend-Routen zu `training_cancellations` verifizieren)
- [ ] **Agenda:** sortierte Liste „Termine im Monat“ unter dem Grid
- [ ] **Event-Aktion:** Klick → sinnvolle Ziel-Navigation (Tagebuch-Tag, Turnier-Detail, Spiel-Detail, externer Link o. ä.) wie `openEvent` im Web
- [ ] **i18n:** Texte über `MobileStrings` / Keys abstimmen mit Web-`$t` wo sinnvoll
- [x] **Navigation & Shell:** Haupt-Tab `MainTab.Calendar` + Start-Hub-Kachel (`AppRoot.kt`), Monat vor/zurück, „Heute“, Monatsüberschrift (`CalendarScreen.kt`)
- [x] **Monatsgitter:** 7×6, außerhalb Monat / heute, Wochentagskopf (`CalendarScreen.kt`, `CalendarAggregator.kt`)
- [x] **Legende / Filter:** Eventtypen mit Schalter und Zähler (`CalendarScreen.kt`)
- [x] **Daten laden (parallel, teilfehlertolerant):** `supervisorScope` + `async` in `CalendarScreen.kt`; Quellen: `DiaryManager.listDates`, Trainingszeiten, `TrainingCancellationApi`, `TournamentsApi`, `MatchesApi`, `OfficialTournamentsReadManager`, `CalendarHolidayApi` (`CalendarDtos.kt`, APIs in `shared`)
- [x] **Logik:** `CalendarAggregator.kt` (wiederkehrende Zeiten, Merge, Ausfälle) an Web angelehnt
- [x] **Trainingsausfall:** Formular + Liste im Monat + API (`TrainingCancellationApi`, `CalendarScreen.kt`)
- [x] **Agenda:** sortierte Monatsliste (`CalendarAggregator.eventsInMonth`, `CalendarScreen.kt`)
- [x] **Event-Aktion:** `CalendarEventAction` → Tagebuch-Tag/Tab, Terminplan, Turniere, Web `/tournament-participations` (`calendar/CalendarAggregator.kt` / `CalendarScreen.kt`, Verdrahtung `AppRoot.kt`)
- [x] **i18n:** `MobileStrings`-Keys mit Fallback in `CalendarScreen.kt` / `AppRoot.kt` (`navigation.calendar`, `home.tileCalendar`, `mobile.calendar*`)
- [x] **Web-Parität (Detail):** Meldung wenn **alle** Kalender-Quellen fehlschlagen; Legenden-Zähler aus **allen** Events (unabhängig von den Schaltern); Agenda-Datum **kurz/lokalisiert**; Ausfall-Liste nur wenn **Startdatum im Monat** liegt (wie `CalendarView.vue`) `CalendarScreen.kt`
### Phase 12 Backlog / offen
- [ ] **iOS:** Kalender-UI + Tab (derzeit nur Android `composeApp` / `MainTab.Calendar`)
- [ ] **i18n:** Kalender-Keys in `MobileStrings.kt` für alle unterstützten Sprachen ergänzen (nicht nur Fallback im Code)
- [ ] **Kalender vs. Web:** Offizielle Teilnahmen mobil per Browser vs. Web in-app bewusst lassen oder später angleichen
- [ ] **Release:** Bei `isMinifyEnabled = true` ProGuard/R8 für Ktor, `kotlinx.serialization`, ggf. Koin (`composeApp/proguard-rules.pro`)
- [x] **Turniere (Produktiv-Crash):** `LazyColumn` mit eindeutigen Keys (`itemsIndexed` für Vereins-Turniere, offizielle Liste, Teilnahmen-Zeilen), vermeidet Duplicate-Key-Abstürze; zudem tolerante Deserialisierung für `allowsExternal` / `isDoublesTournament` (0/1, Strings) `TournamentsScreen.kt`, `FlexibleBooleanSerializers.kt`, `TournamentDtos.kt`
---

View File

@@ -4,6 +4,10 @@ val backendBaseUrl = providers.gradleProperty("backendBaseUrl")
.orElse("https://tt-tagebuch.de")
.get()
val socketBaseUrl = providers.gradleProperty("socketBaseUrl")
.orElse("wss://tt-tagebuch.de:3051")
.get()
plugins {
alias(libs.plugins.kotlinMultiplatform)
alias(libs.plugins.androidApplication)
@@ -57,6 +61,7 @@ android {
versionCode = 1
versionName = "1.0.0"
buildConfigField("String", "BACKEND_BASE_URL", "\"$backendBaseUrl\"")
buildConfigField("String", "SOCKET_BASE_URL", "\"$socketBaseUrl\"")
}
buildFeatures {
buildConfig = true

View File

@@ -1,9 +1,8 @@
package de.tt_tagebuch.app
import android.content.Context
import android.content.Intent
import android.net.Uri
import de.tt_tagebuch.shared.api.BillingApi
import de.tt_tagebuch.shared.api.CalendarHolidayApi
import de.tt_tagebuch.shared.api.AccidentApi
import de.tt_tagebuch.shared.api.ApiLogsApi
import de.tt_tagebuch.shared.api.ClubApprovalsApi
@@ -24,6 +23,7 @@ import de.tt_tagebuch.shared.api.MemberActivitiesApi
import de.tt_tagebuch.shared.api.MemberGroupPhotosApi
import de.tt_tagebuch.shared.api.MemberTransferConfigApi
import de.tt_tagebuch.shared.api.MemberOrdersApi
import de.tt_tagebuch.shared.api.TrainingCancellationApi
import de.tt_tagebuch.shared.api.MembersApi
import de.tt_tagebuch.shared.api.MyTischtennisApi
import de.tt_tagebuch.shared.api.OfficialTournamentsApi
@@ -58,7 +58,6 @@ import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.MutableStateFlow
class AppDependencies(context: Context) {
private val appContext = context.applicationContext
private val applicationJob = SupervisorJob()
/**
@@ -102,13 +101,17 @@ class AppDependencies(context: Context) {
val pendingApprovalsManager = PendingApprovalsManager(ClubApprovalsApi(client))
val permissionsAdminManager = PermissionsAdminManager(permissionsApi)
val apiLogsManager = ApiLogsManager(ApiLogsApi(client))
val clubInternalTournamentsManager = ClubInternalTournamentsManager(TournamentsApi(client))
val tournamentsApi = TournamentsApi(client)
val matchesApi = MatchesApi(client)
val clubInternalTournamentsManager = ClubInternalTournamentsManager(tournamentsApi)
val officialTournamentsReadManager = OfficialTournamentsReadManager(OfficialTournamentsApi(client))
val memberTransferConfigApi = MemberTransferConfigApi(client)
val myTischtennisApi = MyTischtennisApi(client)
val clickTtAccountApi = ClickTtAccountApi(client)
val memberOrdersApi = MemberOrdersApi(client)
val billingApi = BillingApi(client)
val trainingCancellationApi = TrainingCancellationApi(client)
val calendarHolidayApi = CalendarHolidayApi(client)
val diaryManager = DiaryManager(
DiaryApi(client),
@@ -129,7 +132,7 @@ class AppDependencies(context: Context) {
val trainingStatsManager = TrainingStatsManager(TrainingStatsApi(client))
val scheduleManager = ScheduleManager(
ClubTeamsApi(client),
MatchesApi(client),
matchesApi,
)
val languageManager = LanguageManager(AndroidLanguageStorage(context.applicationContext))
val sessionApi = SessionApi(client)
@@ -139,13 +142,4 @@ class AppDependencies(context: Context) {
tokenProvider.token?.let { put("authcode", it) }
tokenProvider.username?.let { put("userid", it) }
}
/** Öffnet einen Pfad auf dem konfigurierten Backend im Browser (z. B. Impressum, Datenschutz). */
fun openBackendPath(path: String) {
val base = apiConfig.baseUrl.trimEnd('/')
val suffix = path.trim().let { p -> if (p.startsWith("/")) p else "/$p" }
val uri = Uri.parse("$base$suffix")
val intent = Intent(Intent.ACTION_VIEW, uri).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
runCatching { appContext.startActivity(intent) }
}
}

View File

@@ -0,0 +1,393 @@
package de.tt_tagebuch.app.calendar
import de.tt_tagebuch.shared.api.models.CalendarHolidayRowDto
import de.tt_tagebuch.shared.api.models.ClubCalendarHolidaysEnvelope
import de.tt_tagebuch.shared.api.models.DiaryDate
import de.tt_tagebuch.shared.api.models.InternalTournamentSummaryDto
import de.tt_tagebuch.shared.api.models.OfficialParticipationBucketDto
import de.tt_tagebuch.shared.api.models.ScheduleMatchDto
import de.tt_tagebuch.shared.api.models.TrainingCancellationDto
import de.tt_tagebuch.shared.api.models.TrainingGroupDto
import de.tt_tagebuch.shared.api.models.TrainingTimeDto
import java.time.DayOfWeek
import java.time.LocalDate
import java.time.LocalTime
import java.time.YearMonth
import java.time.format.DateTimeFormatter
import java.time.format.DateTimeParseException
private val isoFmt: DateTimeFormatter = DateTimeFormatter.ISO_LOCAL_DATE
private val dmyFmt: DateTimeFormatter = DateTimeFormatter.ofPattern("d.M.yyyy")
enum class CalendarEventType {
Training,
Tournament,
OfficialTournament,
Match,
Holiday,
SchoolHoliday,
TrainingCancellation,
}
enum class CalendarEventAction {
OpenDiaryDate,
OpenDiaryTab,
OpenSchedule,
OpenTournaments,
OpenOfficialParticipations,
None,
}
data class CalendarUiEvent(
val id: String,
val type: CalendarEventType,
val date: LocalDate,
val endDate: LocalDate = date,
val timeLabel: String,
val title: String,
val subtitle: String,
val startsAtSort: Long,
val action: CalendarEventAction = CalendarEventAction.None,
val diaryDateId: Int? = null,
val tournamentId: Int? = null,
val matchId: Int? = null,
val cancellationId: Int? = null,
val isRecurringTraining: Boolean = false,
) {
val dateIso: String get() = date.format(isoFmt)
val endDateIso: String get() = endDate.format(isoFmt)
}
private fun parseIso(s: String?): LocalDate? = try {
if (s.isNullOrBlank()) null
else LocalDate.parse(s.trim().take(10), isoFmt)
} catch (_: DateTimeParseException) {
null
}
private fun parseDmy(s: String?): LocalDate? = try {
if (s.isNullOrBlank()) null
else LocalDate.parse(s.trim(), dmyFmt)
} catch (_: DateTimeParseException) {
null
}
private fun minutesFromTime(time: String?): Int {
val t = time?.trim()?.take(5) ?: return 0
val parts = t.split(':')
val h = parts.getOrNull(0)?.toIntOrNull() ?: 0
val m = parts.getOrNull(1)?.toIntOrNull() ?: 0
return h.coerceIn(0, 23) * 60 + m.coerceIn(0, 59)
}
private fun combineSortKey(d: LocalDate, time: String?): Long {
val day = d.toEpochDay()
return day * 1440L + minutesFromTime(time)
}
private fun formatTimeRange(start: String?, end: String?): String {
val a = start?.trim()?.take(5) ?: ""
val b = end?.trim()?.take(5) ?: ""
return when {
a.isNotEmpty() && b.isNotEmpty() -> "$a$b"
a.isNotEmpty() -> a
b.isNotEmpty() -> b
else -> ""
}
}
private fun formatTime(time: String?): String = time?.trim()?.take(5) ?: ""
/** JS Date.getDay(): 0 = Sunday … 6 = Saturday */
private fun jsWeekday(d: LocalDate): Int {
val dow = d.dayOfWeek
return when (dow) {
DayOfWeek.SUNDAY -> 0
DayOfWeek.MONDAY -> 1
DayOfWeek.TUESDAY -> 2
DayOfWeek.WEDNESDAY -> 3
DayOfWeek.THURSDAY -> 4
DayOfWeek.FRIDAY -> 5
DayOfWeek.SATURDAY -> 6
}
}
private fun normalizeTrainingTimeKey(time: String): String =
time.replace('\u2013', '-').replace('\u2012', '-').replace('\u2212', '-')
.replace(" ", "")
.filter { it != '\u00a0' }
private fun genericTitle(t: String) = t.isBlank() || t.trim().equals("training", ignoreCase = true)
object CalendarAggregator {
fun fromDiaryDates(diary: List<DiaryDate>, displayedYear: Int): List<CalendarUiEvent> =
diary.mapNotNull { entry ->
val d = parseIso(entry.date) ?: return@mapNotNull null
if (d.year != displayedYear) return@mapNotNull null
val timeLabel = formatTimeRange(entry.trainingStart, entry.trainingEnd)
val tags = entry.diaryTags.joinToString(", ") { it.name }
CalendarUiEvent(
id = "training-diary-${entry.id}",
type = CalendarEventType.Training,
date = d,
timeLabel = timeLabel,
title = "Training",
subtitle = tags,
startsAtSort = combineSortKey(d, entry.trainingStart),
action = CalendarEventAction.OpenDiaryDate,
diaryDateId = entry.id,
isRecurringTraining = false,
)
}
fun fromRecurringTraining(groups: List<TrainingGroupDto>, displayedYear: Int): List<CalendarUiEvent> {
val out = ArrayList<CalendarUiEvent>()
for (group in groups) {
for (time in group.trainingTimes) {
out.addAll(createRecurringTrainingForSlot(group, time, displayedYear))
}
}
return out
}
private fun createRecurringTrainingForSlot(group: TrainingGroupDto, time: TrainingTimeDto, year: Int): List<CalendarUiEvent> {
val weekday = time.weekday
if (weekday !in 0..6 || time.startTime.isBlank()) return emptyList()
val jan1 = LocalDate.of(year, 1, 1)
val w0 = jsWeekday(jan1)
var firstOffset = (weekday - w0 + 7) % 7
var cursor = jan1.plusDays(firstOffset.toLong())
val list = ArrayList<CalendarUiEvent>()
while (cursor.year == year) {
val timeLabel = formatTimeRange(time.startTime, time.endTime)
list.add(
CalendarUiEvent(
id = "training-time-${group.id}-${time.id}-${cursor.format(isoFmt)}",
type = CalendarEventType.Training,
date = cursor,
timeLabel = timeLabel,
title = group.name.ifBlank { "Training" },
subtitle = "Regelmäßige Trainingszeit",
startsAtSort = combineSortKey(cursor, time.startTime),
action = CalendarEventAction.OpenDiaryTab,
isRecurringTraining = true,
),
)
cursor = cursor.plusWeeks(1)
}
return list
}
fun fromCancellations(rows: List<TrainingCancellationDto>): List<CalendarUiEvent> =
rows.mapNotNull { c ->
val start = parseIso(c.startDate ?: c.date) ?: return@mapNotNull null
val end = parseIso(c.endDate ?: c.startDate ?: c.date) ?: start
CalendarUiEvent(
id = "training-cancellation-${c.id}",
type = CalendarEventType.TrainingCancellation,
date = start,
endDate = end,
timeLabel = "",
title = c.reason?.trim()?.ifBlank { null } ?: "Training fällt aus",
subtitle = "Trainingsausfall",
startsAtSort = combineSortKey(start, null),
cancellationId = c.id,
action = CalendarEventAction.None,
)
}
fun fromTournaments(rows: List<InternalTournamentSummaryDto>): List<CalendarUiEvent> =
rows.mapNotNull { t ->
val d = parseIso(t.date) ?: return@mapNotNull null
CalendarUiEvent(
id = "tournament-${t.id}",
type = CalendarEventType.Tournament,
date = d,
timeLabel = "",
title = t.name?.trim()?.ifBlank { null } ?: "Turnier",
subtitle = if (t.allowsExternal == true) "Offenes Turnier" else "Vereinsturnier",
startsAtSort = combineSortKey(d, null),
tournamentId = t.id,
action = CalendarEventAction.OpenTournaments,
)
}
fun fromMatches(matches: List<ScheduleMatchDto>): List<CalendarUiEvent> =
matches.mapNotNull { m ->
val d = parseIso(m.date) ?: return@mapNotNull null
val home = m.homeTeam?.name?.ifBlank { null } ?: "Heim"
val guest = m.guestTeam?.name?.ifBlank { null } ?: "Gast"
CalendarUiEvent(
id = "match-${m.id}",
type = CalendarEventType.Match,
date = d,
timeLabel = formatTime(m.time),
title = "$home $guest",
subtitle = m.leagueDetails?.name?.ifBlank { null } ?: "Punktspiel",
startsAtSort = combineSortKey(d, m.time),
matchId = m.id,
action = CalendarEventAction.OpenSchedule,
)
}
fun fromOfficialParticipations(buckets: List<OfficialParticipationBucketDto>): List<CalendarUiEvent> =
buckets.mapNotNull { t ->
if (t.entries.isEmpty()) return@mapNotNull null
val date = parseDmy(t.startDate) ?: parseIso(t.startDate) ?: return@mapNotNull null
val end = parseDmy(t.endDate) ?: parseIso(t.endDate) ?: date
val distinctMembers = t.entries.mapNotNull { it.memberId }.toSet().size
val subtitle = if (distinctMembers > 0) "$distinctMembers Teilnehmer" else "${t.entries.size} Starts"
CalendarUiEvent(
id = "official-tournament-${t.tournamentId ?: t.title}",
type = CalendarEventType.OfficialTournament,
date = date,
endDate = end,
timeLabel = "",
title = t.title?.trim()?.ifBlank { null } ?: "Turnierteilnahme",
subtitle = subtitle,
startsAtSort = combineSortKey(date, null),
action = CalendarEventAction.OpenOfficialParticipations,
)
}
fun fromHolidays(env: ClubCalendarHolidaysEnvelope): List<CalendarUiEvent> {
val h = env.holidays.mapNotNull { mapHolidayRow(it, CalendarEventType.Holiday, "Feiertag") }
val s = env.schoolHolidays.mapNotNull { mapHolidayRow(it, CalendarEventType.SchoolHoliday, "Schulferien") }
return h + s
}
private fun mapHolidayRow(entry: CalendarHolidayRowDto, type: CalendarEventType, fallback: String): CalendarUiEvent? {
val start = parseIso(entry.startDate) ?: return null
val end = parseIso(entry.endDate) ?: start
val name = entry.name?.trim()?.ifBlank { null } ?: fallback
return CalendarUiEvent(
id = "${type.name.lowercase()}-${entry.id ?: "${start.format(isoFmt)}-$name"}",
type = type,
date = start,
endDate = end,
timeLabel = "",
title = name,
subtitle = fallback,
startsAtSort = combineSortKey(start, null),
action = CalendarEventAction.None,
)
}
fun mergeRecurringTrainingSlots(events: List<CalendarUiEvent>): List<CalendarUiEvent> {
val slotMap = LinkedHashMap<String, MutableList<CalendarUiEvent>>()
val passthrough = ArrayList<CalendarUiEvent>()
for (e in events) {
if (e.type != CalendarEventType.Training || e.timeLabel.isBlank()) {
passthrough.add(e)
continue
}
val timeKey = normalizeTrainingTimeKey(e.timeLabel)
if (timeKey.isEmpty()) {
passthrough.add(e)
continue
}
val key = "${e.date.format(isoFmt)}|$timeKey"
slotMap.getOrPut(key) { mutableListOf() }.add(e)
}
val merged = ArrayList<CalendarUiEvent>()
for ((slotKey, list) in slotMap) {
if (list.size == 1) {
merged.add(list[0])
continue
}
val sorted = list.sortedBy { it.startsAtSort }
val base = sorted.first()
val rawTitles = sorted.map { it.title.trim() }.filter { it.isNotEmpty() }.distinct()
val specific = rawTitles.filter { !genericTitle(it) }
val titleJoined = if (specific.isNotEmpty()) specific.joinToString(" · ") else rawTitles.joinToString(" · ").ifBlank { base.title }
val safeIdKey = slotKey.replace('|', '-')
val hasRecurring = sorted.any { it.isRecurringTraining }
val otherSubtitles = sorted.map { it.subtitle.trim() }.filter { it.isNotEmpty() && it != "Regelmäßige Trainingszeit" }.distinct()
val subtitleJoined = if (hasRecurring) {
if (otherSubtitles.isNotEmpty()) "Regelmäßige Trainingszeit · ${otherSubtitles.joinToString(" · ")}"
else "Regelmäßige Trainingszeit"
} else {
otherSubtitles.joinToString(" · ").ifBlank { base.subtitle }
}
merged.add(
base.copy(
id = "training-merged-$safeIdKey",
title = titleJoined,
subtitle = subtitleJoined,
startsAtSort = sorted.minOf { it.startsAtSort },
isRecurringTraining = hasRecurring,
),
)
}
return passthrough + merged
}
private fun expandCancellationDates(start: LocalDate, end: LocalDate): List<LocalDate> {
val out = ArrayList<LocalDate>()
var cur = start
var guard = 0
while (!cur.isAfter(end) && guard++ < 800) {
out.add(cur)
cur = cur.plusDays(1)
}
return out
}
fun applyTrainingCancellationsToRecurring(events: List<CalendarUiEvent>): List<CalendarUiEvent> {
val cancellationDates = events
.filter { it.type == CalendarEventType.TrainingCancellation }
.flatMap { expandCancellationDates(it.date, it.endDate) }
.map { it.format(isoFmt) }
.toSet()
return events.filter { e ->
if (e.type != CalendarEventType.Training || !e.isRecurringTraining) return@filter true
!cancellationDates.contains(e.date.format(isoFmt))
}
}
fun buildAll(
displayedYear: Int,
diary: List<DiaryDate>,
trainingGroups: List<TrainingGroupDto>,
cancellations: List<TrainingCancellationDto>,
tournaments: List<InternalTournamentSummaryDto>,
matches: List<ScheduleMatchDto>,
official: List<OfficialParticipationBucketDto>,
holidays: ClubCalendarHolidaysEnvelope,
): List<CalendarUiEvent> {
val raw = ArrayList<CalendarUiEvent>()
raw.addAll(fromDiaryDates(diary, displayedYear))
raw.addAll(fromRecurringTraining(trainingGroups, displayedYear))
raw.addAll(fromCancellations(cancellations))
raw.addAll(fromTournaments(tournaments))
raw.addAll(fromMatches(matches))
raw.addAll(fromOfficialParticipations(official))
raw.addAll(fromHolidays(holidays))
val afterCancel = applyTrainingCancellationsToRecurring(raw)
return mergeRecurringTrainingSlots(afterCancel)
}
fun eventsForDay(events: List<CalendarUiEvent>, day: LocalDate): List<CalendarUiEvent> =
events.filter { e ->
!day.isBefore(e.date) && !day.isAfter(e.endDate)
}.sortedBy { it.startsAtSort }
fun eventsInMonth(events: List<CalendarUiEvent>, yearMonth: YearMonth): List<CalendarUiEvent> {
val start = yearMonth.atDay(1)
val end = yearMonth.atEndOfMonth()
return events.filter { e ->
!e.date.isAfter(end) && !e.endDate.isBefore(start)
}.sortedBy { it.startsAtSort }
}
fun monthGridStart(yearMonth: YearMonth): LocalDate {
val first = yearMonth.atDay(1)
val wd = jsWeekday(first)
val back = (wd + 6) % 7
return first.minusDays(back.toLong())
}
fun monthGridCells(gridStart: LocalDate): List<LocalDate> =
(0 until 42).map { gridStart.plusDays(it.toLong()) }
}

View File

@@ -19,6 +19,7 @@ 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
@@ -592,12 +593,6 @@ private fun BillingClubScreen(dependencies: AppDependencies, onBack: () -> Unit)
)
}
}
OutlinedButton(
onClick = { dependencies.openBackendPath("/billing") },
modifier = Modifier.fillMaxWidth().padding(top = 8.dp).heightIn(min = TouchMin),
) {
Text(tr("mobile.billingOpenWebHint", "Vollständige Abrechnung im Browser (PDF-Vorlagen-Mapping)"))
}
info?.let { Text(it, color = MaterialTheme.colors.primary, modifier = Modifier.padding(top = 8.dp)) }
err?.let { Text(it, color = MaterialTheme.colors.error, modifier = Modifier.padding(top = 8.dp)) }
@@ -901,6 +896,13 @@ private fun BillingClubScreen(dependencies: AppDependencies, onBack: () -> Unit)
}
}
Spacer(Modifier.height(24.dp))
Divider(Modifier.padding(vertical = 8.dp))
Text(
tr("mobile.billingWebOptionalHint", "PDF-Vorlagen mit interaktiver Feldzuordnung bearbeiten (Webbrowser)."),
style = MaterialTheme.typography.caption,
color = MaterialTheme.colors.onSurface.copy(alpha = 0.65f),
)
// Web-UI wird in der Produktiv-App nicht aufgerufen.
}
deleteRunTarget?.let { target ->

View File

@@ -0,0 +1,498 @@
package de.tt_tagebuch.app.ui
import androidx.compose.foundation.background
import androidx.compose.foundation.border
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.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.Button
import androidx.compose.material.Card
import androidx.compose.material.CircularProgressIndicator
import androidx.compose.material.MaterialTheme
import androidx.compose.material.OutlinedTextField
import androidx.compose.material.Switch
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.mutableIntStateOf
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.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import de.tt_tagebuch.app.AppDependencies
import de.tt_tagebuch.app.calendar.CalendarAggregator
import de.tt_tagebuch.app.calendar.CalendarEventAction
import de.tt_tagebuch.app.calendar.CalendarEventType
import de.tt_tagebuch.app.calendar.CalendarUiEvent
import android.util.Log
import de.tt_tagebuch.shared.api.http.ApiException
import de.tt_tagebuch.shared.api.models.ClubCalendarHolidaysEnvelope
import de.tt_tagebuch.shared.api.models.TrainingCancellationUpsertBody
import de.tt_tagebuch.shared.i18n.MobileStrings
import kotlinx.coroutines.async
import kotlinx.coroutines.launch
import kotlinx.coroutines.supervisorScope
import java.time.LocalDate
import java.time.YearMonth
import java.time.format.DateTimeFormatter
import java.util.Locale
private val monthTitleFmt: DateTimeFormatter = DateTimeFormatter.ofPattern("LLLL yyyy", Locale.GERMAN)
private val wdShort = listOf("Mo", "Di", "Mi", "Do", "Fr", "Sa", "So")
private fun localeForApp(languageCode: String): Locale {
val tag = languageCode.replace('_', '-')
return runCatching { Locale.forLanguageTag(tag) }
.getOrNull()
?.takeIf { it.language.isNotBlank() }
?: Locale.GERMAN
}
private fun formatAgendaDay(date: LocalDate, locale: Locale): String {
val fmt = DateTimeFormatter.ofPattern("EEE, dd.MM.", locale)
return date.format(fmt)
}
private fun formatAgendaEventRange(ev: CalendarUiEvent, locale: Locale): String {
if (ev.date == ev.endDate) return formatAgendaDay(ev.date, locale)
return "${formatAgendaDay(ev.date, locale)} ${formatAgendaDay(ev.endDate, locale)}"
}
private const val CalendarLogTag = "TTCalendar"
/**
* Fehlende API-Berechtigung (403) ist für den Kalender erwartbar, wenn die Rolle z. B. kein
* schedule.read hat — dann keine rote „nicht geladen“-Warnung. Andere Fehler erscheinen in Logcat.
*/
private fun recordCalendarSourceFailure(
result: Result<*>,
sourceLabel: String,
warns: MutableList<String>,
) {
if (result.isSuccess) return
val err = result.exceptionOrNull() ?: return
if ((err as? ApiException)?.statusCode == 403) {
Log.i(
CalendarLogTag,
"Kalender-Quelle \"$sourceLabel\": keine Berechtigung (403). ${err.message}",
)
return
}
Log.w(CalendarLogTag, "Kalender-Quelle \"$sourceLabel\" fehlgeschlagen", err)
warns.add(sourceLabel)
}
private fun isUnauthorizedCalendarSource(result: Result<*>): Boolean {
if (result.isSuccess) return false
return (result.exceptionOrNull() as? ApiException)?.statusCode == 403
}
@Composable
fun CalendarScreen(
dependencies: AppDependencies,
onOpenDiaryDate: (Int) -> Unit,
onOpenDiaryTab: () -> Unit,
onOpenSchedule: () -> Unit,
onOpenTournaments: () -> Unit,
onOpenOfficialParticipations: () -> Unit,
) {
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 scope = rememberCoroutineScope()
var calYear by remember { mutableIntStateOf(LocalDate.now().year) }
var calMonth by remember { mutableIntStateOf(LocalDate.now().monthValue) }
val yearMonth = remember(calYear, calMonth) { YearMonth.of(calYear, calMonth) }
val displayedYear = calYear
var loading by remember { mutableStateOf(false) }
var events by remember { mutableStateOf<List<CalendarUiEvent>>(emptyList()) }
var dataGeneration by remember { mutableIntStateOf(0) }
val activeTypes = remember { mutableStateMapOf<CalendarEventType, Boolean>() }
CalendarEventType.entries.forEach { t ->
if (!activeTypes.containsKey(t)) activeTypes[t] = true
}
var sourceWarnings by remember { mutableStateOf<List<String>>(emptyList()) }
var loadAllSourcesFailed by remember { mutableStateOf(false) }
val agendaLocale = remember(languageCode) { localeForApp(languageCode) }
var cancelStart by remember { mutableStateOf("") }
var cancelEnd by remember { mutableStateOf("") }
var cancelReason by remember { mutableStateOf("") }
var cancelBusy by remember { mutableStateOf(false) }
LaunchedEffect(clubId, displayedYear, calMonth, dataGeneration) {
loading = true
loadAllSourcesFailed = false
sourceWarnings = emptyList()
val warns = mutableListOf<String>()
supervisorScope {
val diaryR = async { runCatching { dependencies.diaryManager.listDates(clubId) } }
val ttR = async { runCatching { dependencies.membersManager.trainingScheduleGroups(clubId) } }
val canR = async { runCatching { dependencies.trainingCancellationApi.list(clubId, displayedYear) } }
val tourR = async { runCatching { dependencies.tournamentsApi.listTournaments(clubId) } }
val matchR = async { runCatching { dependencies.matchesApi.listMatchesForLeagues(clubId) } }
val offR = async { runCatching { dependencies.officialTournamentsReadManager.fetchParticipationSummary(clubId) } }
val holR = async { runCatching { dependencies.calendarHolidayApi.getClubHolidays(clubId, displayedYear) } }
val dr = diaryR.await()
val ttr = ttR.await()
val canr = canR.await()
val tourr = tourR.await()
val matchr = matchR.await()
val offr = offR.await()
val holr = holR.await()
recordCalendarSourceFailure(dr, tr("mobile.calendarSourceDiary", "Tagebuch"), warns)
recordCalendarSourceFailure(ttr, tr("mobile.calendarSourceTrainingTimes", "Trainingszeiten"), warns)
recordCalendarSourceFailure(canr, tr("mobile.calendarSourceCancellations", "Trainingsausfälle"), warns)
recordCalendarSourceFailure(tourr, tr("mobile.calendarSourceTournaments", "Turniere"), warns)
recordCalendarSourceFailure(matchr, tr("mobile.calendarSourceMatches", "Punktspiele"), warns)
recordCalendarSourceFailure(offr, tr("mobile.calendarSourceOfficial", "Turnierteilnahmen"), warns)
recordCalendarSourceFailure(holr, tr("mobile.calendarSourceHolidays", "Ferien/Feiertage"), warns)
val results = listOf(dr, ttr, canr, tourr, matchr, offr, holr)
loadAllSourcesFailed =
results.all { it.isFailure } &&
results.any { !isUnauthorizedCalendarSource(it) }
events = CalendarAggregator.buildAll(
displayedYear = displayedYear,
diary = dr.getOrDefault(emptyList()),
trainingGroups = ttr.getOrDefault(emptyList()),
cancellations = canr.getOrDefault(emptyList()),
tournaments = tourr.getOrDefault(emptyList()),
matches = matchr.getOrDefault(emptyList()),
official = offr.getOrDefault(emptyList()),
holidays = holr.getOrElse { ClubCalendarHolidaysEnvelope() },
)
}
sourceWarnings = warns.distinct()
loading = false
}
val activeSnapshot = activeTypes.toMap()
val filtered = remember(events, activeSnapshot) {
events.filter { activeSnapshot[it.type] != false }
}
val visibleMonthEvents = remember(filtered, yearMonth) {
CalendarAggregator.eventsInMonth(filtered, yearMonth)
}
val gridStart = remember(yearMonth) { CalendarAggregator.monthGridStart(yearMonth) }
val gridCells = remember(gridStart) { CalendarAggregator.monthGridCells(gridStart) }
fun typeLabel(t: CalendarEventType): String = when (t) {
CalendarEventType.Training -> tr("mobile.calendarLegendTraining", "Training")
CalendarEventType.Tournament -> tr("mobile.calendarLegendTournament", "Turnier")
CalendarEventType.OfficialTournament -> tr("mobile.calendarLegendOfficial", "Teilnahme")
CalendarEventType.Match -> tr("mobile.calendarLegendMatch", "Punktspiel")
CalendarEventType.Holiday -> tr("mobile.calendarLegendHoliday", "Feiertag")
CalendarEventType.SchoolHoliday -> tr("mobile.calendarLegendSchool", "Ferien")
CalendarEventType.TrainingCancellation -> tr("mobile.calendarLegendCancellation", "Ausfall")
}
@Composable
fun eventColor(t: CalendarEventType) = when (t) {
CalendarEventType.Training -> MaterialTheme.colors.primary
CalendarEventType.Tournament -> MaterialTheme.colors.secondary
CalendarEventType.OfficialTournament -> MaterialTheme.colors.secondaryVariant
CalendarEventType.Match -> MaterialTheme.colors.primaryVariant
CalendarEventType.Holiday -> MaterialTheme.colors.error.copy(alpha = 0.75f)
CalendarEventType.SchoolHoliday -> MaterialTheme.colors.error.copy(alpha = 0.45f)
CalendarEventType.TrainingCancellation -> MaterialTheme.colors.onSurface.copy(alpha = 0.55f)
}
fun onEventClick(e: CalendarUiEvent) {
when (e.action) {
CalendarEventAction.OpenDiaryDate -> e.diaryDateId?.let { onOpenDiaryDate(it) }
CalendarEventAction.OpenDiaryTab -> onOpenDiaryTab()
CalendarEventAction.OpenSchedule -> onOpenSchedule()
CalendarEventAction.OpenTournaments -> onOpenTournaments()
CalendarEventAction.OpenOfficialParticipations -> onOpenOfficialParticipations()
CalendarEventAction.None -> Unit
}
}
Column(
modifier = Modifier
.fillMaxWidth()
.verticalScroll(rememberScrollState())
.padding(horizontal = 16.dp, vertical = 12.dp),
) {
Text(tr("mobile.calendarTitle", "Kalender"), style = MaterialTheme.typography.h6)
Text(
tr("mobile.calendarSubtitle", "Training, Turniere, Spiele und Feiertage im Monat."),
style = MaterialTheme.typography.caption,
color = MaterialTheme.colors.onSurface.copy(alpha = 0.72f),
)
Row(
Modifier.fillMaxWidth().padding(top = 12.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
TextButton(onClick = {
val p = yearMonth.minusMonths(1)
calYear = p.year
calMonth = p.monthValue
}) { Text("") }
TextButton(onClick = {
val n = LocalDate.now()
calYear = n.year
calMonth = n.monthValue
}) { Text(tr("mobile.calendarToday", "Heute")) }
TextButton(onClick = {
val n = yearMonth.plusMonths(1)
calYear = n.year
calMonth = n.monthValue
}) { Text("") }
}
val titleRaw = yearMonth.format(monthTitleFmt)
Text(
titleRaw.replaceFirstChar { if (it.isLowerCase()) it.titlecase(Locale.GERMAN) else it.toString() },
style = MaterialTheme.typography.subtitle1,
fontWeight = FontWeight.SemiBold,
modifier = Modifier.padding(bottom = 8.dp),
)
if (loading) {
CircularProgressIndicator(modifier = Modifier.padding(vertical = 8.dp))
}
if (loadAllSourcesFailed && !loading) {
Text(
tr("mobile.calendarAllSourcesFailed", "Kalenderdaten konnten nicht geladen werden."),
color = MaterialTheme.colors.error,
style = MaterialTheme.typography.body2,
fontWeight = FontWeight.Medium,
modifier = Modifier.padding(bottom = 8.dp),
)
}
if (sourceWarnings.isNotEmpty()) {
Text(
sourceWarnings.joinToString(" · ") { "$it ${tr("mobile.calendarSourceFailed", "nicht geladen")}" },
color = MaterialTheme.colors.error,
style = MaterialTheme.typography.caption,
modifier = Modifier.padding(bottom = 8.dp),
)
}
Text(tr("mobile.calendarLegend", "Anzeige"), style = MaterialTheme.typography.caption, fontWeight = FontWeight.Medium)
CalendarEventType.entries.chunked(2).forEach { rowTypes ->
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
rowTypes.forEach { t ->
val count = events.count { it.type == t }
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.weight(1f).padding(vertical = 2.dp),
) {
Switch(
checked = activeTypes[t] != false,
onCheckedChange = { activeTypes[t] = it },
)
Text(
"${typeLabel(t)} ($count)",
style = MaterialTheme.typography.caption,
modifier = Modifier.padding(start = 4.dp),
)
}
}
}
}
Card(Modifier.fillMaxWidth().padding(top = 12.dp), elevation = 1.dp) {
Column(Modifier.padding(12.dp)) {
Text(tr("mobile.calendarCancellationTitle", "Training fällt aus"), fontWeight = FontWeight.SemiBold)
Text(
tr("mobile.calendarCancellationHint", "Blendet regelmäßige Trainingszeiten an den Tagen aus."),
style = MaterialTheme.typography.caption,
color = MaterialTheme.colors.onSurface.copy(alpha = 0.7f),
)
OutlinedTextField(
value = cancelStart,
onValueChange = { cancelStart = it },
label = { Text(tr("mobile.calendarCancellationStart", "Datum (YYYY-MM-DD)")) },
modifier = Modifier.fillMaxWidth().padding(top = 8.dp),
singleLine = true,
)
OutlinedTextField(
value = cancelEnd,
onValueChange = { cancelEnd = it },
label = { Text(tr("mobile.calendarCancellationEnd", "Bis optional")) },
modifier = Modifier.fillMaxWidth().padding(top = 4.dp),
singleLine = true,
)
OutlinedTextField(
value = cancelReason,
onValueChange = { cancelReason = it },
label = { Text(tr("mobile.calendarCancellationReason", "Grund")) },
modifier = Modifier.fillMaxWidth().padding(top = 4.dp),
singleLine = true,
)
Button(
onClick = {
if (cancelStart.length < 10) return@Button
scope.launch {
cancelBusy = true
runCatching {
val end = cancelEnd.trim().ifBlank { cancelStart.trim() }
dependencies.trainingCancellationApi.upsert(
clubId,
TrainingCancellationUpsertBody(
startDate = cancelStart.trim().take(10),
endDate = end.take(10),
reason = cancelReason.trim().ifBlank { null },
),
)
cancelStart = ""
cancelEnd = ""
cancelReason = ""
dataGeneration++
}
cancelBusy = false
}
},
enabled = !cancelBusy && cancelStart.length >= 10,
modifier = Modifier.fillMaxWidth().padding(top = 8.dp).heightIn(min = 48.dp),
) { Text(if (cancelBusy) "" else tr("mobile.calendarCancellationSave", "Eintragen")) }
val monthCancels = events
.filter { it.type == CalendarEventType.TrainingCancellation && it.cancellationId != null }
.filter { it.date.year == calYear && it.date.monthValue == calMonth }
.sortedBy { it.startsAtSort }
if (monthCancels.isNotEmpty()) {
Text(tr("mobile.calendarCancellationInMonth", "Ausfälle diesen Monat"), modifier = Modifier.padding(top = 12.dp), fontWeight = FontWeight.Medium)
monthCancels.forEach { c ->
Row(
Modifier.fillMaxWidth().padding(vertical = 4.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Column(Modifier.weight(1f)) {
Text("${c.date}${c.endDate}", style = MaterialTheme.typography.caption)
Text(c.title, style = MaterialTheme.typography.body2, maxLines = 1, overflow = TextOverflow.Ellipsis)
}
TextButton(
onClick = {
val id = c.cancellationId ?: return@TextButton
scope.launch {
runCatching {
dependencies.trainingCancellationApi.delete(clubId, id)
dataGeneration++
}
}
},
) { Text(tr("common.delete", "Löschen"), color = MaterialTheme.colors.error) }
}
}
}
}
}
Text(tr("mobile.calendarGridTitle", "Monat"), style = MaterialTheme.typography.subtitle2, modifier = Modifier.padding(top = 16.dp))
Row(Modifier.fillMaxWidth()) {
wdShort.forEach { w ->
Text(w, style = MaterialTheme.typography.caption, modifier = Modifier.weight(1f), fontWeight = FontWeight.Medium)
}
}
gridCells.chunked(7).forEach { week ->
Row(Modifier.fillMaxWidth().padding(vertical = 2.dp)) {
week.forEach { day ->
val inMonth = day.month == yearMonth.month
val today = day == LocalDate.now()
val dayEvents = CalendarAggregator.eventsForDay(filtered, day)
Column(
Modifier
.weight(1f)
.padding(2.dp)
.border(1.dp, MaterialTheme.colors.onSurface.copy(alpha = 0.12f))
.background(
when {
today -> MaterialTheme.colors.primary.copy(alpha = 0.08f)
!inMonth -> MaterialTheme.colors.onSurface.copy(alpha = 0.04f)
else -> MaterialTheme.colors.surface
},
)
.padding(4.dp)
.heightIn(min = 72.dp),
) {
Text(
"${day.dayOfMonth}",
style = MaterialTheme.typography.caption,
fontWeight = if (today) FontWeight.Bold else FontWeight.Normal,
color = if (inMonth) MaterialTheme.colors.onSurface else MaterialTheme.colors.onSurface.copy(alpha = 0.45f),
)
dayEvents.take(2).forEach { ev ->
Text(
buildString {
if (ev.timeLabel.isNotBlank()) append(ev.timeLabel).append(" ")
append(ev.title)
},
style = MaterialTheme.typography.caption,
color = eventColor(ev.type),
maxLines = 2,
overflow = TextOverflow.Ellipsis,
modifier = Modifier
.padding(top = 2.dp)
.clickable { onEventClick(ev) },
)
}
if (dayEvents.size > 2) {
Text(
"+${dayEvents.size - 2}",
style = MaterialTheme.typography.caption,
color = MaterialTheme.colors.onSurface.copy(alpha = 0.5f),
)
}
}
}
}
}
Text(tr("mobile.calendarAgendaTitle", "Termine im Monat"), style = MaterialTheme.typography.subtitle2, modifier = Modifier.padding(top = 16.dp))
if (visibleMonthEvents.isEmpty()) {
Text(tr("mobile.calendarAgendaEmpty", "Keine Termine in diesem Monat."), style = MaterialTheme.typography.caption)
} else {
visibleMonthEvents.forEach { ev ->
Card(
Modifier
.fillMaxWidth()
.padding(vertical = 4.dp)
.clickable { onEventClick(ev) },
elevation = 1.dp,
) {
Row(
Modifier.padding(10.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Column(Modifier.weight(1f)) {
val dr = formatAgendaEventRange(ev, agendaLocale)
Text(dr, style = MaterialTheme.typography.caption, color = eventColor(ev.type))
Text(ev.title, fontWeight = FontWeight.Medium, maxLines = 2, overflow = TextOverflow.Ellipsis)
if (ev.subtitle.isNotBlank()) {
Text(ev.subtitle, style = MaterialTheme.typography.caption, maxLines = 1, overflow = TextOverflow.Ellipsis)
}
}
if (ev.timeLabel.isNotBlank()) {
Text(ev.timeLabel, style = MaterialTheme.typography.caption)
}
}
}
}
}
Spacer(Modifier.height(24.dp))
}
}

View File

@@ -72,6 +72,7 @@ internal enum class ClubStammdatenSection {
ClubSettings,
PredefinedActivities,
MemberTransfer,
Teams,
}
@Composable
@@ -85,6 +86,7 @@ internal fun ClubStammdatenFlowScreen(
ClubStammdatenSection.ClubSettings -> MobileClubSettingsScreen(dependencies, onBack)
ClubStammdatenSection.PredefinedActivities -> MobilePredefinedActivitiesScreen(dependencies, onBack)
ClubStammdatenSection.MemberTransfer -> MobileMemberTransferScreen(dependencies, onBack)
ClubStammdatenSection.Teams -> MobileTeamsScreen(dependencies, onBack)
}
}
@@ -132,10 +134,6 @@ private fun MobilePredefinedActivitiesScreen(dependencies: AppDependencies, onBa
Text(tr("mobile.noAccess", "Keine Berechtigung."))
return@Column
}
TextButton(
onClick = { dependencies.openBackendPath("/predefined-activities") },
modifier = Modifier.fillMaxWidth().heightIn(min = StammdatenTouchMin),
) { Text(tr("mobile.openPredefinedWeb", "Volle Verwaltung (Web)")) }
if (perms.canWritePredefinedActivities()) {
Button(
onClick = { creatingNew = true },
@@ -333,10 +331,6 @@ private fun MobileMemberTransferScreen(dependencies: AppDependencies, onBack: ()
Text(tr("mobile.noAccess", "Keine Berechtigung."))
return@Column
}
TextButton(
onClick = { dependencies.openBackendPath("/member-transfer-settings") },
modifier = Modifier.fillMaxWidth().heightIn(min = StammdatenTouchMin),
) { Text(tr("mobile.openMemberTransferWeb", "Erweiterte Einstellungen (Web)")) }
if (loading) {
CircularProgressIndicator(modifier = Modifier.padding(top = 24.dp))
return@Column
@@ -466,6 +460,59 @@ private fun MobileMemberTransferScreen(dependencies: AppDependencies, onBack: ()
}
}
@Composable
private fun MobileTeamsScreen(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 clubId = clubState.currentClubId ?: return
val perms = clubState.currentPermissions
val scheduleState by dependencies.scheduleManager.state.collectAsState()
LaunchedEffect(clubId) {
dependencies.scheduleManager.loadClubTeams(clubId)
}
Column(
modifier = Modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(StammdatenPad),
) {
StammdatenTopBar(tr("mobile.teamManagement", "Team-Verwaltung"), onBack)
if (perms?.canReadMembers() != true) {
Text(tr("mobile.noAccess", "Keine Berechtigung."))
return@Column
}
scheduleState.error?.let { Text(it, color = MaterialTheme.colors.error) }
if (scheduleState.isLoading) {
CircularProgressIndicator(modifier = Modifier.padding(top = 24.dp))
return@Column
}
if (scheduleState.teams.isEmpty()) {
Text(tr("mobile.noTeams", "Keine Mannschaften gefunden."))
return@Column
}
Text(
tr("mobile.teamsHint", "Mannschaften werden aus Click-TT/nuLiga importiert und im Terminplan genutzt."),
style = MaterialTheme.typography.caption,
color = MaterialTheme.colors.onSurface.copy(alpha = 0.7f),
)
Spacer(modifier = Modifier.height(8.dp))
scheduleState.teams.forEach { t ->
Card(modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp), elevation = 1.dp) {
Column(modifier = Modifier.fillMaxWidth().padding(12.dp)) {
Text(t.name ?: tr("mobile.team", "Mannschaft"), fontWeight = FontWeight.SemiBold)
val league = t.league?.name ?: ""
if (league.isNotBlank()) {
Text(league, style = MaterialTheme.typography.caption)
}
}
}
}
}
}
@Composable
private fun RowSwitch(label: String, checked: Boolean, onChecked: (Boolean) -> Unit) {
Row(

View File

@@ -108,179 +108,186 @@ internal fun ScheduleScreen(dependencies: AppDependencies) {
return
}
Column(
val matches = scheduleState.displayedMatches
LazyColumn(
modifier = Modifier
.fillMaxSize()
.imePadding()
.navigationBarsPadding()
.padding(horizontal = SchedulePad, vertical = 12.dp),
verticalArrangement = Arrangement.spacedBy(8.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),
item {
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),
) {
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)
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))
}
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)
item {
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) },
)
item {
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) }
item {
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")) }
item {
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()
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))
}
}
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))
item {
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))
item {
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))
item { 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 },
)
}
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 },
)
}
}
}

View File

@@ -9,7 +9,7 @@ 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.lazy.itemsIndexed
import androidx.compose.material.Card
import androidx.compose.material.CircularProgressIndicator
import androidx.compose.material.Divider
@@ -95,17 +95,11 @@ internal fun TournamentsScreen(dependencies: AppDependencies) {
fontWeight = FontWeight.SemiBold,
)
Text(
tr("mobile.tournamentsHubHint", "Vereins-Turniere und offizielle Meldelisten. Verwaltung im Browser."),
tr("mobile.tournamentsHubHint", "Vereins-Turniere, importierte Meldelisten und Teilnahmen hier in der App."),
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 {
@@ -146,7 +140,10 @@ internal fun TournamentsScreen(dependencies: AppDependencies) {
)
}
} else {
items(internalState.tournaments, key = { it.id }) { t ->
itemsIndexed(
internalState.tournaments,
key = { index, t -> "${t.id}-$index" },
) { _, t ->
val selected = internalState.selectedId == t.id
Card(
modifier = Modifier.fillMaxWidth(),
@@ -225,7 +222,10 @@ internal fun TournamentsScreen(dependencies: AppDependencies) {
)
}
} else {
items(officialState.tournaments, key = { it.id }) { ot ->
itemsIndexed(
officialState.tournaments,
key = { index, ot -> "${ot.id}-$index" },
) { _, ot ->
Column(modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp)) {
Text(ot.title ?: "Turnier #${ot.id}", fontWeight = FontWeight.Medium)
ot.eventDate?.takeIf { it.isNotBlank() }?.let {
@@ -250,13 +250,23 @@ internal fun TournamentsScreen(dependencies: AppDependencies) {
)
}
} else {
items(
itemsIndexed(
participationFlatRows,
key = { r -> "${r.tournamentId}_${r.entry.memberId}_${r.entry.competitionId}_${r.entry.date}_${r.entry.competitionName}" },
) { r ->
key = { index, _ -> "participation-$index" },
) { _, r ->
ParticipationRow(tournamentTitle = r.tournamentTitle, entry = r.entry)
}
}
item {
Spacer(modifier = Modifier.height(20.dp))
Text(
tr("mobile.tournamentsWebHintFooter", "Turniere anlegen, bearbeiten oder Meldelisten verwalten optional im Webbrowser."),
style = MaterialTheme.typography.caption,
color = MaterialTheme.colors.onSurface.copy(alpha = 0.62f),
)
// Web-UI wird in der Produktiv-App nicht aufgerufen.
}
}
}

View File

@@ -0,0 +1,16 @@
package de.tt_tagebuch.shared.api
import de.tt_tagebuch.shared.api.http.AuthedHttpClient
import de.tt_tagebuch.shared.api.models.ClubCalendarHolidaysEnvelope
import io.ktor.client.call.body
import io.ktor.client.request.get
import io.ktor.client.request.parameter
class CalendarHolidayApi(
private val client: AuthedHttpClient,
) {
suspend fun getClubHolidays(clubId: Int, year: Int): ClubCalendarHolidaysEnvelope =
client.http.get("/api/calendar/club/$clubId/holidays") {
parameter("year", year)
}.body()
}

View File

@@ -0,0 +1,32 @@
package de.tt_tagebuch.shared.api
import de.tt_tagebuch.shared.api.http.AuthedHttpClient
import de.tt_tagebuch.shared.api.models.TrainingCancellationDto
import de.tt_tagebuch.shared.api.models.TrainingCancellationUpsertBody
import io.ktor.client.call.body
import io.ktor.client.request.delete
import io.ktor.client.request.get
import io.ktor.client.request.parameter
import io.ktor.client.request.post
import io.ktor.client.request.setBody
import io.ktor.http.ContentType
import io.ktor.http.contentType
class TrainingCancellationApi(
private val client: AuthedHttpClient,
) {
suspend fun list(clubId: Int, year: Int): List<TrainingCancellationDto> =
client.http.get("/api/training-cancellations/$clubId") {
parameter("year", year)
}.body()
suspend fun upsert(clubId: Int, body: TrainingCancellationUpsertBody): TrainingCancellationDto =
client.http.post("/api/training-cancellations/$clubId") {
contentType(ContentType.Application.Json)
setBody(body)
}.body()
suspend fun delete(clubId: Int, cancellationId: Int) {
client.http.delete("/api/training-cancellations/$clubId/$cancellationId")
}
}

View File

@@ -25,6 +25,7 @@ class AuthedHttpClient(
json(
Json {
ignoreUnknownKeys = true
coerceInputValues = true
}
)
}

View File

@@ -0,0 +1,34 @@
package de.tt_tagebuch.shared.api.models
import kotlinx.serialization.Serializable
@Serializable
data class TrainingCancellationDto(
val id: Int = 0,
val clubId: Int? = null,
val startDate: String? = null,
val endDate: String? = null,
val date: String? = null,
val reason: String? = null,
)
@Serializable
data class TrainingCancellationUpsertBody(
val startDate: String,
val endDate: String,
val reason: String? = null,
)
@Serializable
data class CalendarHolidayRowDto(
val id: String? = null,
val startDate: String? = null,
val endDate: String? = null,
val name: String? = null,
)
@Serializable
data class ClubCalendarHolidaysEnvelope(
val holidays: List<CalendarHolidayRowDto> = emptyList(),
val schoolHolidays: List<CalendarHolidayRowDto> = emptyList(),
)

View File

@@ -1,6 +1,25 @@
package de.tt_tagebuch.shared.api.models
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.JsonTransformingSerializer
object MemberDataQualityRequirementsSerializer :
JsonTransformingSerializer<MemberDataQualityRequirements>(MemberDataQualityRequirements.serializer()) {
override fun transformDeserialize(element: JsonElement): JsonElement {
return if (element is JsonPrimitive && element.isString) {
try {
Json.parseToJsonElement(element.content)
} catch (e: Exception) {
element
}
} else {
element
}
}
}
@Serializable
data class MemberDataQualityRequirements(
@@ -19,6 +38,7 @@ data class UpdateClubSettingsBody(
val stateCode: String? = null,
val myTischtennisFedNickname: String? = null,
val autoFetchRankings: Boolean? = null,
@Serializable(with = MemberDataQualityRequirementsSerializer::class)
val memberDataQualityRequirements: MemberDataQualityRequirements? = null,
)
@@ -32,6 +52,7 @@ data class Club(
val autoFetchRankings: Boolean? = null,
val countryCode: String? = null,
val stateCode: String? = null,
@Serializable(with = MemberDataQualityRequirementsSerializer::class)
val memberDataQualityRequirements: MemberDataQualityRequirements? = null,
)

View File

@@ -43,6 +43,7 @@ fun UserClubPermissions.canWriteSchedule(): Boolean {
fun UserClubPermissions.canReadApprovals(): Boolean {
if (isOwner) return true
if (role.equals("admin", ignoreCase = true)) return true
return permissions.boolAt("approvals", "read")
}
@@ -101,3 +102,8 @@ fun UserClubPermissions.canWritePredefinedActivities(): Boolean {
if (isOwner) return true
return permissions.boolAt("predefined_activities", "write")
}
fun UserClubPermissions.canReadStatistics(): Boolean {
if (isOwner) return true
return permissions.boolAt("statistics", "read")
}

View File

@@ -0,0 +1,55 @@
package de.tt_tagebuch.shared.api.models
import kotlinx.serialization.KSerializer
import kotlinx.serialization.descriptors.PrimitiveKind
import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor
import kotlinx.serialization.descriptors.SerialDescriptor
import kotlinx.serialization.encoding.Decoder
import kotlinx.serialization.encoding.Encoder
import kotlinx.serialization.json.JsonDecoder
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonEncoder
import kotlinx.serialization.json.JsonNull
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.booleanOrNull
import kotlinx.serialization.json.contentOrNull
import kotlinx.serialization.json.intOrNull
import kotlinx.serialization.json.longOrNull
/**
* Akzeptiert `true`/`false`, Zahlen 0/1 und einige String-Formen vermeidet Deserialisierungsfehler,
* wenn die API (oder ein Proxy) Booleans als Zahl liefert.
*/
object FlexibleNullableBooleanSerializer : KSerializer<Boolean?> {
override val descriptor: SerialDescriptor =
PrimitiveSerialDescriptor("FlexibleNullableBoolean", PrimitiveKind.BOOLEAN)
override fun deserialize(decoder: Decoder): Boolean? {
val input = decoder as? JsonDecoder
?: error("FlexibleNullableBooleanSerializer requires JsonDecoder")
return parseBooleanElement(input.decodeJsonElement())
}
private fun parseBooleanElement(element: JsonElement): Boolean? {
if (element is JsonNull) return null
if (element !is JsonPrimitive) return null
element.booleanOrNull?.let { return it }
element.intOrNull?.let { return it != 0 }
element.longOrNull?.let { return it != 0L }
val s = element.contentOrNull ?: return null
return when (s.lowercase()) {
"true", "1", "yes" -> true
"false", "0", "no" -> false
else -> null
}
}
override fun serialize(encoder: Encoder, value: Boolean?) {
when (encoder) {
is JsonEncoder -> encoder.encodeJsonElement(
if (value == null) JsonNull else JsonPrimitive(value),
)
else -> if (value != null) encoder.encodeBoolean(value)
}
}
}

View File

@@ -1,5 +1,6 @@
package de.tt_tagebuch.shared.api.models
import de.tt_tagebuch.shared.api.serialization.LenientIntListSerializer
import kotlinx.serialization.Serializable
@Serializable
@@ -65,8 +66,11 @@ data class ScheduleMatchDto(
val guestMatchPoints: Int = 0,
val isCompleted: Boolean = false,
val pdfUrl: String? = null,
@Serializable(with = LenientIntListSerializer::class)
val playersReady: List<Int> = emptyList(),
@Serializable(with = LenientIntListSerializer::class)
val playersPlanned: List<Int> = emptyList(),
@Serializable(with = LenientIntListSerializer::class)
val playersPlayed: List<Int> = emptyList(),
val homeTeam: ScheduleTeamNameDto? = null,
val guestTeam: ScheduleTeamNameDto? = null,

View File

@@ -7,8 +7,10 @@ data class InternalTournamentSummaryDto(
val id: Int = 0,
val name: String? = null,
val date: String? = null,
@Serializable(with = FlexibleNullableBooleanSerializer::class)
val allowsExternal: Boolean? = null,
val miniChampionshipYear: Int? = null,
@Serializable(with = FlexibleNullableBooleanSerializer::class)
val isDoublesTournament: Boolean? = null,
)
@@ -20,11 +22,13 @@ data class InternalTournamentDetailDto(
val type: String? = null,
val clubId: Int? = null,
val winningSets: Int? = null,
@Serializable(with = FlexibleNullableBooleanSerializer::class)
val allowsExternal: Boolean? = null,
val miniChampionshipYear: Int? = null,
val numberOfTables: Int? = null,
val numberOfGroups: Int? = null,
val advancingPerGroup: Int? = null,
@Serializable(with = FlexibleNullableBooleanSerializer::class)
val isDoublesTournament: Boolean? = null,
val bestOfEndroundSize: Int? = null,
)

View File

@@ -0,0 +1,47 @@
package de.tt_tagebuch.shared.api.serialization
import kotlinx.serialization.KSerializer
import kotlinx.serialization.builtins.ListSerializer
import kotlinx.serialization.builtins.serializer
import kotlinx.serialization.descriptors.SerialDescriptor
import kotlinx.serialization.encoding.Decoder
import kotlinx.serialization.encoding.Encoder
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonDecoder
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonNull
import kotlinx.serialization.json.JsonPrimitive
/**
* Backend liefert `playersReady` / `playersPlanned` / `playersPlayed` als JSON-Spalte: mal `null`,
* mal leeres Objekt statt Array. Die Web-UI parst das nicht strikt; kotlinx.serialization würde sonst
* bei HTTP 200 trotzdem abbrechen.
*/
object LenientIntListSerializer : KSerializer<List<Int>> {
private val delegate = ListSerializer(Int.serializer())
override val descriptor: SerialDescriptor = delegate.descriptor
override fun serialize(encoder: Encoder, value: List<Int>) {
delegate.serialize(encoder, value)
}
override fun deserialize(decoder: Decoder): List<Int> {
if (decoder !is JsonDecoder) {
return delegate.deserialize(decoder)
}
return when (val element = decoder.decodeJsonElement()) {
JsonNull -> emptyList()
is JsonArray -> element.mapNotNull { parseIntJsonElement(it) }
else -> emptyList()
}
}
private fun parseIntJsonElement(el: JsonElement): Int? {
val p = el as? JsonPrimitive ?: return null
val c = p.content
if (c.isBlank()) return null
c.toIntOrNull()?.let { return it }
c.toDoubleOrNull()?.let { return it.toInt() }
return null
}
}

View File

@@ -12,15 +12,25 @@ import org.koin.dsl.module
import org.koin.core.module.Module
import org.koin.dsl.module
fun initKoin(baseUrl: String, additionalModules: List<Module> = emptyList(), appDeclaration: KoinAppDeclaration = {}) =
fun initKoin(
baseUrl: String,
socketBaseUrl: String? = null,
additionalModules: List<Module> = emptyList(),
appDeclaration: KoinAppDeclaration = {},
) =
startKoin {
appDeclaration()
modules(commonModule(baseUrl) + additionalModules)
modules(commonModule(baseUrl, socketBaseUrl) + additionalModules)
}
fun commonModule(baseUrl: String) = module {
fun commonModule(baseUrl: String, socketBaseUrl: String? = null) = module {
single { ApiClient(baseUrl) }
single { SocketService(baseUrl.replace("https://", "wss://").replace("http://", "ws://")) } // Simplified
single {
SocketService(
socketBaseUrl
?: baseUrl.replace("https://", "wss://").replace("http://", "ws://"),
)
}
single { AuthRepository(get()) }
single { DiaryRepository(get(), get()) }
single { MemberRepository(get()) }

View File

@@ -19,7 +19,39 @@ class ClubManager(
suspend fun hydrate() {
val stored = clubStorage.loadCurrentClubId()
_state.value = _state.value.copy(currentClubId = stored)
if (stored == null) {
_state.value = _state.value.copy(
currentClubId = null,
currentPermissions = null,
isLoading = false,
error = null,
)
return
}
_state.value = _state.value.copy(
currentClubId = stored,
isLoading = true,
error = null,
)
try {
val permissions = permissionsApi.getUserPermissions(stored)
clubStorage.saveCurrentClubId(stored)
_state.value = _state.value.copy(
currentClubId = stored,
currentPermissions = permissions,
isLoading = false,
error = null,
)
} catch (t: Throwable) {
if (t is CancellationException) throw t
_state.value = _state.value.copy(
isLoading = false,
error = t.toUserMessage("Keine Berechtigung oder Fehler beim Laden der Permissions"),
currentPermissions = null,
currentClubId = null,
)
clubStorage.saveCurrentClubId(null)
}
}
suspend fun loadClubs() {

View File

@@ -21,6 +21,7 @@ import de.tt_tagebuch.shared.api.models.CreateDiaryPlanActivityRequest
import de.tt_tagebuch.shared.api.models.CreateTrainingGroupBody
import de.tt_tagebuch.shared.api.models.DeleteTrainingGroupBody
import de.tt_tagebuch.shared.api.models.DiaryDateActivityItem
import de.tt_tagebuch.shared.api.models.DiaryDate
import de.tt_tagebuch.shared.api.models.DiaryFreeformActivity
import de.tt_tagebuch.shared.api.models.DiaryMemberActivityLink
import de.tt_tagebuch.shared.api.models.MemberGroupPhotoDto
@@ -46,6 +47,8 @@ class DiaryManager(
private val _state = MutableStateFlow(DiaryState())
val state: StateFlow<DiaryState> = _state.asStateFlow()
suspend fun listDates(clubId: Int): List<DiaryDate> = diaryApi.listDates(clubId)
suspend fun fetchDateActivities(clubId: Int, diaryDateId: Int): List<DiaryDateActivityItem> {
return diaryApi.listDateActivities(clubId, diaryDateId)
}

View File

@@ -47,4 +47,7 @@ class OfficialTournamentsReadManager(
}
}
}
suspend fun fetchParticipationSummary(clubId: Int): List<OfficialParticipationBucketDto> =
api.listParticipationSummary(clubId)
}