feat(tournament): add PDF generation for missing participant data

- Implemented a new feature to generate a PDF report for participants with missing data in mini championships.
- Added a button in the TournamentPlacementsTab component to trigger the PDF generation, which is disabled while loading.
- Enhanced internationalization by adding translation keys for the new PDF feature across multiple languages.
- Updated the TournamentTab component to pass the `isMiniChampionship` prop and handle the new `show-info` event.
This commit is contained in:
Torsten Schulz (local)
2026-02-06 16:11:17 +01:00
parent 26acb588e1
commit 8892392bf2
17 changed files with 284 additions and 3 deletions

View File

@@ -89,6 +89,12 @@
<div v-if="Object.keys(finalPlacementsByClassFiltered).length === 0 && groupPlacements.length === 0" class="no-placements">
<p>{{ $t('tournaments.noPlacementsYet') }}</p>
</div>
<div v-if="isMiniChampionship && (participants.length > 0 || externalParticipants.length > 0)" class="missing-data-pdf" style="margin-top: 1.5rem;">
<button @click="generateMissingDataPDF" class="btn-primary" :disabled="pdfLoading">
{{ pdfLoading ? $t('messages.loading') + '...' : $t('tournaments.missingDataPDF') }}
</button>
</div>
<!-- Player Details Dialog -->
<PlayerDetailsDialog
@@ -105,6 +111,9 @@
<script>
import TournamentClassSelector from './TournamentClassSelector.vue';
import PlayerDetailsDialog from './PlayerDetailsDialog.vue';
import jsPDF from 'jspdf';
import autoTable from 'jspdf-autotable';
import apiClient from '../../apiClient.js';
export default {
name: 'TournamentPlacementsTab',
@@ -122,15 +131,17 @@ export default {
groups: { type: Array, required: true },
groupRankings: { type: Object, required: true },
knockoutMatches: { type: Array, required: true },
clubId: { type: [Number, String], required: true }
clubId: { type: [Number, String], required: true },
isMiniChampionship: { type: Boolean, default: false }
},
emits: ['update:selectedViewClass'],
emits: ['update:selectedViewClass', 'show-info'],
data() {
return {
showPlayerDialog: false,
selectedPlayerId: null,
selectedPlayerIsExternal: false,
selectedPlayerName: ''
selectedPlayerName: '',
pdfLoading: false
};
},
watch: {
@@ -527,6 +538,199 @@ export default {
const c = (this.tournamentClasses || []).find(x => x.id === cid);
return Boolean(c && c.isDoubles);
},
async generateMissingDataPDF() {
this.pdfLoading = true;
try {
// 1. Alle Teilnehmer-Daten laden
const allPlayerData = [];
// Interne Mitglieder laden
let members = [];
try {
const res = await apiClient.get(`/clubmembers/get/${Number(this.clubId)}/true`);
members = res.data || [];
} catch (e) {
console.error('Fehler beim Laden der Mitglieder:', e);
}
// Externe Teilnehmer laden
let externals = [];
try {
const res = await apiClient.post('/tournament/external-participants', {
clubId: Number(this.clubId),
tournamentId: this.selectedDate,
classId: null
});
externals = res.data || [];
} catch (e) {
console.error('Fehler beim Laden der externen Teilnehmer:', e);
}
// Interne Teilnehmer verarbeiten
for (const p of this.participants) {
const memberId = p.member?.id || p.clubMemberId;
const member = members.find(m => m.id === memberId);
if (!member) continue;
let address = '';
const parts = [];
if (member.street) parts.push(member.street);
if (member.postalCode) parts.push(member.postalCode);
if (member.city) parts.push(member.city);
address = parts.join(', ');
let email = '';
if (member.contacts && Array.isArray(member.contacts)) {
email = member.contacts.filter(c => c.type === 'email').map(c => c.value).join(', ');
} else if (member.email) {
email = member.email;
}
const name = `${member.firstName || ''} ${member.lastName || ''}`.trim();
const gender = member.gender && member.gender !== 'unknown' ? member.gender : null;
allPlayerData.push({
name,
birthDate: member.birthDate || null,
gender,
address: address || null,
email: email || null
});
}
// Externe Teilnehmer verarbeiten
for (const ext of this.externalParticipants) {
const found = externals.find(e => e.id === ext.id);
const src = found || ext;
const name = `${src.firstName || ''} ${src.lastName || ''}`.trim();
const gender = src.gender && src.gender !== 'unknown' ? src.gender : null;
allPlayerData.push({
name,
birthDate: src.birthDate || null,
gender,
address: src.address || null,
email: src.email || null
});
}
// 2. Nur Teilnehmer mit fehlenden Daten filtern
const fields = ['birthDate', 'gender', 'address', 'email'];
const playersWithMissing = allPlayerData.filter(p =>
fields.some(f => !p[f])
);
if (playersWithMissing.length === 0) {
this.$emit('show-info', this.$t('messages.info'), this.$t('tournaments.allDataComplete'));
return;
}
// Sortieren nach Name
playersWithMissing.sort((a, b) => a.name.localeCompare(b.name));
// 3. PDF erzeugen
const pdf = new jsPDF('p', 'mm', 'a4');
const margin = 15;
const t = this.$t;
// Titel
pdf.setFontSize(16);
pdf.setFont('helvetica', 'bold');
pdf.text(t('tournaments.missingDataPDFTitle'), margin, 20);
pdf.setFontSize(10);
pdf.setFont('helvetica', 'normal');
pdf.text(t('tournaments.missingDataPDFSubtitle'), margin, 27);
// Formatierung der Felder
const formatGender = (g) => {
if (!g) return '';
const map = { male: t('members.genderMale'), female: t('members.genderFemale'), diverse: t('members.genderDiverse') };
return map[g] || g;
};
const formatDate = (d) => {
if (!d) return '';
try {
const date = new Date(d);
if (isNaN(date.getTime())) return d;
return `${String(date.getDate()).padStart(2, '0')}.${String(date.getMonth() + 1).padStart(2, '0')}.${date.getFullYear()}`;
} catch (e) { return d; }
};
const missing = t('tournaments.dataNotRecorded');
const linePlaceholder = '____________________';
// Tabelle
const head = [[
t('members.firstName') + ' / ' + t('members.lastName'),
t('members.birthdate'),
t('members.gender'),
t('tournaments.address'),
t('members.emailAddress')
]];
const body = playersWithMissing.map(p => [
p.name,
p.birthDate ? formatDate(p.birthDate) : linePlaceholder,
p.gender ? formatGender(p.gender) : linePlaceholder,
p.address || linePlaceholder,
p.email || linePlaceholder
]);
autoTable(pdf, {
startY: 32,
margin: { left: margin, right: margin },
head,
body,
styles: {
fontSize: 9,
cellPadding: 3,
lineColor: [200, 200, 200],
lineWidth: 0.25,
valign: 'middle'
},
headStyles: {
fillColor: [76, 175, 80],
textColor: 255,
fontStyle: 'bold',
fontSize: 9
},
columnStyles: {
0: { cellWidth: 35 },
1: { cellWidth: 28 },
2: { cellWidth: 25 },
3: { cellWidth: 45 },
4: { cellWidth: 45 }
},
didParseCell: (data) => {
// Platzhalter-Zeilen grau und kursiv markieren
if (data.section === 'body' && data.cell.raw === linePlaceholder) {
data.cell.styles.textColor = [180, 180, 180];
data.cell.styles.fontStyle = 'italic';
}
}
});
// Fußzeile
const pageCount = pdf.internal.getNumberOfPages();
for (let i = 1; i <= pageCount; i++) {
pdf.setPage(i);
pdf.setFontSize(8);
pdf.setTextColor(150);
pdf.text(
`${t('tournaments.missingDataPDFTitle')} ${new Date().toLocaleDateString('de-DE')} Seite ${i}/${pageCount}`,
margin,
290
);
}
pdf.save(`Fehlende_Daten_Minimeisterschaft_${new Date().toISOString().slice(0, 10)}.pdf`);
} catch (error) {
console.error('Fehler beim Erstellen des PDFs:', error);
} finally {
this.pdfLoading = false;
}
},
openPlayerDialog(entry) {
console.log('[openPlayerDialog] entry:', entry);
// Für Doppel-Paarungen können wir keine Details anzeigen

View File

@@ -107,6 +107,11 @@
"noFreeTables": "Kei freie Tisch verfüegbar.",
"noAssignableMatches": "Kei Spiel verfüegbar, wo beidi Spieler frei sind.",
"tablesDistributed": "Tisch sind verteilt worde.",
"missingDataPDF": "Fehlendi Date als PDF",
"missingDataPDFTitle": "Fehlendi Teilnehmerdaten Minimeisterschaft",
"missingDataPDFSubtitle": "Bitte d'fehlende Date (markiert mit ____) bi de Teilnehmer nochfroge und do notiere.",
"allDataComplete": "Alli Teilnehmerdaten sind vollständig.",
"address": "Adrässe",
"dataNotRecorded": "No nid erfasst"
}
}

View File

@@ -275,6 +275,11 @@
"noFreeTables": "Keine freien Tische verfügbar.",
"noAssignableMatches": "Keine Spiele verfügbar, bei denen beide Spieler frei sind.",
"tablesDistributed": "Tische wurden verteilt.",
"missingDataPDF": "Fehlende Daten als PDF",
"missingDataPDFTitle": "Fehlende Teilnehmerdaten Minimeisterschaft",
"missingDataPDFSubtitle": "Bitte die fehlenden Daten (markiert mit ____) bei den Teilnehmern erfragen und hier notieren.",
"allDataComplete": "Alle Teilnehmerdaten sind vollständig.",
"address": "Adresse",
"dataNotRecorded": "Noch nicht erfasst"
},
"permissions": {

View File

@@ -581,6 +581,11 @@
"noFreeTables": "Keine freien Tische verfügbar.",
"noAssignableMatches": "Keine Spiele verfügbar, bei denen beide Spieler frei sind.",
"tablesDistributed": "Tische wurden verteilt.",
"missingDataPDF": "Fehlende Daten als PDF",
"missingDataPDFTitle": "Fehlende Teilnehmerdaten Minimeisterschaft",
"missingDataPDFSubtitle": "Bitte die fehlenden Daten (markiert mit ____) bei den Teilnehmern erfragen und hier notieren.",
"allDataComplete": "Alle Teilnehmerdaten sind vollständig.",
"address": "Adresse",
"create": "Erstellen",
"exportPDF": "PDF exportieren",
"playInGroups": "Spielen in Gruppen",

View File

@@ -107,6 +107,11 @@
"noFreeTables": "No free tables available.",
"noAssignableMatches": "No matches available where both players are free.",
"tablesDistributed": "Tables have been distributed.",
"missingDataPDF": "Missing data as PDF",
"missingDataPDFTitle": "Missing participant data Mini championship",
"missingDataPDFSubtitle": "Please collect the missing data (marked with ____) from participants and note them here.",
"allDataComplete": "All participant data is complete.",
"address": "Address",
"dataNotRecorded": "Not yet recorded"
}
}

