diff --git a/backend/controllers/matchController.js b/backend/controllers/matchController.js index 6a70225d..64901262 100755 --- a/backend/controllers/matchController.js +++ b/backend/controllers/matchController.js @@ -22,6 +22,19 @@ export const uploadCSV = async (req, res) => { } }; +export const createManualMatch = async (req, res) => { + try { + const { authcode: userToken } = req.headers; + const { clubId } = req.params; + const result = await MatchService.createManualMatch(userToken, clubId, req.body); + emitScheduleMatchUpdated(result.clubId, result.id, result.match); + return res.status(201).json({ message: 'Spiel erfolgreich angelegt', data: result.match }); + } catch (error) { + console.error('Error creating manual match:', error); + return res.status(error.statusCode || 500).json({ error: error.message || 'Spiel konnte nicht angelegt werden' }); + } +}; + export const getLeaguesForCurrentSeason = async (req, res) => { try { devLog(req.headers, req.params); diff --git a/backend/migrations/20260819_add_manual_match_fields.sql b/backend/migrations/20260819_add_manual_match_fields.sql new file mode 100644 index 00000000..1a2f05ff --- /dev/null +++ b/backend/migrations/20260819_add_manual_match_fields.sql @@ -0,0 +1,3 @@ +ALTER TABLE `match` + ADD COLUMN `fixture_type` VARCHAR(32) NOT NULL DEFAULT 'league', + ADD COLUMN `notes` TEXT NULL; diff --git a/backend/models/Match.js b/backend/models/Match.js index 591bbaca..c15316b2 100755 --- a/backend/models/Match.js +++ b/backend/models/Match.js @@ -127,6 +127,17 @@ const Match = sequelize.define('Match', { comment: 'Array of member IDs who actually played', field: 'players_played' }, + fixtureType: { + type: DataTypes.STRING, + allowNull: false, + defaultValue: 'league', + field: 'fixture_type' + }, + notes: { + type: DataTypes.TEXT, + allowNull: true, + field: 'notes' + }, }, { underscored: true, tableName: 'match', diff --git a/backend/routes/matchRoutes.js b/backend/routes/matchRoutes.js index 30bf156a..027c9566 100755 --- a/backend/routes/matchRoutes.js +++ b/backend/routes/matchRoutes.js @@ -1,5 +1,5 @@ import express from 'express'; -import { uploadCSV, getLeaguesForCurrentSeason, getMatchesForLeagues, getMatchesForLeague, getLeagueTable, fetchLeagueTableFromMyTischtennis, updateMatchPlayers, getPlayerMatchStats, getMatchPlayers } from '../controllers/matchController.js'; +import { uploadCSV, createManualMatch, getLeaguesForCurrentSeason, getMatchesForLeagues, getMatchesForLeague, getLeagueTable, fetchLeagueTableFromMyTischtennis, updateMatchPlayers, getPlayerMatchStats, getMatchPlayers } from '../controllers/matchController.js'; import { authenticate } from '../middleware/authMiddleware.js'; import { authorize } from '../middleware/authorizationMiddleware.js'; import multer from 'multer'; @@ -9,6 +9,7 @@ const router = express.Router(); const upload = multer({ dest: 'uploads/' }); router.post('/import', authenticate, authorize('schedule', 'write'), upload.single('file'), uploadCSV); +router.post('/:clubId', authenticate, authorize('schedule', 'write'), createManualMatch); router.get('/leagues/current/:clubId', authenticate, authorize('schedule', 'read'), getLeaguesForCurrentSeason); router.get('/leagues/:clubId/matches/:leagueId', authenticate, authorize('schedule', 'read'), getMatchesForLeague); router.get('/leagues/:clubId/matches', authenticate, authorize('schedule', 'read'), getMatchesForLeagues); diff --git a/backend/services/matchService.js b/backend/services/matchService.js index ebe29ad3..7dbbf6b0 100755 --- a/backend/services/matchService.js +++ b/backend/services/matchService.js @@ -17,6 +17,72 @@ import HttpError from '../exceptions/HttpError.js'; import { devLog } from '../utils/logger.js'; class MatchService { + async enrichMatch(match) { + const enriched = { + id: match.id, date: match.date, time: match.time, + homeTeamId: match.homeTeamId, guestTeamId: match.guestTeamId, + locationId: match.locationId, leagueId: match.leagueId, + code: match.code, homePin: match.homePin, guestPin: match.guestPin, + homeMatchPoints: match.homeMatchPoints || 0, guestMatchPoints: match.guestMatchPoints || 0, + isCompleted: match.isCompleted || false, pdfUrl: match.pdfUrl, + playersReady: match.playersReady || [], playersPlanned: match.playersPlanned || [], playersPlayed: match.playersPlayed || [], + fixtureType: match.fixtureType || 'league', notes: match.notes || null, + homeTeam: { name: 'Unbekannt' }, guestTeam: { name: 'Unbekannt' }, + location: { name: 'Unbekannt', address: '', city: '', zip: '' }, leagueDetails: { name: 'Unbekannt' } + }; + const [homeTeam, guestTeam, location, league] = await Promise.all([ + match.homeTeamId ? Team.findByPk(match.homeTeamId, { attributes: ['name'] }) : null, + match.guestTeamId ? Team.findByPk(match.guestTeamId, { attributes: ['name'] }) : null, + match.locationId ? Location.findByPk(match.locationId, { attributes: ['name', 'address', 'city', 'zip'] }) : null, + match.leagueId ? League.findByPk(match.leagueId, { attributes: ['name'] }) : null + ]); + if (homeTeam) enriched.homeTeam = homeTeam; + if (guestTeam) enriched.guestTeam = guestTeam; + if (location) enriched.location = location; + if (league) enriched.leagueDetails = league; + return enriched; + } + + async createManualMatch(userToken, clubId, payload = {}) { + await checkAccess(userToken, clubId); + const parsedClubId = Number(clubId); + const clubTeamId = Number(payload.clubTeamId); + const date = String(payload.date || '').trim(); + const time = String(payload.time || '').trim(); + const opponentName = String(payload.opponentName || '').trim(); + const homeAway = payload.homeAway === 'away' ? 'away' : 'home'; + if (!Number.isInteger(clubTeamId) || !/^\d{4}-\d{2}-\d{2}$/.test(date) || !/^\d{2}:\d{2}$/.test(time) || !opponentName) { + throw new HttpError('Mannschaft, Gegner, Datum und Uhrzeit sind erforderlich', 400); + } + const clubTeam = await ClubTeam.findOne({ where: { id: clubTeamId, clubId: parsedClubId } }); + if (!clubTeam?.leagueId || !clubTeam?.seasonId) throw new HttpError('Die ausgewählte Mannschaft ist keiner Liga der Saison zugeordnet', 400); + const league = await League.findOne({ where: { id: clubTeam.leagueId, clubId: parsedClubId, seasonId: clubTeam.seasonId } }); + if (!league) throw new HttpError('Ungültige Liga für die ausgewählte Mannschaft', 400); + const [ownTeam] = await Team.findOrCreate({ + where: { name: clubTeam.name, clubId: parsedClubId, leagueId: league.id, seasonId: clubTeam.seasonId }, + defaults: { name: clubTeam.name, clubId: parsedClubId, leagueId: league.id, seasonId: clubTeam.seasonId } + }); + const [opponentTeam] = await Team.findOrCreate({ + where: { name: opponentName, clubId: parsedClubId, leagueId: league.id, seasonId: clubTeam.seasonId }, + defaults: { name: opponentName, clubId: parsedClubId, leagueId: league.id, seasonId: clubTeam.seasonId } + }); + if (ownTeam.id === opponentTeam.id) throw new HttpError('Gegner darf nicht mit der eigenen Mannschaft identisch sein', 400); + let locationId = null; + const locationName = String(payload.locationName || '').trim(); + if (locationName) { + const locationData = { name: locationName, address: String(payload.locationAddress || '').trim() || null, city: String(payload.locationCity || '').trim() || '', zip: String(payload.locationZip || '').trim() || '' }; + const [location] = await Location.findOrCreate({ where: locationData, defaults: locationData }); + locationId = location.id; + } + const match = await Match.create({ + date: new Date(`${date}T${time}:00`), time, clubId: parsedClubId, leagueId: league.id, locationId, + homeTeamId: homeAway === 'home' ? ownTeam.id : opponentTeam.id, + guestTeamId: homeAway === 'home' ? opponentTeam.id : ownTeam.id, + fixtureType: 'cup', notes: String(payload.notes || '').trim() || null + }); + return { id: match.id, clubId: match.clubId, match: await this.enrichMatch(match) }; + } + /** * Format team name with age class suffix * @param {string} teamName - Base team name (e.g. "Harheimer TC") @@ -362,6 +428,20 @@ class MatchService { ); ownTeamIdSet = new Set(ownTeams.map((t) => t.id)); } + const configuredClubTeams = await ClubTeam.findAll({ + where: { clubId, seasonId: season.id }, + attributes: ['name', 'leagueId'] + }); + const configuredTeamNames = new Set(configuredClubTeams.map((team) => this.normalizeTeamNameForMatch(team.name))); + if (configuredTeamNames.size && leagueIdList.length) { + const configuredTeams = await Team.findAll({ + where: { leagueId: { [Op.in]: leagueIdList } }, + attributes: ['id', 'name'] + }); + configuredTeams + .filter((team) => configuredTeamNames.has(this.normalizeTeamNameForMatch(team.name))) + .forEach((team) => ownTeamIdSet.add(team.id)); + } const matches = await Match.findAll({ where: { @@ -403,6 +483,8 @@ class MatchService { playersReady: match.playersReady || [], playersPlanned: match.playersPlanned || [], playersPlayed: match.playersPlayed || [], + fixtureType: match.fixtureType || 'league', + notes: match.notes || null, homeTeam: { name: 'Unbekannt' }, guestTeam: { name: 'Unbekannt' }, location: { name: 'Unbekannt', address: '', city: '', zip: '' }, @@ -464,7 +546,14 @@ class MatchService { this.isTeamNameForClub(team.name, club.name) ); - const ownTeamIds = ownTeams.map(t => t.id); + const configuredClubTeams = await ClubTeam.findAll({ + where: { clubId, leagueId }, + attributes: ['name'] + }); + const configuredTeamNames = new Set(configuredClubTeams.map((team) => this.normalizeTeamNameForMatch(team.name))); + ownTeams.push(...teamsInLeague.filter((team) => configuredTeamNames.has(this.normalizeTeamNameForMatch(team.name)))); + + const ownTeamIds = [...new Set(ownTeams.map(t => t.id))]; if (ownTeamIds.length > 0) { matches = await Match.findAll({ @@ -502,6 +591,8 @@ class MatchService { playersReady: match.playersReady || [], playersPlanned: match.playersPlanned || [], playersPlayed: match.playersPlayed || [], + fixtureType: match.fixtureType || 'league', + notes: match.notes || null, homeTeam: { name: 'Unbekannt' }, guestTeam: { name: 'Unbekannt' }, location: { name: 'Unbekannt', address: '', city: '', zip: '' }, @@ -641,6 +732,8 @@ class MatchService { playersReady: updated.playersReady || [], playersPlanned: updated.playersPlanned || [], playersPlayed: updated.playersPlayed || [], + fixtureType: updated.fixtureType || 'league', + notes: updated.notes || null, homeTeam: { name: 'Unbekannt' }, guestTeam: { name: 'Unbekannt' }, location: { name: 'Unbekannt', address: '', city: '', zip: '' }, diff --git a/frontend/src/components/schedule/ScheduleLayoutShell.vue b/frontend/src/components/schedule/ScheduleLayoutShell.vue index 4978f8fa..b18c0405 100755 --- a/frontend/src/components/schedule/ScheduleLayoutShell.vue +++ b/frontend/src/components/schedule/ScheduleLayoutShell.vue @@ -22,6 +22,14 @@ > {{ $t('schedule.importSchedule') }} + + {{ $t('schedule.addMatch') }} + Freundschaftsspiel {{ formatDate(match.date) }} {{ match.time ? match.time.toString().slice(0, 5) + ' ' + $t('common.time') : 'N/A' }} - {{ match.homeTeam?.name || 'N/A' }} + {{ match.homeTeam?.name || 'N/A' }} {{ $t('schedule.cupMatch') }} {{ match.guestTeam?.name || 'N/A' }} @@ -269,6 +271,27 @@ + + + + {{ $t('schedule.manualMatchDescription') }} + {{ $t('schedule.fixtureType') }} {{ $t('schedule.cupMatch') }} + {{ manualMatchDialog.error }} + + {{ $t('schedule.ownTeam') }} *{{ $t('schedule.selectTeam') }}{{ team.name }} ({{ team.league.name }}) + {{ $t('schedule.opponent') }} * + {{ $t('schedule.date') }} * + {{ $t('schedule.time') }} * + {{ $t('schedule.venue') }} + {{ $t('schedule.address') }} + {{ $t('schedule.zip') }} + {{ $t('schedule.city') }} + + {{ $t('schedule.location') }} {{ $t('schedule.homeGame') }} {{ $t('schedule.away') }} + {{ $t('schedule.notes') }} + {{ $t('schedule.cancel') }}{{ manualMatchDialog.saving ? $t('schedule.saving') : $t('schedule.save') }} + + @@ -716,7 +739,10 @@ export default { MemberNotesDialog }, computed: { - ...mapGetters(['isAuthenticated', 'currentClub', 'clubs', 'currentClubName']), + ...mapGetters(['isAuthenticated', 'currentClub', 'clubs', 'currentClubName', 'hasPermission']), + canCreateManualMatch() { + return !this.friendlyOnly && this.hasPermission('schedule', 'write'); + }, filteredScheduleTeams() { const query = this.teamSearchQuery.trim().toLowerCase(); if (!query) { @@ -860,6 +886,10 @@ export default { resolveCallback: null }, showImportModal: false, + manualMatchDialog: { + isOpen: false, saving: false, error: '', + form: { clubTeamId: '', opponentName: '', date: new Date().toISOString().slice(0, 10), time: '', homeAway: 'home', locationName: '', locationAddress: '', locationZip: '', locationCity: '', notes: '' } + }, selectedFile: null, teams: [], matches: [], @@ -953,6 +983,39 @@ export default { filterRegularScheduleMatches(matches) { return (Array.isArray(matches) ? matches : []).filter((match) => !match?.isFriendly); }, + resetManualMatchDialogForm() { + this.manualMatchDialog.form = { clubTeamId: this.selectedTeam?.id ? String(this.selectedTeam.id) : '', opponentName: '', date: new Date().toISOString().slice(0, 10), time: '', homeAway: 'home', locationName: '', locationAddress: '', locationZip: '', locationCity: '', notes: '' }; + this.manualMatchDialog.error = ''; + }, + openManualMatchDialog() { + this.resetManualMatchDialogForm(); + this.manualMatchDialog.isOpen = true; + }, + closeManualMatchDialog() { + if (this.manualMatchDialog.saving) return; + this.manualMatchDialog.isOpen = false; + this.resetManualMatchDialogForm(); + }, + async saveManualMatch() { + const form = this.manualMatchDialog.form; + if (!form.clubTeamId || !form.opponentName || !form.date || !form.time) { + this.manualMatchDialog.error = this.$t('schedule.manualMatchRequired'); + return; + } + this.manualMatchDialog.saving = true; + this.manualMatchDialog.error = ''; + try { + await apiClient.post(`/matches/${this.currentClub}`, { ...form, clubTeamId: Number(form.clubTeamId) }); + this.manualMatchDialog.isOpen = false; + this.resetManualMatchDialogForm(); + await this.refreshScheduleData(); + this.showInfo(this.$t('messages.success'), this.$t('schedule.manualMatchSaved'), '', 'success'); + } catch (error) { + this.manualMatchDialog.error = getSafeErrorMessage(error, this.$t('schedule.manualMatchSaveFailed')); + } finally { + this.manualMatchDialog.saving = false; + } + }, getClubNameById(clubId) { const club = (this.clubs || []).find((item) => Number(item.id) === Number(clubId)); return club?.name || `Verein ${clubId}`; @@ -2842,16 +2905,16 @@ export default { } }, - refreshScheduleData() { + async refreshScheduleData() { if (!this.selectedLeague) return; if (this.selectedTeam) { - this.loadMatchesForSpecificTeam(this.selectedTeam); + return this.loadMatchesForSpecificTeam(this.selectedTeam); } else if (this.selectedLeague === this.$t('schedule.overallSchedule')) { - this.loadAllMatches(); + return this.loadAllMatches(); } else if (this.selectedLeague === this.$t('schedule.adultSchedule')) { - this.loadAdultMatches(); + return this.loadAdultMatches(); } else if (this.selectedLeague === this.friendlyMatchesLabel) { - this.loadFriendlyMatches(); + return this.loadFriendlyMatches(); } }, @@ -3827,6 +3890,20 @@ li { justify-content: flex-end; } +.manual-match-form { display: flex; flex-direction: column; gap: 1rem; } +.manual-match-intro { margin: 0; color: var(--text-muted, #6c757d); } +.manual-match-type { padding: .65rem .8rem; border-left: 3px solid var(--primary-color); background: var(--surface-muted, #f5f7f6); font-size: .92rem; } +.manual-match-type strong { margin-left: .4rem; } +.manual-match-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: .85rem; } +.manual-match-grid label, .manual-match-notes { display: grid; gap: .35rem; font-weight: 600; font-size: .9rem; } +.manual-match-grid input, .manual-match-grid select, .manual-match-notes textarea { width: 100%; box-sizing: border-box; padding: .6rem .7rem; border: 1px solid var(--border-color); border-radius: 4px; background: var(--background-light, #fff); color: inherit; font: inherit; } +.manual-match-grid input:focus, .manual-match-grid select:focus, .manual-match-notes textarea:focus { outline: 2px solid var(--primary-color); outline-offset: 1px; } +.manual-match-home-away { display: flex; gap: 1rem; padding: .65rem .8rem; border: 1px solid var(--border-color); border-radius: 4px; } +.manual-match-home-away legend { padding: 0 .3rem; font-size: .86rem; font-weight: 600; } +.manual-match-home-away label { font-size: .9rem; } +.manual-match-error { margin: 0; padding: .65rem .8rem; color: var(--error-color, #b42318); background: #fff1f0; border-left: 3px solid currentColor; } +.manual-match-badge { display: inline-block; margin-left: .35rem; padding: .1rem .35rem; border-radius: 3px; color: var(--primary-strong, #1f5f49); background: rgba(47, 122, 95, .12); font-size: .72rem; font-weight: 700; white-space: nowrap; } + .btn-save, .btn-cancel { padding: 8px 20px; @@ -4180,6 +4257,8 @@ li { } @media (max-width: 640px) { + .manual-match-grid { grid-template-columns: 1fr; } + .manual-match-home-away { flex-direction: column; gap: .45rem; } .player-list { border: 1px solid var(--border-color); border-radius: 8px; } .player-list-scroll-hint { display:block; position:sticky; left:0; margin:0; padding:7px 10px; background:var(--surface-muted); color:var(--text-muted); font-size:.78rem; } .player-selection-table {
{{ $t('schedule.manualMatchDescription') }}
{{ manualMatchDialog.error }}