feat(matches): add fixture type handling and notes to manual match creation; update localization for match types
All checks were successful
Deploy tt-tagebuch / deploy (push) Successful in 56s

This commit is contained in:
Torsten Schulz (local)
2026-08-20 08:39:52 +02:00
parent 18ba120927
commit eb1c4d5842
7 changed files with 140 additions and 11 deletions

View File

@@ -6,6 +6,7 @@
"scripts": {
"postinstall": "cd ../frontend && npm install && npm run build",
"dev": "nodemon server.js",
"db:migrate": "node ./scripts/runMigrations.js",
"cleanup:usertoken": "node ./scripts/cleanupUserTokenKeys.js",
"cleanup:indexes": "node ./scripts/cleanupAllIndexes.js"
},

View File

@@ -0,0 +1,68 @@
import sequelize from '../database.js';
const migrations = [
{
id: '20260819_add_manual_match_fields',
async up() {
const [fixtureTypeColumns] = await sequelize.query("SHOW COLUMNS FROM `match` LIKE 'fixture_type'");
if (fixtureTypeColumns.length === 0) {
await sequelize.query("ALTER TABLE `match` ADD COLUMN `fixture_type` VARCHAR(32) NOT NULL DEFAULT 'league'");
}
const [notesColumns] = await sequelize.query("SHOW COLUMNS FROM `match` LIKE 'notes'");
if (notesColumns.length === 0) {
await sequelize.query('ALTER TABLE `match` ADD COLUMN `notes` TEXT NULL');
}
},
},
{
id: '20260819_create_kaisertisch_tournaments',
async up() {
await sequelize.query(`
CREATE TABLE IF NOT EXISTS kaisertisch_tournaments (
id INT AUTO_INCREMENT PRIMARY KEY,
club_id INT NOT NULL,
diary_date_id INT NOT NULL,
state JSON NOT NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
UNIQUE KEY uniq_kaisertisch_tournament_club_date (club_id, diary_date_id),
CONSTRAINT fk_kaisertisch_tournament_club FOREIGN KEY (club_id) REFERENCES clubs(id) ON DELETE CASCADE,
CONSTRAINT fk_kaisertisch_tournament_date FOREIGN KEY (diary_date_id) REFERENCES diary_dates(id) ON DELETE CASCADE
)
`);
},
},
];
async function runMigrations() {
await sequelize.authenticate();
await sequelize.query(`
CREATE TABLE IF NOT EXISTS schema_migrations (
id VARCHAR(191) PRIMARY KEY,
applied_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
)
`);
const [appliedRows] = await sequelize.query('SELECT id FROM schema_migrations');
const applied = new Set(appliedRows.map((row) => row.id));
for (const migration of migrations) {
if (applied.has(migration.id)) continue;
console.log(`[migration] Applying ${migration.id}`);
await migration.up();
await sequelize.query('INSERT INTO schema_migrations (id) VALUES (:id)', {
replacements: { id: migration.id },
});
console.log(`[migration] Applied ${migration.id}`);
}
}
runMigrations()
.catch((error) => {
console.error('[migration] Failed:', error);
process.exitCode = 1;
})
.finally(async () => {
await sequelize.close();
});

View File

@@ -51,6 +51,10 @@ class MatchService {
const time = String(payload.time || '').trim();
const opponentName = String(payload.opponentName || '').trim();
const homeAway = payload.homeAway === 'away' ? 'away' : 'home';
const fixtureType = String(payload.fixtureType || 'league').trim();
if (!['league', 'cup'].includes(fixtureType)) {
throw new HttpError('Ungültiger Spieltyp', 400);
}
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);
}
@@ -78,7 +82,7 @@ class MatchService {
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
fixtureType, notes: String(payload.notes || '').trim() || null
});
return { id: match.id, clubId: match.clubId, match: await this.enrichMatch(match) };
}

View File

