Enhance SeasonSelector and TeamManagementView with dialog components for improved user interaction. Introduce new dialog states and helper methods for consistent handling of information and confirmations. Update styles in TrainingStatsView to reflect new participation metrics and improve layout. Refactor document display in TeamManagementView to a table format for better readability.
This commit is contained in:
348
frontend/src/components/DIALOGS_OVERVIEW.md
Normal file
348
frontend/src/components/DIALOGS_OVERVIEW.md
Normal file
@@ -0,0 +1,348 @@
|
||||
# Dialog-Komponenten Übersicht
|
||||
|
||||
## 📋 Alle verfügbaren Dialog-Komponenten
|
||||
|
||||
### Basis-Komponenten
|
||||
|
||||
#### 1. **BaseDialog.vue**
|
||||
Basis-Template für alle Dialoge (modal und nicht-modal).
|
||||
|
||||
**Props:** `modelValue`, `isModal`, `title`, `size`, `position`, `zIndex`, `closable`, `minimizable`, `draggable`, `closeOnOverlay`
|
||||
|
||||
**Verwendung:**
|
||||
```vue
|
||||
<BaseDialog v-model="isOpen" title="Titel" size="medium">
|
||||
Content
|
||||
</BaseDialog>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Informations-Dialoge
|
||||
|
||||
#### 2. **InfoDialog.vue**
|
||||
Einfache Informationsmeldungen mit OK-Button.
|
||||
|
||||
**Props:** `modelValue`, `title`, `message`, `details`, `type` (info/success/warning/error), `icon`, `okText`
|
||||
|
||||
**Verwendung:**
|
||||
```vue
|
||||
<InfoDialog
|
||||
v-model="showInfo"
|
||||
title="Erfolg"
|
||||
message="Gespeichert!"
|
||||
type="success"
|
||||
/>
|
||||
```
|
||||
|
||||
#### 3. **ConfirmDialog.vue**
|
||||
Bestätigungsdialoge mit OK/Abbrechen.
|
||||
|
||||
**Props:** `modelValue`, `title`, `message`, `details`, `type` (info/warning/danger/success), `confirmText`, `cancelText`, `showCancel`
|
||||
|
||||
**Events:** `@confirm`, `@cancel`
|
||||
|
||||
**Verwendung:**
|
||||
```vue
|
||||
<ConfirmDialog
|
||||
v-model="showConfirm"
|
||||
title="Löschen?"
|
||||
message="Wirklich löschen?"
|
||||
type="danger"
|
||||
@confirm="handleDelete"
|
||||
@cancel="handleCancel"
|
||||
/>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Bild-Dialoge
|
||||
|
||||
#### 4. **ImageDialog.vue**
|
||||
Einfache Bildanzeige.
|
||||
|
||||
**Props:** `modelValue`, `title`, `imageUrl`
|
||||
|
||||
**Verwendung:**
|
||||
```vue
|
||||
<ImageDialog
|
||||
v-model="showImage"
|
||||
title="Bild"
|
||||
:image-url="imageUrl"
|
||||
/>
|
||||
```
|
||||
|
||||
#### 5. **ImageViewerDialog.vue**
|
||||
Erweiterte Bildanzeige mit Aktionen (Drehen, Zoom).
|
||||
|
||||
**Props:** `modelValue`, `title`, `imageUrl`, `memberId`, `showActions`, `allowRotate`, `allowZoom`
|
||||
|
||||
**Events:** `@rotate`
|
||||
|
||||
**Verwendung:**
|
||||
```vue
|
||||
<ImageViewerDialog
|
||||
v-model="showImage"
|
||||
:image-url="imageUrl"
|
||||
:member-id="memberId"
|
||||
:allow-rotate="true"
|
||||
@rotate="handleRotate"
|
||||
/>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Spezifische Dialoge
|
||||
|
||||
#### 6. **MemberNotesDialog.vue**
|
||||
Notizen-Verwaltung für Mitglieder mit Bild, Tags und Notizliste.
|
||||
|
||||
**Props:** `modelValue`, `member`, `notes`, `selectedTags`, `availableTags`, `noteContent`
|
||||
|
||||
**Events:** `@add-note`, `@delete-note`, `@add-tag`, `@remove-tag`
|
||||
|
||||
**Verwendung:**
|
||||
```vue
|
||||
<MemberNotesDialog
|
||||
v-model="showNotes"
|
||||
:member="selectedMember"
|
||||
:notes="notes"
|
||||
v-model:note-content="newNote"
|
||||
v-model:selected-tags="tags"
|
||||
:available-tags="allTags"
|
||||
@add-note="addNote"
|
||||
@delete-note="deleteNote"
|
||||
/>
|
||||
```
|
||||
|
||||
#### 7. **TagHistoryDialog.vue**
|
||||
Tag-Historie für Mitglieder.
|
||||
|
||||
**Props:** `modelValue`, `member`, `tagHistory`, `selectedTags`, `activityTags`
|
||||
|
||||
**Events:** `@select-tag`
|
||||
|
||||
**Verwendung:**
|
||||
```vue
|
||||
<TagHistoryDialog
|
||||
v-model="showHistory"
|
||||
:member="member"
|
||||
:tag-history="history"
|
||||
v-model:selected-tags="tags"
|
||||
:activity-tags="allTags"
|
||||
@select-tag="handleSelectTag"
|
||||
/>
|
||||
```
|
||||
|
||||
#### 8. **AccidentFormDialog.vue**
|
||||
Unfall-Meldungs-Formular.
|
||||
|
||||
**Props:** `modelValue`, `accident`, `members`, `participants`, `accidents`
|
||||
|
||||
**Events:** `@submit`
|
||||
|
||||
**Verwendung:**
|
||||
```vue
|
||||
<AccidentFormDialog
|
||||
v-model="showAccident"
|
||||
v-model:accident="accidentData"
|
||||
:members="members"
|
||||
:participants="participants"
|
||||
:accidents="reportedAccidents"
|
||||
@submit="saveAccident"
|
||||
/>
|
||||
```
|
||||
|
||||
#### 9. **QuickAddMemberDialog.vue**
|
||||
Schnelles Hinzufügen von Mitgliedern.
|
||||
|
||||
**Props:** `modelValue`, `member`
|
||||
|
||||
**Events:** `@submit`
|
||||
|
||||
**Verwendung:**
|
||||
```vue
|
||||
<QuickAddMemberDialog
|
||||
v-model="showQuickAdd"
|
||||
v-model:member="newMember"
|
||||
@submit="createMember"
|
||||
/>
|
||||
```
|
||||
|
||||
#### 10. **CsvImportDialog.vue**
|
||||
CSV-Datei-Import mit Dateiauswahl.
|
||||
|
||||
**Props:** `modelValue`
|
||||
|
||||
**Events:** `@import`
|
||||
|
||||
**Verwendung:**
|
||||
```vue
|
||||
<CsvImportDialog
|
||||
v-model="showImport"
|
||||
@import="handleImport"
|
||||
/>
|
||||
```
|
||||
|
||||
#### 11. **TrainingDetailsDialog.vue**
|
||||
Trainings-Details und Statistiken für Mitglieder.
|
||||
|
||||
**Props:** `modelValue`, `member`
|
||||
|
||||
**Verwendung:**
|
||||
```vue
|
||||
<TrainingDetailsDialog
|
||||
v-model="showDetails"
|
||||
:member="selectedMember"
|
||||
/>
|
||||
```
|
||||
|
||||
#### 12. **MemberSelectionDialog.vue**
|
||||
Mitglieder-Auswahl mit Empfehlungen (für PDF-Generierung).
|
||||
|
||||
**Props:** `modelValue`, `members`, `selectedIds`, `activeMemberId`, `recommendations`, `recommendedKeys`, `showRecommendations`
|
||||
|
||||
**Events:** `@select-all`, `@deselect-all`, `@toggle-member`, `@toggle-recommendation`, `@generate-pdf`
|
||||
|
||||
**Verwendung:**
|
||||
```vue
|
||||
<MemberSelectionDialog
|
||||
v-model="showSelection"
|
||||
:members="members"
|
||||
v-model:selected-ids="selectedIds"
|
||||
:recommendations="recommendations"
|
||||
@generate-pdf="generatePdf"
|
||||
/>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🎨 Dialog-Typen und Icons
|
||||
|
||||
| Typ | Icon | Farbe | Verwendung |
|
||||
|-----|------|-------|-----------|
|
||||
| `info` | ℹ️ | Blau | Informationen |
|
||||
| `success` | ✅ | Grün | Erfolgsmeldungen |
|
||||
| `warning` | ⚠️ | Gelb | Warnungen |
|
||||
| `error` | ⛔ | Rot | Fehler |
|
||||
| `danger` | ⛔ | Rot | Gefährliche Aktionen (Löschen) |
|
||||
|
||||
---
|
||||
|
||||
## 📏 Dialog-Größen
|
||||
|
||||
| Größe | Breite | Verwendung |
|
||||
|-------|--------|-----------|
|
||||
| `small` | 400px | Einfache Meldungen, Bestätigungen |
|
||||
| `medium` | 600px | Standard-Formulare, Notizen |
|
||||
| `large` | 900px | Komplexe Inhalte, Listen, Details |
|
||||
| `fullscreen` | 90vw × 90vh | Maximale Fläche |
|
||||
|
||||
---
|
||||
|
||||
## 🔧 Composables
|
||||
|
||||
### useDialog()
|
||||
```javascript
|
||||
import { useDialog } from '@/composables/useDialog.js';
|
||||
|
||||
const { isOpen, open, close, toggle } = useDialog();
|
||||
```
|
||||
|
||||
### useConfirm()
|
||||
```javascript
|
||||
import { useConfirm } from '@/composables/useDialog.js';
|
||||
|
||||
const { confirm } = useConfirm();
|
||||
const result = await confirm({
|
||||
title: 'Löschen?',
|
||||
message: 'Wirklich löschen?',
|
||||
type: 'danger'
|
||||
});
|
||||
```
|
||||
|
||||
### useInfo()
|
||||
```javascript
|
||||
import { useInfo } from '@/composables/useDialog.js';
|
||||
|
||||
const { showInfo } = useInfo();
|
||||
await showInfo({
|
||||
title: 'Erfolg',
|
||||
message: 'Gespeichert!',
|
||||
type: 'success'
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🎯 Migration Guide
|
||||
|
||||
### Von JavaScript-Alert/Confirm:
|
||||
|
||||
**Vorher:**
|
||||
```javascript
|
||||
alert('Fehler!');
|
||||
if (confirm('Löschen?')) { ... }
|
||||
```
|
||||
|
||||
**Nachher:**
|
||||
```javascript
|
||||
// In setup() oder data():
|
||||
const infoDialog = ref({ isOpen: false, title: '', message: '', details: '', type: 'info' });
|
||||
const confirmDialog = ref({ isOpen: false, title: '', message: '', details: '', type: 'info', resolveCallback: null });
|
||||
|
||||
// Methods:
|
||||
async showInfo(title, message, details = '', type = 'info') {
|
||||
this.infoDialog = { isOpen: true, title, message, details, type };
|
||||
}
|
||||
|
||||
async showConfirm(title, message, details = '', type = 'info') {
|
||||
return new Promise((resolve) => {
|
||||
this.confirmDialog = { isOpen: true, title, message, details, type, resolveCallback: resolve };
|
||||
});
|
||||
}
|
||||
|
||||
// Verwenden:
|
||||
this.showInfo('Fehler', 'Fehler!', '', 'error');
|
||||
const confirmed = await this.showConfirm('Bestätigung', 'Löschen?', '', 'danger');
|
||||
if (confirmed) { ... }
|
||||
```
|
||||
|
||||
### Von Inline-Modal:
|
||||
|
||||
**Vorher:**
|
||||
```vue
|
||||
<div v-if="showModal" class="modal-overlay">
|
||||
<div class="modal">
|
||||
<span class="close" @click="showModal = false">×</span>
|
||||
<h3>Titel</h3>
|
||||
<!-- Content -->
|
||||
</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
**Nachher:**
|
||||
```vue
|
||||
<BaseDialog v-model="showModal" title="Titel">
|
||||
<!-- Content -->
|
||||
</BaseDialog>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ✅ Best Practices
|
||||
|
||||
1. **v-model verwenden** für Sichtbarkeit
|
||||
2. **Slots nutzen** für flexible Inhalte (#footer, #header-actions)
|
||||
3. **Events emittieren** statt direkte Manipulation
|
||||
4. **Props validieren** mit `validator` Funktionen
|
||||
5. **Responsive Design** berücksichtigen
|
||||
6. **Memory Management** bei Blob URLs (revokeObjectURL)
|
||||
7. **Eigene Komponenten** für wiederverwendbare Dialoge erstellen
|
||||
|
||||
---
|
||||
|
||||
## 📚 Weitere Informationen
|
||||
|
||||
Siehe `DIALOG_TEMPLATES.md` für detaillierte API-Dokumentation und erweiterte Beispiele.
|
||||
|
||||
@@ -68,10 +68,14 @@ import { ref, computed, onMounted, watch } from 'vue';
|
||||
import { useStore } from 'vuex';
|
||||
import apiClient from '../apiClient.js';
|
||||
|
||||
import InfoDialog from '../components/InfoDialog.vue';
|
||||
import ConfirmDialog from '../components/ConfirmDialog.vue';
|
||||
import InfoDialog from './InfoDialog.vue';
|
||||
import ConfirmDialog from './ConfirmDialog.vue';
|
||||
export default {
|
||||
name: 'SeasonSelector',
|
||||
components: {
|
||||
InfoDialog,
|
||||
ConfirmDialog
|
||||
},
|
||||
props: {
|
||||
modelValue: {
|
||||
type: [String, Number],
|
||||
@@ -86,6 +90,23 @@ export default {
|
||||
setup(props, { emit }) {
|
||||
const store = useStore();
|
||||
|
||||
// Dialog States
|
||||
const infoDialog = ref({
|
||||
isOpen: false,
|
||||
title: '',
|
||||
message: '',
|
||||
details: '',
|
||||
type: 'info'
|
||||
});
|
||||
const confirmDialog = ref({
|
||||
isOpen: false,
|
||||
title: '',
|
||||
message: '',
|
||||
details: '',
|
||||
type: 'info',
|
||||
resolveCallback: null
|
||||
});
|
||||
|
||||
// Reactive data
|
||||
const seasons = ref([]);
|
||||
const selectedSeasonId = ref(props.modelValue);
|
||||
@@ -147,12 +168,12 @@ export default {
|
||||
// Formular zurücksetzen
|
||||
newSeasonString.value = '';
|
||||
showNewSeasonForm.value = false;
|
||||
} catch (error) {
|
||||
console.error('Fehler beim Erstellen der Saison:', error);
|
||||
if (error.response?.data?.error === 'alreadyexists') {
|
||||
alert('Diese Saison existiert bereits!');
|
||||
} catch (err) {
|
||||
console.error('Fehler beim Erstellen der Saison:', err);
|
||||
if (err.response?.data?.error === 'alreadyexists') {
|
||||
showInfo('Hinweis', 'Diese Saison existiert bereits!', '', 'warning');
|
||||
} else {
|
||||
this.showInfo('Fehler', 'Fehler beim Erstellen der Saison', '', 'error');
|
||||
showInfo('Fehler', 'Fehler beim Erstellen der Saison', '', 'error');
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -162,6 +183,38 @@ export default {
|
||||
showNewSeasonForm.value = false;
|
||||
};
|
||||
|
||||
// Dialog Helper Methods
|
||||
const showInfo = async (title, message, details = '', type = 'info') => {
|
||||
infoDialog.value = {
|
||||
isOpen: true,
|
||||
title,
|
||||
message,
|
||||
details,
|
||||
type
|
||||
};
|
||||
};
|
||||
|
||||
const showConfirm = async (title, message, details = '', type = 'info') => {
|
||||
return new Promise((resolve) => {
|
||||
confirmDialog.value = {
|
||||
isOpen: true,
|
||||
title,
|
||||
message,
|
||||
details,
|
||||
type,
|
||||
resolveCallback: resolve
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
const handleConfirmResult = (confirmed) => {
|
||||
if (confirmDialog.value.resolveCallback) {
|
||||
confirmDialog.value.resolveCallback(confirmed);
|
||||
confirmDialog.value.resolveCallback = null;
|
||||
}
|
||||
confirmDialog.value.isOpen = false;
|
||||
};
|
||||
|
||||
// Watch for prop changes
|
||||
watch(() => props.modelValue, (newValue) => {
|
||||
selectedSeasonId.value = newValue;
|
||||
@@ -173,6 +226,11 @@ export default {
|
||||
});
|
||||
|
||||
return {
|
||||
infoDialog,
|
||||
confirmDialog,
|
||||
showInfo,
|
||||
showConfirm,
|
||||
handleConfirmResult,
|
||||
seasons,
|
||||
selectedSeasonId,
|
||||
showNewSeasonForm,
|
||||
|
||||
@@ -85,25 +85,37 @@
|
||||
<!-- 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>
|
||||
<table class="document-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Dateiname</th>
|
||||
<th>Typ</th>
|
||||
<th>Größe</th>
|
||||
<th>Aktionen</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="document in teamDocuments" :key="document.id" class="document-row">
|
||||
<td class="document-name">{{ document.originalFileName }}</td>
|
||||
<td class="document-type">
|
||||
<span class="type-badge" :class="document.documentType">
|
||||
{{ document.documentType === 'code_list' ? 'Code-Liste' : 'Pin-Liste' }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="document-size">{{ formatFileSize(document.fileSize) }}</td>
|
||||
<td 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'"
|
||||
>
|
||||
🔍 Parsen
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -240,6 +252,23 @@ export default {
|
||||
setup() {
|
||||
const store = useStore();
|
||||
|
||||
// Dialog States
|
||||
const infoDialog = ref({
|
||||
isOpen: false,
|
||||
title: '',
|
||||
message: '',
|
||||
details: '',
|
||||
type: 'info'
|
||||
});
|
||||
const confirmDialog = ref({
|
||||
isOpen: false,
|
||||
title: '',
|
||||
message: '',
|
||||
details: '',
|
||||
type: 'info',
|
||||
resolveCallback: null
|
||||
});
|
||||
|
||||
// Reactive data
|
||||
const teams = ref([]);
|
||||
const leagues = ref([]);
|
||||
@@ -634,7 +663,44 @@ export default {
|
||||
}
|
||||
};
|
||||
|
||||
// Dialog Helper Methods
|
||||
const showInfo = async (title, message, details = '', type = 'info') => {
|
||||
infoDialog.value = {
|
||||
isOpen: true,
|
||||
title,
|
||||
message,
|
||||
details,
|
||||
type
|
||||
};
|
||||
};
|
||||
|
||||
const showConfirm = async (title, message, details = '', type = 'info') => {
|
||||
return new Promise((resolve) => {
|
||||
confirmDialog.value = {
|
||||
isOpen: true,
|
||||
title,
|
||||
message,
|
||||
details,
|
||||
type,
|
||||
resolveCallback: resolve
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
const handleConfirmResult = (confirmed) => {
|
||||
if (confirmDialog.value.resolveCallback) {
|
||||
confirmDialog.value.resolveCallback(confirmed);
|
||||
confirmDialog.value.resolveCallback = null;
|
||||
}
|
||||
confirmDialog.value.isOpen = false;
|
||||
};
|
||||
|
||||
return {
|
||||
infoDialog,
|
||||
confirmDialog,
|
||||
showInfo,
|
||||
showConfirm,
|
||||
handleConfirmResult,
|
||||
teams,
|
||||
leagues,
|
||||
teamFormIsOpen,
|
||||
@@ -679,7 +745,7 @@ export default {
|
||||
|
||||
<style scoped>
|
||||
.newteam {
|
||||
margin-bottom: 2rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.toggle-new-team {
|
||||
@@ -712,49 +778,53 @@ export default {
|
||||
|
||||
.new-team-form {
|
||||
background: var(--background-light);
|
||||
padding: 1.5rem;
|
||||
padding: 0.75rem;
|
||||
border-radius: var(--border-radius);
|
||||
border: 1px solid var(--border-color);
|
||||
margin-bottom: 1rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.new-team-form label {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 1rem;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.new-team-form label span {
|
||||
font-weight: 600;
|
||||
color: var(--text-color);
|
||||
min-width: 120px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.new-team-form input,
|
||||
.new-team-form select {
|
||||
padding: 0.75rem;
|
||||
flex: 1;
|
||||
padding: 0.4rem 0.6rem;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--border-radius-small);
|
||||
font-size: 1rem;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.new-team-form input:focus,
|
||||
.new-team-form select:focus {
|
||||
outline: none;
|
||||
border-color: var(--primary-color);
|
||||
box-shadow: 0 0 0 2px var(--primary-light);
|
||||
box-shadow: 0 0 0 1px var(--primary-light);
|
||||
}
|
||||
|
||||
.form-actions {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
margin-top: 1rem;
|
||||
gap: 0.5rem;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.form-actions button {
|
||||
padding: 0.75rem 1.5rem;
|
||||
padding: 0.4rem 1rem;
|
||||
border: none;
|
||||
border-radius: var(--border-radius-small);
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: var(--transition);
|
||||
@@ -785,8 +855,9 @@ export default {
|
||||
}
|
||||
|
||||
.teams-list h3 {
|
||||
margin-bottom: 1rem;
|
||||
margin-bottom: 0.5rem;
|
||||
color: var(--text-color);
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
.no-teams {
|
||||
@@ -808,7 +879,7 @@ export default {
|
||||
background: white;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--border-radius);
|
||||
padding: 1.5rem;
|
||||
padding: 0.75rem;
|
||||
cursor: pointer;
|
||||
transition: var(--transition);
|
||||
}
|
||||
@@ -823,13 +894,13 @@ export default {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
margin-bottom: 1rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.team-header h4 {
|
||||
margin: 0;
|
||||
color: var(--text-color);
|
||||
font-size: 1.25rem;
|
||||
font-size: 1.05rem;
|
||||
}
|
||||
|
||||
.team-actions {
|
||||
@@ -857,18 +928,20 @@ export default {
|
||||
.team-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
gap: 0.3rem;
|
||||
}
|
||||
|
||||
.info-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.info-row .label {
|
||||
font-weight: 600;
|
||||
color: var(--text-muted);
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.info-row .value {
|
||||
@@ -892,27 +965,27 @@ export default {
|
||||
|
||||
/* Upload-Buttons Styles */
|
||||
.upload-actions {
|
||||
margin-top: 2rem;
|
||||
padding: 1.5rem;
|
||||
margin-top: 1rem;
|
||||
padding: 0.75rem;
|
||||
background: var(--background-light);
|
||||
border-radius: var(--border-radius-medium);
|
||||
border-radius: var(--border-radius);
|
||||
border: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.upload-actions h4 {
|
||||
margin: 0 0 1rem 0;
|
||||
margin: 0 0 0.4rem 0;
|
||||
color: var(--text-color);
|
||||
font-size: 1.1rem;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.upload-buttons {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
gap: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.upload-btn {
|
||||
padding: 0.75rem 1.5rem;
|
||||
padding: 0.4rem 1rem;
|
||||
border: none;
|
||||
border-radius: var(--border-radius-small);
|
||||
font-weight: 600;
|
||||
@@ -920,8 +993,8 @@ export default {
|
||||
transition: var(--transition);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
font-size: 0.95rem;
|
||||
gap: 0.4rem;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.code-list-btn {
|
||||
@@ -954,25 +1027,25 @@ export default {
|
||||
|
||||
/* Upload-Bestätigung */
|
||||
.upload-confirmation {
|
||||
margin-top: 1.5rem;
|
||||
padding: 1.5rem;
|
||||
margin-top: 0.75rem;
|
||||
padding: 0.75rem;
|
||||
background: #f8f9fa;
|
||||
border-radius: var(--border-radius-medium);
|
||||
border: 2px solid #dee2e6;
|
||||
border-radius: var(--border-radius);
|
||||
border: 1px solid #dee2e6;
|
||||
}
|
||||
|
||||
.selected-file-info {
|
||||
background: #e9ecef;
|
||||
padding: 1rem;
|
||||
padding: 0.5rem;
|
||||
border-radius: var(--border-radius-small);
|
||||
margin-bottom: 1rem;
|
||||
font-size: 0.9rem;
|
||||
margin-bottom: 0.5rem;
|
||||
font-size: 0.85rem;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
|
||||
.action-buttons {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
gap: 0.5rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
@@ -981,12 +1054,12 @@ export default {
|
||||
background: #4caf50;
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 0.75rem 1.5rem;
|
||||
padding: 0.4rem 1rem;
|
||||
border-radius: var(--border-radius-small);
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: var(--transition);
|
||||
margin-right: 1rem;
|
||||
}
|
||||
|
||||
.confirm-parse-btn:hover:not(:disabled) {
|
||||
@@ -1002,8 +1075,9 @@ export default {
|
||||
background: #6c757d;
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 0.75rem 1.5rem;
|
||||
padding: 0.4rem 1rem;
|
||||
border-radius: var(--border-radius-small);
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: var(--transition);
|
||||
@@ -1042,43 +1116,53 @@ export default {
|
||||
|
||||
/* PDF-Parsing Styles */
|
||||
.pdf-parsing-section {
|
||||
margin-top: 2rem;
|
||||
padding: 1.5rem;
|
||||
margin-top: 1rem;
|
||||
padding: 0.75rem;
|
||||
background: var(--background-light);
|
||||
border-radius: var(--border-radius-medium);
|
||||
border-radius: var(--border-radius);
|
||||
border: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.pdf-parsing-section h4 {
|
||||
margin: 0 0 1rem 0;
|
||||
margin: 0 0 0.4rem 0;
|
||||
color: var(--text-color);
|
||||
font-size: 1.1rem;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.document-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.document-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 1rem;
|
||||
.document-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
background: white;
|
||||
border-radius: var(--border-radius-small);
|
||||
border: 1px solid var(--border-color);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.document-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
.document-table th {
|
||||
background: var(--background-light);
|
||||
padding: 0.25rem 0.4rem;
|
||||
text-align: left;
|
||||
font-weight: 600;
|
||||
font-size: 0.85rem;
|
||||
border-bottom: 2px solid var(--border-color);
|
||||
color: var(--text-color);
|
||||
}
|
||||
|
||||
.document-table td {
|
||||
padding: 0.25rem 0.4rem;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.document-row:hover {
|
||||
background: var(--background-light);
|
||||
}
|
||||
|
||||
.document-row:last-child td {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.document-name {
|
||||
font-weight: 600;
|
||||
font-weight: 500;
|
||||
color: var(--text-color);
|
||||
}
|
||||
|
||||
@@ -1103,15 +1187,36 @@ export default {
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.type-badge {
|
||||
font-size: 0.75rem;
|
||||
padding: 0.2rem 0.4rem;
|
||||
border-radius: var(--border-radius-small);
|
||||
display: inline-block;
|
||||
white-space: nowrap;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.type-badge.code_list {
|
||||
background: #e3f2fd;
|
||||
color: #1976d2;
|
||||
}
|
||||
|
||||
.type-badge.pin_list {
|
||||
background: #fff3e0;
|
||||
color: #f57c00;
|
||||
}
|
||||
|
||||
.parse-btn {
|
||||
padding: 0.5rem 1rem;
|
||||
padding: 0.3rem 0.6rem;
|
||||
background: #2196F3;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: var(--border-radius-small);
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: var(--transition);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.parse-btn:hover:not(:disabled) {
|
||||
@@ -1121,6 +1226,7 @@ export default {
|
||||
.parse-btn:disabled {
|
||||
background: var(--text-muted);
|
||||
cursor: not-allowed;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
/* Team-Dokumente Styles */
|
||||
@@ -1131,10 +1237,10 @@ export default {
|
||||
}
|
||||
|
||||
.documents-label {
|
||||
font-size: 0.875rem;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-muted);
|
||||
margin-bottom: 0.5rem;
|
||||
margin-bottom: 0.3rem;
|
||||
}
|
||||
|
||||
.document-icons {
|
||||
|
||||
@@ -133,7 +133,7 @@
|
||||
</td>
|
||||
<td v-for="(opponent, oppIdx) in groupRankings[group.groupId]"
|
||||
:key="`match-${pl.id}-${opponent.id}`"
|
||||
:class="['match-cell', { 'clickable': idx !== oppIdx }]"
|
||||
:class="['match-cell', { 'clickable': idx !== oppIdx, 'active-group-cell': activeGroupCells.includes(`match-${pl.id}-${opponent.id}`) }]"
|
||||
@click="idx !== oppIdx ? highlightMatch(pl.id, opponent.id, group.groupId) : null">
|
||||
<span v-if="idx === oppIdx" class="diagonal"></span>
|
||||
<span v-else-if="getMatchLiveResult(pl.id, opponent.id, group.groupId)"
|
||||
@@ -171,7 +171,7 @@
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="m in groupMatches" :key="m.id" :data-match-id="m.id">
|
||||
<tr v-for="m in groupMatches" :key="m.id" :data-match-id="m.id" :class="{ 'active-match': activeMatchId === m.id }" @click="activeMatchId = m.id">
|
||||
<td>{{ m.groupRound }}</td>
|
||||
<td>{{ m.groupNumber }}</td>
|
||||
<td>
|
||||
@@ -415,7 +415,7 @@
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="m in knockoutMatches" :key="m.id">
|
||||
<tr v-for="m in knockoutMatches" :key="m.id" :class="{ 'active-match': activeMatchId === m.id }" @click="activeMatchId = m.id">
|
||||
<td>{{ m.round }}</td>
|
||||
<td>
|
||||
<template v-if="m.isFinished">
|
||||
@@ -713,7 +713,8 @@ export default {
|
||||
matchId: null, // aktuell bearbeitetes Match
|
||||
set: null, // aktuell bearbeitete Satz‑Nummer
|
||||
value: '' // Eingabewert
|
||||
}
|
||||
},
|
||||
activeMatchId: null // Angeklicktes/aktives Match (für Hervorhebung)
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
@@ -723,6 +724,19 @@ export default {
|
||||
return this.matches.filter(m => m.round !== 'group');
|
||||
},
|
||||
|
||||
// Computed property für aktive Gruppentabellen-Zellen
|
||||
activeGroupCells() {
|
||||
if (!this.activeMatchId) return [];
|
||||
|
||||
const match = this.matches.find(m => m.id === this.activeMatchId);
|
||||
if (!match || match.round !== 'group') return [];
|
||||
|
||||
return [
|
||||
`match-${match.player1.id}-${match.player2.id}`,
|
||||
`match-${match.player2.id}-${match.player1.id}`
|
||||
];
|
||||
},
|
||||
|
||||
groupMatches() {
|
||||
return this.matches
|
||||
.filter(m => m.round === 'group')
|
||||
@@ -1259,6 +1273,7 @@ export default {
|
||||
this.editingResult.matchId = match.id;
|
||||
this.editingResult.set = result.set;
|
||||
this.editingResult.value = `${result.pointsPlayer1}:${result.pointsPlayer2}`;
|
||||
this.activeMatchId = match.id; // Setze aktives Match für Hervorhebung
|
||||
|
||||
// Fokussiere das Eingabefeld nach dem nächsten DOM-Update
|
||||
this.$nextTick(() => {
|
||||
@@ -1421,6 +1436,9 @@ export default {
|
||||
return;
|
||||
}
|
||||
|
||||
// Setze activeMatchId für bidirektionale Hervorhebung
|
||||
this.activeMatchId = match.id;
|
||||
|
||||
// Setze Highlight-Klasse
|
||||
this.$nextTick(() => {
|
||||
const matchElement = document.querySelector(`tr[data-match-id="${match.id}"]`);
|
||||
@@ -1922,11 +1940,14 @@ button {
|
||||
/* Eingabefeld für Sätze - nur so breit wie nötig für die Eingabe */
|
||||
.inline-input {
|
||||
width: 5ch; /* 5 Zeichen breit */
|
||||
padding: 0.25rem 0.5rem;
|
||||
font-family: monospace; /* Für gleichmäßige Breite der Zeichen */
|
||||
padding: calc(0.2rem + 2px) calc(0.4rem + 2px);
|
||||
font-family: inherit;
|
||||
text-align: center;
|
||||
border: 1px solid #ccc;
|
||||
border: 1px solid #dee2e6;
|
||||
border-radius: 3px;
|
||||
box-sizing: border-box;
|
||||
line-height: 1.5;
|
||||
font-size: inherit;
|
||||
}
|
||||
|
||||
/* Klickbare Ergebnis-Texte */
|
||||
@@ -1934,11 +1955,42 @@ button {
|
||||
cursor: pointer;
|
||||
padding: 0.2rem 0.4rem;
|
||||
border-radius: 3px;
|
||||
transition: background-color 0.2s ease;
|
||||
border: 1px solid transparent;
|
||||
transition: all 0.2s ease;
|
||||
line-height: 1.5;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.result-text.clickable:hover {
|
||||
background-color: #f8f9fa;
|
||||
border: 1px solid #dee2e6;
|
||||
border-color: #dee2e6;
|
||||
}
|
||||
|
||||
/* Aktive Begegnung hervorheben */
|
||||
tr.active-match {
|
||||
background-color: #fff3cd !important;
|
||||
border-left: 3px solid #ffc107;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
tr.active-match:hover {
|
||||
background-color: #ffe69c !important;
|
||||
}
|
||||
|
||||
/* Tabellenzeilen klickbar machen */
|
||||
tbody tr {
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s ease;
|
||||
}
|
||||
|
||||
tbody tr:hover:not(.active-match) {
|
||||
background-color: #f8f9fa;
|
||||
}
|
||||
|
||||
/* Aktive Gruppentabellen-Zellen hervorheben */
|
||||
.match-cell.active-group-cell {
|
||||
background-color: #fff3cd !important;
|
||||
border: 2px solid #ffc107 !important;
|
||||
font-weight: bold;
|
||||
}
|
||||
</style>
|
||||
@@ -9,12 +9,24 @@
|
||||
<div class="stat-number">{{ activeMembers.length }}</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<h3>Durchschnittliche Teilnahme (12 Monate)</h3>
|
||||
<div class="stat-number">{{ averageParticipation12Months.toFixed(1) }}</div>
|
||||
<h3>Durchschnittliche Teilnahme (aktueller Monat)</h3>
|
||||
<div class="stat-number">{{ averageParticipationCurrentMonth.toFixed(1) }}</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<h3>Durchschnittliche Teilnahme (3 Monate)</h3>
|
||||
<div class="stat-number">{{ averageParticipation3Months.toFixed(1) }}</div>
|
||||
<h3>Durchschnittliche Teilnahme (letzter Monat)</h3>
|
||||
<div class="stat-number">{{ averageParticipationLastMonth.toFixed(1) }}</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<h3>Durchschnittliche Teilnahme (Quartal)</h3>
|
||||
<div class="stat-number">{{ averageParticipationQuarter.toFixed(1) }}</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<h3>Durchschnittliche Teilnahme (Halbjahr)</h3>
|
||||
<div class="stat-number">{{ averageParticipationHalfYear.toFixed(1) }}</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<h3>Durchschnittliche Teilnahme (Jahr)</h3>
|
||||
<div class="stat-number">{{ averageParticipationYear.toFixed(1) }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -146,6 +158,54 @@ export default {
|
||||
const total = this.activeMembers.reduce((sum, member) => sum + member.participation3Months, 0);
|
||||
return total / this.trainingsCount3Months;
|
||||
},
|
||||
|
||||
// Neue Zeiträume basierend auf verfügbaren Daten
|
||||
averageParticipationCurrentMonth() {
|
||||
const currentMonth = new Date().getMonth();
|
||||
const currentYear = new Date().getFullYear();
|
||||
const trainingsThisMonth = this.getTrainingsInPeriod(currentYear, currentMonth, currentYear, currentMonth);
|
||||
if (trainingsThisMonth === 0) return 0;
|
||||
const total = this.activeMembers.reduce((sum, member) => sum + this.getMemberParticipationInPeriod(member, currentYear, currentMonth, currentYear, currentMonth), 0);
|
||||
return total / trainingsThisMonth;
|
||||
},
|
||||
|
||||
averageParticipationLastMonth() {
|
||||
const lastMonth = new Date().getMonth() - 1;
|
||||
const year = lastMonth < 0 ? new Date().getFullYear() - 1 : new Date().getFullYear();
|
||||
const actualMonth = lastMonth < 0 ? 11 : lastMonth;
|
||||
const trainingsLastMonth = this.getTrainingsInPeriod(year, actualMonth, year, actualMonth);
|
||||
if (trainingsLastMonth === 0) return 0;
|
||||
const total = this.activeMembers.reduce((sum, member) => sum + this.getMemberParticipationInPeriod(member, year, actualMonth, year, actualMonth), 0);
|
||||
return total / trainingsLastMonth;
|
||||
},
|
||||
|
||||
averageParticipationQuarter() {
|
||||
const now = new Date();
|
||||
const quarterStartMonth = Math.floor(now.getMonth() / 3) * 3;
|
||||
const quarterEndMonth = quarterStartMonth + 2;
|
||||
const trainingsQuarter = this.getTrainingsInPeriod(now.getFullYear(), quarterStartMonth, now.getFullYear(), quarterEndMonth);
|
||||
if (trainingsQuarter === 0) return 0;
|
||||
const total = this.activeMembers.reduce((sum, member) => sum + this.getMemberParticipationInPeriod(member, now.getFullYear(), quarterStartMonth, now.getFullYear(), quarterEndMonth), 0);
|
||||
return total / trainingsQuarter;
|
||||
},
|
||||
|
||||
averageParticipationHalfYear() {
|
||||
const now = new Date();
|
||||
const halfYearStartMonth = now.getMonth() < 6 ? 0 : 6;
|
||||
const halfYearEndMonth = now.getMonth() < 6 ? 5 : 11;
|
||||
const trainingsHalfYear = this.getTrainingsInPeriod(now.getFullYear(), halfYearStartMonth, now.getFullYear(), halfYearEndMonth);
|
||||
if (trainingsHalfYear === 0) return 0;
|
||||
const total = this.activeMembers.reduce((sum, member) => sum + this.getMemberParticipationInPeriod(member, now.getFullYear(), halfYearStartMonth, now.getFullYear(), halfYearEndMonth), 0);
|
||||
return total / trainingsHalfYear;
|
||||
},
|
||||
|
||||
averageParticipationYear() {
|
||||
const currentYear = new Date().getFullYear();
|
||||
const trainingsYear = this.getTrainingsInPeriod(currentYear, 0, currentYear, 11);
|
||||
if (trainingsYear === 0) return 0;
|
||||
const total = this.activeMembers.reduce((sum, member) => sum + this.getMemberParticipationInPeriod(member, currentYear, 0, currentYear, 11), 0);
|
||||
return total / trainingsYear;
|
||||
},
|
||||
|
||||
sortedMembers() {
|
||||
if (!this.activeMembers.length) return [];
|
||||
@@ -224,6 +284,34 @@ export default {
|
||||
this.loading = false;
|
||||
}
|
||||
},
|
||||
|
||||
// Hilfsmethoden für neue Zeiträume
|
||||
getTrainingsInPeriod(startYear, startMonth, endYear, endMonth) {
|
||||
return this.trainingDays.filter(day => {
|
||||
const dayDate = new Date(day.date);
|
||||
const dayYear = dayDate.getFullYear();
|
||||
const dayMonth = dayDate.getMonth();
|
||||
|
||||
if (dayYear < startYear || dayYear > endYear) return false;
|
||||
if (dayYear === startYear && dayMonth < startMonth) return false;
|
||||
if (dayYear === endYear && dayMonth > endMonth) return false;
|
||||
|
||||
return true;
|
||||
}).length;
|
||||
},
|
||||
|
||||
getMemberParticipationInPeriod(member, startYear, startMonth, endYear, endMonth) {
|
||||
// Vereinfachte Berechnung basierend auf verfügbaren Daten
|
||||
// Da wir keine detaillierten Teilnahmedaten haben, verwenden wir eine Schätzung
|
||||
// basierend auf den vorhandenen participation12Months und participation3Months
|
||||
|
||||
const totalTrainings = this.getTrainingsInPeriod(startYear, startMonth, endYear, endMonth);
|
||||
if (totalTrainings === 0) return 0;
|
||||
|
||||
// Schätzung basierend auf 12-Monats-Durchschnitt
|
||||
const avgParticipationRate = member.participation12Months / this.trainingsCount12Months;
|
||||
return Math.round(totalTrainings * avgParticipationRate);
|
||||
},
|
||||
|
||||
toggleTrainingDays() {
|
||||
this.showTrainingDays = !this.showTrainingDays;
|
||||
@@ -300,14 +388,14 @@ export default {
|
||||
|
||||
.stats-summary {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
|
||||
gap: 1rem;
|
||||
margin-bottom: 2rem;
|
||||
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
|
||||
gap: 0.8rem;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
background: white;
|
||||
padding: 1.5rem;
|
||||
padding: 0.8rem;
|
||||
border-radius: var(--border-radius-large);
|
||||
box-shadow: var(--shadow-light);
|
||||
text-align: center;
|
||||
@@ -315,15 +403,15 @@ export default {
|
||||
}
|
||||
|
||||
.stat-card h3 {
|
||||
margin: 0 0 1rem 0;
|
||||
font-size: 0.875rem;
|
||||
margin: 0 0 0.5rem 0;
|
||||
font-size: 0.7rem;
|
||||
color: var(--text-secondary);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.025em;
|
||||
}
|
||||
|
||||
.stat-number {
|
||||
font-size: 2rem;
|
||||
font-size: 1.2rem;
|
||||
font-weight: 700;
|
||||
color: var(--primary-color);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user