From 1c70ca97bb66d2f22a246f273d8d641be0b5f12e Mon Sep 17 00:00:00 2001 From: "Torsten Schulz (local)" Date: Thu, 2 Oct 2025 09:04:19 +0200 Subject: [PATCH] =?UTF-8?q?F=C3=BCgt=20Unterst=C3=BCtzung=20f=C3=BCr=20Tea?= =?UTF-8?q?m-Dokumente=20hinzu.=20Aktualisiert=20die=20Backend-Modelle=20u?= =?UTF-8?q?nd=20-Routen,=20um=20Team-Dokumente=20zu=20verwalten,=20einschl?= =?UTF-8?q?ie=C3=9Flich=20Upload-=20und=20Parsing-Funktionen=20f=C3=BCr=20?= =?UTF-8?q?Code-=20und=20Pin-Listen.=20Erg=C3=A4nzt=20die=20Benutzeroberfl?= =?UTF-8?q?=C3=A4che=20in=20TeamManagementView.vue=20zur=20Anzeige=20und?= =?UTF-8?q?=20Verwaltung=20von=20Team-Dokumenten=20sowie=20zur=20Integrati?= =?UTF-8?q?on=20von=20PDF-Parsing.=20Aktualisiert=20die=20Match-Modelle,?= =?UTF-8?q?=20um=20zus=C3=A4tzliche=20Felder=20f=C3=BCr=20Spiel-Codes=20un?= =?UTF-8?q?d=20PINs=20zu=20ber=C3=BCcksichtigen.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/controllers/teamDocumentController.js | 233 ++++++ backend/models/Match.js | 15 + backend/models/TeamDocument.js | 52 ++ backend/models/index.js | 6 + backend/routes/teamDocumentRoutes.js | 33 + backend/server.js | 4 +- backend/services/matchService.js | 6 + backend/services/pdfParserService.js | 690 +++++++++++++++++ backend/services/teamDocumentService.js | 204 +++++ backend/test-spielplan.txt | 7 + .../7_code_list_1759354257578.pdf | Bin 0 -> 27910 bytes .../9_code_list_1759357969975.pdf | Bin 0 -> 27910 bytes .../9_pin_list_1759386673266.pdf | Bin 0 -> 6946 bytes frontend/src/views/ScheduleView.vue | 48 ++ frontend/src/views/TeamManagementView.vue | 694 +++++++++++++++++- 15 files changed, 1973 insertions(+), 19 deletions(-) create mode 100644 backend/controllers/teamDocumentController.js create mode 100644 backend/models/TeamDocument.js create mode 100644 backend/routes/teamDocumentRoutes.js create mode 100644 backend/services/pdfParserService.js create mode 100644 backend/services/teamDocumentService.js create mode 100644 backend/test-spielplan.txt create mode 100644 backend/uploads/team-documents/7_code_list_1759354257578.pdf create mode 100644 backend/uploads/team-documents/9_code_list_1759357969975.pdf create mode 100644 backend/uploads/team-documents/9_pin_list_1759386673266.pdf 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 0000000000000000000000000000000000000000..f8be61f76bcd02c580d884291d0d80ad4208f3b1 GIT binary patch literal 27910 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 z

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 aEGkzw3m3P;Wdg)ELX4c7TUK3;{Qm&23?upg literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..f8be61f76bcd02c580d884291d0d80ad4208f3b1 GIT binary patch literal 27910 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 z

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 aEGkzw3m3P;Wdg)ELX4c7TUK3;{Qm&23?upg literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..07db070bc2a90e3fdbcde53923550f0c85443db9 GIT binary patch literal 6946 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 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; + } +}