@@ -2,6 +2,12 @@
Diese Datei sammelt SQL-Aenderungen, die auf Live/Test manuell eingespielt werden muessen, wenn das Produktions-Setup keine automatische Schema-Migration ausfuehrt.
## Ausführung per Node
Neue, registrierte Migrationen können vor dem Neustart des Backends mit
`cd backend && npm run db:migrate` ausgeführt werden. Der Runner führt jede
Migration nur einmal aus und protokolliert sie in `schema_migrations`.
## Pflege-Regel
Ab jetzt gilt fuer jede Schemaaenderung:

View File

@@ -1482,7 +1482,14 @@
"addMatch": "Spiel hinzufügen",
"manualMatchDescription": "Manuell gepflegte Spiele erscheinen neben den importierten Click-TT-Terminen.",
"fixtureType": "Spieltyp:",
"regularMatch": "Reguläres Spiel",
"cupMatch": "Pokalspiel",
"friendlyMatch": "Freundschaftsspiel",
"fixtureTypeDescription": {
"league": "Reguläre und Pokalspiele werden im Spielplan gespeichert.",
"cup": "Reguläre und Pokalspiele werden im Spielplan gespeichert.",
"friendly": "Freundschaftsspiele werden separat verwaltet; die Angaben werden in das passende Formular übernommen."
},
"ownTeam": "Eigene Mannschaft",
"selectTeam": "Mannschaft wählen",
"opponent": "Gegner",
@@ -1495,7 +1502,10 @@
"notes": "Notizen",
"saving": "Speichert…",
"manualMatchRequired": "Bitte Mannschaft, Gegner, Datum und Uhrzeit ausfüllen.",
"manualMatchSaved": "Pokalspiel wurde hinzugefügt.",
"manualMatchSaved": {
"league": "Reguläres Spiel wurde hinzugefügt.",
"cup": "Pokalspiel wurde hinzugefügt."
},
"manualMatchSaveFailed": "Pokalspiel konnte nicht gespeichert werden.",
"galleryLoading": "Galerie wird geladen…",
"gallery": "Mitglieder-Galerie",

View File

@@ -1459,7 +1459,14 @@
"addMatch": "Add match",
"manualMatchDescription": "Manually maintained matches appear alongside imported Click-TT fixtures.",
"fixtureType": "Fixture type:",
"regularMatch": "Regular match",
"cupMatch": "Cup match",
"friendlyMatch": "Friendly match",
"fixtureTypeDescription": {
"league": "Regular and cup matches are stored in the schedule.",
"cup": "Regular and cup matches are stored in the schedule.",
"friendly": "Friendly matches are managed separately; the details are transferred to the appropriate form."
},
"ownTeam": "Own team",
"selectTeam": "Select team",
"opponent": "Opponent",
@@ -1472,7 +1479,10 @@
"notes": "Notes",
"saving": "Saving…",
"manualMatchRequired": "Please fill in the team, opponent, date and time.",
"manualMatchSaved": "Cup match added.",
"manualMatchSaved": {
"league": "Regular match added.",
"cup": "Cup match added."
},
"manualMatchSaveFailed": "Cup match could not be saved.",
"galleryLoading": "Loading gallery…",
"gallery": "Member gallery",

View File