View File

@@ -107,6 +107,11 @@
"noFreeTables": "No free tables available.",
"noAssignableMatches": "No matches available where both players are free.",
"tablesDistributed": "Tables have been distributed.",
"missingDataPDF": "Missing data as PDF",
"missingDataPDFTitle": "Missing participant data Mini championship",
"missingDataPDFSubtitle": "Please collect the missing data (marked with ____) from participants and note them here.",
"allDataComplete": "All participant data is complete.",
"address": "Address",
"dataNotRecorded": "Not yet recorded"
}
}

View File

@@ -107,6 +107,11 @@
"noFreeTables": "No free tables available.",
"noAssignableMatches": "No matches available where both players are free.",
"tablesDistributed": "Tables have been distributed.",
"missingDataPDF": "Missing data as PDF",
"missingDataPDFTitle": "Missing participant data Mini championship",
"missingDataPDFSubtitle": "Please collect the missing data (marked with ____) from participants and note them here.",
"allDataComplete": "All participant data is complete.",
"address": "Address",
"dataNotRecorded": "Not yet recorded"
}
}

View File

@@ -107,6 +107,11 @@
"noFreeTables": "No hay mesas libres disponibles.",
"noAssignableMatches": "No hay partidos disponibles en los que ambos jugadores estén libres.",
"tablesDistributed": "Las mesas han sido distribuidas.",
"missingDataPDF": "Datos faltantes como PDF",
"missingDataPDFTitle": "Datos de participantes faltantes Mini campeonato",
"missingDataPDFSubtitle": "Por favor, recopile los datos faltantes (marcados con ____) de los participantes y anótelos aquí.",
"allDataComplete": "Todos los datos de los participantes están completos.",
"address": "Dirección",
"dataNotRecorded": "Aún no registrado"
}
}

