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
All checks were successful
Deploy tt-tagebuch / deploy (push) Successful in 56s
This commit is contained in:
@@ -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"
|
||||
},
|
||||
|
||||
68
backend/scripts/runMigrations.js
Normal file
68
backend/scripts/runMigrations.js
Normal 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();
|
||||
});
|
||||
@@ -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) };
|
||||
}
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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; }
|
||||
|
||||
Reference in New Issue
Block a user