@@ -275,7 +275,13 @@
<BaseDialog v-model="manualMatchDialog.isOpen" :title="$t('schedule.addMatch')" :max-width="620" @close="closeManualMatchDialog">
<form class="manual-match-form" @submit.prevent="saveManualMatch">
<p class="manual-match-intro">{{ $t('schedule.manualMatchDescription') }}</p>
<div class="manual-match-type">{{ $t('schedule.fixtureType') }} <strong>{{ $t('schedule.cupMatch') }}</strong></div>
<fieldset class="manual-match-type">
<legend>{{ $t('schedule.fixtureType') }}</legend>
<label><input v-model="manualMatchDialog.form.fixtureType" type="radio" value="league"> {{ $t('schedule.regularMatch') }}</label>
<label><input v-model="manualMatchDialog.form.fixtureType" type="radio" value="cup"> {{ $t('schedule.cupMatch') }}</label>
<label><input v-model="manualMatchDialog.form.fixtureType" type="radio" value="friendly"> {{ $t('schedule.friendlyMatch') }}</label>
</fieldset>
<p class="manual-match-type-hint">{{ $t(`schedule.fixtureTypeDescription.${manualMatchDialog.form.fixtureType}`) }}</p>
<p v-if="manualMatchDialog.error" class="manual-match-error" role="alert">{{ manualMatchDialog.error }}</p>
<div class="manual-match-grid">
<label><span>{{ $t('schedule.ownTeam') }} *</span><select v-model="manualMatchDialog.form.clubTeamId" required><option value="">{{ $t('schedule.selectTeam') }}</option><option v-for="team in teams" :key="team.id" :value="String(team.id)">{{ team.name }} <template v-if="team.league?.name">({{ team.league.name }})</template></option></select></label>
@@ -888,7 +894,7 @@ export default {
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: '' }
form: { fixtureType: 'league', clubTeamId: '', opponentName: '', date: new Date().toISOString().slice(0, 10), time: '', homeAway: 'home', locationName: '', locationAddress: '', locationZip: '', locationCity: '', notes: '' }
},
selectedFile: null,
teams: [],
@@ -984,7 +990,7 @@ export default {
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.form = { fixtureType: 'league', 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() {
@@ -1005,11 +1011,33 @@ export default {
this.manualMatchDialog.saving = true;
this.manualMatchDialog.error = '';
try {
if (form.fixtureType === 'friendly') {
const ownTeam = this.teams.find((team) => String(team.id) === String(form.clubTeamId));
if (!ownTeam?.name) {
this.manualMatchDialog.error = this.$t('schedule.manualMatchRequired');
return;
}
const prefill = {
...this.emptyFriendlyMatchForm(),
date: form.date,
time: form.time,
homeTeamName: form.homeAway === 'home' ? ownTeam.name : form.opponentName,
guestTeamName: form.homeAway === 'home' ? form.opponentName : ownTeam.name,
locationName: form.locationName,
locationAddress: form.locationAddress,
locationZip: form.locationZip,
locationCity: form.locationCity,
};
this.manualMatchDialog.isOpen = false;
this.resetManualMatchDialogForm();
await this.openFriendlyMatchDialog(null, prefill);
return;
}
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');
this.showInfo(this.$t('messages.success'), this.$t(`schedule.manualMatchSaved.${form.fixtureType}`), '', 'success');
} catch (error) {
this.manualMatchDialog.error = getSafeErrorMessage(error, this.$t('schedule.manualMatchSaveFailed'));
} finally {
@@ -1730,7 +1758,7 @@ export default {
this.friendlyMatchDialog.homeMembers = members;
this.friendlyMatchDialog.guestMembers = [];
},
async openFriendlyMatchDialog(match = null) {
async openFriendlyMatchDialog(match = null, prefill = null) {
await this.loadFriendlyMembers(match);
this.friendlyMatchDialog.match = match?.isFriendly ? match : null;
this.friendlyMatchDialog.editingId = match?.isFriendly ? match.id : null;
@@ -1756,7 +1784,7 @@ export default {
guestParticipants: [...this.parseFriendlyArray(match.guestParticipants)],
resultDetails: [...this.parseFriendlyArray(match.resultDetails)]
}
: this.emptyFriendlyMatchForm();
: (prefill || this.emptyFriendlyMatchForm());
this.syncFriendlyEditDoubleRows();
await this.loadFriendlyVenues(match);
const selectedVenue = this.findFriendlyVenueForForm();
@@ -3892,8 +3920,10 @@ li {
.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-type { display: flex; flex-wrap: wrap; gap: .6rem 1rem; margin: 0; padding: .65rem .8rem; border: 1px solid var(--border-color); border-left: 3px solid var(--primary-color); background: var(--surface-muted, #f5f7f6); font-size: .92rem; }
.manual-match-type legend { width: 100%; padding: 0 .2rem; font-size: .86rem; font-weight: 600; }
.manual-match-type label { font-weight: 600; cursor: pointer; }
.manual-match-type-hint { margin: -.35rem 0 0; color: var(--text-muted, #6c757d); font-size: .86rem; }
.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; }