Fügt Unterstützung für Team-Dokumente hinzu. Aktualisiert die Backend-Modelle und -Routen, um Team-Dokumente zu verwalten, einschließlich Upload- und Parsing-Funktionen für Code- und Pin-Listen. Ergänzt die Benutzeroberfläche in TeamManagementView.vue zur Anzeige und Verwaltung von Team-Dokumenten sowie zur Integration von PDF-Parsing. Aktualisiert die Match-Modelle, um zusätzliche Felder für Spiel-Codes und PINs zu berücksichtigen.

This commit is contained in:
Torsten Schulz (local)
2025-10-02 09:04:19 +02:00
parent a6493990d3
commit 1c70ca97bb
15 changed files with 1973 additions and 19 deletions

View File

@@ -35,6 +35,9 @@
<th>Heimmannschaft</th>
<th>Gastmannschaft</th>
<th v-if="selectedLeague === 'Gesamtspielplan' || selectedLeague === 'Spielplan Erwachsene'">Altersklasse</th>
<th>Code</th>
<th>Heim-PIN</th>
<th>Gast-PIN</th>
</tr>
</thead>
<tbody>
@@ -45,6 +48,18 @@
<td v-html="highlightClubName(match.homeTeam?.name || 'N/A')"></td>
<td v-html="highlightClubName(match.guestTeam?.name || 'N/A')"></td>
<td v-if="selectedLeague === 'Gesamtspielplan' || selectedLeague === 'Spielplan Erwachsene'">{{ match.leagueDetails?.name || 'N/A' }}</td>
<td class="code-cell">
<span v-if="match.code" class="code-value">{{ match.code }}</span>
<span v-else class="no-data">-</span>
</td>
<td class="pin-cell">
<span v-if="match.homePin" class="pin-value">{{ match.homePin }}</span>
<span v-else class="no-data">-</span>
</td>
<td class="pin-cell">
<span v-if="match.guestPin" class="pin-value">{{ match.guestPin }}</span>
<span v-else class="no-data">-</span>
</td>
</tr>
</tbody>
</table>
@@ -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 */
}

View File

@@ -50,12 +50,61 @@
<h4>Team-Dokumente hochladen</h4>
<div class="upload-buttons">
<button @click="uploadCodeList" class="upload-btn code-list-btn">
📋 Code-Liste hochladen
📋 Code-Liste hochladen & parsen
</button>
<button @click="uploadPinList" class="upload-btn pin-list-btn">
🔐 Pin-Liste hochladen
🔐 Pin-Liste hochladen & parsen
</button>
</div>
<!-- Upload-Bestätigung -->
<div v-if="showLeagueSelection" class="upload-confirmation">
<div class="selected-file-info">
<strong>Ausgewählte Datei:</strong> {{ pendingUploadFile?.name }}
<br>
<strong>Typ:</strong> {{ pendingUploadType === 'code_list' ? 'Code-Liste' : 'Pin-Liste' }}
<br>
<strong>Team:</strong> {{ teamToEdit?.name }}
<br>
<strong>Liga:</strong> {{ getTeamLeagueName() }}
</div>
<div class="action-buttons">
<button
@click="confirmUploadAndParse"
:disabled="parsingInProgress"
class="confirm-parse-btn"
>
{{ parsingInProgress ? '⏳ Parse läuft...' : '🚀 Hochladen & Parsen' }}
</button>
<button @click="cancelUpload" class="cancel-parse-btn">
Abbrechen
</button>
</div>
</div>
<!-- PDF-Parsing Bereich für bereits hochgeladene Dokumente -->
<div v-if="teamDocuments.length > 0" class="pdf-parsing-section">
<h4>Bereits hochgeladene PDF-Dokumente parsen</h4>
<div class="document-list">
<div v-for="document in teamDocuments" :key="document.id" class="document-item">
<div class="document-info">
<span class="document-name">{{ document.originalFileName }}</span>
<span class="document-type">{{ document.documentType === 'code_list' ? 'Code-Liste' : 'Pin-Liste' }}</span>
<span class="document-size">{{ formatFileSize(document.fileSize) }}</span>
</div>
<div class="document-actions">
<button
@click="parsePDF(document)"
:disabled="document.mimeType !== 'application/pdf'"
class="parse-btn"
:title="document.mimeType !== 'application/pdf' ? 'Nur PDF-Dateien können geparst werden' : 'PDF parsen und Matches extrahieren'"
>
🔍 PDF parsen
</button>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
@@ -102,6 +151,50 @@
<span class="value">{{ formatDate(team.createdAt) }}</span>
</div>
</div>
<!-- PDF-Dokumente Icons -->
<div class="team-documents">
<div class="documents-label">Dokumente:</div>
<div class="document-icons">
<button
v-if="getTeamDocuments(team.id, 'code_list').length > 0"
@click.stop="showPDFDialog(team.id, 'code_list')"
class="document-icon code-list-icon"
title="Code-Liste anzeigen"
>
📋
</button>
<button
v-if="getTeamDocuments(team.id, 'pin_list').length > 0"
@click.stop="showPDFDialog(team.id, 'pin_list')"
class="document-icon pin-list-icon"
title="Pin-Liste anzeigen"
>
🔐
</button>
</div>
</div>
</div>
</div>
</div>
<!-- PDF-Dialog -->
<div v-if="showPDFViewer" class="pdf-dialog-overlay" @click="closePDFDialog">
<div class="pdf-dialog" @click.stop>
<div class="pdf-dialog-header">
<h3>{{ pdfDialogTitle }}</h3>
<button @click="closePDFDialog" class="close-btn"></button>
</div>
<div class="pdf-dialog-content">
<iframe
v-if="pdfUrl"
:src="pdfUrl"
class="pdf-viewer"
frameborder="0"
></iframe>
<div v-else class="no-pdf">
<p>PDF konnte nicht geladen werden.</p>
</div>
</div>
</div>
</div>
@@ -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;
}
}
</style>