feat(TrainingCancellation): enhance cancellation functionality and localization support
All checks were successful
Deploy tt-tagebuch / deploy (push) Successful in 44s

- Updated the training cancellation controller to accept training group IDs, improving the cancellation process.
- Modified the database schema to include a JSON field for training group IDs in the training cancellations table.
- Enhanced the TrainingCancellation model to support the new training group IDs field.
- Updated the training cancellation service to normalize and handle training group IDs effectively.
- Added localization support for training cancellation features across multiple languages, improving user experience.
This commit is contained in:
Torsten Schulz (local)
2026-05-13 10:57:23 +02:00
parent 004801b1a6
commit 7981371136
22 changed files with 2306 additions and 275 deletions

View File

@@ -19,13 +19,14 @@ export const upsertTrainingCancellation = async (req, res) => {
try {
const { authcode: userToken } = req.headers;
const { clubId } = req.params;
const { date, startDate, endDate, reason } = req.body;
const { date, startDate, endDate, reason, trainingGroupIds } = req.body;
const result = await trainingCancellationService.upsertTrainingCancellation(
userToken,
clubId,
startDate || date,
reason,
endDate || date || startDate
endDate || date || startDate,
trainingGroupIds
);
res.status(200).json(result);
} catch (error) {

View File

@@ -0,0 +1,2 @@
ALTER TABLE training_cancellations
ADD COLUMN IF NOT EXISTS training_group_ids JSON NULL;

View File

@@ -5,6 +5,7 @@ CREATE TABLE IF NOT EXISTS training_cancellations (
end_date DATE NOT NULL,
date DATE NULL,
reason VARCHAR(255) NULL,
training_group_ids JSON NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
UNIQUE KEY uniq_training_cancellation_club_range (club_id, start_date, end_date),

View File

@@ -36,6 +36,11 @@ const TrainingCancellation = sequelize.define('TrainingCancellation', {
type: DataTypes.STRING(255),
allowNull: true,
},
trainingGroupIds: {
type: DataTypes.JSON,
allowNull: true,
field: 'training_group_ids',
},
}, {
tableName: 'training_cancellations',
underscored: true,

View File

@@ -24,10 +24,11 @@ class TrainingCancellationService {
});
}
async upsertTrainingCancellation(userToken, clubId, date, reason, endDate = null) {
async upsertTrainingCancellation(userToken, clubId, date, reason, endDate = null, trainingGroupIds = []) {
await checkAccess(userToken, clubId);
const normalizedStartDate = this.normalizeDate(date);
const normalizedEndDate = this.normalizeDate(endDate || date);
const normalizedTrainingGroupIds = this.normalizeTrainingGroupIds(trainingGroupIds);
if (!normalizedStartDate || !normalizedEndDate) {
throw new HttpError('Ungültiges Datum', 400);
}
@@ -41,6 +42,7 @@ class TrainingCancellationService {
endDate: normalizedEndDate,
date: normalizedStartDate,
reason: String(reason || '').trim() || null,
trainingGroupIds: normalizedTrainingGroupIds,
});
return cancellation || await TrainingCancellation.findOne({
where: { clubId, startDate: normalizedStartDate, endDate: normalizedEndDate },
@@ -71,6 +73,30 @@ class TrainingCancellationService {
const text = String(date || '').slice(0, 10);
return /^\d{4}-\d{2}-\d{2}$/.test(text) ? text : null;
}
normalizeTrainingGroupIds(trainingGroupIds) {
const values = this.parseTrainingGroupIdsValue(trainingGroupIds);
return [...new Set(
values
.map(id => Number.parseInt(id, 10))
.filter(id => Number.isInteger(id) && id > 0)
)];
}
parseTrainingGroupIdsValue(trainingGroupIds) {
if (Array.isArray(trainingGroupIds)) return trainingGroupIds;
if (trainingGroupIds === null || trainingGroupIds === undefined || trainingGroupIds === '') return [];
if (typeof trainingGroupIds === 'string') {
try {
const parsed = JSON.parse(trainingGroupIds);
if (Array.isArray(parsed)) return parsed;
if (parsed !== null && parsed !== undefined) return [parsed];
} catch (error) {
return trainingGroupIds.split(',');
}
}
return [];
}
}
export default new TrainingCancellationService();

View File

@@ -62,6 +62,106 @@
"ok": "OK",
"saving": "Speichere..."
},
"calendar": {
"eyebrow": "Vereinskalender",
"title": "Kalender",
"description": "Trainingstage, Vereinsturniere und Punktspiele in einer Monatsansicht.",
"today": "Heute",
"loading": "Kalenderdaten werden geladen...",
"noClub": "Bitte zuerst einen Verein auswählen.",
"loadError": "Kalenderdaten konnten nicht geladen werden.",
"sourceWarning": "{source} konnte nicht geladen werden",
"agendaTitle": "Termine im Monat",
"agendaEmpty": "Keine Termine in diesem Monat.",
"recurringTrainingTime": "Regelmäßige Trainingszeit",
"participants": "{count} Teilnehmer",
"starts": "{count} Starts",
"options": {
"title": "Optionen",
"subtitle": "Ausfälle & eigene Termine"
},
"legend": {
"training": "Training",
"tournament": "Turnier",
"officialTournament": "Teilnahme",
"match": "Punktspiel",
"holiday": "Feiertag",
"schoolHoliday": "Ferien",
"trainingCancellation": "Ausfall",
"customEvent": "Termin"
},
"cancellation": {
"title": "Training fällt aus",
"description": "Blendet regelmäßige Trainingszeiten aus.",
"date": "Datum",
"untilOptional": "Bis optional",
"trainingGroups": "Trainingsgruppen",
"reasonPlaceholder": "Grund (optional)",
"saving": "Speichern...",
"submit": "Eintragen",
"fallbackTitle": "Training fällt aus",
"subtitle": "Trainingsausfall"
},
"customEvent": {
"title": "Eigene Termine",
"description": "Kreistage, Sitzungen, interne Meetings, ...",
"titlePlaceholder": "Titel",
"categoryPlaceholder": "Kategorie (optional)",
"saving": "Speichern...",
"submit": "Anlegen",
"subtitleFallback": "Eigener Termin"
},
"quickCancellation": {
"title": "Trainingsausfall",
"message": "„{title}“ als Trainingsausfall markieren?",
"useWholeRange": "Für den gesamten Zeitraum ({start} bis {end}) eintragen",
"trainingGroups": "Betroffene Trainingsgruppen",
"confirm": "Ausfall eintragen",
"noGroups": "Es sind keine Trainingsgruppen mit Trainingszeiten vorhanden."
},
"sources": {
"trainingDays": "Trainingstage",
"trainingTimes": "Trainingszeiten",
"trainingCancellations": "Trainingsausfälle",
"customEvents": "Eigene Termine",
"tournaments": "Turniere",
"officialTournaments": "Turnierteilnahmen",
"matches": "Punktspiele",
"holidays": "Ferien/Feiertage"
},
"eventTitles": {
"training": "Training",
"tournament": "Turnier",
"officialTournament": "Turnierteilnahme",
"match": "Punktspiel"
},
"tournament": {
"open": "Offenes Turnier",
"club": "Vereinsturnier"
},
"match": {
"home": "Heim",
"guest": "Gast"
},
"holidayTypes": {
"holiday": "Feiertag",
"schoolHoliday": "Schulferien"
},
"weekdays": {
"monday": "Mo",
"tuesday": "Di",
"wednesday": "Mi",
"thursday": "Do",
"friday": "Fr",
"saturday": "Sa",
"sunday": "So"
},
"cancelCancellation": {
"title": "Trainingsausfall stornieren",
"message": "Trainingsausfall „{title}“ am {date} wirklich stornieren?",
"confirm": "Ausfall stornieren"
}
},
"unknown": "Unbekannt",
"navigation": {
"home": "Startseite",

View File

@@ -62,6 +62,106 @@
"period": "Zeitraum",
"saving": "Speichere..."
},
"calendar": {
"eyebrow": "Vereinskalender",
"title": "Kalender",
"description": "Trainingstage, Vereinsturniere und Punktspiele in einer Monatsansicht.",
"today": "Heute",
"loading": "Kalenderdaten werden geladen...",
"noClub": "Bitte zuerst einen Verein auswählen.",
"loadError": "Kalenderdaten konnten nicht geladen werden.",
"sourceWarning": "{source} konnte nicht geladen werden",
"agendaTitle": "Termine im Monat",
"agendaEmpty": "Keine Termine in diesem Monat.",
"recurringTrainingTime": "Regelmäßige Trainingszeit",
"participants": "{count} Teilnehmer",
"starts": "{count} Starts",
"options": {
"title": "Optionen",
"subtitle": "Ausfälle & eigene Termine"
},
"legend": {
"training": "Training",
"tournament": "Turnier",
"officialTournament": "Teilnahme",
"match": "Punktspiel",
"holiday": "Feiertag",
"schoolHoliday": "Ferien",
"trainingCancellation": "Ausfall",
"customEvent": "Termin"
},
"cancellation": {
"title": "Training fällt aus",
"description": "Blendet regelmäßige Trainingszeiten aus.",
"date": "Datum",
"untilOptional": "Bis optional",
"trainingGroups": "Trainingsgruppen",
"reasonPlaceholder": "Grund (optional)",
"saving": "Speichern...",
"submit": "Eintragen",
"fallbackTitle": "Training fällt aus",
"subtitle": "Trainingsausfall"
},
"customEvent": {
"title": "Eigene Termine",
"description": "Kreistage, Sitzungen, interne Meetings, ...",
"titlePlaceholder": "Titel",
"categoryPlaceholder": "Kategorie (optional)",
"saving": "Speichern...",
"submit": "Anlegen",
"subtitleFallback": "Eigener Termin"
},
"quickCancellation": {
"title": "Trainingsausfall",
"message": "„{title}“ als Trainingsausfall markieren?",
"useWholeRange": "Für den gesamten Zeitraum ({start} bis {end}) eintragen",
"trainingGroups": "Betroffene Trainingsgruppen",
"confirm": "Ausfall eintragen",
"noGroups": "Es sind keine Trainingsgruppen mit Trainingszeiten vorhanden."
},
"sources": {
"trainingDays": "Trainingstage",
"trainingTimes": "Trainingszeiten",
"trainingCancellations": "Trainingsausfälle",
"customEvents": "Eigene Termine",
"tournaments": "Turniere",
"officialTournaments": "Turnierteilnahmen",
"matches": "Punktspiele",
"holidays": "Ferien/Feiertage"
},
"eventTitles": {
"training": "Training",
"tournament": "Turnier",
"officialTournament": "Turnierteilnahme",
"match": "Punktspiel"
},
"tournament": {
"open": "Offenes Turnier",
"club": "Vereinsturnier"
},
"match": {
"home": "Heim",
"guest": "Gast"
},
"holidayTypes": {
"holiday": "Feiertag",
"schoolHoliday": "Schulferien"
},
"weekdays": {
"monday": "Mo",
"tuesday": "Di",
"wednesday": "Mi",
"thursday": "Do",
"friday": "Fr",
"saturday": "Sa",
"sunday": "So"
},
"cancelCancellation": {
"title": "Trainingsausfall stornieren",
"message": "Trainingsausfall „{title}“ am {date} wirklich stornieren?",
"confirm": "Ausfall stornieren"
}
},
"unknown": "Unbekannt",
"navigation": {
"home": "Startseite",

View File

@@ -62,6 +62,106 @@
"period": "Zeitraum",
"saving": "Speichere..."
},
"calendar": {
"eyebrow": "Vereinskalender",
"title": "Kalender",
"description": "Trainingstage, Vereinsturniere und Punktspiele in einer Monatsansicht.",
"today": "Heute",
"loading": "Kalenderdaten werden geladen...",
"noClub": "Bitte zuerst einen Verein auswählen.",
"loadError": "Kalenderdaten konnten nicht geladen werden.",
"sourceWarning": "{source} konnte nicht geladen werden",
"agendaTitle": "Termine im Monat",
"agendaEmpty": "Keine Termine in diesem Monat.",
"recurringTrainingTime": "Regelmäßige Trainingszeit",
"participants": "{count} Teilnehmer",
"starts": "{count} Starts",
"options": {
"title": "Optionen",
"subtitle": "Ausfälle & eigene Termine"
},
"legend": {
"training": "Training",
"tournament": "Turnier",
"officialTournament": "Teilnahme",
"match": "Punktspiel",
"holiday": "Feiertag",
"schoolHoliday": "Ferien",
"trainingCancellation": "Ausfall",
"customEvent": "Termin"
},
"cancellation": {
"title": "Training fällt aus",
"description": "Blendet regelmäßige Trainingszeiten aus.",
"date": "Datum",
"untilOptional": "Bis optional",
"trainingGroups": "Trainingsgruppen",
"reasonPlaceholder": "Grund (optional)",
"saving": "Speichern...",
"submit": "Eintragen",
"fallbackTitle": "Training fällt aus",
"subtitle": "Trainingsausfall"
},
"customEvent": {
"title": "Eigene Termine",
"description": "Kreistage, Sitzungen, interne Meetings, ...",
"titlePlaceholder": "Titel",
"categoryPlaceholder": "Kategorie (optional)",
"saving": "Speichern...",
"submit": "Anlegen",
"subtitleFallback": "Eigener Termin"
},
"quickCancellation": {
"title": "Trainingsausfall",
"message": "„{title}“ als Trainingsausfall markieren?",
"useWholeRange": "Für den gesamten Zeitraum ({start} bis {end}) eintragen",
"trainingGroups": "Betroffene Trainingsgruppen",
"confirm": "Ausfall eintragen",
"noGroups": "Es sind keine Trainingsgruppen mit Trainingszeiten vorhanden."
},
"sources": {
"trainingDays": "Trainingstage",
"trainingTimes": "Trainingszeiten",
"trainingCancellations": "Trainingsausfälle",
"customEvents": "Eigene Termine",
"tournaments": "Turniere",
"officialTournaments": "Turnierteilnahmen",
"matches": "Punktspiele",
"holidays": "Ferien/Feiertage"
},
"eventTitles": {
"training": "Training",
"tournament": "Turnier",
"officialTournament": "Turnierteilnahme",
"match": "Punktspiel"
},
"tournament": {
"open": "Offenes Turnier",
"club": "Vereinsturnier"
},
"match": {
"home": "Heim",
"guest": "Gast"
},
"holidayTypes": {
"holiday": "Feiertag",
"schoolHoliday": "Schulferien"
},
"weekdays": {
"monday": "Mo",
"tuesday": "Di",
"wednesday": "Mi",
"thursday": "Do",
"friday": "Fr",
"saturday": "Sa",
"sunday": "So"
},
"cancelCancellation": {
"title": "Trainingsausfall stornieren",
"message": "Trainingsausfall „{title}“ am {date} wirklich stornieren?",
"confirm": "Ausfall stornieren"
}
},
"unknown": "Unbekannt",
"navigation": {
"home": "Startseite",

View File

@@ -62,6 +62,106 @@
"years": "years",
"ok": "OK"
},
"calendar": {
"eyebrow": "Club calendar",
"title": "Calendar",
"description": "Training days, club tournaments and league matches in a monthly view.",
"today": "Today",
"loading": "Loading calendar data...",
"noClub": "Please select a club first.",
"loadError": "Calendar data could not be loaded.",
"sourceWarning": "{source} could not be loaded",
"agendaTitle": "Events this month",
"agendaEmpty": "No events this month.",
"recurringTrainingTime": "Regular training time",
"participants": "{count} participants",
"starts": "{count} starts",
"options": {
"title": "Options",
"subtitle": "Cancellations & own events"
},
"legend": {
"training": "Training",
"tournament": "Tournament",
"officialTournament": "Participation",
"match": "League match",
"holiday": "Holiday",
"schoolHoliday": "School holidays",
"trainingCancellation": "Cancelled",
"customEvent": "Event"
},
"cancellation": {
"title": "Training cancelled",
"description": "Hides regular training times.",
"date": "Date",
"untilOptional": "Until optional",
"trainingGroups": "Training groups",
"reasonPlaceholder": "Reason (optional)",
"saving": "Saving...",
"submit": "Add",
"fallbackTitle": "Training cancelled",
"subtitle": "Training cancellation"
},
"customEvent": {
"title": "Own events",
"description": "District meetings, sessions, internal meetings, ...",
"titlePlaceholder": "Title",
"categoryPlaceholder": "Category (optional)",
"saving": "Saving...",
"submit": "Create",
"subtitleFallback": "Own event"
},
"quickCancellation": {
"title": "Training cancellation",
"message": "Mark “{title}” as training cancellation?",
"useWholeRange": "Apply to the full period ({start} to {end})",
"trainingGroups": "Affected training groups",
"confirm": "Add cancellation",
"noGroups": "No training groups with training times are available."
},
"sources": {
"trainingDays": "Training days",
"trainingTimes": "Training times",
"trainingCancellations": "Training cancellations",
"customEvents": "Own events",
"tournaments": "Tournaments",
"officialTournaments": "Tournament participations",
"matches": "League matches",
"holidays": "Holidays/school holidays"
},
"eventTitles": {
"training": "Training",
"tournament": "Tournament",
"officialTournament": "Tournament participation",
"match": "League match"
},
"tournament": {
"open": "Open tournament",
"club": "Club tournament"
},
"match": {
"home": "Home",
"guest": "Away"
},
"holidayTypes": {
"holiday": "Holiday",
"schoolHoliday": "School holidays"
},
"weekdays": {
"monday": "Mon",
"tuesday": "Tue",
"wednesday": "Wed",
"thursday": "Thu",
"friday": "Fri",
"saturday": "Sat",
"sunday": "Sun"
},
"cancelCancellation": {
"title": "Cancel training cancellation",
"message": "Really cancel the training cancellation “{title}” on {date}?",
"confirm": "Cancel cancellation"
}
},
"navigation": {
"home": "Home",
"members": "Members",

View File

@@ -62,6 +62,106 @@
"years": "years",
"ok": "OK"
},
"calendar": {
"eyebrow": "Club calendar",
"title": "Calendar",
"description": "Training days, club tournaments and league matches in a monthly view.",
"today": "Today",
"loading": "Loading calendar data...",
"noClub": "Please select a club first.",
"loadError": "Calendar data could not be loaded.",
"sourceWarning": "{source} could not be loaded",
"agendaTitle": "Events this month",
"agendaEmpty": "No events this month.",
"recurringTrainingTime": "Regular training time",
"participants": "{count} participants",
"starts": "{count} starts",
"options": {
"title": "Options",
"subtitle": "Cancellations & own events"
},
"legend": {
"training": "Training",
"tournament": "Tournament",
"officialTournament": "Participation",
"match": "League match",
"holiday": "Holiday",
"schoolHoliday": "School holidays",
"trainingCancellation": "Cancelled",
"customEvent": "Event"
},
"cancellation": {
"title": "Training cancelled",
"description": "Hides regular training times.",
"date": "Date",
"untilOptional": "Until optional",
"trainingGroups": "Training groups",
"reasonPlaceholder": "Reason (optional)",
"saving": "Saving...",
"submit": "Add",
"fallbackTitle": "Training cancelled",
"subtitle": "Training cancellation"
},
"customEvent": {
"title": "Own events",
"description": "District meetings, sessions, internal meetings, ...",
"titlePlaceholder": "Title",
"categoryPlaceholder": "Category (optional)",
"saving": "Saving...",
"submit": "Create",
"subtitleFallback": "Own event"
},
"quickCancellation": {
"title": "Training cancellation",
"message": "Mark “{title}” as training cancellation?",
"useWholeRange": "Apply to the full period ({start} to {end})",
"trainingGroups": "Affected training groups",
"confirm": "Add cancellation",
"noGroups": "No training groups with training times are available."
},
"sources": {
"trainingDays": "Training days",
"trainingTimes": "Training times",
"trainingCancellations": "Training cancellations",
"customEvents": "Own events",
"tournaments": "Tournaments",
"officialTournaments": "Tournament participations",
"matches": "League matches",
"holidays": "Holidays/school holidays"
},
"eventTitles": {
"training": "Training",
"tournament": "Tournament",
"officialTournament": "Tournament participation",
"match": "League match"
},
"tournament": {
"open": "Open tournament",
"club": "Club tournament"
},
"match": {
"home": "Home",
"guest": "Away"
},
"holidayTypes": {
"holiday": "Holiday",
"schoolHoliday": "School holidays"
},
"weekdays": {
"monday": "Mon",
"tuesday": "Tue",
"wednesday": "Wed",
"thursday": "Thu",
"friday": "Fri",
"saturday": "Sat",
"sunday": "Sun"
},
"cancelCancellation": {
"title": "Cancel training cancellation",
"message": "Really cancel the training cancellation “{title}” on {date}?",
"confirm": "Cancel cancellation"
}
},
"navigation": {
"home": "Home",
"members": "Members",

View File

@@ -62,6 +62,106 @@
"years": "years",
"ok": "OK"
},
"calendar": {
"eyebrow": "Club calendar",
"title": "Calendar",
"description": "Training days, club tournaments and league matches in a monthly view.",
"today": "Today",
"loading": "Loading calendar data...",
"noClub": "Please select a club first.",
"loadError": "Calendar data could not be loaded.",
"sourceWarning": "{source} could not be loaded",
"agendaTitle": "Events this month",
"agendaEmpty": "No events this month.",
"recurringTrainingTime": "Regular training time",
"participants": "{count} participants",
"starts": "{count} starts",
"options": {
"title": "Options",
"subtitle": "Cancellations & own events"
},
"legend": {
"training": "Training",
"tournament": "Tournament",
"officialTournament": "Participation",
"match": "League match",
"holiday": "Holiday",
"schoolHoliday": "School holidays",
"trainingCancellation": "Cancelled",
"customEvent": "Event"
},
"cancellation": {
"title": "Training cancelled",
"description": "Hides regular training times.",
"date": "Date",
"untilOptional": "Until optional",
"trainingGroups": "Training groups",
"reasonPlaceholder": "Reason (optional)",
"saving": "Saving...",
"submit": "Add",
"fallbackTitle": "Training cancelled",
"subtitle": "Training cancellation"
},
"customEvent": {
"title": "Own events",
"description": "District meetings, sessions, internal meetings, ...",
"titlePlaceholder": "Title",
"categoryPlaceholder": "Category (optional)",
"saving": "Saving...",
"submit": "Create",
"subtitleFallback": "Own event"
},
"quickCancellation": {
"title": "Training cancellation",
"message": "Mark “{title}” as training cancellation?",
"useWholeRange": "Apply to the full period ({start} to {end})",
"trainingGroups": "Affected training groups",
"confirm": "Add cancellation",
"noGroups": "No training groups with training times are available."
},
"sources": {
"trainingDays": "Training days",
"trainingTimes": "Training times",
"trainingCancellations": "Training cancellations",
"customEvents": "Own events",
"tournaments": "Tournaments",
"officialTournaments": "Tournament participations",
"matches": "League matches",
"holidays": "Holidays/school holidays"
},
"eventTitles": {
"training": "Training",
"tournament": "Tournament",
"officialTournament": "Tournament participation",
"match": "League match"
},
"tournament": {
"open": "Open tournament",
"club": "Club tournament"
},
"match": {
"home": "Home",
"guest": "Away"
},
"holidayTypes": {
"holiday": "Holiday",
"schoolHoliday": "School holidays"
},
"weekdays": {
"monday": "Mon",
"tuesday": "Tue",
"wednesday": "Wed",
"thursday": "Thu",
"friday": "Fri",
"saturday": "Sat",
"sunday": "Sun"
},
"cancelCancellation": {
"title": "Cancel training cancellation",
"message": "Really cancel the training cancellation “{title}” on {date}?",
"confirm": "Cancel cancellation"
}
},
"navigation": {
"home": "Home",
"members": "Members",

View File

@@ -62,6 +62,106 @@
"ok": "OK",
"period": "Periodo"
},
"calendar": {
"eyebrow": "Calendario del club",
"title": "Calendario",
"description": "Training days, club tournaments and league matches in a monthly view.",
"today": "Hoy",
"loading": "Cargando datos del calendario...",
"noClub": "Seleccione primero un club.",
"loadError": "Calendar data could not be loaded.",
"sourceWarning": "{source} could not be loaded",
"agendaTitle": "Eventos del mes",
"agendaEmpty": "No hay eventos este mes.",
"recurringTrainingTime": "Regular training time",
"participants": "{count} participants",
"starts": "{count} starts",
"options": {
"title": "Options",
"subtitle": "Cancellations & own events"
},
"legend": {
"training": "Training",
"tournament": "Tournament",
"officialTournament": "Participation",
"match": "League match",
"holiday": "Holiday",
"schoolHoliday": "School holidays",
"trainingCancellation": "Cancelled",
"customEvent": "Event"
},
"cancellation": {
"title": "Training cancelled",
"description": "Hides regular training times.",
"date": "Date",
"untilOptional": "Until optional",
"trainingGroups": "Training groups",
"reasonPlaceholder": "Reason (optional)",
"saving": "Saving...",
"submit": "Add",
"fallbackTitle": "Training cancelled",
"subtitle": "Training cancellation"
},
"customEvent": {
"title": "Own events",
"description": "District meetings, sessions, internal meetings, ...",
"titlePlaceholder": "Title",
"categoryPlaceholder": "Category (optional)",
"saving": "Saving...",
"submit": "Create",
"subtitleFallback": "Own event"
},
"quickCancellation": {
"title": "Training cancellation",
"message": "Mark “{title}” as training cancellation?",
"useWholeRange": "Apply to the full period ({start} to {end})",
"trainingGroups": "Affected training groups",
"confirm": "Add cancellation",
"noGroups": "No training groups with training times are available."
},
"sources": {
"trainingDays": "Training days",
"trainingTimes": "Training times",
"trainingCancellations": "Training cancellations",
"customEvents": "Own events",
"tournaments": "Tournaments",
"officialTournaments": "Tournament participations",
"matches": "League matches",
"holidays": "Holidays/school holidays"
},
"eventTitles": {
"training": "Training",
"tournament": "Tournament",
"officialTournament": "Tournament participation",
"match": "League match"
},
"tournament": {
"open": "Open tournament",
"club": "Club tournament"
},
"match": {
"home": "Home",
"guest": "Away"
},
"holidayTypes": {
"holiday": "Holiday",
"schoolHoliday": "School holidays"
},
"weekdays": {
"monday": "Lun",
"tuesday": "Mar",
"wednesday": "Mié",
"thursday": "Jue",
"friday": "Vie",
"saturday": "Sáb",
"sunday": "Dom"
},
"cancelCancellation": {
"title": "Cancelar ausencia de entrenamiento",
"message": "¿Cancelar realmente la ausencia “{title}” el {date}?",
"confirm": "Cancelar ausencia"
}
},
"navigation": {
"home": "Inicio",
"members": "Miembros",

View File

@@ -62,6 +62,106 @@
"ok": "OK",
"period": "Panahon"
},
"calendar": {
"eyebrow": "Kalendaryo ng club",
"title": "Kalendaryo",
"description": "Training days, club tournaments and league matches in a monthly view.",
"today": "Ngayon",
"loading": "Nilo-load ang datos ng kalendaryo...",
"noClub": "Pumili muna ng club.",
"loadError": "Calendar data could not be loaded.",
"sourceWarning": "{source} could not be loaded",
"agendaTitle": "Mga event ngayong buwan",
"agendaEmpty": "Walang event ngayong buwan.",
"recurringTrainingTime": "Regular training time",
"participants": "{count} participants",
"starts": "{count} starts",
"options": {
"title": "Options",
"subtitle": "Cancellations & own events"
},
"legend": {
"training": "Training",
"tournament": "Tournament",
"officialTournament": "Participation",
"match": "League match",
"holiday": "Holiday",
"schoolHoliday": "School holidays",
"trainingCancellation": "Cancelled",
"customEvent": "Event"
},
"cancellation": {
"title": "Training cancelled",
"description": "Hides regular training times.",
"date": "Date",
"untilOptional": "Until optional",
"trainingGroups": "Training groups",
"reasonPlaceholder": "Reason (optional)",
"saving": "Saving...",
"submit": "Add",
"fallbackTitle": "Training cancelled",
"subtitle": "Training cancellation"
},
"customEvent": {
"title": "Own events",
"description": "District meetings, sessions, internal meetings, ...",
"titlePlaceholder": "Title",
"categoryPlaceholder": "Category (optional)",
"saving": "Saving...",
"submit": "Create",
"subtitleFallback": "Own event"
},
"quickCancellation": {
"title": "Training cancellation",
"message": "Mark “{title}” as training cancellation?",
"useWholeRange": "Apply to the full period ({start} to {end})",
"trainingGroups": "Affected training groups",
"confirm": "Add cancellation",
"noGroups": "No training groups with training times are available."
},
"sources": {
"trainingDays": "Training days",
"trainingTimes": "Training times",
"trainingCancellations": "Training cancellations",
"customEvents": "Own events",
"tournaments": "Tournaments",
"officialTournaments": "Tournament participations",
"matches": "League matches",
"holidays": "Holidays/school holidays"
},
"eventTitles": {
"training": "Training",
"tournament": "Tournament",
"officialTournament": "Tournament participation",
"match": "League match"
},
"tournament": {
"open": "Open tournament",
"club": "Club tournament"
},
"match": {
"home": "Home",
"guest": "Away"
},
"holidayTypes": {
"holiday": "Holiday",
"schoolHoliday": "School holidays"
},
"weekdays": {
"monday": "Lun",
"tuesday": "Mar",
"wednesday": "Miy",
"thursday": "Huw",
"friday": "Biy",
"saturday": "Sab",
"sunday": "Lin"
},
"cancelCancellation": {
"title": "Bawiin ang pagkansela ng training",
"message": "Bawiin talaga ang pagkansela ng training “{title}” sa {date}?",
"confirm": "Bawiin ang pagkansela"
}
},
"navigation": {
"home": "Home",
"members": "Mga miyembro",

View File

@@ -62,6 +62,106 @@
"ok": "OK",
"period": "Période"
},
"calendar": {
"eyebrow": "Calendrier du club",
"title": "Calendrier",
"description": "Training days, club tournaments and league matches in a monthly view.",
"today": "Aujourdhui",
"loading": "Chargement des données du calendrier...",
"noClub": "Veuillez dabord sélectionner un club.",
"loadError": "Calendar data could not be loaded.",
"sourceWarning": "{source} could not be loaded",
"agendaTitle": "Événements du mois",
"agendaEmpty": "Aucun événement ce mois-ci.",
"recurringTrainingTime": "Regular training time",
"participants": "{count} participants",
"starts": "{count} starts",
"options": {
"title": "Options",
"subtitle": "Cancellations & own events"
},
"legend": {
"training": "Training",
"tournament": "Tournament",
"officialTournament": "Participation",
"match": "League match",
"holiday": "Holiday",
"schoolHoliday": "School holidays",
"trainingCancellation": "Cancelled",
"customEvent": "Event"
},
"cancellation": {
"title": "Training cancelled",
"description": "Hides regular training times.",
"date": "Date",
"untilOptional": "Until optional",
"trainingGroups": "Training groups",
"reasonPlaceholder": "Reason (optional)",
"saving": "Saving...",
"submit": "Add",
"fallbackTitle": "Training cancelled",
"subtitle": "Training cancellation"
},
"customEvent": {
"title": "Own events",
"description": "District meetings, sessions, internal meetings, ...",
"titlePlaceholder": "Title",
"categoryPlaceholder": "Category (optional)",
"saving": "Saving...",
"submit": "Create",
"subtitleFallback": "Own event"
},
"quickCancellation": {
"title": "Training cancellation",
"message": "Mark “{title}” as training cancellation?",
"useWholeRange": "Apply to the full period ({start} to {end})",
"trainingGroups": "Affected training groups",
"confirm": "Add cancellation",
"noGroups": "No training groups with training times are available."
},
"sources": {
"trainingDays": "Training days",
"trainingTimes": "Training times",
"trainingCancellations": "Training cancellations",
"customEvents": "Own events",
"tournaments": "Tournaments",
"officialTournaments": "Tournament participations",
"matches": "League matches",
"holidays": "Holidays/school holidays"
},
"eventTitles": {
"training": "Training",
"tournament": "Tournament",
"officialTournament": "Tournament participation",
"match": "League match"
},
"tournament": {
"open": "Open tournament",
"club": "Club tournament"
},
"match": {
"home": "Home",
"guest": "Away"
},
"holidayTypes": {
"holiday": "Holiday",
"schoolHoliday": "School holidays"
},
"weekdays": {
"monday": "Lun",
"tuesday": "Mar",
"wednesday": "Mer",
"thursday": "Jeu",
"friday": "Ven",
"saturday": "Sam",
"sunday": "Dim"
},
"cancelCancellation": {
"title": "Annuler lannulation de lentraînement",
"message": "Vraiment annuler lannulation « {title} » le {date} ?",
"confirm": "Annuler lannulation"
}
},
"navigation": {
"home": "Accueil",
"members": "Membres",

View File

@@ -62,6 +62,106 @@
"ok": "OK",
"period": "Periodo"
},
"calendar": {
"eyebrow": "Calendario del club",
"title": "Calendario",
"description": "Training days, club tournaments and league matches in a monthly view.",
"today": "Oggi",
"loading": "Caricamento dati calendario...",
"noClub": "Seleziona prima un club.",
"loadError": "Calendar data could not be loaded.",
"sourceWarning": "{source} could not be loaded",
"agendaTitle": "Eventi del mese",
"agendaEmpty": "Nessun evento questo mese.",
"recurringTrainingTime": "Regular training time",
"participants": "{count} participants",
"starts": "{count} starts",
"options": {
"title": "Options",
"subtitle": "Cancellations & own events"
},
"legend": {
"training": "Training",
"tournament": "Tournament",
"officialTournament": "Participation",
"match": "League match",
"holiday": "Holiday",
"schoolHoliday": "School holidays",
"trainingCancellation": "Cancelled",
"customEvent": "Event"
},
"cancellation": {
"title": "Training cancelled",
"description": "Hides regular training times.",
"date": "Date",
"untilOptional": "Until optional",
"trainingGroups": "Training groups",
"reasonPlaceholder": "Reason (optional)",
"saving": "Saving...",
"submit": "Add",
"fallbackTitle": "Training cancelled",
"subtitle": "Training cancellation"
},
"customEvent": {
"title": "Own events",
"description": "District meetings, sessions, internal meetings, ...",
"titlePlaceholder": "Title",
"categoryPlaceholder": "Category (optional)",
"saving": "Saving...",
"submit": "Create",
"subtitleFallback": "Own event"
},
"quickCancellation": {
"title": "Training cancellation",
"message": "Mark “{title}” as training cancellation?",
"useWholeRange": "Apply to the full period ({start} to {end})",
"trainingGroups": "Affected training groups",
"confirm": "Add cancellation",
"noGroups": "No training groups with training times are available."
},
"sources": {
"trainingDays": "Training days",
"trainingTimes": "Training times",
"trainingCancellations": "Training cancellations",
"customEvents": "Own events",
"tournaments": "Tournaments",
"officialTournaments": "Tournament participations",
"matches": "League matches",
"holidays": "Holidays/school holidays"
},
"eventTitles": {
"training": "Training",
"tournament": "Tournament",
"officialTournament": "Tournament participation",
"match": "League match"
},
"tournament": {
"open": "Open tournament",
"club": "Club tournament"
},
"match": {
"home": "Home",
"guest": "Away"
},
"holidayTypes": {
"holiday": "Holiday",
"schoolHoliday": "School holidays"
},
"weekdays": {
"monday": "Lun",
"tuesday": "Mar",
"wednesday": "Mer",
"thursday": "Gio",
"friday": "Ven",
"saturday": "Sab",
"sunday": "Dom"
},
"cancelCancellation": {
"title": "Annulla cancellazione allenamento",
"message": "Annullare davvero la cancellazione “{title}” del {date}?",
"confirm": "Annulla cancellazione"
}
},
"navigation": {
"home": "Home",
"members": "Membri",

View File

@@ -62,6 +62,106 @@
"ok": "OK",
"period": "期間"
},
"calendar": {
"eyebrow": "クラブカレンダー",
"title": "カレンダー",
"description": "Training days, club tournaments and league matches in a monthly view.",
"today": "今日",
"loading": "カレンダーデータを読み込んでいます...",
"noClub": "最初にクラブを選択してください。",
"loadError": "Calendar data could not be loaded.",
"sourceWarning": "{source} could not be loaded",
"agendaTitle": "今月の予定",
"agendaEmpty": "今月の予定はありません。",
"recurringTrainingTime": "Regular training time",
"participants": "{count} participants",
"starts": "{count} starts",
"options": {
"title": "Options",
"subtitle": "Cancellations & own events"
},
"legend": {
"training": "Training",
"tournament": "Tournament",
"officialTournament": "Participation",
"match": "League match",
"holiday": "Holiday",
"schoolHoliday": "School holidays",
"trainingCancellation": "Cancelled",
"customEvent": "Event"
},
"cancellation": {
"title": "Training cancelled",
"description": "Hides regular training times.",
"date": "Date",
"untilOptional": "Until optional",
"trainingGroups": "Training groups",
"reasonPlaceholder": "Reason (optional)",
"saving": "Saving...",
"submit": "Add",
"fallbackTitle": "Training cancelled",
"subtitle": "Training cancellation"
},
"customEvent": {
"title": "Own events",
"description": "District meetings, sessions, internal meetings, ...",
"titlePlaceholder": "Title",
"categoryPlaceholder": "Category (optional)",
"saving": "Saving...",
"submit": "Create",
"subtitleFallback": "Own event"
},
"quickCancellation": {
"title": "Training cancellation",
"message": "Mark “{title}” as training cancellation?",
"useWholeRange": "Apply to the full period ({start} to {end})",
"trainingGroups": "Affected training groups",
"confirm": "Add cancellation",
"noGroups": "No training groups with training times are available."
},
"sources": {
"trainingDays": "Training days",
"trainingTimes": "Training times",
"trainingCancellations": "Training cancellations",
"customEvents": "Own events",
"tournaments": "Tournaments",
"officialTournaments": "Tournament participations",
"matches": "League matches",
"holidays": "Holidays/school holidays"
},
"eventTitles": {
"training": "Training",
"tournament": "Tournament",
"officialTournament": "Tournament participation",
"match": "League match"
},
"tournament": {
"open": "Open tournament",
"club": "Club tournament"
},
"match": {
"home": "Home",
"guest": "Away"
},
"holidayTypes": {
"holiday": "Holiday",
"schoolHoliday": "School holidays"
},
"weekdays": {
"monday": "月",
"tuesday": "火",
"wednesday": "水",
"thursday": "木",
"friday": "金",
"saturday": "土",
"sunday": "日"
},
"cancelCancellation": {
"title": "練習中止を取り消す",
"message": "{date} の「{title}」の練習中止を本当に取り消しますか?",
"confirm": "中止を取り消す"
}
},
"navigation": {
"home": "ホーム",
"members": "メンバー",

View File

@@ -62,6 +62,106 @@
"ok": "OK",
"period": "Okres"
},
"calendar": {
"eyebrow": "Kalendarz klubu",
"title": "Kalendarz",
"description": "Training days, club tournaments and league matches in a monthly view.",
"today": "Dzisiaj",
"loading": "Ładowanie danych kalendarza...",
"noClub": "Najpierw wybierz klub.",
"loadError": "Calendar data could not be loaded.",
"sourceWarning": "{source} could not be loaded",
"agendaTitle": "Wydarzenia w tym miesiącu",
"agendaEmpty": "Brak wydarzeń w tym miesiącu.",
"recurringTrainingTime": "Regular training time",
"participants": "{count} participants",
"starts": "{count} starts",
"options": {
"title": "Options",
"subtitle": "Cancellations & own events"
},
"legend": {
"training": "Training",
"tournament": "Tournament",
"officialTournament": "Participation",
"match": "League match",
"holiday": "Holiday",
"schoolHoliday": "School holidays",
"trainingCancellation": "Cancelled",
"customEvent": "Event"
},
"cancellation": {
"title": "Training cancelled",
"description": "Hides regular training times.",
"date": "Date",
"untilOptional": "Until optional",
"trainingGroups": "Training groups",
"reasonPlaceholder": "Reason (optional)",
"saving": "Saving...",
"submit": "Add",
"fallbackTitle": "Training cancelled",
"subtitle": "Training cancellation"
},
"customEvent": {
"title": "Own events",
"description": "District meetings, sessions, internal meetings, ...",
"titlePlaceholder": "Title",
"categoryPlaceholder": "Category (optional)",
"saving": "Saving...",
"submit": "Create",
"subtitleFallback": "Own event"
},
"quickCancellation": {
"title": "Training cancellation",
"message": "Mark “{title}” as training cancellation?",
"useWholeRange": "Apply to the full period ({start} to {end})",
"trainingGroups": "Affected training groups",
"confirm": "Add cancellation",
"noGroups": "No training groups with training times are available."
},
"sources": {
"trainingDays": "Training days",
"trainingTimes": "Training times",
"trainingCancellations": "Training cancellations",
"customEvents": "Own events",
"tournaments": "Tournaments",
"officialTournaments": "Tournament participations",
"matches": "League matches",
"holidays": "Holidays/school holidays"
},
"eventTitles": {
"training": "Training",
"tournament": "Tournament",
"officialTournament": "Tournament participation",
"match": "League match"
},
"tournament": {
"open": "Open tournament",
"club": "Club tournament"
},
"match": {
"home": "Home",
"guest": "Away"
},
"holidayTypes": {
"holiday": "Holiday",
"schoolHoliday": "School holidays"
},
"weekdays": {
"monday": "Pon",
"tuesday": "Wt",
"wednesday": "Śr",
"thursday": "Czw",
"friday": "Pt",
"saturday": "Sob",
"sunday": "Nd"
},
"cancelCancellation": {
"title": "Cofnij odwołanie treningu",
"message": "Na pewno cofnąć odwołanie „{title}” w dniu {date}?",
"confirm": "Cofnij odwołanie"
}
},
"navigation": {
"home": "Strona główna",
"members": "Członkowie",

View File

@@ -62,6 +62,106 @@
"ok": "ตกลง",
"period": "ช่วงเวลา"
},
"calendar": {
"eyebrow": "ปฏิทินสโมสร",
"title": "ปฏิทิน",
"description": "Training days, club tournaments and league matches in a monthly view.",
"today": "วันนี้",
"loading": "กำลังโหลดข้อมูลปฏิทิน...",
"noClub": "โปรดเลือกสโมสรก่อน",
"loadError": "Calendar data could not be loaded.",
"sourceWarning": "{source} could not be loaded",
"agendaTitle": "กิจกรรมในเดือนนี้",
"agendaEmpty": "ไม่มีกิจกรรมในเดือนนี้",
"recurringTrainingTime": "Regular training time",
"participants": "{count} participants",
"starts": "{count} starts",
"options": {
"title": "Options",
"subtitle": "Cancellations & own events"
},
"legend": {
"training": "Training",
"tournament": "Tournament",
"officialTournament": "Participation",
"match": "League match",
"holiday": "Holiday",
"schoolHoliday": "School holidays",
"trainingCancellation": "Cancelled",
"customEvent": "Event"
},
"cancellation": {
"title": "Training cancelled",
"description": "Hides regular training times.",
"date": "Date",
"untilOptional": "Until optional",
"trainingGroups": "Training groups",
"reasonPlaceholder": "Reason (optional)",
"saving": "Saving...",
"submit": "Add",
"fallbackTitle": "Training cancelled",
"subtitle": "Training cancellation"
},
"customEvent": {
"title": "Own events",
"description": "District meetings, sessions, internal meetings, ...",
"titlePlaceholder": "Title",
"categoryPlaceholder": "Category (optional)",
"saving": "Saving...",
"submit": "Create",
"subtitleFallback": "Own event"
},
"quickCancellation": {
"title": "Training cancellation",
"message": "Mark “{title}” as training cancellation?",
"useWholeRange": "Apply to the full period ({start} to {end})",
"trainingGroups": "Affected training groups",
"confirm": "Add cancellation",
"noGroups": "No training groups with training times are available."
},
"sources": {
"trainingDays": "Training days",
"trainingTimes": "Training times",
"trainingCancellations": "Training cancellations",
"customEvents": "Own events",
"tournaments": "Tournaments",
"officialTournaments": "Tournament participations",
"matches": "League matches",
"holidays": "Holidays/school holidays"
},
"eventTitles": {
"training": "Training",
"tournament": "Tournament",
"officialTournament": "Tournament participation",
"match": "League match"
},
"tournament": {
"open": "Open tournament",
"club": "Club tournament"
},
"match": {
"home": "Home",
"guest": "Away"
},
"holidayTypes": {
"holiday": "Holiday",
"schoolHoliday": "School holidays"
},
"weekdays": {
"monday": "จ.",
"tuesday": "อ.",
"wednesday": "พ.",
"thursday": "พฤ.",
"friday": "ศ.",
"saturday": "ส.",
"sunday": "อา."
},
"cancelCancellation": {
"title": "ยกเลิกการงดซ้อม",
"message": "ต้องการยกเลิกการงดซ้อม “{title}” วันที่ {date} หรือไม่?",
"confirm": "ยกเลิกการงดซ้อม"
}
},
"navigation": {
"home": "หน้าแรก",
"members": "สมาชิก",

View File

@@ -62,6 +62,106 @@
"ok": "OK",
"period": "Panahon"
},
"calendar": {
"eyebrow": "Kalendaryo ng club",
"title": "Kalendaryo",
"description": "Training days, club tournaments and league matches in a monthly view.",
"today": "Ngayon",
"loading": "Nilo-load ang datos ng kalendaryo...",
"noClub": "Pumili muna ng club.",
"loadError": "Calendar data could not be loaded.",
"sourceWarning": "{source} could not be loaded",
"agendaTitle": "Mga event ngayong buwan",
"agendaEmpty": "Walang event ngayong buwan.",
"recurringTrainingTime": "Regular training time",
"participants": "{count} participants",
"starts": "{count} starts",
"options": {
"title": "Options",
"subtitle": "Cancellations & own events"
},
"legend": {
"training": "Training",
"tournament": "Tournament",
"officialTournament": "Participation",
"match": "League match",
"holiday": "Holiday",
"schoolHoliday": "School holidays",
"trainingCancellation": "Cancelled",
"customEvent": "Event"
},
"cancellation": {
"title": "Training cancelled",
"description": "Hides regular training times.",
"date": "Date",
"untilOptional": "Until optional",
"trainingGroups": "Training groups",
"reasonPlaceholder": "Reason (optional)",
"saving": "Saving...",
"submit": "Add",
"fallbackTitle": "Training cancelled",
"subtitle": "Training cancellation"
},
"customEvent": {
"title": "Own events",
"description": "District meetings, sessions, internal meetings, ...",
"titlePlaceholder": "Title",
"categoryPlaceholder": "Category (optional)",
"saving": "Saving...",
"submit": "Create",
"subtitleFallback": "Own event"
},
"quickCancellation": {
"title": "Training cancellation",
"message": "Mark “{title}” as training cancellation?",
"useWholeRange": "Apply to the full period ({start} to {end})",
"trainingGroups": "Affected training groups",
"confirm": "Add cancellation",
"noGroups": "No training groups with training times are available."
},
"sources": {
"trainingDays": "Training days",
"trainingTimes": "Training times",
"trainingCancellations": "Training cancellations",
"customEvents": "Own events",
"tournaments": "Tournaments",
"officialTournaments": "Tournament participations",
"matches": "League matches",
"holidays": "Holidays/school holidays"
},
"eventTitles": {
"training": "Training",
"tournament": "Tournament",
"officialTournament": "Tournament participation",
"match": "League match"
},
"tournament": {
"open": "Open tournament",
"club": "Club tournament"
},
"match": {
"home": "Home",
"guest": "Away"
},
"holidayTypes": {
"holiday": "Holiday",
"schoolHoliday": "School holidays"
},
"weekdays": {
"monday": "Lun",
"tuesday": "Mar",
"wednesday": "Miy",
"thursday": "Huw",
"friday": "Biy",
"saturday": "Sab",
"sunday": "Lin"
},
"cancelCancellation": {
"title": "Bawiin ang pagkansela ng training",
"message": "Bawiin talaga ang pagkansela ng training “{title}” sa {date}?",
"confirm": "Bawiin ang pagkansela"
}
},
"navigation": {
"home": "Home",
"members": "Mga miyembro",

View File

@@ -62,6 +62,106 @@
"ok": "确定",
"period": "期间"
},
"calendar": {
"eyebrow": "俱乐部日历",
"title": "日历",
"description": "Training days, club tournaments and league matches in a monthly view.",
"today": "今天",
"loading": "正在加载日历数据...",
"noClub": "请先选择俱乐部。",
"loadError": "Calendar data could not be loaded.",
"sourceWarning": "{source} could not be loaded",
"agendaTitle": "本月活动",
"agendaEmpty": "本月没有活动。",
"recurringTrainingTime": "Regular training time",
"participants": "{count} participants",
"starts": "{count} starts",
"options": {
"title": "Options",
"subtitle": "Cancellations & own events"
},
"legend": {
"training": "Training",
"tournament": "Tournament",
"officialTournament": "Participation",
"match": "League match",
"holiday": "Holiday",
"schoolHoliday": "School holidays",
"trainingCancellation": "Cancelled",
"customEvent": "Event"
},
"cancellation": {
"title": "Training cancelled",
"description": "Hides regular training times.",
"date": "Date",
"untilOptional": "Until optional",
"trainingGroups": "Training groups",
"reasonPlaceholder": "Reason (optional)",
"saving": "Saving...",
"submit": "Add",
"fallbackTitle": "Training cancelled",
"subtitle": "Training cancellation"
},
"customEvent": {
"title": "Own events",
"description": "District meetings, sessions, internal meetings, ...",
"titlePlaceholder": "Title",
"categoryPlaceholder": "Category (optional)",
"saving": "Saving...",
"submit": "Create",
"subtitleFallback": "Own event"
},
"quickCancellation": {
"title": "Training cancellation",
"message": "Mark “{title}” as training cancellation?",
"useWholeRange": "Apply to the full period ({start} to {end})",
"trainingGroups": "Affected training groups",
"confirm": "Add cancellation",
"noGroups": "No training groups with training times are available."
},
"sources": {
"trainingDays": "Training days",
"trainingTimes": "Training times",
"trainingCancellations": "Training cancellations",
"customEvents": "Own events",
"tournaments": "Tournaments",
"officialTournaments": "Tournament participations",
"matches": "League matches",
"holidays": "Holidays/school holidays"
},
"eventTitles": {
"training": "Training",
"tournament": "Tournament",
"officialTournament": "Tournament participation",
"match": "League match"
},
"tournament": {
"open": "Open tournament",
"club": "Club tournament"
},
"match": {
"home": "Home",
"guest": "Away"
},
"holidayTypes": {
"holiday": "Holiday",
"schoolHoliday": "School holidays"
},
"weekdays": {
"monday": "周一",
"tuesday": "周二",
"wednesday": "周三",
"thursday": "周四",
"friday": "周五",
"saturday": "周六",
"sunday": "周日"
},
"cancelCancellation": {
"title": "取消训练停课",
"message": "确定要取消 {date} 的“{title}”训练停课吗?",
"confirm": "取消停课"
}
},
"navigation": {
"home": "首页",
"members": "成员",

View File

@@ -2,13 +2,13 @@
<div class="calendar-page">
<header class="calendar-header">
<div>
<span class="calendar-eyebrow">Vereinskalender</span>
<h2>Kalender</h2>
<p>Trainingstage, Vereinsturniere und Punktspiele in einer Monatsansicht.</p>
<span class="calendar-eyebrow">{{ $t('calendar.eyebrow') }}</span>
<h2>{{ $t('calendar.title') }}</h2>
<p>{{ $t('calendar.description') }}</p>
</div>
<div class="calendar-actions">
<button type="button" class="calendar-nav-button" @click="goToPreviousMonth"></button>
<button type="button" class="calendar-today-button" @click="goToToday">Heute</button>
<button type="button" class="calendar-today-button" @click="goToToday">{{ $t('calendar.today') }}</button>
<button type="button" class="calendar-nav-button" @click="goToNextMonth"></button>
</div>
</header>
@@ -31,27 +31,39 @@
<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>
<span>{{ $t('calendar.options.title') }}</span>
<small>{{ $t('calendar.options.subtitle') }}</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>
<h3>{{ $t('calendar.cancellation.title') }}</h3>
<p>{{ $t('calendar.cancellation.description') }}</p>
</div>
<form class="training-cancellation-form" @submit.prevent="saveTrainingCancellation">
<label>
<span>Datum</span>
<span>{{ $t('calendar.cancellation.date') }}</span>
<input v-model="cancellationForm.startDate" type="date" required />
</label>
<label>
<span>Bis optional</span>
<span>{{ $t('calendar.cancellation.untilOptional') }}</span>
<input v-model="cancellationForm.endDate" type="date" />
</label>
<input v-model="cancellationForm.reason" type="text" placeholder="Grund (optional)" />
<label>
<span>{{ $t('calendar.cancellation.trainingGroups') }}</span>
<select v-model="cancellationForm.trainingGroupIds" multiple>
<option
v-for="group in trainingGroupsWithTimes"
:key="`cancel-group-${group.id}`"
:value="String(group.id)"
>
{{ group.name }}
</option>
</select>
</label>
<input v-model="cancellationForm.reason" type="text" :placeholder="$t('calendar.cancellation.reasonPlaceholder')" />
<button type="submit" :disabled="cancellationSaving">
{{ cancellationSaving ? 'Speichern...' : 'Eintragen' }}
{{ cancellationSaving ? $t('calendar.cancellation.saving') : $t('calendar.cancellation.submit') }}
</button>
</form>
<div v-if="visibleTrainingCancellations.length" class="training-cancellation-list">
@@ -60,28 +72,28 @@
:key="`cancel-${cancellation.cancellationId}`"
type="button"
class="training-cancellation-item"
@click="deleteTrainingCancellation(cancellation)"
title="Löschen"
@click="openTrainingCancellationDeleteDialog(cancellation)"
:title="$t('common.delete')"
>
<strong>{{ formatShortDate(cancellation.date) }}</strong>
<span>{{ cancellation.title }}</span>
<small>Löschen</small>
<small>{{ $t('common.delete') }}</small>
</button>
</div>
</section>
<section class="custom-event-panel">
<div>
<h3>Eigene Termine</h3>
<p>Kreistage, Sitzungen, interne Meetings, ...</p>
<h3>{{ $t('calendar.customEvent.title') }}</h3>
<p>{{ $t('calendar.customEvent.description') }}</p>
</div>
<form class="custom-event-form" @submit.prevent="saveCustomEvent">
<input v-model="customEventForm.title" type="text" placeholder="Titel" required />
<input v-model="customEventForm.title" type="text" :placeholder="$t('calendar.customEvent.titlePlaceholder')" 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)" />
<input v-model="customEventForm.category" type="text" :placeholder="$t('calendar.customEvent.categoryPlaceholder')" />
<button type="submit" :disabled="customEventSaving">
{{ customEventSaving ? 'Speichern...' : 'Anlegen' }}
{{ customEventSaving ? $t('calendar.customEvent.saving') : $t('calendar.customEvent.submit') }}
</button>
</form>
<div v-if="visibleCustomEvents.length" class="custom-event-list">
@@ -91,11 +103,11 @@
type="button"
class="custom-event-item"
@click="deleteCustomEvent(event)"
title="Löschen"
:title="$t('common.delete')"
>
<strong>{{ formatEventDate(event) }}</strong>
<span>{{ event.title }}</span>
<small>Löschen</small>
<small>{{ $t('common.delete') }}</small>
</button>
</div>
</section>
@@ -105,12 +117,12 @@
<section v-if="sourceWarnings.length" class="calendar-state calendar-state-warning">
{{ sourceWarnings.join(' · ') }}
</section>
<section v-if="loading" class="calendar-state">Kalenderdaten werden geladen...</section>
<section v-if="loading" class="calendar-state">{{ $t('calendar.loading') }}</section>
<section v-else-if="error" class="calendar-state calendar-state-error">{{ error }}</section>
<section v-if="!currentClub" class="calendar-state">Bitte zuerst einen Verein auswählen.</section>
<section v-if="!currentClub" class="calendar-state">{{ $t('calendar.noClub') }}</section>
<section v-else class="calendar-grid" aria-label="Kalender">
<section v-else class="calendar-grid" :aria-label="$t('calendar.title')">
<div v-for="day in weekdays" :key="day" class="calendar-weekday">{{ day }}</div>
<article
v-for="day in calendarDays"
@@ -137,8 +149,8 @@
</section>
<section class="calendar-agenda">
<h3>Termine im Monat</h3>
<p v-if="visibleEvents.length === 0" class="agenda-empty">Keine Termine in diesem Monat.</p>
<h3>{{ $t('calendar.agendaTitle') }}</h3>
<p v-if="visibleEvents.length === 0" class="agenda-empty">{{ $t('calendar.agendaEmpty') }}</p>
<button
v-for="event in visibleEvents"
:key="`agenda-${event.id}`"
@@ -156,32 +168,98 @@
</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)"
/>
<BaseDialog
v-model="quickCancellationDialog.isOpen"
:title="$t('calendar.quickCancellation.title')"
size="small"
:close-on-overlay="false"
@close="closeQuickCancellationDialog"
>
<div class="quick-cancellation-dialog">
<p>
{{ $t('calendar.quickCancellation.message', { title: quickCancellationDialog.eventTitle }) }}
</p>
<label v-if="quickCancellationDialog.isRange" class="quick-cancellation-checkbox">
<input v-model="quickCancellationDialog.useRange" type="checkbox" />
<span>
{{ $t('calendar.quickCancellation.useWholeRange', {
start: quickCancellationDialog.startDate,
end: quickCancellationDialog.endDate
}) }}
</span>
</label>
<label class="quick-cancellation-groups">
<span>{{ $t('calendar.quickCancellation.trainingGroups') }}</span>
<select v-model="quickCancellationDialog.trainingGroupIds" multiple>
<option
v-for="group in trainingGroupsWithTimes"
:key="`quick-cancel-group-${group.id}`"
:value="String(group.id)"
>
{{ group.name }}
</option>
</select>
</label>
<p v-if="!trainingGroupsWithTimes.length" class="quick-cancellation-hint">
{{ $t('calendar.quickCancellation.noGroups') }}
</p>
</div>
<template #footer>
<button type="button" class="dialog-secondary-button" @click="closeQuickCancellationDialog">
{{ $t('common.cancel') }}
</button>
<button
type="button"
class="dialog-primary-button"
:disabled="quickCancellationDialog.trainingGroupIds.length === 0"
@click="confirmQuickCancellation"
>
{{ $t('calendar.quickCancellation.confirm') }}
</button>
</template>
</BaseDialog>
<BaseDialog
v-model="cancellationDeleteDialog.isOpen"
:title="$t('calendar.cancelCancellation.title')"
size="small"
:close-on-overlay="false"
@close="closeTrainingCancellationDeleteDialog"
>
<div class="quick-cancellation-dialog">
<p>
{{ $t('calendar.cancelCancellation.message', {
title: cancellationDeleteDialog.title,
date: cancellationDeleteDialog.dateLabel
}) }}
</p>
<p v-if="cancellationDeleteDialog.subtitle" class="quick-cancellation-hint">
{{ cancellationDeleteDialog.subtitle }}
</p>
</div>
<template #footer>
<button type="button" class="dialog-secondary-button" @click="closeTrainingCancellationDeleteDialog">
{{ $t('common.cancel') }}
</button>
<button type="button" class="dialog-danger-button" @click="confirmTrainingCancellationDelete">
{{ $t('calendar.cancelCancellation.confirm') }}
</button>
</template>
</BaseDialog>
</div>
</template>
<script>
import { mapGetters } from 'vuex';
import apiClient from '../apiClient';
import ConfirmDialog from '../components/ConfirmDialog.vue';
import BaseDialog from '../components/BaseDialog.vue';
const WEEKDAYS = ['Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa', 'So'];
const WEEKDAY_KEYS = ['monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday', 'sunday'];
export default {
name: 'CalendarView',
components: {
ConfirmDialog,
BaseDialog,
},
data() {
const today = new Date();
@@ -205,7 +283,8 @@ export default {
cancellationForm: {
startDate: '',
endDate: '',
reason: ''
reason: '',
trainingGroupIds: []
},
customEventSaving: false,
customEventForm: {
@@ -215,39 +294,47 @@ export default {
category: ''
},
optionsOpen: false,
confirmDialog: {
quickCancellationDialog: {
isOpen: false,
eventTitle: '',
startDate: '',
endDate: '',
isRange: false,
useRange: false,
reason: '',
trainingGroupIds: [],
},
cancellationDeleteDialog: {
isOpen: false,
cancellation: null,
title: '',
message: '',
details: '',
type: 'info',
confirmText: '',
cancelText: '',
showCancel: true,
resolveCallback: null,
subtitle: '',
dateLabel: '',
},
plannedTrainingByDateKey: {},
plannedTrainingGroupIdsByDateKey: {},
trainingGroups: [],
};
},
computed: {
...mapGetters(['currentClub', 'currentClubName']),
weekdays() {
return WEEKDAYS;
return WEEKDAY_KEYS.map(key => this.$t(`calendar.weekdays.${key}`));
},
eventTypes() {
return [
{ key: 'training', label: 'Training' },
{ key: 'tournament', label: 'Turnier' },
{ key: 'officialTournament', label: 'Teilnahme' },
{ key: 'match', label: 'Punktspiel' },
{ key: 'holiday', label: 'Feiertag' },
{ key: 'schoolHoliday', label: 'Ferien' },
{ key: 'trainingCancellation', label: 'Ausfall' },
{ key: 'customEvent', label: 'Termin' }
{ key: 'training', label: this.$t('calendar.legend.training') },
{ key: 'tournament', label: this.$t('calendar.legend.tournament') },
{ key: 'officialTournament', label: this.$t('calendar.legend.officialTournament') },
{ key: 'match', label: this.$t('calendar.legend.match') },
{ key: 'holiday', label: this.$t('calendar.legend.holiday') },
{ key: 'schoolHoliday', label: this.$t('calendar.legend.schoolHoliday') },
{ key: 'trainingCancellation', label: this.$t('calendar.legend.trainingCancellation') },
{ key: 'customEvent', label: this.$t('calendar.legend.customEvent') }
];
},
monthLabel() {
return this.cursor.toLocaleDateString('de-DE', { month: 'long', year: 'numeric' });
return this.cursor.toLocaleDateString(this.$i18n?.locale || 'de-DE', { month: 'long', year: 'numeric' });
},
displayedYear() {
return this.cursor.getFullYear();
@@ -288,7 +375,10 @@ export default {
.sort((a, b) => a.startsAt - b.startsAt);
},
sourceWarnings() {
return this.sourceErrors.map(source => `${source} konnte nicht geladen werden`);
return this.sourceErrors.map(source => this.$t('calendar.sourceWarning', { source }));
},
trainingGroupsWithTimes() {
return this.trainingGroups.filter(group => Array.isArray(group.trainingTimes) && group.trainingTimes.length > 0);
},
calendarDays() {
const year = this.cursor.getFullYear();
@@ -328,28 +418,6 @@ 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);
@@ -365,35 +433,40 @@ export default {
this.sourceErrors = [];
const sources = await Promise.allSettled([
this.loadSource('Trainingstage', () => this.loadTrainingEvents()),
this.loadSource('Trainingszeiten', () => this.loadRecurringTrainingEvents()),
this.loadSource('Trainingsausfälle', () => this.loadTrainingCancellationEvents()),
this.loadSource('Eigene Termine', () => this.loadCustomEvents()),
this.loadSource('Turniere', () => this.loadTournamentEvents()),
this.loadSource('Turnierteilnahmen', () => this.loadOfficialTournamentEvents()),
this.loadSource('Punktspiele', () => this.loadMatchEvents()),
this.loadSource('Ferien/Feiertage', () => this.loadHolidayEvents())
this.loadSource(this.$t('calendar.sources.trainingDays'), () => this.loadTrainingEvents()),
this.loadSource(this.$t('calendar.sources.trainingTimes'), () => this.loadRecurringTrainingEvents()),
this.loadSource(this.$t('calendar.sources.trainingCancellations'), () => this.loadTrainingCancellationEvents()),
this.loadSource(this.$t('calendar.sources.customEvents'), () => this.loadCustomEvents()),
this.loadSource(this.$t('calendar.sources.tournaments'), () => this.loadTournamentEvents()),
this.loadSource(this.$t('calendar.sources.officialTournaments'), () => this.loadOfficialTournamentEvents()),
this.loadSource(this.$t('calendar.sources.matches'), () => this.loadMatchEvents()),
this.loadSource(this.$t('calendar.sources.holidays'), () => this.loadHolidayEvents())
]);
const loadedEvents = sources
.filter(result => result.status === 'fulfilled')
.flatMap(result => result.value.events)
.filter(event => event.date && !Number.isNaN(event.date.getTime()));
const calendarEvents = this.enrichTrainingCancellationSubtitles(loadedEvents);
this.plannedTrainingByDateKey = loadedEvents
this.plannedTrainingByDateKey = calendarEvents
.filter(event => event.type === 'training')
.reduce((map, event) => {
map[this.toDateKey(event.date)] = true;
return map;
}, {});
this.plannedTrainingGroupIdsByDateKey = calendarEvents
.filter(event => event.type === 'training' && event.trainingGroupId)
.reduce((map, event) => {
const dateKey = this.toDateKey(event.date);
if (!map[dateKey]) map[dateKey] = {};
map[dateKey][String(event.trainingGroupId)] = true;
return map;
}, {});
const cancellationDates = new Set(
loadedEvents
.filter(event => event.type === 'trainingCancellation')
.flatMap(event => this.getDateKeysForRange(event.date, event.endDate || event.date))
);
const afterCancellations = loadedEvents.filter(event => (
!event.isRecurringTraining || !cancellationDates.has(this.toDateKey(event.date))
const cancellationEvents = calendarEvents.filter(event => event.type === 'trainingCancellation');
const afterCancellations = calendarEvents.filter(event => (
!event.isRecurringTraining || !this.isRecurringTrainingCancelled(event, cancellationEvents)
));
this.events = this.mergeRecurringTrainingSlots(afterCancellations);
this.sourceErrors = sources
@@ -402,7 +475,7 @@ export default {
.filter(Boolean);
if (sources.every(result => result.status === 'rejected')) {
this.error = 'Kalenderdaten konnten nicht geladen werden.';
this.error = this.$t('calendar.loadError');
}
this.loading = false;
@@ -452,14 +525,15 @@ export default {
const titleJoined = specific.length ? specific.join(' · ') : (rawTitles.join(' · ') || base.title);
const safeIdKey = slotKey.replace(/\|/g, '-');
const hasRecurring = sorted.some((x) => x.isRecurringTraining);
const recurringSubtitle = this.$t('calendar.recurringTrainingTime');
const otherSubtitles = [...new Set(
sorted.map((x) => String(x.subtitle || '').trim()).filter((s) => s && s !== 'Regelmäßige Trainingszeit')
sorted.map((x) => String(x.subtitle || '').trim()).filter((s) => s && s !== recurringSubtitle)
)];
let subtitleJoined = '';
if (hasRecurring) {
subtitleJoined = otherSubtitles.length
? `Regelmäßige Trainingszeit · ${otherSubtitles.join(' · ')}`
: 'Regelmäßige Trainingszeit';
? `${recurringSubtitle} · ${otherSubtitles.join(' · ')}`
: recurringSubtitle;
} else {
subtitleJoined = otherSubtitles.join(' · ') || String(base.subtitle || '').trim() || '';
}
@@ -483,9 +557,16 @@ export default {
throw error;
}
},
enrichTrainingCancellationSubtitles(events) {
return events.map(event => (
event.type === 'trainingCancellation'
? { ...event, subtitle: this.formatCancellationSubtitle(event.trainingGroupIds) }
: event
));
},
async loadTrainingEvents() {
const response = await apiClient.get(`/diary/${this.currentClub}`);
this.ensureSuccess(response, 'Trainingstage');
this.ensureSuccess(response, this.$t('calendar.sources.trainingDays'));
return (response.data || []).map(entry => {
const date = this.parseDate(entry.date);
return {
@@ -494,7 +575,7 @@ export default {
date,
startsAt: this.combineDateTime(date, entry.trainingStart),
time: this.formatTimeRange(entry.trainingStart, entry.trainingEnd),
title: 'Training',
title: this.$t('calendar.eventTitles.training'),
subtitle: entry.diaryTags?.map(tag => tag.name).join(', ') || '',
route: '/diary'
};
@@ -502,8 +583,9 @@ export default {
},
async loadRecurringTrainingEvents() {
const response = await apiClient.get(`/training-times/${this.currentClub}`);
this.ensureSuccess(response, 'Trainingszeiten');
this.ensureSuccess(response, this.$t('calendar.sources.trainingTimes'));
const groups = Array.isArray(response.data) ? response.data : [];
this.trainingGroups = groups.filter(group => Array.isArray(group.trainingTimes) && group.trainingTimes.length > 0);
return groups.flatMap(group => this.mapGroupTrainingTimesToEvents(group));
},
mapGroupTrainingTimesToEvents(group) {
@@ -530,10 +612,11 @@ export default {
date: eventDate,
startsAt: this.combineDateTime(eventDate, time.startTime),
time: this.formatTimeRange(time.startTime, time.endTime),
title: group?.name || 'Training',
subtitle: 'Regelmäßige Trainingszeit',
title: group?.name || this.$t('calendar.eventTitles.training'),
subtitle: this.$t('calendar.recurringTrainingTime'),
route: '/diary',
isRecurringTraining: true
isRecurringTraining: true,
trainingGroupId: group?.id
});
date.setDate(date.getDate() + 7);
}
@@ -544,10 +627,11 @@ export default {
const response = await apiClient.get(`/training-cancellations/${this.currentClub}`, {
params: { year: this.displayedYear }
});
this.ensureSuccess(response, 'Trainingsausfälle');
this.ensureSuccess(response, this.$t('calendar.sources.trainingCancellations'));
return (response.data || []).map(cancellation => {
const date = this.parseDate(cancellation.startDate || cancellation.date);
const endDate = this.parseDate(cancellation.endDate || cancellation.startDate || cancellation.date);
const trainingGroupIds = this.normalizeTrainingGroupIds(cancellation.trainingGroupIds);
return {
id: `training-cancellation-${cancellation.id}`,
cancellationId: cancellation.id,
@@ -556,8 +640,9 @@ export default {
endDate,
startsAt: this.combineDateTime(date),
time: '',
title: cancellation.reason || 'Training fällt aus',
subtitle: 'Trainingsausfall'
title: cancellation.reason || this.$t('calendar.cancellation.fallbackTitle'),
subtitle: this.formatCancellationSubtitle(trainingGroupIds),
trainingGroupIds
};
});
},
@@ -568,10 +653,11 @@ export default {
const response = await apiClient.post(`/training-cancellations/${this.currentClub}`, {
startDate: this.cancellationForm.startDate,
endDate: this.cancellationForm.endDate || this.cancellationForm.startDate,
reason: this.cancellationForm.reason
reason: this.cancellationForm.reason,
trainingGroupIds: this.normalizeTrainingGroupIds(this.cancellationForm.trainingGroupIds)
});
this.ensureSuccess(response, 'Trainingsausfälle');
this.cancellationForm = { startDate: '', endDate: '', reason: '' };
this.ensureSuccess(response, this.$t('calendar.sources.trainingCancellations'));
this.cancellationForm = { startDate: '', endDate: '', reason: '', trainingGroupIds: [] };
await this.loadCalendarEvents();
} finally {
this.cancellationSaving = false;
@@ -590,14 +676,39 @@ export default {
async deleteTrainingCancellation(cancellation) {
if (!this.currentClub || !cancellation?.cancellationId) return;
const response = await apiClient.delete(`/training-cancellations/${this.currentClub}/${cancellation.cancellationId}`);
this.ensureSuccess(response, 'Trainingsausfälle');
this.ensureSuccess(response, this.$t('calendar.sources.trainingCancellations'));
await this.loadCalendarEvents();
},
openTrainingCancellationDeleteDialog(cancellation) {
if (!cancellation?.cancellationId) return;
this.cancellationDeleteDialog = {
isOpen: true,
cancellation,
title: cancellation.title || this.$t('calendar.cancellation.fallbackTitle'),
subtitle: cancellation.subtitle || '',
dateLabel: this.formatEventDate(cancellation),
};
},
closeTrainingCancellationDeleteDialog() {
this.cancellationDeleteDialog = {
isOpen: false,
cancellation: null,
title: '',
subtitle: '',
dateLabel: '',
};
},
async confirmTrainingCancellationDelete() {
const cancellation = this.cancellationDeleteDialog.cancellation;
if (!cancellation) return;
await this.deleteTrainingCancellation(cancellation);
this.closeTrainingCancellationDeleteDialog();
},
async loadCustomEvents() {
const response = await apiClient.get(`/calendar-events/${this.currentClub}`, {
params: { year: this.displayedYear },
});
this.ensureSuccess(response, 'Eigene Termine');
this.ensureSuccess(response, this.$t('calendar.sources.customEvents'));
return (response.data || []).map(event => {
const date = this.parseDate(event.startDate);
const endDate = this.parseDate(event.endDate || event.startDate);
@@ -610,7 +721,7 @@ export default {
startsAt: this.combineDateTime(date),
time: '',
title: event.title,
subtitle: event.category || 'Eigener Termin',
subtitle: event.category || this.$t('calendar.customEvent.subtitleFallback'),
};
});
},
@@ -624,7 +735,7 @@ export default {
endDate: this.customEventForm.endDate || this.customEventForm.startDate,
category: this.customEventForm.category || null,
});
this.ensureSuccess(response, 'Eigene Termine');
this.ensureSuccess(response, this.$t('calendar.sources.customEvents'));
this.customEventForm = { title: '', startDate: '', endDate: '', category: '' };
await this.loadCalendarEvents();
} finally {
@@ -634,12 +745,12 @@ export default {
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');
this.ensureSuccess(response, this.$t('calendar.sources.customEvents'));
await this.loadCalendarEvents();
},
async loadTournamentEvents() {
const response = await apiClient.get(`/tournament/${this.currentClub}`);
this.ensureSuccess(response, 'Turniere');
this.ensureSuccess(response, this.$t('calendar.sources.tournaments'));
return (response.data || []).map(tournament => {
const date = this.parseDate(tournament.date);
return {
@@ -648,21 +759,21 @@ export default {
date,
startsAt: this.combineDateTime(date),
time: '',
title: tournament.name || tournament.tournamentName || 'Turnier',
subtitle: tournament.allowsExternal ? 'Offenes Turnier' : 'Vereinsturnier',
title: tournament.name || tournament.tournamentName || this.$t('calendar.eventTitles.tournament'),
subtitle: tournament.allowsExternal ? this.$t('calendar.tournament.open') : this.$t('calendar.tournament.club'),
route: '/tournaments'
};
});
},
async loadMatchEvents() {
const response = await apiClient.get(`/matches/leagues/${this.currentClub}/matches`);
this.ensureSuccess(response, 'Punktspiele');
this.ensureSuccess(response, this.$t('calendar.sources.matches'));
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';
const home = match.homeTeam?.name || this.$t('calendar.match.home');
const guest = match.guestTeam?.name || this.$t('calendar.match.guest');
return {
id: `match-${match.id}`,
type: 'match',
@@ -670,14 +781,14 @@ export default {
startsAt: this.combineDateTime(date, match.time),
time: this.formatTime(match.time),
title: `${home} - ${guest}`,
subtitle: match.leagueDetails?.name || match.league?.name || 'Punktspiel',
subtitle: match.leagueDetails?.name || match.league?.name || this.$t('calendar.eventTitles.match'),
route: '/schedule'
};
});
},
async loadOfficialTournamentEvents() {
const response = await apiClient.get(`/official-tournaments/${this.currentClub}/participations/summary`);
this.ensureSuccess(response, 'Turnierteilnahmen');
this.ensureSuccess(response, this.$t('calendar.sources.officialTournaments'));
return (response.data || [])
.filter(tournament => Array.isArray(tournament.entries) && tournament.entries.length > 0)
.map(tournament => {
@@ -693,8 +804,10 @@ export default {
endDate: endDate || date,
startsAt: this.combineDateTime(date),
time: '',
title: tournament.tournamentName || tournament.title || 'Turnierteilnahme',
subtitle: participantCount > 0 ? `${participantCount} Teilnehmer` : `${entries.length} Starts`,
title: tournament.tournamentName || tournament.title || this.$t('calendar.eventTitles.officialTournament'),
subtitle: participantCount > 0
? this.$t('calendar.participants', { count: participantCount })
: this.$t('calendar.starts', { count: entries.length }),
route: '/tournament-participations'
};
})
@@ -704,12 +817,12 @@ export default {
const response = await apiClient.get(`/calendar/club/${this.currentClub}/holidays`, {
params: { year: this.displayedYear }
});
this.ensureSuccess(response, 'Ferien/Feiertage');
this.ensureSuccess(response, this.$t('calendar.sources.holidays'));
const holidays = response.data?.holidays || [];
const schoolHolidays = response.data?.schoolHolidays || [];
return [
...holidays.map(entry => this.mapCalendarDayEvent(entry, 'holiday', 'Feiertag')),
...schoolHolidays.map(entry => this.mapCalendarDayEvent(entry, 'schoolHoliday', 'Schulferien'))
...holidays.map(entry => this.mapCalendarDayEvent(entry, 'holiday', this.$t('calendar.holidayTypes.holiday'))),
...schoolHolidays.map(entry => this.mapCalendarDayEvent(entry, 'schoolHoliday', this.$t('calendar.holidayTypes.schoolHoliday')))
].filter(Boolean);
},
mapCalendarDayEvent(entry, type, fallbackTitle) {
@@ -774,7 +887,52 @@ export default {
},
shouldShowEventOnDate(event, dateKey) {
if (event.type !== 'trainingCancellation') return true;
return Boolean(this.plannedTrainingByDateKey[dateKey]);
const trainingGroupIds = this.normalizeTrainingGroupIds(event.trainingGroupIds);
if (!trainingGroupIds.length) return Boolean(this.plannedTrainingByDateKey[dateKey]);
const plannedGroupIds = this.plannedTrainingGroupIdsByDateKey[dateKey] || {};
return trainingGroupIds.some(groupId => Boolean(plannedGroupIds[String(groupId)]));
},
isRecurringTrainingCancelled(event, cancellationEvents) {
return cancellationEvents.some(cancellation => (
this.isEventOnDate(cancellation, event.date)
&& this.matchesCancellationGroup(cancellation, event.trainingGroupId)
));
},
matchesCancellationGroup(cancellation, trainingGroupId) {
const trainingGroupIds = this.normalizeTrainingGroupIds(cancellation.trainingGroupIds);
return trainingGroupIds.length === 0 || trainingGroupIds.includes(Number.parseInt(trainingGroupId, 10));
},
normalizeTrainingGroupIds(trainingGroupIds) {
const values = this.parseTrainingGroupIdsValue(trainingGroupIds);
return [...new Set(
values
.map(groupId => Number.parseInt(groupId, 10))
.filter(groupId => Number.isInteger(groupId) && groupId > 0)
)];
},
parseTrainingGroupIdsValue(trainingGroupIds) {
if (Array.isArray(trainingGroupIds)) return trainingGroupIds;
if (trainingGroupIds === null || trainingGroupIds === undefined || trainingGroupIds === '') return [];
if (typeof trainingGroupIds === 'string') {
try {
const parsed = JSON.parse(trainingGroupIds);
if (Array.isArray(parsed)) return parsed;
if (parsed !== null && parsed !== undefined) return [parsed];
} catch (error) {
return trainingGroupIds.split(',');
}
}
return [];
},
formatCancellationSubtitle(trainingGroupIds) {
const normalizedIds = this.normalizeTrainingGroupIds(trainingGroupIds);
if (!normalizedIds.length) return this.$t('calendar.cancellation.subtitle');
const names = normalizedIds
.map(groupId => this.trainingGroupsWithTimes.find(group => Number(group.id) === groupId)?.name)
.filter(Boolean);
return names.length
? `${this.$t('calendar.cancellation.subtitle')} · ${names.join(', ')}`
: this.$t('calendar.cancellation.subtitle');
},
startOfDay(date) {
const result = new Date(date);
@@ -798,7 +956,7 @@ export default {
return startTime || endTime || '';
},
formatShortDate(date) {
return date.toLocaleDateString('de-DE', { weekday: 'short', day: '2-digit', month: '2-digit' });
return date.toLocaleDateString(this.$i18n?.locale || 'de-DE', { weekday: 'short', day: '2-digit', month: '2-digit' });
},
formatEventDate(event) {
if (!event.endDate || this.toDateKey(event.date) === this.toDateKey(event.endDate)) {
@@ -821,47 +979,56 @@ export default {
this.offerTrainingCancellationForHoliday(event);
return;
}
if (event?.type === 'trainingCancellation') {
this.openTrainingCancellationDeleteDialog(event);
return;
}
if (event.route) {
this.$router.push(event.route);
}
},
async offerTrainingCancellationForHoliday(event) {
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);
this.quickCancellationDialog = {
isOpen: true,
eventTitle: event.title,
startDate: startDateKey,
endDate: endDateKey,
isRange,
useRange: isRange,
reason,
trainingGroupIds: this.trainingGroupsWithTimes.map(group => String(group.id)),
};
},
closeQuickCancellationDialog() {
this.quickCancellationDialog.isOpen = false;
},
async confirmQuickCancellation() {
const dialog = this.quickCancellationDialog;
if (!this.currentClub || !dialog.startDate || dialog.trainingGroupIds.length === 0) return;
const endDate = dialog.isRange && dialog.useRange ? dialog.endDate : dialog.startDate;
await this.createTrainingCancellation(
dialog.startDate,
endDate,
dialog.reason,
dialog.trainingGroupIds
);
this.closeQuickCancellationDialog();
await this.loadCalendarEvents();
},
async createTrainingCancellation(startDate, endDate, reason) {
async createTrainingCancellation(startDate, endDate, reason, trainingGroupIds = []) {
const response = await apiClient.post(`/training-cancellations/${this.currentClub}`, {
startDate,
endDate,
reason
reason,
trainingGroupIds: this.normalizeTrainingGroupIds(trainingGroupIds)
});
this.ensureSuccess(response, 'Trainingsausfälle');
this.ensureSuccess(response, this.$t('calendar.sources.trainingCancellations'));
}
}
};
@@ -1104,7 +1271,7 @@ export default {
.training-cancellation-form {
display: grid;
grid-template-columns: 10rem 10rem minmax(12rem, 1fr) auto;
grid-template-columns: 10rem 10rem 12rem minmax(12rem, 1fr) auto;
gap: 0.5rem;
}
@@ -1121,6 +1288,7 @@ export default {
}
.training-cancellation-form input,
.training-cancellation-form select,
.training-cancellation-form button {
border: 1px solid #cfdad4;
border-radius: 8px;
@@ -1128,6 +1296,11 @@ export default {
padding: 0 0.75rem;
}
.training-cancellation-form select {
min-height: 4.8rem;
padding: 0.35rem 0.5rem;
}
.training-cancellation-form button {
background: #2f7a5f;
color: #fff;
@@ -1318,6 +1491,82 @@ export default {
color: #607169;
}
.quick-cancellation-dialog {
display: flex;
flex-direction: column;
gap: 0.85rem;
}
.quick-cancellation-dialog p {
margin: 0;
color: #40524b;
}
.quick-cancellation-checkbox,
.quick-cancellation-groups {
display: flex;
gap: 0.5rem;
}
.quick-cancellation-checkbox {
align-items: flex-start;
}
.quick-cancellation-groups {
flex-direction: column;
}
.quick-cancellation-groups span {
color: #40524b;
font-size: 0.85rem;
font-weight: 800;
}
.quick-cancellation-groups select {
min-height: 8rem;
border: 1px solid #cfdad4;
border-radius: 8px;
padding: 0.45rem;
}
.quick-cancellation-hint {
color: #8a4b11;
font-weight: 700;
}
.dialog-primary-button,
.dialog-danger-button,
.dialog-secondary-button {
min-height: 2.35rem;
border-radius: 8px;
cursor: pointer;
font-weight: 800;
padding: 0 0.85rem;
}
.dialog-primary-button {
border: 1px solid #2f7a5f;
background: #2f7a5f;
color: #fff;
}
.dialog-danger-button {
border: 1px solid #b91c1c;
background: #dc2626;
color: #fff;
}
.dialog-primary-button:disabled {
cursor: not-allowed;
opacity: 0.55;
}
.dialog-secondary-button {
border: 1px solid #cfdad4;
background: #f8fbf9;
color: #173d31;
}
.agenda-time {
color: #40524b;
font-weight: 800;

View File

@@ -50,8 +50,12 @@ import androidx.compose.material.Text
import androidx.compose.material.TextButton
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.Edit
import androidx.compose.material.icons.filled.ExpandLess
import androidx.compose.material.icons.filled.ExpandMore
import androidx.compose.material.icons.filled.KeyboardArrowDown
import androidx.compose.material.icons.filled.KeyboardArrowUp
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.CompositionLocalProvider
@@ -74,10 +78,13 @@ import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import kotlin.math.max
import de.tt_tagebuch.app.AppDependencies
import de.tt_tagebuch.app.pdf.sharePdfFile
import de.tt_tagebuch.app.pdf.writeTrainingDaySummaryPdf
@@ -1455,6 +1462,7 @@ private fun DiaryDetailScreen(
var planGroups by remember { mutableStateOf<List<DiaryPlanGroup>>(emptyList()) }
var planMutating by remember { mutableStateOf(false) }
var planActionError by remember { mutableStateOf<String?>(null) }
var expandedPlanActionsItemId by remember { mutableStateOf<Int?>(null) }
var showAddPlanActivity by rememberSaveable { mutableStateOf(false) }
var showAddPlanGroupActivity by rememberSaveable { mutableStateOf(false) }
var showAddTrainingGroup by rememberSaveable { mutableStateOf(false) }
@@ -1474,6 +1482,8 @@ private fun DiaryDetailScreen(
var editPlanDuration by remember { mutableStateOf("") }
var editPlanDurationText by remember { mutableStateOf("") }
var editPlanGroupId by remember { mutableStateOf<Int?>(null) }
var assigningPlanItem by remember { mutableStateOf<DiaryDateActivityItem?>(null) }
var assignPlanGroupId by remember { mutableStateOf<Int?>(null) }
var participants by remember { mutableStateOf<List<DiaryTrainingParticipant>>(emptyList()) }
var participantsLoading by remember { mutableStateOf(false) }
var participantsError by remember { mutableStateOf<String?>(null) }
@@ -3161,103 +3171,220 @@ private fun DiaryDetailScreen(
val planStartTimes = remember(sortedPlan, entry.trainingStart) {
calculatePlanStartLabels(sortedPlan, entry.trainingStart)
}
if (expandedPlanActionsItemId != null && sortedPlan.none { it.id == expandedPlanActionsItemId }) {
expandedPlanActionsItemId = null
}
val planTableScroll = rememberScrollState()
val planTableWidth = max(LocalConfiguration.current.screenWidthDp, 380)
Row(
modifier = Modifier
.fillMaxWidth()
.padding(top = 8.dp, bottom = 4.dp),
verticalAlignment = Alignment.CenterVertically,
.horizontalScroll(planTableScroll)
.clickable { expandedPlanActionsItemId = null },
) {
Text("STARTZEIT", style = MaterialTheme.typography.caption, fontWeight = FontWeight.Bold, modifier = Modifier.width(72.dp))
Text("AKTIVITÄT / ZEITBLOCK", style = MaterialTheme.typography.caption, fontWeight = FontWeight.Bold, modifier = Modifier.weight(1f))
Text("GRUPPE", style = MaterialTheme.typography.caption, fontWeight = FontWeight.Bold, modifier = Modifier.width(90.dp))
Text("DAUER", style = MaterialTheme.typography.caption, fontWeight = FontWeight.Bold, modifier = Modifier.width(64.dp))
Spacer(modifier = Modifier.width(140.dp))
}
Divider()
sortedPlan.forEach { item ->
val cfg = dependencies.apiConfig
val mainImg = item.mainActivityImagePath()?.let { cfg.toAbsoluteUrl(it) }
val nestedImg = item.groupActivities.firstNotNullOfOrNull { ga ->
ga.nestedActivityImagePath()?.let { cfg.toAbsoluteUrl(it) }
Column(modifier = Modifier.width(planTableWidth.dp)) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(top = 8.dp, bottom = 2.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
"STARTZEIT",
style = MaterialTheme.typography.caption,
fontWeight = FontWeight.Bold,
modifier = Modifier.width(DiaryPlanColStart),
)
Text(
"AKTIVITÄT / ZEITBLOCK",
style = MaterialTheme.typography.caption,
fontWeight = FontWeight.Bold,
modifier = Modifier.weight(1f),
)
Text(
"GRUPPE",
style = MaterialTheme.typography.caption,
fontWeight = FontWeight.Bold,
modifier = Modifier.width(DiaryPlanColGroup),
)
Text(
"DAUER",
style = MaterialTheme.typography.caption,
fontWeight = FontWeight.Bold,
modifier = Modifier.width(DiaryPlanColDuration),
)
Spacer(modifier = Modifier.width(26.dp))
}
Divider()
sortedPlan.forEach { item ->
val cfg = dependencies.apiConfig
val mainImg = item.mainActivityImagePath()?.let { cfg.toAbsoluteUrl(it) }
val nestedImg = item.groupActivities.firstNotNullOfOrNull { ga ->
ga.nestedActivityImagePath()?.let { cfg.toAbsoluteUrl(it) }
}
DiaryPlanEditableCard(
item = item,
allPlanItems = planItems,
scheduledStart = planStartTimes[item.id],
planGroups = planGroups,
planMutating = planMutating,
canWriteDiary = canWriteDiary,
mainImageUrl = mainImg,
nestedImageUrl = nestedImg,
canReadImages = canReadDiary,
isExpanded = expandedPlanActionsItemId == item.id,
onToggleExpand = {
expandedPlanActionsItemId = if (expandedPlanActionsItemId == item.id) null else item.id
},
onAssign = {
assignPlanGroupId = item.groupId
assigningPlanItem = item
},
onOpenImage = { url -> planImageViewerUrl = url },
onEdit = { editingPlanItem = item },
onDelete = {
dependencies.applicationScope.launch {
planMutating = true
planActionError = null
try {
dependencies.diaryManager.deletePlanActivity(clubId, item.id)
planItems = dependencies.diaryManager.fetchDateActivities(clubId, entry.id)
planGroups = dependencies.diaryManager.listTrainingGroups(clubId, entry.id)
} catch (t: Throwable) {
planActionError = t.message
} finally {
planMutating = false
}
}
},
onMoveUp = {
val scope = sameTrainingPlanScope(planItems, item)
val idx = scope.indexOfFirst { it.id == item.id }
if (idx > 0) {
val targetOrder = scope[idx - 1].orderId
dependencies.applicationScope.launch {
planMutating = true
planActionError = null
try {
dependencies.diaryManager.updatePlanActivityOrder(clubId, item.id, targetOrder)
planItems = dependencies.diaryManager.fetchDateActivities(clubId, entry.id)
} catch (t: Throwable) {
planActionError = t.message
} finally {
planMutating = false
}
}
}
},
onMoveDown = {
val scope = sameTrainingPlanScope(planItems, item)
val idx = scope.indexOfFirst { it.id == item.id }
if (idx >= 0 && idx < scope.lastIndex) {
val targetOrder = scope[idx + 1].orderId
dependencies.applicationScope.launch {
planMutating = true
planActionError = null
try {
dependencies.diaryManager.updatePlanActivityOrder(clubId, item.id, targetOrder)
planItems = dependencies.diaryManager.fetchDateActivities(clubId, entry.id)
} catch (t: Throwable) {
planActionError = t.message
} finally {
planMutating = false
}
}
}
},
onDeleteNested = { nestedId ->
dependencies.applicationScope.launch {
planMutating = true
planActionError = null
try {
dependencies.diaryManager.deletePlanNestedGroupActivity(clubId, nestedId)
planItems = dependencies.diaryManager.fetchDateActivities(clubId, entry.id)
} catch (t: Throwable) {
planActionError = t.message
} finally {
planMutating = false
}
}
},
)
}
}
DiaryPlanEditableCard(
item = item,
allPlanItems = planItems,
scheduledStart = planStartTimes[item.id],
planGroups = planGroups,
planMutating = planMutating,
canWriteDiary = canWriteDiary,
mainImageUrl = mainImg,
nestedImageUrl = nestedImg,
canReadImages = canReadDiary,
onOpenImage = { url -> planImageViewerUrl = url },
onEdit = { editingPlanItem = item },
onDelete = {
dependencies.applicationScope.launch {
planMutating = true
planActionError = null
try {
dependencies.diaryManager.deletePlanActivity(clubId, item.id)
planItems = dependencies.diaryManager.fetchDateActivities(clubId, entry.id)
planGroups = dependencies.diaryManager.listTrainingGroups(clubId, entry.id)
} catch (t: Throwable) {
planActionError = t.message
} finally {
planMutating = false
}
}
},
onMoveUp = {
val scope = sameTrainingPlanScope(planItems, item)
val idx = scope.indexOfFirst { it.id == item.id }
if (idx > 0) {
val targetOrder = scope[idx - 1].orderId
dependencies.applicationScope.launch {
planMutating = true
planActionError = null
try {
dependencies.diaryManager.updatePlanActivityOrder(clubId, item.id, targetOrder)
planItems = dependencies.diaryManager.fetchDateActivities(clubId, entry.id)
} catch (t: Throwable) {
planActionError = t.message
} finally {
planMutating = false
}
assigningPlanItem?.let { assignItem ->
var assignGroupMenu by remember { mutableStateOf(false) }
AlertDialog(
onDismissRequest = { if (!planMutating) assigningPlanItem = null },
title = { Text(tr("diary.planAssignGroup", "Zuordnen")) },
text = {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text(
assignItem.displayTitle(tr("diary.timeblock", "Zeitblock")),
style = MaterialTheme.typography.body2,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
)
Box(modifier = Modifier.fillMaxWidth()) {
val selectedLabel = assignPlanGroupId?.let { id ->
planGroups.find { it.id == id }?.name ?: "Gruppe $id"
} ?: tr("diary.planGroupGlobal", "Alle / keine Gruppe")
OutlinedButton(
onClick = { assignGroupMenu = true },
enabled = canWriteDiary && !planMutating,
modifier = Modifier.fillMaxWidth(),
) { Text("${tr("diary.planAssignGroup", "Zuordnen")}: $selectedLabel") }
DropdownMenu(expanded = assignGroupMenu, onDismissRequest = { assignGroupMenu = false }) {
DropdownMenuItem(
onClick = {
assignPlanGroupId = null
assignGroupMenu = false
},
) { Text(tr("diary.planGroupGlobal", "Alle / keine Gruppe")) }
planGroups.forEach { g ->
DropdownMenuItem(
onClick = {
assignPlanGroupId = g.id
assignGroupMenu = false
},
) { Text(g.name ?: "Gruppe ${g.id}") }
}
}
}
}
},
onMoveDown = {
val scope = sameTrainingPlanScope(planItems, item)
val idx = scope.indexOfFirst { it.id == item.id }
if (idx >= 0 && idx < scope.lastIndex) {
val targetOrder = scope[idx + 1].orderId
dependencies.applicationScope.launch {
planMutating = true
planActionError = null
try {
dependencies.diaryManager.updatePlanActivityOrder(clubId, item.id, targetOrder)
planItems = dependencies.diaryManager.fetchDateActivities(clubId, entry.id)
} catch (t: Throwable) {
planActionError = t.message
} finally {
planMutating = false
confirmButton = {
TextButton(
enabled = canWriteDiary && !planMutating,
onClick = {
dependencies.applicationScope.launch {
planMutating = true
planActionError = null
try {
dependencies.diaryManager.updatePlanActivity(
clubId,
assignItem.id,
UpdateDiaryPlanActivityRequest(groupId = assignPlanGroupId),
)
assigningPlanItem = null
planItems = dependencies.diaryManager.fetchDateActivities(clubId, entry.id)
planGroups = dependencies.diaryManager.listTrainingGroups(clubId, entry.id)
} catch (t: Throwable) {
planActionError = t.message
} finally {
planMutating = false
}
}
}
}
},
) { Text(tr("common.save", "Speichern")) }
},
onDeleteNested = { nestedId ->
dependencies.applicationScope.launch {
planMutating = true
planActionError = null
try {
dependencies.diaryManager.deletePlanNestedGroupActivity(clubId, nestedId)
planItems = dependencies.diaryManager.fetchDateActivities(clubId, entry.id)
} catch (t: Throwable) {
planActionError = t.message
} finally {
planMutating = false
}
}
dismissButton = {
TextButton(
enabled = canWriteDiary && !planMutating,
onClick = { assigningPlanItem = null },
) { Text(tr("mobile.cancel", "Abbrechen")) }
},
)
}
@@ -4735,6 +4862,48 @@ private fun diaryPlanItemComparator(a: DiaryDateActivityItem, b: DiaryDateActivi
return a.id.compareTo(b.id)
}
private val DiaryPlanColStart = 60.dp
private val DiaryPlanColGroup = 68.dp
private val DiaryPlanColDuration = 48.dp
@Composable
private fun DiaryPlanQuickIconAction(
imageVector: ImageVector,
contentDescription: String,
enabled: Boolean,
onClick: () -> Unit,
) {
Box(
modifier = Modifier
.size(width = 33.dp, height = 30.dp)
.clickable(enabled = enabled, onClick = onClick),
contentAlignment = Alignment.Center,
) {
Icon(
imageVector = imageVector,
contentDescription = contentDescription,
modifier = Modifier.size(20.dp),
tint = if (enabled) MaterialTheme.colors.primary
else MaterialTheme.colors.onSurface.copy(alpha = 0.38f),
)
}
}
@Composable
private fun DiaryPlanQuickTextAction(
label: String,
enabled: Boolean,
onClick: () -> Unit,
) {
OutlinedButton(
onClick = onClick,
enabled = enabled,
modifier = Modifier.heightIn(min = 30.dp),
) {
Text(label, style = MaterialTheme.typography.caption, maxLines = 1)
}
}
private fun sameTrainingPlanScope(items: List<DiaryDateActivityItem>, item: DiaryDateActivityItem): List<DiaryDateActivityItem> {
return items.filter { it.groupId == item.groupId }.sortedWith(::diaryPlanItemComparator)
}
@@ -4778,6 +4947,9 @@ private fun DiaryPlanEditableCard(
mainImageUrl: String?,
nestedImageUrl: String?,
canReadImages: Boolean,
isExpanded: Boolean,
onToggleExpand: () -> Unit,
onAssign: () -> Unit,
onOpenImage: (String) -> Unit,
onEdit: () -> Unit,
onDelete: () -> Unit,
@@ -4803,42 +4975,108 @@ private fun DiaryPlanEditableCard(
?: item.groupId?.let { gid -> planGroups.find { it.id == gid }?.name ?: "Gruppe $gid" }
val showImageUrl = mainImageUrl ?: nestedImageUrl
Column(modifier = Modifier.fillMaxWidth().padding(vertical = 2.dp)) {
val canMutate = canWriteDiary && !planMutating
Column(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 1.dp),
) {
Row(
modifier = Modifier.fillMaxWidth().padding(vertical = 6.dp),
modifier = Modifier
.fillMaxWidth()
.clickable { onToggleExpand() }
.padding(vertical = 2.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
scheduledStart ?: "",
style = MaterialTheme.typography.body2,
modifier = Modifier.width(72.dp),
style = MaterialTheme.typography.caption,
modifier = Modifier.width(DiaryPlanColStart),
)
Row(modifier = Modifier.weight(1f), verticalAlignment = Alignment.CenterVertically) {
Text(title, fontWeight = FontWeight.SemiBold, maxLines = 1)
Text(
title,
fontWeight = FontWeight.SemiBold,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
style = MaterialTheme.typography.body2,
modifier = Modifier.weight(1f),
)
if (item.isTimeblock) {
Text(" · $timeblockLabel", style = MaterialTheme.typography.caption, color = MaterialTheme.colors.primary)
Text(
" · $timeblockLabel",
style = MaterialTheme.typography.caption,
color = MaterialTheme.colors.primary,
maxLines = 1,
)
}
}
Text(
groupLine ?: "",
style = MaterialTheme.typography.caption,
maxLines = 1,
modifier = Modifier.width(90.dp),
overflow = TextOverflow.Ellipsis,
modifier = Modifier.width(DiaryPlanColGroup),
)
Text(duration ?: "", style = MaterialTheme.typography.body2, modifier = Modifier.width(64.dp))
Row(horizontalArrangement = Arrangement.spacedBy(2.dp), modifier = Modifier.width(140.dp)) {
TextButton(onClick = onMoveUp, enabled = canWriteDiary && !planMutating && canUp) { Text("") }
TextButton(onClick = onMoveDown, enabled = canWriteDiary && !planMutating && canDown) { Text("") }
TextButton(onClick = onEdit, enabled = canWriteDiary && !planMutating) { Text(tr("common.edit", "Bearbeiten")) }
TextButton(onClick = onDelete, enabled = canWriteDiary && !planMutating) { Text(tr("common.delete", "Löschen")) }
Text(
duration ?: "",
style = MaterialTheme.typography.caption,
maxLines = 1,
modifier = Modifier.width(DiaryPlanColDuration),
)
Icon(
imageVector = if (isExpanded) Icons.Filled.ExpandLess else Icons.Filled.ExpandMore,
contentDescription = tr("mobile.more", "Mehr"),
modifier = Modifier.size(20.dp),
tint = MaterialTheme.colors.onSurface.copy(alpha = 0.75f),
)
}
if (isExpanded) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(start = DiaryPlanColStart, top = 3.dp, bottom = 3.dp, end = 4.dp)
.background(MaterialTheme.colors.onSurface.copy(alpha = 0.04f))
.padding(horizontal = 6.dp, vertical = 4.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(4.dp),
) {
DiaryPlanQuickTextAction(
label = tr("diary.planActionUp", "↑ Hoch"),
enabled = canMutate && canUp,
onClick = onMoveUp,
)
DiaryPlanQuickTextAction(
label = tr("diary.planActionDown", "↓ Runter"),
enabled = canMutate && canDown,
onClick = onMoveDown,
)
DiaryPlanQuickTextAction(
label = tr("diary.planAssignGroup", "Zuordnen"),
enabled = canMutate,
onClick = onAssign,
)
DiaryPlanQuickTextAction(
label = tr("common.edit", "Bearbeiten"),
enabled = canMutate,
onClick = onEdit,
)
DiaryPlanQuickTextAction(
label = tr("common.delete", "Löschen"),
enabled = canMutate,
onClick = onDelete,
)
}
}
if (canReadImages && showImageUrl != null) {
TextButton(
onClick = { onOpenImage(showImageUrl) },
enabled = !planMutating,
modifier = Modifier.padding(start = 72.dp),
) { Text(tr("diary.planShowImage", "Übungsbild anzeigen")) }
Text(
tr("diary.planShowImage", "Übungsbild anzeigen"),
style = MaterialTheme.typography.caption,
color = MaterialTheme.colors.primary,
modifier = Modifier
.padding(start = DiaryPlanColStart, top = 0.dp, bottom = 0.dp)
.clickable(enabled = !planMutating) { onOpenImage(showImageUrl) },
)
}
item.groupActivities
.sortedBy { it.orderId ?: it.id ?: 0 }
@@ -4846,15 +5084,24 @@ private fun DiaryPlanEditableCard(
val sub = ga.groupPredefinedActivity.displayLabel()
val line = if (sub.isNotEmpty()) sub else "${tr("mobile.activityFallback", "Aktivität")} ${ga.id}"
Row(
modifier = Modifier.fillMaxWidth().padding(start = 72.dp, top = 2.dp, bottom = 2.dp),
modifier = Modifier.fillMaxWidth().padding(start = DiaryPlanColStart, top = 0.dp, bottom = 0.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Text("· $line", style = MaterialTheme.typography.caption, modifier = Modifier.weight(1f))
Text(
"· $line",
style = MaterialTheme.typography.caption,
modifier = Modifier.weight(1f),
maxLines = 2,
overflow = TextOverflow.Ellipsis,
)
val nid = ga.id
if (nid != null) {
TextButton(onClick = { onDeleteNested(nid) }, enabled = canWriteDiary && !planMutating) {
Text(tr("common.delete", "Löschen"))
}
DiaryPlanQuickIconAction(
imageVector = Icons.Filled.Delete,
contentDescription = tr("diary.planDeleteNested", "Gruppenübung löschen"),
enabled = canWriteDiary && !planMutating,
onClick = { onDeleteNested(nid) },
)
}
}
}