diff --git a/backend/controllers/teamDocumentController.js b/backend/controllers/teamDocumentController.js new file mode 100644 index 0000000..0dde544 --- /dev/null +++ b/backend/controllers/teamDocumentController.js @@ -0,0 +1,233 @@ +import multer from 'multer'; +import path from 'path'; +import TeamDocumentService from '../services/teamDocumentService.js'; +import PDFParserService from '../services/pdfParserService.js'; +import { getUserByToken } from '../utils/userUtils.js'; +import { devLog } from '../utils/logger.js'; + +// Multer-Konfiguration für Datei-Uploads +const storage = multer.diskStorage({ + destination: (req, file, cb) => { + cb(null, 'uploads/temp/'); + }, + filename: (req, file, cb) => { + const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1E9); + cb(null, file.fieldname + '-' + uniqueSuffix + path.extname(file.originalname)); + } +}); + +const upload = multer({ + storage: storage, + limits: { + fileSize: 10 * 1024 * 1024 // 10MB Limit + }, + fileFilter: (req, file, cb) => { + // Erlaube nur PDF, DOC, DOCX, TXT, CSV Dateien + const allowedTypes = /pdf|doc|docx|txt|csv/; + const extname = allowedTypes.test(path.extname(file.originalname).toLowerCase()); + const mimetype = allowedTypes.test(file.mimetype); + + if (mimetype && extname) { + return cb(null, true); + } else { + cb(new Error('Nur PDF, DOC, DOCX, TXT und CSV Dateien sind erlaubt!')); + } + } +}); + +export const uploadMiddleware = upload.single('document'); + +export const uploadDocument = async (req, res) => { + try { + const { authcode: token } = req.headers; + const { clubteamid: clubTeamId } = req.params; + const { documentType } = req.body; + + devLog('[uploadDocument] - Uploading document for club team:', clubTeamId, 'type:', documentType); + const user = await getUserByToken(token); + + if (!req.file) { + return res.status(400).json({ error: "nofile" }); + } + + if (!documentType || !['code_list', 'pin_list'].includes(documentType)) { + return res.status(400).json({ error: "invaliddocumenttype" }); + } + + const document = await TeamDocumentService.uploadDocument(req.file, clubTeamId, documentType); + + devLog('[uploadDocument] - Document uploaded successfully:', document.id); + res.status(201).json(document); + } catch (error) { + console.error('[uploadDocument] - Error:', error); + + // Lösche temporäre Datei bei Fehler + if (req.file && req.file.path) { + try { + const fs = await import('fs'); + fs.unlinkSync(req.file.path); + } catch (cleanupError) { + console.error('Fehler beim Löschen der temporären Datei:', cleanupError); + } + } + + if (error.message === 'Club-Team nicht gefunden') { + return res.status(404).json({ error: "clubteamnotfound" }); + } + + res.status(500).json({ error: "internalerror" }); + } +}; + +export const getDocuments = async (req, res) => { + try { + const { authcode: token } = req.headers; + const { clubteamid: clubTeamId } = req.params; + + devLog('[getDocuments] - Getting documents for club team:', clubTeamId); + const user = await getUserByToken(token); + + const documents = await TeamDocumentService.getDocumentsByClubTeam(clubTeamId); + devLog('[getDocuments] - Found documents:', documents.length); + + res.status(200).json(documents); + } catch (error) { + console.error('[getDocuments] - Error:', error); + res.status(500).json({ error: "internalerror" }); + } +}; + +export const getDocument = async (req, res) => { + try { + const { authcode: token } = req.headers; + const { documentid: documentId } = req.params; + + devLog('[getDocument] - Getting document:', documentId); + const user = await getUserByToken(token); + + const document = await TeamDocumentService.getDocumentById(documentId); + if (!document) { + return res.status(404).json({ error: "notfound" }); + } + + res.status(200).json(document); + } catch (error) { + console.error('[getDocument] - Error:', error); + res.status(500).json({ error: "internalerror" }); + } +}; + +export const downloadDocument = async (req, res) => { + try { + const { authcode: token } = req.headers; + const { documentid: documentId } = req.params; + + devLog('[downloadDocument] - Downloading document:', documentId); + const user = await getUserByToken(token); + + const document = await TeamDocumentService.getDocumentById(documentId); + if (!document) { + return res.status(404).json({ error: "notfound" }); + } + + const filePath = await TeamDocumentService.getDocumentPath(documentId); + if (!filePath) { + return res.status(404).json({ error: "filenotfound" }); + } + + // Prüfe ob Datei existiert + const fs = await import('fs'); + if (!fs.existsSync(filePath)) { + return res.status(404).json({ error: "filenotfound" }); + } + + // Setze Headers für Inline-Anzeige (PDF-Viewer) + res.setHeader('Content-Disposition', `inline; filename="${document.originalFileName}"`); + res.setHeader('Content-Type', document.mimeType); + + // Sende die Datei + res.sendFile(filePath); + } catch (error) { + console.error('[downloadDocument] - Error:', error); + res.status(500).json({ error: "internalerror" }); + } +}; + +export const deleteDocument = async (req, res) => { + try { + const { authcode: token } = req.headers; + const { documentid: documentId } = req.params; + + devLog('[deleteDocument] - Deleting document:', documentId); + const user = await getUserByToken(token); + + const success = await TeamDocumentService.deleteDocument(documentId); + if (!success) { + return res.status(404).json({ error: "notfound" }); + } + + res.status(200).json({ message: "Document deleted successfully" }); + } catch (error) { + console.error('[deleteDocument] - Error:', error); + res.status(500).json({ error: "internalerror" }); + } +}; + +export const parsePDF = async (req, res) => { + try { + const { authcode: token } = req.headers; + const { documentid: documentId } = req.params; + const { leagueid: leagueId } = req.query; + + devLog('[parsePDF] - Parsing PDF document:', documentId, 'league:', leagueId); + const user = await getUserByToken(token); + + if (!leagueId) { + return res.status(400).json({ error: "missingleagueid" }); + } + + // Hole Dokument-Informationen + const document = await TeamDocumentService.getDocumentById(documentId); + if (!document) { + return res.status(404).json({ error: "documentnotfound" }); + } + + // Prüfe ob es eine PDF- oder TXT-Datei ist + if (!document.mimeType.includes('pdf') && !document.mimeType.includes('text/plain')) { + return res.status(400).json({ error: "notapdfortxt" }); + } + + // Parse PDF + const parseResult = await PDFParserService.parsePDF(document.filePath, document.clubTeam.clubId); + + // Speichere Matches in Datenbank + const saveResult = await PDFParserService.saveMatchesToDatabase(parseResult.matches, parseInt(leagueId)); + + devLog('[parsePDF] - PDF parsed successfully:', { + matchesFound: parseResult.matches.length, + created: saveResult.created, + updated: saveResult.updated, + errors: saveResult.errors.length + }); + + res.status(200).json({ + message: "PDF parsed successfully", + parseResult: { + matchesFound: parseResult.matches.length, + errors: parseResult.errors, + metadata: parseResult.metadata, + debugInfo: parseResult.debugInfo, + allLines: parseResult.allLines, + rawText: parseResult.rawText + }, + saveResult: { + created: saveResult.created, + updated: saveResult.updated, + errors: saveResult.errors + } + }); + } catch (error) { + console.error('[parsePDF] - Error:', error); + res.status(500).json({ error: "internalerror" }); + } +}; \ No newline at end of file diff --git a/backend/models/Match.js b/backend/models/Match.js index 24de295..ee385e1 100644 --- a/backend/models/Match.js +++ b/backend/models/Match.js @@ -60,6 +60,21 @@ const Match = sequelize.define('Match', { }, allowNull: false, }, + code: { + type: DataTypes.STRING, + allowNull: true, + comment: 'Spiel-Code aus PDF-Parsing' + }, + homePin: { + type: DataTypes.STRING, + allowNull: true, + comment: 'Pin-Code für Heimteam aus PDF-Parsing' + }, + guestPin: { + type: DataTypes.STRING, + allowNull: true, + comment: 'Pin-Code für Gastteam aus PDF-Parsing' + }, }, { underscored: true, tableName: 'match', diff --git a/backend/models/TeamDocument.js b/backend/models/TeamDocument.js new file mode 100644 index 0000000..992ca9f --- /dev/null +++ b/backend/models/TeamDocument.js @@ -0,0 +1,52 @@ +import { DataTypes } from 'sequelize'; +import sequelize from '../database.js'; +import ClubTeam from './ClubTeam.js'; + +const TeamDocument = sequelize.define('TeamDocument', { + id: { + type: DataTypes.INTEGER, + primaryKey: true, + autoIncrement: true, + allowNull: false, + }, + fileName: { + type: DataTypes.STRING, + allowNull: false, + }, + originalFileName: { + type: DataTypes.STRING, + allowNull: false, + }, + filePath: { + type: DataTypes.STRING, + allowNull: false, + }, + fileSize: { + type: DataTypes.INTEGER, + allowNull: false, + }, + mimeType: { + type: DataTypes.STRING, + allowNull: false, + }, + documentType: { + type: DataTypes.ENUM('code_list', 'pin_list'), + allowNull: false, + }, + clubTeamId: { + type: DataTypes.INTEGER, + allowNull: false, + references: { + model: ClubTeam, + key: 'id', + }, + onDelete: 'CASCADE', + onUpdate: 'CASCADE', + }, +}, { + underscored: true, + tableName: 'team_document', + timestamps: true, +}); + +export default TeamDocument; diff --git a/backend/models/index.js b/backend/models/index.js index f686330..73e14f7 100644 --- a/backend/models/index.js +++ b/backend/models/index.js @@ -20,6 +20,7 @@ import Match from './Match.js'; import League from './League.js'; import Team from './Team.js'; import ClubTeam from './ClubTeam.js'; +import TeamDocument from './TeamDocument.js'; import Season from './Season.js'; import Location from './Location.js'; import Group from './Group.js'; @@ -135,6 +136,10 @@ ClubTeam.belongsTo(League, { foreignKey: 'leagueId', as: 'league' }); Season.hasMany(ClubTeam, { foreignKey: 'seasonId', as: 'clubTeams' }); ClubTeam.belongsTo(Season, { foreignKey: 'seasonId', as: 'season' }); +// TeamDocument relationships +ClubTeam.hasMany(TeamDocument, { foreignKey: 'clubTeamId', as: 'documents' }); +TeamDocument.belongsTo(ClubTeam, { foreignKey: 'clubTeamId', as: 'clubTeam' }); + Match.belongsTo(Location, { foreignKey: 'locationId', as: 'location' }); Location.hasMany(Match, { foreignKey: 'locationId', as: 'matches' }); @@ -246,6 +251,7 @@ export { League, Team, ClubTeam, + TeamDocument, Group, GroupActivity, Tournament, diff --git a/backend/routes/teamDocumentRoutes.js b/backend/routes/teamDocumentRoutes.js new file mode 100644 index 0000000..af2e737 --- /dev/null +++ b/backend/routes/teamDocumentRoutes.js @@ -0,0 +1,33 @@ +import express from 'express'; +import { authenticate } from '../middleware/authMiddleware.js'; +import { + uploadMiddleware, + uploadDocument, + getDocuments, + getDocument, + downloadDocument, + deleteDocument, + parsePDF +} from '../controllers/teamDocumentController.js'; + +const router = express.Router(); + +// Upload eines Dokuments für ein Club-Team +router.post('/club-team/:clubteamid/upload', authenticate, uploadMiddleware, uploadDocument); + +// Alle Dokumente für ein Club-Team abrufen +router.get('/club-team/:clubteamid', authenticate, getDocuments); + +// Ein spezifisches Dokument abrufen +router.get('/:documentid', authenticate, getDocument); + +// Ein Dokument herunterladen +router.get('/:documentid/download', authenticate, downloadDocument); + +// Ein Dokument löschen +router.delete('/:documentid', authenticate, deleteDocument); + +// PDF parsen und Matches extrahieren +router.post('/:documentid/parse', authenticate, parsePDF); + +export default router; \ No newline at end of file diff --git a/backend/server.js b/backend/server.js index 013957b..ea4a109 100644 --- a/backend/server.js +++ b/backend/server.js @@ -6,7 +6,7 @@ import cors from 'cors'; import { User, Log, Club, UserClub, Member, DiaryDate, Participant, Activity, MemberNote, DiaryNote, DiaryTag, MemberDiaryTag, DiaryDateTag, DiaryMemberNote, DiaryMemberTag, - PredefinedActivity, PredefinedActivityImage, DiaryDateActivity, DiaryMemberActivity, Match, League, Team, ClubTeam, Group, + PredefinedActivity, PredefinedActivityImage, DiaryDateActivity, DiaryMemberActivity, Match, League, Team, ClubTeam, TeamDocument, Group, GroupActivity, Tournament, TournamentGroup, TournamentMatch, TournamentResult, TournamentMember, Accident, UserToken, OfficialTournament, OfficialCompetition, OfficialCompetitionMember, MyTischtennis } from './models/index.js'; @@ -36,6 +36,7 @@ import officialTournamentRoutes from './routes/officialTournamentRoutes.js'; import myTischtennisRoutes from './routes/myTischtennisRoutes.js'; import teamRoutes from './routes/teamRoutes.js'; import clubTeamRoutes from './routes/clubTeamRoutes.js'; +import teamDocumentRoutes from './routes/teamDocumentRoutes.js'; import seasonRoutes from './routes/seasonRoutes.js'; const app = express(); @@ -84,6 +85,7 @@ app.use('/api/official-tournaments', officialTournamentRoutes); app.use('/api/mytischtennis', myTischtennisRoutes); app.use('/api/teams', teamRoutes); app.use('/api/club-teams', clubTeamRoutes); +app.use('/api/team-documents', teamDocumentRoutes); app.use('/api/seasons', seasonRoutes); app.use(express.static(path.join(__dirname, '../frontend/dist'))); diff --git a/backend/services/matchService.js b/backend/services/matchService.js index 958bcb1..3ab1f08 100644 --- a/backend/services/matchService.js +++ b/backend/services/matchService.js @@ -171,6 +171,9 @@ class MatchService { guestTeamId: match.guestTeamId, locationId: match.locationId, leagueId: match.leagueId, + code: match.code, + homePin: match.homePin, + guestPin: match.guestPin, homeTeam: { name: 'Unbekannt' }, guestTeam: { name: 'Unbekannt' }, location: { name: 'Unbekannt', address: '', city: '', zip: '' }, @@ -228,6 +231,9 @@ class MatchService { guestTeamId: match.guestTeamId, locationId: match.locationId, leagueId: match.leagueId, + code: match.code, + homePin: match.homePin, + guestPin: match.guestPin, homeTeam: { name: 'Unbekannt' }, guestTeam: { name: 'Unbekannt' }, location: { name: 'Unbekannt', address: '', city: '', zip: '' }, diff --git a/backend/services/pdfParserService.js b/backend/services/pdfParserService.js new file mode 100644 index 0000000..f2c40ca --- /dev/null +++ b/backend/services/pdfParserService.js @@ -0,0 +1,690 @@ +import fs from 'fs'; +import path from 'path'; +import { fileURLToPath } from 'url'; +import { createRequire } from 'module'; +const require = createRequire(import.meta.url); +const pdfParse = require('pdf-parse/lib/pdf-parse.js'); +import { Op } from 'sequelize'; +import Match from '../models/Match.js'; +import Team from '../models/Team.js'; +import ClubTeam from '../models/ClubTeam.js'; +import League from '../models/League.js'; +import Location from '../models/Location.js'; +import { devLog } from '../utils/logger.js'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +class PDFParserService { + /** + * Parst eine PDF-Datei und extrahiert Spiel-Daten + * @param {string} filePath - Pfad zur PDF-Datei + * @param {number} clubId - ID des Vereins + * @returns {Promise} Geparste Spiel-Daten + */ + static async parsePDF(filePath, clubId) { + try { + devLog('[PDFParserService.parsePDF] - Parsing PDF:', filePath); + + if (!fs.existsSync(filePath)) { + throw new Error('PDF-Datei nicht gefunden'); + } + + // Bestimme Dateityp basierend auf Dateiendung + const fileExtension = path.extname(filePath).toLowerCase(); + let fileContent; + + if (fileExtension === '.pdf') { + // Echte PDF-Parsing + const pdfBuffer = fs.readFileSync(filePath); + const pdfData = await pdfParse(pdfBuffer); + fileContent = pdfData.text; + devLog('[PDFParserService.parsePDF] - PDF parsed, text length:', fileContent.length); + } else { + // Fallback für TXT-Dateien (für Tests) + fileContent = fs.readFileSync(filePath, 'utf8'); + devLog('[PDFParserService.parsePDF] - Text file loaded, text length:', fileContent.length); + } + + // Parse den Text nach Spiel-Daten + const parsedData = this.extractMatchData(fileContent, clubId); + + devLog('[PDFParserService.parsePDF] - Extracted matches:', parsedData.matches.length); + + return parsedData; + } catch (error) { + console.error('[PDFParserService.parsePDF] - Error:', error); + throw error; + } + } + + /** + * Extrahiert Spiel-Daten aus dem PDF-Text + * @param {string} text - Der extrahierte Text aus der PDF + * @param {number} clubId - ID des Vereins + * @returns {Object} Geparste Daten mit Matches und Metadaten + */ + static extractMatchData(text, clubId) { + const matches = []; + const errors = []; + const metadata = { + totalLines: 0, + parsedMatches: 0, + errors: 0 + }; + + try { + // Teile Text in Zeilen auf + const lines = text.split('\n').map(line => line.trim()).filter(line => line.length > 0); + metadata.totalLines = lines.length; + + devLog('[PDFParserService.extractMatchData] - Processing lines:', lines.length); + + // Verschiedene Parsing-Strategien je nach PDF-Format + const strategies = [ + { name: 'Standard Format', fn: this.parseStandardFormat }, + { name: 'Table Format', fn: this.parseTableFormat }, + { name: 'List Format', fn: this.parseListFormat } + ]; + + devLog('[PDFParserService.extractMatchData] - Trying parsing strategies...'); + + for (const strategy of strategies) { + try { + devLog(`[PDFParserService.extractMatchData] - Trying ${strategy.name}...`); + const result = strategy.fn(lines, clubId); + devLog(`[PDFParserService.extractMatchData] - ${strategy.name} found ${result.matches.length} matches`); + + if (result.matches.length > 0) { + matches.push(...result.matches); + metadata.parsedMatches += result.matches.length; + devLog(`[PDFParserService.extractMatchData] - Using ${strategy.name} strategy`); + break; // Erste erfolgreiche Strategie verwenden + } + } catch (strategyError) { + devLog(`[PDFParserService.extractMatchData] - ${strategy.name} failed:`, strategyError.message); + errors.push(`Strategy ${strategy.name} failed: ${strategyError.message}`); + } + } + + metadata.errors = errors.length; + + return { + matches, + errors, + metadata, + rawText: text.substring(0, 1000), // Erste 1000 Zeichen für Debugging + allLines: lines, // Alle Zeilen für Debugging + debugInfo: { + totalTextLength: text.length, + totalLines: lines.length, + firstFewLines: lines.slice(0, 10), + lastFewLines: lines.slice(-5) + } + }; + } catch (error) { + console.error('[PDFParserService.extractMatchData] - Error:', error); + throw error; + } + } + + /** + * Standard-Format Parser (Datum, Zeit, Heimteam, Gastteam, Code, Pins) + * @param {Array} lines - Textzeilen + * @param {number} clubId - ID des Vereins + * @returns {Object} Geparste Matches + */ + static parseStandardFormat(lines, clubId) { + const matches = []; + + devLog('[PDFParserService.parseStandardFormat] - Starting standard format parsing...'); + + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + + // Suche nach Datum-Pattern (dd.mm.yyyy oder dd/mm/yyyy) + const dateMatch = line.match(/(\d{1,2})[./](\d{1,2})[./](\d{4})/); + if (dateMatch) { + devLog(`[PDFParserService.parseStandardFormat] - Found date in line ${i + 1}: "${line}"`); + + // Debug: Zeige die gesamte Zeile mit sichtbaren Whitespaces + const debugLine = line.replace(/\s/g, (match) => { + if (match === ' ') return '·'; // Mittelpunkt für normales Leerzeichen + if (match === '\t') return '→'; // Pfeil für Tab + if (match === '\n') return '↵'; // Enter-Zeichen + if (match === '\r') return '⏎'; // Carriage Return + return `[${match.charCodeAt(0)}]`; // Zeichencode für andere Whitespaces + }); + devLog(`[PDFParserService.parseStandardFormat] - Full line with visible whitespaces: "${debugLine}"`); + + try { + const [, day, month, year] = dateMatch; + const date = new Date(`${year}-${month.padStart(2, '0')}-${day.padStart(2, '0')}`); + + // Suche nach Zeit-Pattern direkt nach dem Datum (hh:mm) - Format: Wt.dd.mm.yyyyhh:MM + const timeMatch = line.match(/(\d{1,2})[./](\d{1,2})[./](\d{4})(\d{1,2}):(\d{2})/); + let time = null; + if (timeMatch) { + time = `${timeMatch[4].padStart(2, '0')}:${timeMatch[5]}`; + } + + devLog(`[PDFParserService.parseStandardFormat] - Parsed date: ${date.toISOString().split('T')[0]}, time: ${time}`); + + // Entferne Datum und Zeit vom Anfang der Zeile + const cleanLine = line.replace(/^[A-Za-z]{2}\.(\d{1,2})[./](\d{1,2})[./](\d{4})(\d{1,2}):(\d{2})\s*/, ''); + devLog(`[PDFParserService.parseStandardFormat] - Clean line after date/time removal: "${cleanLine}"`); + + // Entferne Nummerierung am Anfang (z.B. "(1)") + const cleanLine2 = cleanLine.replace(/^\(\d+\)/, ''); + devLog(`[PDFParserService.parseStandardFormat] - Clean line after numbering removal: "${cleanLine2}"`); + + // Entferne alle Inhalte in Klammern (z.B. "(J11)") + const cleanLine3 = cleanLine2.replace(/\([^)]*\)/g, ''); + devLog(`[PDFParserService.parseStandardFormat] - Clean line after removing brackets: "${cleanLine3}"`); + + // Suche nach Code (12 Zeichen) oder PIN (4 Ziffern) am Ende + const codeMatch = cleanLine3.match(/([A-Z0-9]{12})$/); + const pinMatch = cleanLine3.match(/(\d{4})$/); + + let code = null; + let homePin = null; + let guestPin = null; + let teamsPart = cleanLine3; + + if (codeMatch) { + // Code gefunden (12 Zeichen) + code = codeMatch[1]; + teamsPart = cleanLine3.substring(0, cleanLine3.length - code.length).trim(); + devLog(`[PDFParserService.parseStandardFormat] - Found code: "${code}"`); + } else if (pinMatch) { + // PIN gefunden (4 Ziffern) + const pin = pinMatch[1]; + teamsPart = cleanLine3.substring(0, cleanLine3.length - pin.length).trim(); + + // PIN gehört zu dem Team, das direkt vor der PIN steht + // Analysiere die Position der PIN in der ursprünglichen Zeile + const pinIndex = cleanLine3.lastIndexOf(pin); + const teamsPartIndex = cleanLine3.indexOf(teamsPart); + + // Wenn PIN direkt nach dem Teams-Part steht, gehört sie zur Heimmannschaft + // Wenn PIN zwischen den Teams steht, gehört sie zur Gastmannschaft + if (pinIndex === teamsPartIndex + teamsPart.length) { + // PIN steht direkt nach den Teams -> Heimmannschaft + homePin = pin; + devLog(`[PDFParserService.parseStandardFormat] - Found PIN: "${pin}" -> Home team (at end)`); + } else { + // PIN steht zwischen den Teams -> Gastmannschaft + guestPin = pin; + devLog(`[PDFParserService.parseStandardFormat] - Found PIN: "${pin}" -> Guest team (between teams)`); + } + } + + if (code || pinMatch) { + + devLog(`[PDFParserService.parseStandardFormat] - Teams part: "${teamsPart}"`); + + // Debug: Zeige Whitespaces als lesbare Zeichen + const debugTeamsPart = teamsPart.replace(/\s/g, (match) => { + if (match === ' ') return '·'; // Mittelpunkt für normales Leerzeichen + if (match === '\t') return '→'; // Pfeil für Tab + return `[${match.charCodeAt(0)}]`; // Zeichencode für andere Whitespaces + }); + devLog(`[PDFParserService.parseStandardFormat] - Teams part with visible whitespaces: "${debugTeamsPart}"`); + + // Neue Strategie: Teile die Zeile durch mehrere Leerzeichen (wie in der Tabelle) + // Die Struktur ist: Heimmannschaft Gastmannschaft Code + const parts = teamsPart.split(/\s{2,}/); // Mindestens 2 Leerzeichen als Trenner + + devLog(`[PDFParserService.parseStandardFormat] - Split by multiple spaces:`, parts); + + let homeTeamName = ''; + let guestTeamName = ''; + + if (parts.length >= 2) { + homeTeamName = parts[0].trim(); + guestTeamName = parts[1].trim(); + + // Entferne noch verbleibende Klammern aus den Team-Namen + homeTeamName = homeTeamName.replace(/\([^)]*\)/g, '').trim(); + guestTeamName = guestTeamName.replace(/\([^)]*\)/g, '').trim(); + + devLog(`[PDFParserService.parseStandardFormat] - After bracket removal: "${homeTeamName}" vs "${guestTeamName}"`); + + // Erkenne römische Ziffern am Ende der Team-Namen + // Römische Ziffern: I, II, III, IV, V, VI, VII, VIII, IX, X, XI, XII, etc. + const romanNumeralPattern = /\s+(I{1,3}|IV|V|VI{0,3}|IX|X|XI{0,2})$/; + + // Prüfe Heimteam auf römische Ziffern + const homeRomanMatch = homeTeamName.match(romanNumeralPattern); + if (homeRomanMatch) { + const romanNumeral = homeRomanMatch[1]; + const baseName = homeTeamName.replace(romanNumeralPattern, '').trim(); + homeTeamName = `${baseName} ${romanNumeral}`; + devLog(`[PDFParserService.parseStandardFormat] - Home team roman numeral found: "${romanNumeral}" -> "${homeTeamName}"`); + } + + // Prüfe Gastteam auf römische Ziffern + const guestRomanMatch = guestTeamName.match(romanNumeralPattern); + if (guestRomanMatch) { + const romanNumeral = guestRomanMatch[1]; + const baseName = guestTeamName.replace(romanNumeralPattern, '').trim(); + guestTeamName = `${baseName} ${romanNumeral}`; + devLog(`[PDFParserService.parseStandardFormat] - Guest team roman numeral found: "${romanNumeral}" -> "${guestTeamName}"`); + } + + devLog(`[PDFParserService.parseStandardFormat] - Final teams: "${homeTeamName}" vs "${guestTeamName}"`); + } else { + // Fallback: Versuche mit einzelnen Leerzeichen zu trennen + devLog(`[PDFParserService.parseStandardFormat] - Fallback: trying single space split`); + + // Strategie 1: Suche nach "Harheimer TC" als Heimteam + if (teamsPart.includes('Harheimer TC')) { + const harheimerIndex = teamsPart.indexOf('Harheimer TC'); + homeTeamName = 'Harheimer TC'; + guestTeamName = teamsPart.substring(harheimerIndex + 'Harheimer TC'.length).trim(); + + // Entferne Klammern aus Gastteam + guestTeamName = guestTeamName.replace(/\([^)]*\)/g, '').trim(); + + devLog(`[PDFParserService.parseStandardFormat] - Fallback Strategy 1: "${homeTeamName}" vs "${guestTeamName}"`); + } else { + // Strategie 2: Suche nach Großbuchstaben am Anfang des zweiten Teams + const teamSplitMatch = teamsPart.match(/^([A-Za-z0-9\s\-\.]+?)\s+([A-Z][A-Za-z0-9\s\-\.]+)$/); + + if (teamSplitMatch) { + homeTeamName = teamSplitMatch[1].trim(); + guestTeamName = teamSplitMatch[2].trim(); + + devLog(`[PDFParserService.parseStandardFormat] - Fallback Strategy 2: Split teams: "${homeTeamName}" vs "${guestTeamName}"`); + } else { + devLog(`[PDFParserService.parseStandardFormat] - Could not split teams from: "${teamsPart}"`); + continue; + } + } + } + + if (homeTeamName && guestTeamName) { + let debugInfo; + if (code) { + debugInfo = `code: "${code}"`; + } else if (homePin && guestPin) { + debugInfo = `homePin: "${homePin}", guestPin: "${guestPin}"`; + } else if (homePin) { + debugInfo = `homePin: "${homePin}"`; + } else if (guestPin) { + debugInfo = `guestPin: "${guestPin}"`; + } + devLog(`[PDFParserService.parseStandardFormat] - Final parsed teams: "${homeTeamName}" vs "${guestTeamName}", ${debugInfo}`); + + matches.push({ + date: date, + time: time, + homeTeamName: homeTeamName, + guestTeamName: guestTeamName, + code: code, + homePin: homePin, + guestPin: guestPin, + clubId: clubId, + rawLine: line + }); + } + } else { + devLog(`[PDFParserService.parseStandardFormat] - Could not find code pattern in: "${cleanLine3}"`); + } + } catch (parseError) { + devLog('[PDFParserService.parseStandardFormat] - Parse error for line:', line, parseError.message); + } + } + } + + return { matches }; + } + + /** + * Tabellen-Format Parser + * @param {Array} lines - Textzeilen + * @param {number} clubId - ID des Vereins + * @returns {Object} Geparste Matches + */ + static parseTableFormat(lines, clubId) { + const matches = []; + + // Suche nach Tabellen-Header + let headerIndex = -1; + for (let i = 0; i < lines.length; i++) { + if (lines[i].toLowerCase().includes('datum') && + lines[i].toLowerCase().includes('zeit') && + lines[i].toLowerCase().includes('heim') && + lines[i].toLowerCase().includes('gast')) { + headerIndex = i; + break; + } + } + + if (headerIndex >= 0) { + // Parse Tabellen-Zeilen + for (let i = headerIndex + 1; i < lines.length; i++) { + const line = lines[i]; + const columns = line.split(/\s{2,}|\t/); // Split bei mehreren Leerzeichen oder Tabs + + if (columns.length >= 4) { + try { + const dateStr = columns[0]; + const timeStr = columns[1]; + const homeTeam = columns[2]; + const guestTeam = columns[3]; + const code = columns[4] || null; + const homePin = columns[5] || null; + const guestPin = columns[6] || null; + + // Parse Datum + const dateMatch = dateStr.match(/(\d{1,2})[./](\d{1,2})[./](\d{4})/); + if (dateMatch) { + const [, day, month, year] = dateMatch; + const date = new Date(`${year}-${month.padStart(2, '0')}-${day.padStart(2, '0')}`); + + matches.push({ + date: date, + time: timeStr || null, + homeTeamName: homeTeam.trim(), + guestTeamName: guestTeam.trim(), + code: code ? code.trim() : null, + homePin: homePin ? homePin.trim() : null, + guestPin: guestPin ? guestPin.trim() : null, + clubId: clubId, + rawLine: line + }); + } + } catch (parseError) { + devLog('[PDFParserService.parseTableFormat] - Parse error for line:', line, parseError.message); + } + } + } + } + + return { matches }; + } + + /** + * Listen-Format Parser + * @param {Array} lines - Textzeilen + * @param {number} clubId - ID des Vereins + * @returns {Object} Geparste Matches + */ + static parseListFormat(lines, clubId) { + const matches = []; + + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + + // Suche nach Nummerierten Listen (1., 2., etc.) + const listMatch = line.match(/^\d+\.\s*(.+)/); + if (listMatch) { + const content = listMatch[1]; + + // Versuche verschiedene Formate zu parsen + const patterns = [ + /(\d{1,2}[./]\d{1,2}[./]\d{4})\s+(\d{1,2}:\d{2})?\s+(.+?)\s+vs?\s+(.+?)(?:\s+code[:\s]*([A-Za-z0-9]+))?(?:\s+home[:\s]*pin[:\s]*([A-Za-z0-9]+))?(?:\s+guest[:\s]*pin[:\s]*([A-Za-z0-9]+))?/i, + /(\d{1,2}[./]\d{1,2}[./]\d{4})\s+(\d{1,2}:\d{2})?\s+(.+?)\s+-\s+(.+?)(?:\s+code[:\s]*([A-Za-z0-9]+))?(?:\s+heim[:\s]*pin[:\s]*([A-Za-z0-9]+))?(?:\s+gast[:\s]*pin[:\s]*([A-Za-z0-9]+))?/i + ]; + + for (const pattern of patterns) { + const match = content.match(pattern); + if (match) { + try { + const [, dateStr, timeStr, homeTeam, guestTeam, code, homePin, guestPin] = match; + + // Parse Datum + const dateMatch = dateStr.match(/(\d{1,2})[./](\d{1,2})[./](\d{4})/); + if (dateMatch) { + const [, day, month, year] = dateMatch; + const date = new Date(`${year}-${month.padStart(2, '0')}-${day.padStart(2, '0')}`); + + matches.push({ + date: date, + time: timeStr || null, + homeTeamName: homeTeam.trim(), + guestTeamName: guestTeam.trim(), + code: code ? code.trim() : null, + homePin: homePin ? homePin.trim() : null, + guestPin: guestPin ? guestPin.trim() : null, + clubId: clubId, + rawLine: line + }); + break; // Erste erfolgreiche Pattern verwenden + } + } catch (parseError) { + devLog('[PDFParserService.parseListFormat] - Parse error for line:', line, parseError.message); + } + } + } + } + } + + return { matches }; + } + + /** + * Speichert geparste Matches in der Datenbank + * @param {Array} matches - Array von Match-Objekten + * @param {number} leagueId - ID der Liga + * @returns {Promise} Ergebnis der Speicherung + */ + static async saveMatchesToDatabase(matches, leagueId) { + try { + devLog('[PDFParserService.saveMatchesToDatabase] - Saving matches:', matches.length); + + const results = { + created: 0, + updated: 0, + errors: [] + }; + + for (const matchData of matches) { + try { + let debugInfo; + if (matchData.code) { + debugInfo = `Code: ${matchData.code}`; + } else if (matchData.homePin && matchData.guestPin) { + debugInfo = `HomePin: ${matchData.homePin}, GuestPin: ${matchData.guestPin}`; + } else if (matchData.homePin) { + debugInfo = `HomePin: ${matchData.homePin}`; + } else if (matchData.guestPin) { + debugInfo = `GuestPin: ${matchData.guestPin}`; + } + devLog(`[PDFParserService.saveMatchesToDatabase] - Processing match: ${matchData.date.toISOString().split('T')[0]} ${matchData.time} - ${matchData.homeTeamName} vs ${matchData.guestTeamName} (${debugInfo})`); + + // Lade alle Matches für das Datum und die Liga + console.log('matchData', matchData); + + // Konvertiere das Datum zu einem Datum ohne Zeit für den Vergleich + const dateOnly = new Date(matchData.date.getFullYear(), matchData.date.getMonth(), matchData.date.getDate()); + const nextDay = new Date(dateOnly); + nextDay.setDate(nextDay.getDate() + 1); + + const existingMatches = await Match.findAll({ + where: { + date: { + [Op.gte]: dateOnly, // Größer oder gleich dem Datum + [Op.lt]: nextDay // Kleiner als der nächste Tag + }, + leagueId: leagueId, + ...(matchData.time && { time: matchData.time }) // Füge Zeit hinzu wenn vorhanden + }, + include: [ + { + model: Team, + as: 'homeTeam', + attributes: ['id', 'name'] + }, + { + model: Team, + as: 'guestTeam', + attributes: ['id', 'name'] + } + ] + }); + console.log('existingMatches', JSON.parse(JSON.stringify(existingMatches, null, 2))); + + const timeFilter = matchData.time ? ` and time ${matchData.time}` : ''; + devLog(`[PDFParserService.saveMatchesToDatabase] - Found ${existingMatches.length} existing matches for date ${matchData.date.toISOString().split('T')[0]}${timeFilter} and league ${leagueId}`); + + // Debug: Zeige alle gefundenen Matches und lade Teams manuell + for (let i = 0; i < existingMatches.length; i++) { + const match = existingMatches[i]; + + // Lade Teams manuell + const homeTeam = await Team.findByPk(match.homeTeamId); + const guestTeam = await Team.findByPk(match.guestTeamId); + + devLog(`[PDFParserService.saveMatchesToDatabase] - Match ${i + 1}: ${homeTeam?.name || 'Unknown'} vs ${guestTeam?.name || 'Unknown'} (${match.time || 'no time'})`); + + // Füge die Teams zum Match-Objekt hinzu + match.homeTeam = homeTeam; + match.guestTeam = guestTeam; + } + + // Suche nach dem passenden Match basierend auf Gastmannschaft + const matchingMatch = existingMatches.find(match => { + if (!match.guestTeam) return false; + + const guestTeamName = match.guestTeam.name.toLowerCase(); + const searchGuestName = matchData.guestTeamName.toLowerCase(); + + // Exakte Übereinstimmung oder Teilstring-Match + return guestTeamName === searchGuestName || + guestTeamName.includes(searchGuestName) || + searchGuestName.includes(guestTeamName); + }); + + if (matchingMatch) { + devLog(`[PDFParserService.saveMatchesToDatabase] - Found matching match: ${matchingMatch.homeTeam.name} vs ${matchingMatch.guestTeam.name}`); + + // Update das bestehende Match mit Code und Pins + // Erstelle Update-Objekt nur mit vorhandenen Feldern + const updateData = {}; + if (matchData.code) { + updateData.code = matchData.code; + } + if (matchData.homePin) { + updateData.homePin = matchData.homePin; + } + if (matchData.guestPin) { + updateData.guestPin = matchData.guestPin; + } + + await matchingMatch.update(updateData); + results.updated++; + + let updateInfo; + if (matchData.code) { + updateInfo = `code: ${matchData.code}`; + } else if (matchData.homePin && matchData.guestPin) { + updateInfo = `homePin: ${matchData.homePin}, guestPin: ${matchData.guestPin}`; + } else if (matchData.homePin) { + updateInfo = `homePin: ${matchData.homePin}`; + } else if (matchData.guestPin) { + updateInfo = `guestPin: ${matchData.guestPin}`; + } + devLog(`[PDFParserService.saveMatchesToDatabase] - Updated match with ${updateInfo}`); + + // Lade das aktualisierte Match neu, um die aktuellen Werte zu zeigen + await matchingMatch.reload(); + const currentValues = []; + if (matchingMatch.code) currentValues.push(`code: ${matchingMatch.code}`); + if (matchingMatch.homePin) currentValues.push(`homePin: ${matchingMatch.homePin}`); + if (matchingMatch.guestPin) currentValues.push(`guestPin: ${matchingMatch.guestPin}`); + devLog(`[PDFParserService.saveMatchesToDatabase] - Match now has: ${currentValues.join(', ')}`); + } else { + devLog(`[PDFParserService.saveMatchesToDatabase] - No matching match found for guest team: "${matchData.guestTeamName}"`); + + // Fallback: Versuche Teams direkt zu finden + const homeTeam = await Team.findOne({ + where: { + name: matchData.homeTeamName, + clubId: matchData.clubId + } + }); + + const guestTeam = await Team.findOne({ + where: { + name: matchData.guestTeamName, + clubId: matchData.clubId + } + }); + + // Debug: Zeige alle verfügbaren Teams für diesen Club + if (!homeTeam || !guestTeam) { + const allTeams = await Team.findAll({ + where: { clubId: matchData.clubId }, + attributes: ['id', 'name'] + }); + devLog(`[PDFParserService.saveMatchesToDatabase] - Available teams for club ${matchData.clubId}:`, allTeams.map(t => t.name)); + devLog(`[PDFParserService.saveMatchesToDatabase] - Looking for home team: "${matchData.homeTeamName}"`); + devLog(`[PDFParserService.saveMatchesToDatabase] - Looking for guest team: "${matchData.guestTeamName}"`); + + // Versuche Fuzzy-Matching für Team-Namen + const homeTeamFuzzy = allTeams.find(t => + t.name.toLowerCase().includes(matchData.homeTeamName.toLowerCase()) || + matchData.homeTeamName.toLowerCase().includes(t.name.toLowerCase()) + ); + const guestTeamFuzzy = allTeams.find(t => + t.name.toLowerCase().includes(matchData.guestTeamName.toLowerCase()) || + matchData.guestTeamName.toLowerCase().includes(t.name.toLowerCase()) + ); + + if (homeTeamFuzzy) { + devLog(`[PDFParserService.saveMatchesToDatabase] - Fuzzy match found for home team: "${homeTeamFuzzy.name}"`); + } + if (guestTeamFuzzy) { + devLog(`[PDFParserService.saveMatchesToDatabase] - Fuzzy match found for guest team: "${guestTeamFuzzy.name}"`); + } + } + + if (!homeTeam || !guestTeam) { + let errorInfo; + if (matchData.code) { + errorInfo = `Code: ${matchData.code}`; + } else if (matchData.homePin && matchData.guestPin) { + errorInfo = `HomePin: ${matchData.homePin}, GuestPin: ${matchData.guestPin}`; + } else if (matchData.homePin) { + errorInfo = `HomePin: ${matchData.homePin}`; + } else if (matchData.guestPin) { + errorInfo = `GuestPin: ${matchData.guestPin}`; + } + results.errors.push(`Teams nicht gefunden: "${matchData.homeTeamName}" oder "${matchData.guestTeamName}" (Datum: ${matchData.date.toISOString().split('T')[0]}, Zeit: ${matchData.time}, ${errorInfo})`); + continue; + } + + // Erstelle neues Match (Fallback) + await Match.create({ + date: matchData.date, + time: matchData.time, + homeTeamId: homeTeam.id, + guestTeamId: guestTeam.id, + leagueId: leagueId, + clubId: matchData.clubId, + code: matchData.code, + homePin: matchData.homePin, + guestPin: matchData.guestPin, + locationId: 1 // Default Location, kann später angepasst werden + }); + results.created++; + devLog(`[PDFParserService.saveMatchesToDatabase] - Created new match: ${matchData.homeTeamName} vs ${matchData.guestTeamName}`); + } + } catch (matchError) { + console.error('[PDFParserService.saveMatchesToDatabase] - Error:', matchError); + results.errors.push(`Fehler beim Speichern von Match: ${matchData.rawLine} - ${matchError.message}`); + } + } + + devLog('[PDFParserService.saveMatchesToDatabase] - Results:', results); + return results; + } catch (error) { + console.error('[PDFParserService.saveMatchesToDatabase] - Error:', error); + throw error; + } + } +} + +export default PDFParserService; diff --git a/backend/services/teamDocumentService.js b/backend/services/teamDocumentService.js new file mode 100644 index 0000000..ef0ba01 --- /dev/null +++ b/backend/services/teamDocumentService.js @@ -0,0 +1,204 @@ +import fs from 'fs'; +import path from 'path'; +import { fileURLToPath } from 'url'; +import TeamDocument from '../models/TeamDocument.js'; +import ClubTeam from '../models/ClubTeam.js'; +import { devLog } from '../utils/logger.js'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +class TeamDocumentService { + /** + * Speichert ein hochgeladenes Dokument für ein Club-Team + * @param {Object} file - Das hochgeladene File-Objekt (von multer) + * @param {number} clubTeamId - Die ID des Club-Teams + * @param {string} documentType - Der Typ des Dokuments ('code_list' oder 'pin_list') + * @returns {Promise} Das erstellte TeamDocument + */ + static async uploadDocument(file, clubTeamId, documentType) { + try { + devLog('[TeamDocumentService.uploadDocument] - Uploading document:', { + fileName: file.originalname, + clubTeamId, + documentType, + size: file.size + }); + + // Prüfe ob das Club-Team existiert + const clubTeam = await ClubTeam.findByPk(clubTeamId); + if (!clubTeam) { + throw new Error('Club-Team nicht gefunden'); + } + + // Generiere einen eindeutigen Dateinamen + const fileExtension = path.extname(file.originalname); + const uniqueFileName = `${clubTeamId}_${documentType}_${Date.now()}${fileExtension}`; + + // Zielverzeichnis für Team-Dokumente + const uploadDir = path.join(__dirname, '..', 'uploads', 'team-documents'); + + // Erstelle Upload-Verzeichnis falls es nicht existiert + if (!fs.existsSync(uploadDir)) { + fs.mkdirSync(uploadDir, { recursive: true }); + } + + const filePath = path.join(uploadDir, uniqueFileName); + + // Verschiebe die Datei vom temporären Verzeichnis zum finalen Speicherort + fs.renameSync(file.path, filePath); + + // Lösche alte Dokumente des gleichen Typs für dieses Team + await this.deleteDocumentsByType(clubTeamId, documentType); + + // Erstelle Datenbankeintrag + const teamDocument = await TeamDocument.create({ + fileName: uniqueFileName, + originalFileName: file.originalname, + filePath: filePath, + fileSize: file.size, + mimeType: file.mimetype, + documentType: documentType, + clubTeamId: clubTeamId + }); + + devLog('[TeamDocumentService.uploadDocument] - Document uploaded successfully:', teamDocument.id); + return teamDocument; + } catch (error) { + console.error('[TeamDocumentService.uploadDocument] - Error:', error); + throw error; + } + } + + /** + * Holt alle Dokumente für ein Club-Team + * @param {number} clubTeamId - Die ID des Club-Teams + * @returns {Promise>} Liste der Dokumente + */ + static async getDocumentsByClubTeam(clubTeamId) { + try { + devLog('[TeamDocumentService.getDocumentsByClubTeam] - Getting documents for club team:', clubTeamId); + + const documents = await TeamDocument.findAll({ + where: { clubTeamId }, + order: [['createdAt', 'DESC']] + }); + + devLog('[TeamDocumentService.getDocumentsByClubTeam] - Found documents:', documents.length); + return documents; + } catch (error) { + console.error('[TeamDocumentService.getDocumentsByClubTeam] - Error:', error); + throw error; + } + } + + /** + * Holt ein spezifisches Dokument + * @param {number} documentId - Die ID des Dokuments + * @returns {Promise} Das Dokument oder null + */ + static async getDocumentById(documentId) { + try { + devLog('[TeamDocumentService.getDocumentById] - Getting document:', documentId); + + const document = await TeamDocument.findByPk(documentId, { + include: [{ + model: ClubTeam, + as: 'clubTeam', + attributes: ['id', 'name', 'clubId'] + }] + }); + + devLog('[TeamDocumentService.getDocumentById] - Found document:', document ? 'yes' : 'no'); + return document; + } catch (error) { + console.error('[TeamDocumentService.getDocumentById] - Error:', error); + throw error; + } + } + + /** + * Löscht ein Dokument + * @param {number} documentId - Die ID des Dokuments + * @returns {Promise} True wenn gelöscht, sonst false + */ + static async deleteDocument(documentId) { + try { + devLog('[TeamDocumentService.deleteDocument] - Deleting document:', documentId); + + const document = await TeamDocument.findByPk(documentId); + if (!document) { + return false; + } + + // Lösche die physische Datei + if (fs.existsSync(document.filePath)) { + fs.unlinkSync(document.filePath); + } + + // Lösche den Datenbankeintrag + const deletedRows = await TeamDocument.destroy({ + where: { id: documentId } + }); + + devLog('[TeamDocumentService.deleteDocument] - Deleted rows:', deletedRows); + return deletedRows > 0; + } catch (error) { + console.error('[TeamDocumentService.deleteDocument] - Error:', error); + throw error; + } + } + + /** + * Löscht alle Dokumente eines bestimmten Typs für ein Club-Team + * @param {number} clubTeamId - Die ID des Club-Teams + * @param {string} documentType - Der Typ des Dokuments + * @returns {Promise} Anzahl der gelöschten Dokumente + */ + static async deleteDocumentsByType(clubTeamId, documentType) { + try { + devLog('[TeamDocumentService.deleteDocumentsByType] - Deleting documents by type:', clubTeamId, documentType); + + const documents = await TeamDocument.findAll({ + where: { clubTeamId, documentType } + }); + + let deletedCount = 0; + for (const document of documents) { + // Lösche die physische Datei + if (fs.existsSync(document.filePath)) { + fs.unlinkSync(document.filePath); + } + deletedCount++; + } + + // Lösche die Datenbankeinträge + const deletedRows = await TeamDocument.destroy({ + where: { clubTeamId, documentType } + }); + + devLog('[TeamDocumentService.deleteDocumentsByType] - Deleted documents:', deletedRows); + return deletedRows; + } catch (error) { + console.error('[TeamDocumentService.deleteDocumentsByType] - Error:', error); + throw error; + } + } + + /** + * Holt den Dateipfad für ein Dokument + * @param {number} documentId - Die ID des Dokuments + * @returns {Promise} Der Dateipfad oder null + */ + static async getDocumentPath(documentId) { + try { + const document = await TeamDocument.findByPk(documentId); + return document ? document.filePath : null; + } catch (error) { + console.error('[TeamDocumentService.getDocumentPath] - Error:', error); + throw error; + } + } +} + +export default TeamDocumentService; diff --git a/backend/test-spielplan.txt b/backend/test-spielplan.txt new file mode 100644 index 0000000..4a50c18 --- /dev/null +++ b/backend/test-spielplan.txt @@ -0,0 +1,7 @@ +Spielplan 2025/2026 - Test Liga + +1. 15.01.2025 19:00 Team Alpha vs Team Beta code: ABC123 home pin: PIN001 guest pin: PIN002 +2. 22.01.2025 20:00 Team Gamma gegen Team Delta code: DEF456 heim pin: PIN003 gast pin: PIN004 +3. 29.01.2025 18:30 Team Epsilon - Team Zeta code: GHI789 home pin: PIN005 guest pin: PIN006 +4. 05.02.2025 19:30 Team Alpha vs Team Gamma code: JKL012 home pin: PIN007 guest pin: PIN008 +5. 12.02.2025 20:00 Team Beta gegen Team Delta code: MNO345 heim pin: PIN009 gast pin: PIN010 diff --git a/backend/uploads/team-documents/7_code_list_1759354257578.pdf b/backend/uploads/team-documents/7_code_list_1759354257578.pdf new file mode 100644 index 0000000..f8be61f Binary files /dev/null and b/backend/uploads/team-documents/7_code_list_1759354257578.pdf differ diff --git a/backend/uploads/team-documents/9_code_list_1759357969975.pdf b/backend/uploads/team-documents/9_code_list_1759357969975.pdf new file mode 100644 index 0000000..f8be61f Binary files /dev/null and b/backend/uploads/team-documents/9_code_list_1759357969975.pdf differ diff --git a/backend/uploads/team-documents/9_pin_list_1759386673266.pdf b/backend/uploads/team-documents/9_pin_list_1759386673266.pdf new file mode 100644 index 0000000..07db070 Binary files /dev/null and b/backend/uploads/team-documents/9_pin_list_1759386673266.pdf differ diff --git a/frontend/src/views/ScheduleView.vue b/frontend/src/views/ScheduleView.vue index 0426fa7..3cdb63f 100644 --- a/frontend/src/views/ScheduleView.vue +++ b/frontend/src/views/ScheduleView.vue @@ -35,6 +35,9 @@ Heimmannschaft Gastmannschaft Altersklasse + Code + Heim-PIN + Gast-PIN @@ -45,6 +48,18 @@ {{ match.leagueDetails?.name || 'N/A' }} + + {{ match.code }} + - + + + {{ match.homePin }} + - + + + {{ match.guestPin }} + - + @@ -473,6 +488,39 @@ li { color: transparent !important; } +/* Code und PIN Styles */ +.code-cell, .pin-cell { + text-align: center; + font-family: 'Courier New', monospace; + font-weight: bold; + min-width: 80px; +} + +.code-value { + background: #e3f2fd; + color: #1976d2; + padding: 4px 8px; + border-radius: 4px; + font-size: 0.9rem; + display: inline-block; + border: 1px solid #bbdefb; +} + +.pin-value { + background: #fff3e0; + color: #f57c00; + padding: 4px 8px; + border-radius: 4px; + font-size: 0.9rem; + display: inline-block; + border: 1px solid #ffcc02; +} + +.no-data { + color: #999; + font-style: italic; +} + .match-today { background-color: #fff3cd !important; /* Gelb für heute */ } diff --git a/frontend/src/views/TeamManagementView.vue b/frontend/src/views/TeamManagementView.vue index e5ef6fe..f549941 100644 --- a/frontend/src/views/TeamManagementView.vue +++ b/frontend/src/views/TeamManagementView.vue @@ -50,12 +50,61 @@

