feat(Calendar): integrate CalendarEvent model and enhance calendar functionality
All checks were successful
Deploy tt-tagebuch / deploy (push) Successful in 43s
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:
42
backend/controllers/calendarEventController.js
Normal file
42
backend/controllers/calendarEventController.js
Normal 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 });
|
||||
}
|
||||
};
|
||||
15
backend/migrations/create_calendar_events_table.sql
Normal file
15
backend/migrations/create_calendar_events_table.sql
Normal 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
|
||||
);
|
||||
25
backend/models/CalendarEvent.js
Normal file
25
backend/models/CalendarEvent.js
Normal 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;
|
||||
@@ -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,
|
||||
|
||||
16
backend/routes/calendarEventRoutes.js
Normal file
16
backend/routes/calendarEventRoutes.js
Normal 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;
|
||||
@@ -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
|
||||
|
||||
59
backend/services/calendarEventService.js
Normal file
59
backend/services/calendarEventService.js
Normal 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();
|
||||
@@ -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;
|
||||
|
||||
@@ -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. 600 dp `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`.
|
||||
|
||||
@@ -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`
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()) }
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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 ->
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -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 },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -25,6 +25,7 @@ class AuthedHttpClient(
|
||||
json(
|
||||
Json {
|
||||
ignoreUnknownKeys = true
|
||||
coerceInputValues = true
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
)
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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()) }
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -47,4 +47,7 @@ class OfficialTournamentsReadManager(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun fetchParticipationSummary(clubId: Int): List<OfficialParticipationBucketDto> =
|
||||
api.listParticipationSummary(clubId)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user