View File

@@ -107,6 +107,11 @@
"noFreeTables": "Walang bakanteng mesa.",
"noAssignableMatches": "Walang laban kung saan pareho ang mga manlalaro ay bakante.",
"tablesDistributed": "Ang mga mesa ay naipamahagi na.",
"missingDataPDF": "Nawawalang datos bilang PDF",
"missingDataPDFTitle": "Nawawalang datos ng mga kalahok Mini championship",
"missingDataPDFSubtitle": "Pakikolekta ang nawawalang datos (may markang ____) mula sa mga kalahok at itala dito.",
"allDataComplete": "Kumpleto na ang lahat ng datos ng mga kalahok.",
"address": "Address",
"dataNotRecorded": "Hindi pa naitala"
}
}

View File

@@ -107,6 +107,11 @@
"noFreeTables": "Aucune table libre disponible.",
"noAssignableMatches": "Aucun match disponible où les deux joueurs sont libres.",
"tablesDistributed": "Les tables ont été distribuées.",
"missingDataPDF": "Données manquantes en PDF",
"missingDataPDFTitle": "Données manquantes des participants Mini-championnat",
"missingDataPDFSubtitle": "Veuillez collecter les données manquantes (marquées ____) auprès des participants et les noter ici.",
"allDataComplete": "Toutes les données des participants sont complètes.",
"address": "Adresse",
"dataNotRecorded": "Pas encore enregistré"
}
}

