Erweitert den MatchReportApiDialog um neue Funktionen zur Verwaltung von Spielberichten. Implementiert eine verbesserte Logik zur Berechnung der Gesamtpunkte und Sätze sowie zur Validierung von Eingaben. Fügt visuelle Hinweise für den Abschlussstatus und Warnungen bei fehlerhaften Eingaben hinzu. Optimiert die Benutzeroberfläche mit neuen CSS-Stilen für eine bessere Benutzererfahrung.

This commit is contained in:
Torsten Schulz (local)
2025-11-12 13:40:55 +01:00
524 changed files with 55207 additions and 17236 deletions

102
backend/models/ApiLog.js Normal file
View File

@@ -0,0 +1,102 @@
import { DataTypes } from 'sequelize';
import sequelize from '../database.js';
import User from './User.js';
const ApiLog = sequelize.define('ApiLog', {
id: {
type: DataTypes.INTEGER,
primaryKey: true,
autoIncrement: true,
allowNull: false,
},
userId: {
type: DataTypes.INTEGER,
allowNull: true,
references: {
model: User,
key: 'id',
},
onDelete: 'SET NULL',
onUpdate: 'CASCADE',
},
method: {
type: DataTypes.STRING(10),
allowNull: false,
comment: 'HTTP method (GET, POST, PUT, DELETE, etc.)'
},
path: {
type: DataTypes.STRING(500),
allowNull: false,
comment: 'Request path'
},
statusCode: {
type: DataTypes.INTEGER,
allowNull: true,
comment: 'HTTP status code'
},
requestBody: {
type: DataTypes.TEXT,
allowNull: true,
comment: 'Request body (truncated if too long)'
},
responseBody: {
type: DataTypes.TEXT,
allowNull: true,
comment: 'Response body (truncated if too long)'
},
executionTime: {
type: DataTypes.INTEGER,
allowNull: true,
comment: 'Execution time in milliseconds'
},
errorMessage: {
type: DataTypes.TEXT,
allowNull: true,
comment: 'Error message if completes failed'
},
ipAddress: {
type: DataTypes.STRING(45),
allowNull: true,
comment: 'Client IP address'
},
userAgent: {
type: DataTypes.STRING(500),
allowNull: true,
comment: 'User agent string'
},
logType: {
type: DataTypes.ENUM('api_request', 'scheduler', 'cron_job', 'manual'),
allowNull: false,
defaultValue: 'api_request',
comment: 'Type of log entry'
},
schedulerJobType: {
type: DataTypes.STRING(50),
allowNull: true,
comment: 'Type of scheduler job (rating_updates, match_results, etc.)'
},
}, {
underscored: true,
tableName: 'api_log',
timestamps: true,
indexes: [
{
fields: ['user_id', 'created_at']
},
{
fields: ['path', 'created_at']
},
{
fields: ['log_type', 'created_at']
},
{
fields: ['created_at']
},
{
fields: ['status_code']
}
]
});
export default ApiLog;

View File