Team-Dokumente hochladen

+ + +
+
+ Ausgewählte Datei: {{ pendingUploadFile?.name }} +
+ Typ: {{ pendingUploadType === 'code_list' ? 'Code-Liste' : 'Pin-Liste' }} +
+ Team: {{ teamToEdit?.name }} +
+ Liga: {{ getTeamLeagueName() }} +
+
+ + +
+
+ + +
+

Bereits hochgeladene PDF-Dokumente parsen

+
+
+
+ {{ document.originalFileName }} + {{ document.documentType === 'code_list' ? 'Code-Liste' : 'Pin-Liste' }} + {{ formatFileSize(document.fileSize) }} +
+
+ +
+
+
+
@@ -102,6 +151,50 @@ {{ formatDate(team.createdAt) }} + + +
+
Dokumente:
+
+ + +
+
+ + + + + +
+
+
+

{{ pdfDialogTitle }}

+ +
+
+ +
+

PDF konnte nicht geladen werden.

+
@@ -131,6 +224,16 @@ export default { const newLeagueId = ref(''); const selectedSeasonId = ref(null); const currentSeason = ref(null); + const teamDocuments = ref([]); + const pendingUploadFile = ref(null); + const pendingUploadType = ref(null); + const showLeagueSelection = ref(false); + const parsingInProgress = ref(false); + + // PDF-Dialog Variablen + const showPDFViewer = ref(false); + const pdfUrl = ref(''); + const pdfDialogTitle = ref(''); // Computed const selectedClub = computed(() => store.state.currentClub); @@ -169,6 +272,9 @@ export default { const response = await apiClient.get(`/club-teams/club/${selectedClub.value}?seasonid=${selectedSeasonId.value}`); console.log('TeamManagementView: Loaded club teams:', response.data); teams.value = response.data; + + // Lade alle Team-Dokumente nach dem Laden der Teams + await loadAllTeamDocuments(); } catch (error) { console.error('Fehler beim Laden der Club-Teams:', error); } @@ -228,6 +334,7 @@ export default { newTeamName.value = team.name; newLeagueId.value = team.leagueId || ''; teamFormIsOpen.value = true; + loadTeamDocuments(); }; const deleteTeam = async (team) => { @@ -254,14 +361,12 @@ export default { const file = event.target.files[0]; if (!file) return; - try { - console.log('Code-Liste hochladen für Team:', teamToEdit.value.name, 'Datei:', file.name); - // TODO: Implementiere Upload-Logik für Code-Liste - alert(`Code-Liste "${file.name}" würde für Team "${teamToEdit.value.name}" hochgeladen werden.`); - } catch (error) { - console.error('Fehler beim Hochladen der Code-Liste:', error); - alert('Fehler beim Hochladen der Code-Liste'); - } + // Speichere die Datei und den Typ für späteres Parsing + pendingUploadFile.value = file; + pendingUploadType.value = 'code_list'; + + // Zeige Liga-Auswahl für Parsing + showLeagueSelection.value = true; }; input.click(); }; @@ -277,14 +382,12 @@ export default { const file = event.target.files[0]; if (!file) return; - try { - console.log('Pin-Liste hochladen für Team:', teamToEdit.value.name, 'Datei:', file.name); - // TODO: Implementiere Upload-Logik für Pin-Liste - alert(`Pin-Liste "${file.name}" würde für Team "${teamToEdit.value.name}" hochgeladen werden.`); - } catch (error) { - console.error('Fehler beim Hochladen der Pin-Liste:', error); - alert('Fehler beim Hochladen der Pin-Liste'); - } + // Speichere die Datei und den Typ für späteres Parsing + pendingUploadFile.value = file; + pendingUploadType.value = 'pin_list'; + + // Zeige Liga-Auswahl für Parsing + showLeagueSelection.value = true; }; input.click(); }; @@ -292,6 +395,181 @@ export default { const formatDate = (dateString) => { return new Date(dateString).toLocaleDateString('de-DE'); }; + + const formatFileSize = (bytes) => { + if (bytes === 0) return '0 Bytes'; + const k = 1024; + const sizes = ['Bytes', 'KB', 'MB', 'GB']; + const i = Math.floor(Math.log(bytes) / Math.log(k)); + return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]; + }; + + const loadTeamDocuments = async () => { + if (!teamToEdit.value) return; + + try { + console.log('TeamManagementView: Loading documents for team:', teamToEdit.value.id); + const response = await apiClient.get(`/team-documents/club-team/${teamToEdit.value.id}`); + console.log('TeamManagementView: Loaded documents:', response.data); + teamDocuments.value = response.data; + } catch (error) { + console.error('Fehler beim Laden der Team-Dokumente:', error); + } + }; + + const loadAllTeamDocuments = async () => { + if (!selectedClub.value) return; + + try { + console.log('TeamManagementView: Loading all team documents for club:', selectedClub.value); + // Lade alle Dokumente für alle Teams des Clubs + const allDocuments = []; + for (const team of teams.value) { + try { + const response = await apiClient.get(`/team-documents/club-team/${team.id}`); + allDocuments.push(...response.data); + } catch (error) { + console.warn(`Fehler beim Laden der Dokumente für Team ${team.id}:`, error); + } + } + teamDocuments.value = allDocuments; + console.log('TeamManagementView: Loaded all team documents:', allDocuments.length); + } catch (error) { + console.error('Fehler beim Laden aller Team-Dokumente:', error); + } + }; + + const confirmUploadAndParse = async () => { + if (!pendingUploadFile.value || !teamToEdit.value?.leagueId) { + alert('Team ist keiner Liga zugeordnet!'); + return; + } + + parsingInProgress.value = true; + + try { + console.log('Datei hochladen und parsen:', pendingUploadFile.value.name, 'Typ:', pendingUploadType.value, 'für Liga:', teamToEdit.value.leagueId); + + // Schritt 1: Datei als Team-Dokument hochladen + const formData = new FormData(); + formData.append('document', pendingUploadFile.value); + formData.append('documentType', pendingUploadType.value); + + const uploadResponse = await apiClient.post(`/team-documents/club-team/${teamToEdit.value.id}/upload`, formData, { + headers: { + 'Content-Type': 'multipart/form-data' + } + }); + + console.log('Datei hochgeladen:', uploadResponse.data); + + // Schritt 2: Datei parsen (nur für PDF/TXT-Dateien) + const fileExtension = pendingUploadFile.value.name.toLowerCase().split('.').pop(); + if (fileExtension === 'pdf' || fileExtension === 'txt') { + const parseResponse = await apiClient.post(`/team-documents/${uploadResponse.data.id}/parse?leagueid=${teamToEdit.value.leagueId}`); + + console.log('Datei erfolgreich geparst:', parseResponse.data); + + const { parseResult, saveResult } = parseResponse.data; + + let message = `${pendingUploadType.value === 'code_list' ? 'Code-Liste' : 'Pin-Liste'} erfolgreich hochgeladen und geparst!\n\n`; + message += `Gefundene Spiele: ${parseResult.matchesFound}\n`; + message += `Neue Spiele erstellt: ${saveResult.created}\n`; + message += `Spiele aktualisiert: ${saveResult.updated}\n`; + + if (saveResult.errors.length > 0) { + message += `\nFehler: ${saveResult.errors.length}\n`; + message += saveResult.errors.slice(0, 3).join('\n'); + if (saveResult.errors.length > 3) { + message += `\n... und ${saveResult.errors.length - 3} weitere`; + } + } + + // Debug-Informationen anzeigen wenn keine Matches gefunden wurden + if (parseResult.matchesFound === 0) { + message += `\n\n--- DEBUG-INFORMATIONEN ---\n`; + message += `Text-Länge: ${parseResult.debugInfo.totalTextLength} Zeichen\n`; + message += `Zeilen: ${parseResult.debugInfo.totalLines}\n`; + message += `Erste Zeilen:\n`; + parseResult.debugInfo.firstFewLines.forEach((line, index) => { + message += `${index + 1}: "${line}"\n`; + }); + message += `\nLetzte Zeilen:\n`; + parseResult.debugInfo.lastFewLines.forEach((line, index) => { + message += `${parseResult.debugInfo.totalLines - 5 + index + 1}: "${line}"\n`; + }); + } + + alert(message); + } else { + // Für andere Dateitypen nur Upload-Bestätigung + alert(`${pendingUploadType.value === 'code_list' ? 'Code-Liste' : 'Pin-Liste'} "${pendingUploadFile.value.name}" wurde erfolgreich hochgeladen!`); + } + + // Dokumente neu laden + await loadTeamDocuments(); + + } catch (error) { + console.error('Fehler beim Hochladen und Parsen der Datei:', error); + alert('Fehler beim Hochladen und Parsen der Datei'); + } finally { + parsingInProgress.value = false; + pendingUploadFile.value = null; + pendingUploadType.value = null; + showLeagueSelection.value = false; + } + }; + + const cancelUpload = () => { + pendingUploadFile.value = null; + pendingUploadType.value = null; + showLeagueSelection.value = false; + }; + + const getTeamLeagueName = () => { + if (!teamToEdit.value?.leagueId) return 'Keine Liga zugeordnet'; + const league = leagues.value.find(l => l.id === teamToEdit.value.leagueId); + return league ? league.name : 'Unbekannte Liga'; + }; + + + + const parsePDF = async (document) => { + // Finde das Team für dieses Dokument + const team = teams.value.find(t => t.id === document.clubTeamId); + if (!team || !team.leagueId) { + alert('Team ist keiner Liga zugeordnet!'); + return; + } + + try { + console.log('PDF parsen:', document.originalFileName, 'für Liga:', team.leagueId); + + const response = await apiClient.post(`/team-documents/${document.id}/parse?leagueid=${team.leagueId}`); + + console.log('PDF erfolgreich geparst:', response.data); + + const { parseResult, saveResult } = response.data; + + let message = `PDF erfolgreich geparst!\n\n`; + message += `Gefundene Spiele: ${parseResult.matchesFound}\n`; + message += `Neue Spiele erstellt: ${saveResult.created}\n`; + message += `Spiele aktualisiert: ${saveResult.updated}\n`; + + if (saveResult.errors.length > 0) { + message += `\nFehler: ${saveResult.errors.length}\n`; + message += saveResult.errors.slice(0, 3).join('\n'); + if (saveResult.errors.length > 3) { + message += `\n... und ${saveResult.errors.length - 3} weitere`; + } + } + + alert(message); + } catch (error) { + console.error('Fehler beim Parsen der PDF:', error); + alert('Fehler beim Parsen der PDF-Datei'); + } + }; const onSeasonChange = (season) => { currentSeason.value = season; @@ -310,6 +588,46 @@ export default { loadLeagues(); }); + // PDF-Dialog Funktionen + const getTeamDocuments = (teamId, documentType) => { + return teamDocuments.value.filter(doc => + doc.clubTeamId === teamId && doc.documentType === documentType + ); + }; + + const showPDFDialog = async (teamId, documentType) => { + const documents = getTeamDocuments(teamId, documentType); + if (documents.length === 0) return; + + const document = documents[0]; // Nehme das erste Dokument + const team = teams.value.find(t => t.id === teamId); + + pdfDialogTitle.value = `${team?.name || 'Team'} - ${documentType === 'code_list' ? 'Code-Liste' : 'Pin-Liste'}`; + + try { + // Lade das PDF über die API + const response = await apiClient.get(`/team-documents/${document.id}/download`, { + responseType: 'blob' + }); + + // Erstelle eine URL für das PDF + const blob = new Blob([response.data], { type: 'application/pdf' }); + pdfUrl.value = URL.createObjectURL(blob); + showPDFViewer.value = true; + } catch (error) { + console.error('Fehler beim Laden des PDFs:', error); + alert('Fehler beim Laden des PDFs'); + } + }; + + const closePDFDialog = () => { + showPDFViewer.value = false; + if (pdfUrl.value) { + URL.revokeObjectURL(pdfUrl.value); + pdfUrl.value = ''; + } + }; + return { teams, leagues, @@ -319,6 +637,14 @@ export default { newLeagueId, selectedSeasonId, currentSeason, + teamDocuments, + pendingUploadFile, + pendingUploadType, + showLeagueSelection, + parsingInProgress, + showPDFViewer, + pdfUrl, + pdfDialogTitle, filteredLeagues, toggleNewTeam, resetToNewTeam, @@ -328,7 +654,17 @@ export default { deleteTeam, uploadCodeList, uploadPinList, + loadTeamDocuments, + loadAllTeamDocuments, + confirmUploadAndParse, + cancelUpload, + getTeamLeagueName, + parsePDF, + getTeamDocuments, + showPDFDialog, + closePDFDialog, formatDate, + formatFileSize, onSeasonChange }; } @@ -609,4 +945,326 @@ export default { justify-content: center; } } + +/* Upload-Bestätigung */ +.upload-confirmation { + margin-top: 1.5rem; + padding: 1.5rem; + background: #f8f9fa; + border-radius: var(--border-radius-medium); + border: 2px solid #dee2e6; +} + +.selected-file-info { + background: #e9ecef; + padding: 1rem; + border-radius: var(--border-radius-small); + margin-bottom: 1rem; + font-size: 0.9rem; +} + + +.action-buttons { + display: flex; + gap: 1rem; + align-items: center; +} + + +.confirm-parse-btn { + background: #4caf50; + color: white; + border: none; + padding: 0.75rem 1.5rem; + border-radius: var(--border-radius-small); + font-weight: 600; + cursor: pointer; + transition: var(--transition); + margin-right: 1rem; +} + +.confirm-parse-btn:hover:not(:disabled) { + background: #45a049; +} + +.confirm-parse-btn:disabled { + background: #cccccc; + cursor: not-allowed; +} + +.cancel-parse-btn { + background: #6c757d; + color: white; + border: none; + padding: 0.75rem 1.5rem; + border-radius: var(--border-radius-small); + font-weight: 600; + cursor: pointer; + transition: var(--transition); +} + +.cancel-parse-btn:hover { + background: #5a6268; +} + +.selected-file { + display: block; + margin-top: 0.5rem; + font-size: 0.875rem; + color: #2e7d32; + font-weight: 600; +} + +.parse-options { + display: flex; + gap: 1rem; + align-items: end; + flex-wrap: wrap; +} + +.parse-options label { + display: flex; + flex-direction: column; + gap: 0.5rem; + min-width: 200px; +} + +.parse-options label span { + font-weight: 600; + color: var(--text-color); +} + +/* PDF-Parsing Styles */ +.pdf-parsing-section { + margin-top: 2rem; + padding: 1.5rem; + background: var(--background-light); + border-radius: var(--border-radius-medium); + border: 1px solid var(--border-color); +} + +.pdf-parsing-section h4 { + margin: 0 0 1rem 0; + color: var(--text-color); + font-size: 1.1rem; +} + +.document-list { + display: flex; + flex-direction: column; + gap: 1rem; +} + +.document-item { + display: flex; + justify-content: space-between; + align-items: center; + padding: 1rem; + background: white; + border-radius: var(--border-radius-small); + border: 1px solid var(--border-color); +} + +.document-info { + display: flex; + flex-direction: column; + gap: 0.25rem; +} + +.document-name { + font-weight: 600; + color: var(--text-color); +} + +.document-type { + font-size: 0.875rem; + color: var(--text-muted); + background: var(--primary-light); + padding: 0.25rem 0.5rem; + border-radius: var(--border-radius-small); + display: inline-block; + width: fit-content; +} + +.document-size { + font-size: 0.75rem; + color: var(--text-muted); +} + +.document-actions { + display: flex; + gap: 1rem; + align-items: center; +} + +.parse-btn { + padding: 0.5rem 1rem; + background: #2196F3; + color: white; + border: none; + border-radius: var(--border-radius-small); + font-weight: 600; + cursor: pointer; + transition: var(--transition); +} + +.parse-btn:hover:not(:disabled) { + background: #1976D2; +} + +.parse-btn:disabled { + background: var(--text-muted); + cursor: not-allowed; +} + +/* Team-Dokumente Styles */ +.team-documents { + margin-top: 1rem; + padding-top: 1rem; + border-top: 1px solid var(--border-color); +} + +.documents-label { + font-size: 0.875rem; + font-weight: 600; + color: var(--text-muted); + margin-bottom: 0.5rem; +} + +.document-icons { + display: flex; + gap: 0.5rem; +} + +.document-icon { + background: none; + border: 2px solid var(--border-color); + border-radius: var(--border-radius-small); + padding: 0.5rem; + cursor: pointer; + font-size: 1.25rem; + transition: var(--transition); + display: flex; + align-items: center; + justify-content: center; + min-width: 2.5rem; + height: 2.5rem; +} + +.document-icon:hover { + border-color: var(--primary-color); + background: var(--primary-color); + color: white; + transform: translateY(-2px); +} + +.code-list-icon:hover { + border-color: #4CAF50; + background: #4CAF50; +} + +.pin-list-icon:hover { + border-color: #FF9800; + background: #FF9800; +} + +/* PDF-Dialog Styles */ +.pdf-dialog-overlay { + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + background: rgba(0, 0, 0, 0.8); + display: flex; + align-items: center; + justify-content: center; + z-index: 1000; +} + +.pdf-dialog { + background: white; + border-radius: var(--border-radius-medium); + width: 90%; + height: 90%; + max-width: 1200px; + max-height: 800px; + display: flex; + flex-direction: column; + box-shadow: 0 10px 30px rgba(0, 0, 0, 0.3); +} + +.pdf-dialog-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 1rem 1.5rem; + border-bottom: 1px solid var(--border-color); + background: var(--background-light); + border-radius: var(--border-radius-medium) var(--border-radius-medium) 0 0; +} + +.pdf-dialog-header h3 { + margin: 0; + color: var(--text-primary); + font-size: 1.25rem; +} + +.close-btn { + background: none; + border: none; + font-size: 1.5rem; + cursor: pointer; + color: var(--text-muted); + padding: 0.25rem; + border-radius: var(--border-radius-small); + transition: var(--transition); +} + +.close-btn:hover { + background: var(--background-light); + color: var(--text-primary); +} + +.pdf-dialog-content { + flex: 1; + padding: 0; + overflow: hidden; +} + +.pdf-viewer { + width: 100%; + height: 100%; + border: none; +} + +.no-pdf { + display: flex; + align-items: center; + justify-content: center; + height: 100%; + color: var(--text-muted); +} + +@media (max-width: 768px) { + .document-item { + flex-direction: column; + align-items: flex-start; + gap: 1rem; + } + + .document-actions { + width: 100%; + justify-content: space-between; + } + + .parse-options { + flex-direction: column; + align-items: stretch; + } + + .parse-options label { + min-width: auto; + } +}