Fügt die Methode listClubParticipations im OfficialTournamentController hinzu, um die Teilnahme von Mitgliedern an offiziellen Turnieren zu listen. Aktualisiert die Routen, um diese neue Funktionalität zu integrieren. Verbessert die Benutzeroberfläche in OfficialTournaments.vue mit Tabs zur Anzeige von Veranstaltungen und Turnierbeteiligungen sowie einer Filteroption für den Zeitraum der Beteiligungen.
This commit is contained in:
@@ -5,6 +5,8 @@ import { checkAccess } from '../utils/userUtils.js';
|
||||
import OfficialTournament from '../models/OfficialTournament.js';
|
||||
import OfficialCompetition from '../models/OfficialCompetition.js';
|
||||
import OfficialCompetitionMember from '../models/OfficialCompetitionMember.js';
|
||||
import Member from '../models/Member.js';
|
||||
import { Op } from 'sequelize';
|
||||
|
||||
// In-Memory Store (einfacher Start); später DB-Modell
|
||||
const parsedTournaments = new Map(); // key: id, value: { id, clubId, rawText, parsedData }
|
||||
@@ -170,6 +172,110 @@ export const listOfficialTournaments = async (req, res) => {
|
||||
}
|
||||
};
|
||||
|
||||
export const listClubParticipations = async (req, res) => {
|
||||
try {
|
||||
const { authcode: userToken } = req.headers;
|
||||
const { clubId } = req.params;
|
||||
await checkAccess(userToken, clubId);
|
||||
const tournaments = await OfficialTournament.findAll({ where: { clubId } });
|
||||
if (!tournaments || tournaments.length === 0) return res.status(200).json([]);
|
||||
const tournamentIds = tournaments.map(t => t.id);
|
||||
|
||||
const rows = await OfficialCompetitionMember.findAll({
|
||||
where: { tournamentId: { [Op.in]: tournamentIds }, participated: true },
|
||||
include: [
|
||||
{ model: OfficialCompetition, as: 'competition', attributes: ['id', 'tournamentId', 'ageClassCompetition', 'startTime'] },
|
||||
{ model: OfficialTournament, as: 'tournament', attributes: ['id', 'title', 'eventDate'] },
|
||||
{ model: Member, as: 'member', attributes: ['id', 'firstName', 'lastName'] },
|
||||
]
|
||||
});
|
||||
|
||||
const parseDmy = (s) => {
|
||||
if (!s) return null;
|
||||
const m = String(s).match(/(\d{1,2})\.(\d{1,2})\.(\d{4})/);
|
||||
if (!m) return null;
|
||||
const d = new Date(Number(m[3]), Number(m[2]) - 1, Number(m[1]));
|
||||
return isNaN(d.getTime()) ? null : d;
|
||||
};
|
||||
const fmtDmy = (d) => {
|
||||
const dd = String(d.getDate()).padStart(2, '0');
|
||||
const mm = String(d.getMonth() + 1).padStart(2, '0');
|
||||
const yyyy = d.getFullYear();
|
||||
return `${dd}.${mm}.${yyyy}`;
|
||||
};
|
||||
|
||||
const byTournament = new Map();
|
||||
for (const r of rows) {
|
||||
const t = r.tournament;
|
||||
const c = r.competition;
|
||||
const m = r.member;
|
||||
if (!t || !c || !m) continue;
|
||||
if (!byTournament.has(t.id)) {
|
||||
byTournament.set(t.id, {
|
||||
tournamentId: String(t.id),
|
||||
title: t.title || null,
|
||||
startDate: null,
|
||||
endDate: null,
|
||||
entries: [],
|
||||
_dates: [],
|
||||
_eventDate: t.eventDate || null,
|
||||
});
|
||||
}
|
||||
const bucket = byTournament.get(t.id);
|
||||
const compDate = parseDmy(c.startTime || '') || null;
|
||||
if (compDate) bucket._dates.push(compDate);
|
||||
bucket.entries.push({
|
||||
memberId: m.id,
|
||||
memberName: `${m.firstName || ''} ${m.lastName || ''}`.trim(),
|
||||
competitionId: c.id,
|
||||
competitionName: c.ageClassCompetition || '',
|
||||
placement: r.placement || null,
|
||||
date: compDate ? fmtDmy(compDate) : null,
|
||||
});
|
||||
}
|
||||
|
||||
const out = [];
|
||||
for (const t of tournaments) {
|
||||
const bucket = byTournament.get(t.id) || {
|
||||
tournamentId: String(t.id),
|
||||
title: t.title || null,
|
||||
startDate: null,
|
||||
endDate: null,
|
||||
entries: [],
|
||||
_dates: [],
|
||||
_eventDate: t.eventDate || null,
|
||||
};
|
||||
// Ableiten Start/Ende
|
||||
if (bucket._dates.length) {
|
||||
bucket._dates.sort((a, b) => a - b);
|
||||
bucket.startDate = fmtDmy(bucket._dates[0]);
|
||||
bucket.endDate = fmtDmy(bucket._dates[bucket._dates.length - 1]);
|
||||
} else if (bucket._eventDate) {
|
||||
const all = String(bucket._eventDate).match(/(\d{1,2}\.\d{1,2}\.\d{4})/g) || [];
|
||||
if (all.length >= 1) {
|
||||
const d1 = parseDmy(all[0]);
|
||||
const d2 = all.length >= 2 ? parseDmy(all[1]) : d1;
|
||||
if (d1) bucket.startDate = fmtDmy(d1);
|
||||
if (d2) bucket.endDate = fmtDmy(d2);
|
||||
}
|
||||
}
|
||||
// Sort entries: Mitglied, dann Konkurrenz
|
||||
bucket.entries.sort((a, b) => {
|
||||
const mcmp = (a.memberName || '').localeCompare(b.memberName || '', 'de', { sensitivity: 'base' });
|
||||
if (mcmp !== 0) return mcmp;
|
||||
return (a.competitionName || '').localeCompare(b.competitionName || '', 'de', { sensitivity: 'base' });
|
||||
});
|
||||
delete bucket._dates;
|
||||
delete bucket._eventDate;
|
||||
out.push(bucket);
|
||||
}
|
||||
|
||||
res.status(200).json(out);
|
||||
} catch (e) {
|
||||
res.status(500).json({ error: 'Failed to list club participations' });
|
||||
}
|
||||
};
|
||||
|
||||
export const deleteOfficialTournament = async (req, res) => {
|
||||
try {
|
||||
const { authcode: userToken } = req.headers;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import express from 'express';
|
||||
import multer from 'multer';
|
||||
import { authenticate } from '../middleware/authMiddleware.js';
|
||||
import { uploadTournamentPdf, getParsedTournament, listOfficialTournaments, deleteOfficialTournament, upsertCompetitionMember } from '../controllers/officialTournamentController.js';
|
||||
import { uploadTournamentPdf, getParsedTournament, listOfficialTournaments, deleteOfficialTournament, upsertCompetitionMember, listClubParticipations } from '../controllers/officialTournamentController.js';
|
||||
|
||||
const router = express.Router();
|
||||
const upload = multer({ storage: multer.memoryStorage() });
|
||||
@@ -9,6 +9,7 @@ const upload = multer({ storage: multer.memoryStorage() });
|
||||
router.use(authenticate);
|
||||
|
||||
router.get('/:clubId', listOfficialTournaments);
|
||||
router.get('/:clubId/participations/summary', listClubParticipations);
|
||||
router.post('/:clubId/upload', upload.single('pdf'), uploadTournamentPdf);
|
||||
router.get('/:clubId/:id', getParsedTournament);
|
||||
router.delete('/:clubId/:id', deleteOfficialTournament);
|
||||
|
||||
@@ -2,17 +2,22 @@
|
||||
<div class="official-tournaments">
|
||||
<h2>Offizielle Turniere</h2>
|
||||
<div v-if="list && list.length" class="list">
|
||||
<h3>Gespeicherte Veranstaltungen</h3>
|
||||
<ul>
|
||||
<li v-for="t in list" :key="t.id" style="display:flex; align-items:center; gap:.5rem;">
|
||||
<a href="#" @click.prevent="uploadedId = String(t.id); reload();" style="flex:1;">
|
||||
{{ t.title || ('Turnier #' + t.id) }}
|
||||
</a>
|
||||
<span v-if="t.termin || t.eventDate"> — {{ t.termin || t.eventDate }}</span>
|
||||
<button class="btn-secondary" @click.prevent="removeTournament(t)" title="Löschen">🗑️</button>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="tabs">
|
||||
<button :class="['tab', topActiveTab==='events' ? 'active' : '']" @click="switchTopTab('events')" title="Gespeicherte Veranstaltungen anzeigen">Veranstaltungen</button>
|
||||
<button :class="['tab', topActiveTab==='participations' ? 'active' : '']" @click="switchTopTab('participations')" title="Turnierbeteiligungen anzeigen">Turnierbeteiligungen</button>
|
||||
</div>
|
||||
<div v-if="topActiveTab==='events'">
|
||||
<h3>Gespeicherte Veranstaltungen</h3>
|
||||
<ul>
|
||||
<li v-for="t in list" :key="t.id" style="display:flex; align-items:center; gap:.5rem;">
|
||||
<a href="#" @click.prevent="uploadedId = String(t.id); reload();" style="flex:1;">
|
||||
{{ t.title || ('Turnier #' + t.id) }}
|
||||
</a>
|
||||
<span v-if="t.termin || t.eventDate"> — {{ t.termin || t.eventDate }}</span>
|
||||
<button class="btn-secondary" @click.prevent="removeTournament(t)" title="Löschen">🗑️</button>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<div class="uploader">
|
||||
<input type="file" accept="application/pdf" @change="onFile" />
|
||||
<button class="btn-primary" :disabled="!selectedFile" @click="upload">PDF hochladen</button>
|
||||
@@ -48,6 +53,11 @@
|
||||
<button class="btn-secondary" @click="openMemberDialog" :disabled="!parsed || !activeMembers.length">Mitglieder auswählen</button>
|
||||
<button class="btn-primary" :disabled="!selectedMemberIds.length" @click="generateMembersPdf">PDF für markierte Mitglieder</button>
|
||||
</div>
|
||||
<div class="tabs">
|
||||
<button :class="['tab', activeTab==='competitions' ? 'active' : '']" @click="activeTab='competitions'" title="Konkurrenzen anzeigen">Konkurrenzen</button>
|
||||
<button :class="['tab', activeTab==='results' ? 'active' : '']" @click="activeTab='results'" title="Ergebnisse anzeigen">Ergebnisse</button>
|
||||
</div>
|
||||
<div v-if="activeTab==='competitions'">
|
||||
<h3>Konkurrenzen</h3>
|
||||
<table>
|
||||
<thead>
|
||||
@@ -62,7 +72,7 @@
|
||||
<template v-for="(c,idx) in parsed.parsedData.competitions" :key="idx">
|
||||
<tr>
|
||||
<td style="width:2.5rem;">
|
||||
<button class="btn-secondary" @click.prevent="toggleRow(c, idx)" :aria-expanded="isExpanded(c, idx)">
|
||||
<button class="btn-secondary" @click.prevent="toggleRow(c, idx)" :aria-expanded="isExpanded(c, idx)" title="Details ein-/ausklappen">
|
||||
{{ isExpanded(c, idx) ? '▾' : '▸' }}
|
||||
</button>
|
||||
</td>
|
||||
@@ -133,6 +143,66 @@
|
||||
</template>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div v-else>
|
||||
<h3>Ergebnisse</h3>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Mitglied</th>
|
||||
<th>Konkurrenz</th>
|
||||
<th>Startzeit</th>
|
||||
<th>Angemeldet</th>
|
||||
<th>Teilgenommen</th>
|
||||
<th>Platzierung</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="row in resultsRows" :key="row.key">
|
||||
<td>{{ row.memberName }}</td>
|
||||
<td>{{ row.competitionName }}</td>
|
||||
<td>{{ row.start }}</td>
|
||||
<td>{{ row.registered ? 'Ja' : 'Nein' }}</td>
|
||||
<td>{{ row.participated ? 'Ja' : 'Nein' }}</td>
|
||||
<td>{{ row.placement || '–' }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else-if="topActiveTab==='participations'">
|
||||
<h3>Turnierbeteiligungen</h3>
|
||||
<div class="filters">
|
||||
<label for="participationRange">Zeitraum:</label>
|
||||
<select id="participationRange" v-model="participationRange" @change="onParticipationRangeChange">
|
||||
<option value="3m">Letzte 3 Monate</option>
|
||||
<option value="6m">Letzte 6 Monate</option>
|
||||
<option value="12m">Letzte 12 Monate</option>
|
||||
<option value="2y">Letzte 2 Jahre</option>
|
||||
<option value="prev">Vorherige Saison</option>
|
||||
<option value="all">Alle</option>
|
||||
</select>
|
||||
</div>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Mitglied</th>
|
||||
<th>Konkurrenz</th>
|
||||
<th>Datum</th>
|
||||
<th>Platzierung</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="row in clubParticipationRows()" :key="row.key">
|
||||
<td>{{ row.memberName }}</td>
|
||||
<td>{{ row.competitionName }}</td>
|
||||
<td>{{ row.date }}</td>
|
||||
<td>{{ row.placement || '–' }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="showMemberDialog" class="modal-overlay" @click.self="closeMemberDialog">
|
||||
@@ -197,12 +267,21 @@ export default {
|
||||
memberRecommendations: {},
|
||||
selectedMemberIdForDialog: null,
|
||||
participationMap: {}, // key: `${competitionId}-${memberId}` => { wants, registered, participated, placement }
|
||||
collator: new Intl.Collator('de', { sensitivity: 'base' }),
|
||||
activeTab: 'competitions',
|
||||
topActiveTab: 'events',
|
||||
loadingClubParticipations: false,
|
||||
clubParticipationRowsData: [],
|
||||
participationRange: 'all',
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
...mapGetters(['currentClub']),
|
||||
activeMembers() {
|
||||
return (this.members || []).filter(m => m.active === true);
|
||||
return (this.members || [])
|
||||
.filter(m => m.active === true)
|
||||
.slice()
|
||||
.sort((a, b) => this.compareMembers(a, b));
|
||||
},
|
||||
selectedMemberInDialog() {
|
||||
const id = this.selectedMemberIdForDialog;
|
||||
@@ -213,9 +292,151 @@ export default {
|
||||
const m = this.selectedMemberInDialog;
|
||||
if (!m) return [];
|
||||
return this.competitionsForMember(m);
|
||||
},
|
||||
resultsRows() {
|
||||
const comps = (this.parsed?.parsedData?.competitions) || [];
|
||||
const compById = Object.fromEntries(comps.map(c => [String(c.id), c]));
|
||||
const rows = [];
|
||||
const entries = this.participationMap || {};
|
||||
for (const [key, p] of Object.entries(entries)) {
|
||||
if (!p || !p.participated) continue;
|
||||
const [competitionId, memberId] = key.split('-');
|
||||
const c = compById[String(competitionId)];
|
||||
if (!c) continue;
|
||||
const mname = this.memberNameById(memberId);
|
||||
const start = String(c.startTime || c.startzeit || '–');
|
||||
rows.push({
|
||||
key: `${competitionId}-${memberId}`,
|
||||
memberName: mname,
|
||||
competitionName: c.ageClassCompetition || c.altersklasseWettbewerb || '',
|
||||
start,
|
||||
registered: !!p.registered,
|
||||
participated: !!p.participated,
|
||||
placement: p.placement || null,
|
||||
});
|
||||
}
|
||||
return rows.sort((a, b) => {
|
||||
const m = this.collator.compare(a.memberName, b.memberName);
|
||||
if (m !== 0) return m;
|
||||
return this.collator.compare(a.competitionName, b.competitionName);
|
||||
});
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
switchTopTab(tab) { this.topActiveTab = tab; if (tab === 'participations') this.loadClubParticipations(); },
|
||||
compareMembers(a, b) {
|
||||
const fnA = String(a.firstName || '');
|
||||
const fnB = String(b.firstName || '');
|
||||
const lnA = String(a.lastName || '');
|
||||
const lnB = String(b.lastName || '');
|
||||
const byFirst = this.collator.compare(fnA, fnB);
|
||||
if (byFirst !== 0) return byFirst;
|
||||
return this.collator.compare(lnA, lnB);
|
||||
},
|
||||
clubParticipationRows() {
|
||||
if (this.clubParticipationRowsData && this.clubParticipationRowsData.length) {
|
||||
return this.clubParticipationRowsData;
|
||||
}
|
||||
// Fallback: nur aktuelles Turnier
|
||||
const rows = [];
|
||||
if (!this.parsed || !this.parsed.parsedData) return rows;
|
||||
const parts = (this.parsed.participation) || [];
|
||||
const comps = this.parsed.parsedData.competitions || [];
|
||||
const compById = Object.fromEntries(comps.map(c => [String(c.id), c]));
|
||||
for (const p of parts) {
|
||||
if (!p.participated) continue;
|
||||
const c = compById[String(p.competitionId)];
|
||||
if (!c) continue;
|
||||
const mname = this.memberNameById(p.memberId);
|
||||
const date = (String(c.startTime || c.startzeit || '')).match(/(\d{1,2}\.\d{1,2}\.\d{4})/);
|
||||
rows.push({
|
||||
key: `club-${p.competitionId}-${p.memberId}`,
|
||||
memberName: mname,
|
||||
competitionName: c.ageClassCompetition || c.altersklasseWettbewerb || '',
|
||||
date: date ? date[1] : '–',
|
||||
placement: p.placement || null,
|
||||
});
|
||||
}
|
||||
return rows.sort((a, b) => {
|
||||
const byMember = this.collator.compare(a.memberName, b.memberName);
|
||||
if (byMember !== 0) return byMember;
|
||||
return this.collator.compare(a.competitionName, b.competitionName);
|
||||
});
|
||||
},
|
||||
async ensureMembersLoaded() {
|
||||
if (!this.members || !this.members.length) {
|
||||
const m = await apiClient.get(`/clubmembers/get/${this.currentClub}/true`);
|
||||
this.members = m.data;
|
||||
}
|
||||
},
|
||||
computeRange() {
|
||||
const now = new Date();
|
||||
let from = null, to = null;
|
||||
switch (this.participationRange) {
|
||||
case '3m': from = new Date(now); from.setMonth(from.getMonth() - 3); break;
|
||||
case '6m': from = new Date(now); from.setMonth(from.getMonth() - 6); break;
|
||||
case '12m': from = new Date(now); from.setMonth(from.getMonth() - 12); break;
|
||||
case '2y': from = new Date(now); from.setFullYear(from.getFullYear() - 2); break;
|
||||
case 'prev': {
|
||||
const y = now.getMonth() + 1 >= 7 ? now.getFullYear() : now.getFullYear() - 1;
|
||||
from = new Date(y - 1, 6, 1); // 01.07.(y-1)
|
||||
to = new Date(y, 5, 30, 23, 59, 59, 999); // 30.06.y
|
||||
break;
|
||||
}
|
||||
case 'all':
|
||||
default:
|
||||
from = null; to = null; break;
|
||||
}
|
||||
return { from, to };
|
||||
},
|
||||
dateFromDmy(dmy) {
|
||||
if (!dmy) return null;
|
||||
const m = String(dmy).match(/(\d{1,2})\.(\d{1,2})\.(\d{4})/);
|
||||
if (!m) return null;
|
||||
const d = new Date(Number(m[3]), Number(m[2]) - 1, Number(m[1]));
|
||||
return isNaN(d.getTime()) ? null : d;
|
||||
},
|
||||
onParticipationRangeChange() {
|
||||
// Neu laden, damit serverseitig bereits gefiltert werden könnte; ansonsten clientseitig filtern
|
||||
this.loadClubParticipations();
|
||||
},
|
||||
async loadClubParticipations() {
|
||||
if (this.loadingClubParticipations) return;
|
||||
this.loadingClubParticipations = true;
|
||||
try {
|
||||
await this.ensureMembersLoaded();
|
||||
// neuen kompakten EP nutzen
|
||||
const r = await apiClient.get(`/official-tournaments/${this.currentClub}/participations/summary`);
|
||||
const rows = [];
|
||||
const { from, to } = this.computeRange();
|
||||
for (const t of (r.data || [])) {
|
||||
for (const e of (t.entries || [])) {
|
||||
const d = this.dateFromDmy(e.date || t.startDate || null);
|
||||
if (from && d && d < from) continue;
|
||||
if (to && d && d > to) continue;
|
||||
rows.push({
|
||||
key: `club-${t.tournamentId}-${e.competitionId}-${e.memberId}`,
|
||||
memberName: e.memberName,
|
||||
competitionName: e.competitionName,
|
||||
date: e.date || t.startDate || '–',
|
||||
placement: e.placement || null,
|
||||
});
|
||||
}
|
||||
}
|
||||
rows.sort((a, b) => {
|
||||
const byMember = this.collator.compare(a.memberName, b.memberName);
|
||||
if (byMember !== 0) return byMember;
|
||||
return this.collator.compare(a.competitionName, b.competitionName);
|
||||
});
|
||||
this.clubParticipationRowsData = rows;
|
||||
} finally {
|
||||
this.loadingClubParticipations = false;
|
||||
}
|
||||
},
|
||||
memberNameById(id) {
|
||||
const m = (this.members || []).find(x => String(x.id) === String(id));
|
||||
return m ? `${m.firstName} ${m.lastName}` : `#${id}`;
|
||||
},
|
||||
onFile(e) {
|
||||
this.selectedFile = e.target.files && e.target.files[0] ? e.target.files[0] : null;
|
||||
},
|
||||
@@ -444,8 +665,11 @@ export default {
|
||||
if (!this.isEligibleByAge(member, c)) return false;
|
||||
return true;
|
||||
},
|
||||
eligibleMembers(c) {
|
||||
return (this.members || []).filter(m => this.isEligibleForCompetition(m, c));
|
||||
eligibleMembers(c) {
|
||||
return (this.members || [])
|
||||
.filter(m => this.isEligibleForCompetition(m, c))
|
||||
.slice()
|
||||
.sort((a, b) => this.compareMembers(a, b));
|
||||
},
|
||||
ageOnRef(member, c) {
|
||||
const bd = this.getMemberBirthDate(member);
|
||||
@@ -503,9 +727,7 @@ export default {
|
||||
}
|
||||
return true;
|
||||
},
|
||||
eligibleMembers(c) {
|
||||
return (this.members || []).filter(m => this.isEligibleForCompetition(m, c));
|
||||
},
|
||||
|
||||
async removeTournament(t) {
|
||||
if (!confirm(`Turnier wirklich löschen?\n${t.title || 'Ohne Titel'} (ID ${t.id})`)) return;
|
||||
await apiClient.delete(`/official-tournaments/${this.currentClub}/${t.id}`);
|
||||
@@ -539,11 +761,20 @@ export default {
|
||||
<style scoped>
|
||||
.official-tournaments { display: flex; flex-direction: column; gap: 0.75rem; }
|
||||
.top-actions { display: flex; gap: .5rem; margin-bottom: .5rem; }
|
||||
.tabs { display: flex; gap: .25rem; border-bottom: 1px solid var(--border-color); margin: .25rem 0 .5rem; }
|
||||
.tab { background: #f8f9fb; color: var(--text-color, #222); border: none; padding: .4rem .6rem; cursor: pointer; border-bottom: 2px solid transparent; }
|
||||
.tab:hover { background: #eef1f5; }
|
||||
.tab.active { border-bottom-color: var(--primary, #2b7cff); font-weight: bold; color: var(--primary, #2b7cff); background: #e9f1ff; }
|
||||
.filters { display: flex; gap: .5rem; align-items: center; margin: .5rem 0; }
|
||||
.uploader { display: flex; gap: 0.5rem; align-items: center; }
|
||||
table { width: 100%; border-collapse: collapse; }
|
||||
th, td { border-bottom: 1px solid var(--border-color); padding: 0.5rem; text-align: left; }
|
||||
.comp-details td { background: var(--background-light, #fafafa); }
|
||||
.details { display: grid; grid-template-columns: 1fr; gap: .4rem 0; padding: .5rem 0; }
|
||||
.official-tournaments .btn-primary { color: #fff; }
|
||||
.official-tournaments .btn-secondary { color: #222; }
|
||||
.official-tournaments .btn-primary:disabled,
|
||||
.official-tournaments .btn-secondary:disabled { opacity: .6; }
|
||||
.detail-item { font-size: .95rem; }
|
||||
.eligible-list { margin-top: .25rem; display: flex; flex-wrap: wrap; gap: .25rem .5rem; }
|
||||
.eligible-name { background: var(--background, #f1f1f1); border: 1px solid var(--border-color, #ddd); border-radius: 4px; padding: 2px 6px; }
|
||||
|
||||
Reference in New Issue
Block a user