View File

@@ -107,6 +107,11 @@
"noFreeTables": "Nessun tavolo libero disponibile.",
"noAssignableMatches": "Nessuna partita disponibile in cui entrambi i giocatori sono liberi.",
"tablesDistributed": "I tavoli sono stati distribuiti.",
"missingDataPDF": "Dati mancanti come PDF",
"missingDataPDFTitle": "Dati mancanti dei partecipanti Mini campionato",
"missingDataPDFSubtitle": "Si prega di raccogliere i dati mancanti (contrassegnati con ____) dai partecipanti e annotarli qui.",
"allDataComplete": "Tutti i dati dei partecipanti sono completi.",
"address": "Indirizzo",
"dataNotRecorded": "Non ancora registrato"
}
}

View File

@@ -107,6 +107,11 @@
"noFreeTables": "空き卓がありません。",
"noAssignableMatches": "両選手が空いている試合がありません。",
"tablesDistributed": "卓が割り当てられました。",
"missingDataPDF": "不足データをPDFで",
"missingDataPDFTitle": "参加者の不足データ ミニ選手権",
"missingDataPDFSubtitle": "不足しているデータ____で表示を参加者から収集し、ここに記入してください。",
"allDataComplete": "すべての参加者データが揃っています。",
"address": "住所",
"dataNotRecorded": "未登録"
}
}

View File

@@ -107,6 +107,11 @@
"noFreeTables": "Brak wolnych stołów.",
"noAssignableMatches": "Brak meczów, w których obaj gracze są wolni.",
"tablesDistributed": "Stoły zostały rozdzielone.",
"missingDataPDF": "Brakujące dane jako PDF",
"missingDataPDFTitle": "Brakujące dane uczestników Mini mistrzostwa",
"missingDataPDFSubtitle": "Proszę zebrać brakujące dane (oznaczone ____) od uczestników i zanotować je tutaj.",
"allDataComplete": "Wszystkie dane uczestników są kompletne.",
"address": "Adres",
"dataNotRecorded": "Jeszcze nie wprowadzono"
}
}

View File

@@ -107,6 +107,11 @@
"noFreeTables": "ไม่มีโต๊ะว่าง",
"noAssignableMatches": "ไม่มีแมตช์ที่ผู้เล่นทั้งสองว่าง",
"tablesDistributed": "จัดสรรโต๊ะเรียบร้อยแล้ว",
"missingDataPDF": "ข้อมูลที่ขาดหายเป็น PDF",
"missingDataPDFTitle": "ข้อมูลผู้เข้าร่วมที่ขาดหาย มินิแชมเปี้ยนชิพ",
"missingDataPDFSubtitle": "กรุณาเก็บข้อมูลที่ขาดหาย (ทำเครื่องหมาย ____) จากผู้เข้าร่วมและจดบันทึกที่นี่",
"allDataComplete": "ข้อมูลผู้เข้าร่วมทั้งหมดครบถ้วน",
"address": "ที่อยู่",
"dataNotRecorded": "ยังไม่ได้บันทึก"
}
}

View File

@@ -107,6 +107,11 @@
"noFreeTables": "Walang bakanteng mesa.",
"noAssignableMatches": "Walang laban kung saan pareho ang mga manlalaro ay bakante.",
"tablesDistributed": "Ang mga mesa ay naipamahagi na.",
"missingDataPDF": "Nawawalang datos bilang PDF",
"missingDataPDFTitle": "Nawawalang datos ng mga kalahok Mini championship",
"missingDataPDFSubtitle": "Pakikolekta ang nawawalang datos (may markang ____) mula sa mga kalahok at itala dito.",
"allDataComplete": "Kumpleto na ang lahat ng datos ng mga kalahok.",
"address": "Address",
"dataNotRecorded": "Hindi pa naitala"
}
}

View File

@@ -107,6 +107,11 @@
"noFreeTables": "没有可用的空闲球台。",
"noAssignableMatches": "没有两位选手都空闲的比赛。",
"tablesDistributed": "球台已分配。",
"missingDataPDF": "缺失数据导出为PDF",
"missingDataPDFTitle": "参赛者缺失数据 迷你锦标赛",
"missingDataPDFSubtitle": "请向参赛者收集缺失数据标记为____并在此记录。",
"allDataComplete": "所有参赛者数据已完整。",
"address": "地址",
"dataNotRecorded": "尚未录入"
}
}

View File

@@ -257,7 +257,9 @@
:external-participants="externalParticipants"
:pairings="pairings"
:club-id="currentClub"
:is-mini-championship="isMiniChampionship"
@update:selectedViewClass="selectedViewClass = $event"
@show-info="showInfo"
/>
</div>
</div>