@@ -45,6 +45,12 @@ const ClubTeam = sequelize.define('ClubTeam', {
onDelete: 'CASCADE',
onUpdate: 'CASCADE',
},
myTischtennisTeamId: {
type: DataTypes.STRING,
allowNull: true,
comment: 'Team ID from myTischtennis (e.g. 2995094)',
field: 'my_tischtennis_team_id'
},
}, {
underscored: true,
tableName: 'club_team',

View File

@@ -34,6 +34,22 @@ const League = sequelize.define('League', {
onDelete: 'CASCADE',
onUpdate: 'CASCADE',
},
myTischtennisGroupId: {
type: DataTypes.STRING,
allowNull: true,
comment: 'Group ID from myTischtennis (e.g. 504417)',
field: 'my_tischtennis_group_id'
},
association: {
type: DataTypes.STRING,
allowNull: true,
comment: 'Association/Verband (e.g. HeTTV)',
},
groupname: {
type: DataTypes.STRING,
allowNull: true,
comment: 'Group name for URL (e.g. 1.Kreisklasse)',
},
}, {
underscored: true,
tableName: 'league',

View File

@@ -26,7 +26,7 @@ const Match = sequelize.define('Match', {
model: Location,
key: 'id',
},
allowNull: false,
allowNull: true,
},
homeTeamId: {
type: DataTypes.INTEGER,
@@ -75,6 +75,58 @@ const Match = sequelize.define('Match', {
allowNull: true,
comment: 'Pin-Code für Gastteam aus PDF-Parsing'
},
myTischtennisMeetingId: {
type: DataTypes.STRING,
allowNull: true,
unique: true,
comment: 'Meeting ID from myTischtennis (e.g. 15440488)',
field: 'my_tischtennis_meeting_id'
},
homeMatchPoints: {
type: DataTypes.INTEGER,
allowNull: true,
defaultValue: 0,
comment: 'Match points won by home team',
field: 'home_match_points'
},
guestMatchPoints: {
type: DataTypes.INTEGER,
allowNull: true,
defaultValue: 0,
comment: 'Match points won by guest team',
field: 'guest_match_points'
},
isCompleted: {
type: DataTypes.BOOLEAN,
allowNull: false,
defaultValue: false,
comment: 'Whether the match is completed',
field: 'is_completed'
},
pdfUrl: {
type: DataTypes.STRING,
allowNull: true,
comment: 'PDF URL from myTischtennis',
field: 'pdf_url'
},
playersReady: {
type: DataTypes.JSON,
allowNull: true,
comment: 'Array of member IDs who are ready to play',
field: 'players_ready'
},
playersPlanned: {
type: DataTypes.JSON,
allowNull: true,
comment: 'Array of member IDs who are planned to play',
field: 'players_planned'
},
playersPlayed: {
type: DataTypes.JSON,
allowNull: true,
comment: 'Array of member IDs who actually played',
field: 'players_played'
},
}, {
underscored: true,
tableName: 'match',

View File

@@ -45,13 +45,14 @@ const Member = sequelize.define('Member', {
},
birthDate: {
type: DataTypes.STRING,
allowNull: false,
allowNull: true,
set(value) {
const encryptedValue = encryptData(value);
const encryptedValue = encryptData(value || '');
this.setDataValue('birthDate', encryptedValue);
},
get() {
const encryptedValue = this.getDataValue('birthDate');
if (!encryptedValue) return null;
return decryptData(encryptedValue);
}
},
@@ -91,6 +92,21 @@ const Member = sequelize.define('Member', {
return decryptData(encryptedValue);
}
},
postalCode: {
type: DataTypes.STRING,
allowNull: true,
set(value) {
const encryptedValue = encryptData(value || '');
this.setDataValue('postalCode', encryptedValue);
},
get() {
const encryptedValue = this.getDataValue('postalCode');
if (!encryptedValue) return null;
return decryptData(encryptedValue);
},
field: 'postal_code',
comment: 'Postal code (PLZ)'
},
email: {
type: DataTypes.STRING,
allowNull: false,
@@ -137,6 +153,19 @@ const Member = sequelize.define('Member', {
type: DataTypes.INTEGER,
allowNull: true,
defaultValue: null
},
memberFormHandedOver: {
type: DataTypes.BOOLEAN,
allowNull: false,
defaultValue: false,
field: 'member_form_handed_over',
comment: 'Mitgliedsformular ausgehändigt'
},
myTischtennisPlayerId: {
type: DataTypes.STRING,
allowNull: true,
comment: 'Player ID from myTischtennis (e.g. NU2705037)',
field: 'my_tischtennis_player_id'
}
}, {
underscored: true,
@@ -151,13 +180,7 @@ const Member = sequelize.define('Member', {
member.save();
},
}
},
{
underscored: true,
tableName: 'log',
timestamps: true
}
);
});
Member.belongsTo(Club, { as: 'club' });
Club.hasMany(Member, { as: 'members' });

View File

@@ -0,0 +1,89 @@
import { DataTypes } from 'sequelize';
import sequelize from '../database.js';
import Member from './Member.js';
import { encryptData, decryptData } from '../utils/encrypt.js';
const MemberContact = sequelize.define('MemberContact', {
id: {
type: DataTypes.INTEGER,
primaryKey: true,
autoIncrement: true,
allowNull: false
},
memberId: {
type: DataTypes.INTEGER,
allowNull: false,
references: {
model: 'member',
key: 'id'
},
onDelete: 'CASCADE',
field: 'member_id'
},
type: {
type: DataTypes.ENUM('phone', 'email'),
allowNull: false,
comment: 'Type of contact: phone or email'
},
value: {
type: DataTypes.STRING,
allowNull: false,
set(value) {
const encryptedValue = encryptData(value);
this.setDataValue('value', encryptedValue);
},
get() {
const encryptedValue = this.getDataValue('value');
if (!encryptedValue) return null;
try {
return decryptData(encryptedValue);
} catch (error) {
console.error('[MemberContact] Error decrypting value:', error);
return encryptedValue; // Fallback: return encrypted value if decryption fails
}
}
},
isParent: {
type: DataTypes.BOOLEAN,
allowNull: false,
defaultValue: false,
field: 'is_parent',
comment: 'Whether this contact belongs to a parent'
},
parentName: {
type: DataTypes.STRING,
allowNull: true,
set(value) {
if (value) {
const encryptedValue = encryptData(value);
this.setDataValue('parentName', encryptedValue);
} else {
this.setDataValue('parentName', null);
}
},
get() {
const encryptedValue = this.getDataValue('parentName');
return encryptedValue ? decryptData(encryptedValue) : null;
},
field: 'parent_name',
comment: 'Name of the parent (e.g. "Mutter", "Vater", "Elternteil 1")'
},
isPrimary: {
type: DataTypes.BOOLEAN,
allowNull: false,
defaultValue: false,
field: 'is_primary',
comment: 'Whether this is the primary contact of this type'
}
}, {
underscored: true,
sequelize,
modelName: 'MemberContact',
tableName: 'member_contact',
timestamps: true
});
// Associations are defined in models/index.js to avoid duplicate alias errors
export default MemberContact;

View File

@@ -0,0 +1,40 @@
import { DataTypes } from 'sequelize';
import sequelize from '../database.js';
const MemberImage = sequelize.define('MemberImage', {
id: {
type: DataTypes.INTEGER,
autoIncrement: true,
primaryKey: true,
allowNull: false
},
memberId: {
type: DataTypes.INTEGER,
allowNull: false,
field: 'member_id',
references: {
model: 'member',
key: 'id'
},
onDelete: 'CASCADE',
onUpdate: 'CASCADE'
},
fileName: {
type: DataTypes.STRING(255),
allowNull: false,
field: 'file_name'
},
sortOrder: {
type: DataTypes.INTEGER,
allowNull: false,
defaultValue: 0,
field: 'sort_order'
}
}, {
tableName: 'member_image',
underscored: true,
timestamps: true
});
export default MemberImage;

View File

@@ -0,0 +1,117 @@
import { DataTypes } from 'sequelize';
import sequelize from '../database.js';
import Club from './Club.js';
import { encryptData, decryptData } from '../utils/encrypt.js';
const MemberTransferConfig = sequelize.define('MemberTransferConfig', {
id: {
type: DataTypes.INTEGER,
primaryKey: true,
autoIncrement: true,
allowNull: false
},
clubId: {
type: DataTypes.INTEGER,
allowNull: false,
unique: true,
references: {
model: Club,
key: 'id'
},
onDelete: 'CASCADE'
},
server: {
type: DataTypes.STRING,
allowNull: true,
field: 'server',
comment: 'Base URL des Servers (z.B. https://example.com)'
},
loginEndpoint: {
type: DataTypes.STRING,
allowNull: true,
field: 'login_endpoint',
comment: 'Relativer Pfad zum Login-Endpoint (z.B. /api/auth/login)'
},
loginFormat: {
type: DataTypes.STRING,
allowNull: true,
defaultValue: 'json',
field: 'login_format'
},
encryptedLoginCredentials: {
type: DataTypes.TEXT,
allowNull: true,
field: 'encrypted_login_credentials',
comment: 'Verschlüsselte Login-Daten als JSON-String'
},
transferEndpoint: {
type: DataTypes.STRING,
allowNull: false,
field: 'transfer_endpoint',
comment: 'Relativer Pfad zum Übertragungs-Endpoint (z.B. /api/members/bulk)'
},
transferMethod: {
type: DataTypes.STRING,
allowNull: false,
defaultValue: 'POST',
field: 'transfer_method'
},
transferFormat: {
type: DataTypes.STRING,
allowNull: false,
defaultValue: 'json',
field: 'transfer_format'
},
transferTemplate: {
type: DataTypes.TEXT,
allowNull: false,
field: 'transfer_template'
},
useBulkMode: {
type: DataTypes.BOOLEAN,
allowNull: false,
defaultValue: false,
field: 'use_bulk_mode'
},
bulkWrapperTemplate: {
type: DataTypes.TEXT,
allowNull: true,
field: 'bulk_wrapper_template',
comment: 'Optionales Template für die äußere Struktur im Bulk-Modus (z.B. {"data": {"members": "{{members}}"}})'
}
}, {
underscored: true,
tableName: 'member_transfer_config',
timestamps: true
});
// Getter/Setter für verschlüsselte Login-Daten
MemberTransferConfig.prototype.getLoginCredentials = function() {
if (!this.encryptedLoginCredentials) {
return null;
}
try {
const decrypted = decryptData(this.encryptedLoginCredentials);
return JSON.parse(decrypted);
} catch (error) {
console.error('[MemberTransferConfig] Error decrypting login credentials:', error);
return null;
}
};
MemberTransferConfig.prototype.setLoginCredentials = function(credentials) {
if (!credentials || Object.keys(credentials).length === 0) {
this.encryptedLoginCredentials = null;
return;
}
try {
const jsonString = JSON.stringify(credentials);
this.encryptedLoginCredentials = encryptData(jsonString);
} catch (error) {
console.error('[MemberTransferConfig] Error encrypting login credentials:', error);
this.encryptedLoginCredentials = null;
}
};
export default MemberTransferConfig;

View File

@@ -34,6 +34,12 @@ const MyTischtennis = sequelize.define('MyTischtennis', {
allowNull: false,
field: 'save_password'
},
autoUpdateRatings: {
type: DataTypes.BOOLEAN,
defaultValue: false,
allowNull: false,
field: 'auto_update_ratings'
},
accessToken: {
type: DataTypes.TEXT,
allowNull: true,
@@ -82,6 +88,11 @@ const MyTischtennis = sequelize.define('MyTischtennis', {
type: DataTypes.DATE,
allowNull: true,
field: 'last_login_success'
},
lastUpdateRatings: {
type: DataTypes.DATE,
allowNull: true,
field: 'last_update_ratings'
}
}, {
underscored: true,

View File

@@ -0,0 +1,72 @@
import { DataTypes } from 'sequelize';
import sequelize from '../database.js';
import User from './User.js';
const MyTischtennisFetchLog = sequelize.define('MyTischtennisFetchLog', {
id: {
type: DataTypes.INTEGER,
primaryKey: true,
autoIncrement: true,
allowNull: false,
},
userId: {
type: DataTypes.INTEGER,
allowNull: false,
references: {
model: User,
key: 'id',
},
onDelete: 'CASCADE',
onUpdate: 'CASCADE',
},
fetchType: {
type: DataTypes.ENUM('ratings', 'match_results', 'league_table'),
allowNull: false,
comment: 'Type of data fetch: ratings, match_results, or league_table'
},
success: {
type: DataTypes.BOOLEAN,
allowNull: false,
defaultValue: false,
},
message: {
type: DataTypes.TEXT,
allowNull: true,
},
errorDetails: {
type: DataTypes.TEXT,
allowNull: true,
},
recordsProcessed: {
type: DataTypes.INTEGER,
allowNull: false,
defaultValue: 0,
comment: 'Number of records processed (e.g., players updated, matches fetched)'
},
executionTime: {
type: DataTypes.INTEGER,
allowNull: true,
comment: 'Execution time in milliseconds'
},
isAutomatic: {
type: DataTypes.BOOLEAN,
allowNull: false,
defaultValue: false,
comment: 'Whether this was an automatic or manual fetch'
},
}, {
underscored: true,
tableName: 'my_tischtennis_fetch_log',
timestamps: true,
indexes: [
{
fields: ['user_id', 'fetch_type', 'created_at']
},
{
fields: ['created_at']
}
]
});
export default MyTischtennisFetchLog;

View File

@@ -0,0 +1,63 @@
import { DataTypes } from 'sequelize';
import sequelize from '../database.js';
const MyTischtennisUpdateHistory = sequelize.define('MyTischtennisUpdateHistory', {
id: {
type: DataTypes.INTEGER,
primaryKey: true,
autoIncrement: true,
allowNull: false
},
userId: {
type: DataTypes.INTEGER,
allowNull: false,
references: {
model: 'user',
key: 'id'
},
onDelete: 'CASCADE'
},
success: {
type: DataTypes.BOOLEAN,
allowNull: false,
defaultValue: false
},
message: {
type: DataTypes.TEXT,
allowNull: true
},
errorDetails: {
type: DataTypes.TEXT,
allowNull: true,
field: 'error_details'
},
updatedCount: {
type: DataTypes.INTEGER,
allowNull: true,
defaultValue: 0,
field: 'updated_count'
},
executionTime: {
type: DataTypes.INTEGER,
allowNull: true,
comment: 'Execution time in milliseconds',
field: 'execution_time'
}
}, {
underscored: true,
tableName: 'my_tischtennis_update_history',
timestamps: true,
indexes: [
{
fields: ['user_id']
},
{
fields: ['created_at']
},
{
fields: ['success']
}
]
});
export default MyTischtennisUpdateHistory;

View File

@@ -2,6 +2,7 @@ import { DataTypes } from 'sequelize';
import sequelize from '../database.js';
import Member from './Member.js';
import DiaryDate from './DiaryDates.js';
import Group from './Group.js';
import { encryptData, decryptData } from '../utils/encrypt.js';
const Participant = sequelize.define('Participant', {
@@ -27,6 +28,16 @@ const Participant = sequelize.define('Participant', {
key: 'id'
}
},
groupId: {
type: DataTypes.INTEGER,
allowNull: true,
references: {
model: Group,
key: 'id'
},
onDelete: 'SET NULL',
onUpdate: 'CASCADE'
},
notes: {
type: DataTypes.STRING(4096),
allowNull: true,

View File

@@ -45,6 +45,62 @@ const Team = sequelize.define('Team', {
onDelete: 'CASCADE',
onUpdate: 'CASCADE',
},
// Tabellenfelder
matchesPlayed: {
type: DataTypes.INTEGER,
allowNull: false,
defaultValue: 0,
},
matchesWon: {
type: DataTypes.INTEGER,
allowNull: false,
defaultValue: 0,
},
matchesLost: {
type: DataTypes.INTEGER,
allowNull: false,
defaultValue: 0,
},
matchesTied: {
type: DataTypes.INTEGER,
allowNull: false,
defaultValue: 0,
},
setsWon: {
type: DataTypes.INTEGER,
allowNull: false,
defaultValue: 0,
},
setsLost: {
type: DataTypes.INTEGER,
allowNull: false,
defaultValue: 0,
},
pointsWon: {
type: DataTypes.INTEGER,
allowNull: false,
defaultValue: 0,
},
pointsLost: {
type: DataTypes.INTEGER,
allowNull: false,
defaultValue: 0,
},
tablePoints: {
type: DataTypes.INTEGER,
allowNull: false,
defaultValue: 0,
},
tablePointsWon: {
type: DataTypes.INTEGER,
allowNull: false,
defaultValue: 0,
},
tablePointsLost: {
type: DataTypes.INTEGER,
allowNull: false,
defaultValue: 0,
},
}, {
underscored: true,
tableName: 'team',

View File

@@ -6,6 +6,7 @@ import Club from './Club.js';
const UserClub = sequelize.define('UserClub', {
userId: {
type: DataTypes.INTEGER,
primaryKey: true,
references: {
model: User,
key: 'id',
@@ -13,6 +14,7 @@ const UserClub = sequelize.define('UserClub', {
},
clubId: {
type: DataTypes.INTEGER,
primaryKey: true,
references: {
model: Club,
key: 'id',
@@ -22,6 +24,23 @@ const UserClub = sequelize.define('UserClub', {
type: DataTypes.BOOLEAN,
defaultValue: false,
},
role: {
type: DataTypes.STRING(50),
defaultValue: 'member',
allowNull: false,
comment: 'User role: admin, trainer, team_manager, member'
},
permissions: {
type: DataTypes.JSON,
allowNull: true,
comment: 'Specific permissions: {diary: {read: true, write: true}, members: {...}, ...}'
},
isOwner: {
type: DataTypes.BOOLEAN,
defaultValue: false,
allowNull: false,
comment: 'True if user created the club'
}
}, {
underscored: true,
tableName: 'user_club',

View File

@@ -36,6 +36,12 @@ import OfficialTournament from './OfficialTournament.js';
import OfficialCompetition from './OfficialCompetition.js';
import OfficialCompetitionMember from './OfficialCompetitionMember.js';
import MyTischtennis from './MyTischtennis.js';
import MyTischtennisUpdateHistory from './MyTischtennisUpdateHistory.js';
import MyTischtennisFetchLog from './MyTischtennisFetchLog.js';
import ApiLog from './ApiLog.js';
import MemberTransferConfig from './MemberTransferConfig.js';
import MemberContact from './MemberContact.js';
import MemberImage from './MemberImage.js';
// Official tournaments relations
OfficialTournament.hasMany(OfficialCompetition, { foreignKey: 'tournamentId', as: 'competitions' });
OfficialCompetition.belongsTo(OfficialTournament, { foreignKey: 'tournamentId', as: 'tournament' });
@@ -120,6 +126,9 @@ Team.belongsTo(Club, { foreignKey: 'clubId', as: 'club' });
Club.hasMany(League, { foreignKey: 'clubId', as: 'leagues' });
League.belongsTo(Club, { foreignKey: 'clubId', as: 'club' });
Season.hasMany(League, { foreignKey: 'seasonId', as: 'leagues' });
League.belongsTo(Season, { foreignKey: 'seasonId', as: 'season' });
League.hasMany(Team, { foreignKey: 'leagueId', as: 'teams' });
Team.belongsTo(League, { foreignKey: 'leagueId', as: 'league' });
@@ -155,7 +164,7 @@ Club.hasMany(UserClub, { foreignKey: 'clubId' });
Group.belongsTo(DiaryDate, { foreignKey: 'diaryDateId', as: 'diaryDateGroup' });
DiaryDate.hasMany(Group, { foreignKey: 'diaryDateId', as: 'groupsDiaryDate' });
GroupActivity.belongsTo(DiaryDateActivity, { foreignKey: 'id', as: 'activityGroupActivity' });
GroupActivity.belongsTo(DiaryDateActivity, { foreignKey: 'diaryDateActivity', as: 'activityGroupActivity' });
DiaryDateActivity.hasMany(GroupActivity, { foreignKey: 'diaryDateActivity', as: 'groupActivities' });
Group.hasOne(GroupActivity, { foreignKey: 'groupId', as: 'groupGroupActivity' });
@@ -227,6 +236,24 @@ DiaryDate.hasMany(Accident, { foreignKey: 'diaryDateId', as: 'accidents' });
User.hasOne(MyTischtennis, { foreignKey: 'userId', as: 'myTischtennis' });
MyTischtennis.belongsTo(User, { foreignKey: 'userId', as: 'user' });
User.hasMany(MyTischtennisUpdateHistory, { foreignKey: 'userId', as: 'updateHistory' });
MyTischtennisUpdateHistory.belongsTo(User, { foreignKey: 'userId', as: 'user' });
User.hasMany(MyTischtennisFetchLog, { foreignKey: 'userId', as: 'fetchLogs' });
MyTischtennisFetchLog.belongsTo(User, { foreignKey: 'userId', as: 'user' });
User.hasMany(ApiLog, { foreignKey: 'userId', as: 'apiLogs' });
ApiLog.belongsTo(User, { foreignKey: 'userId', as: 'user' });
Club.hasOne(MemberTransferConfig, { foreignKey: 'clubId', as: 'memberTransferConfig' });
MemberTransferConfig.belongsTo(Club, { foreignKey: 'clubId', as: 'club' });
Member.hasMany(MemberContact, { foreignKey: 'memberId', as: 'contacts' });
MemberContact.belongsTo(Member, { foreignKey: 'memberId', as: 'member' });
Member.hasMany(MemberImage, { foreignKey: 'memberId', as: 'images' });
MemberImage.belongsTo(Member, { foreignKey: 'memberId', as: 'member' });
export {
User,
Log,
@@ -265,4 +292,10 @@ export {
OfficialCompetition,
OfficialCompetitionMember,
MyTischtennis,
MyTischtennisUpdateHistory,
MyTischtennisFetchLog,
ApiLog,
MemberTransferConfig,
MemberContact,
MemberImage,
};