Refactor modals in DiaryView, MembersView, OfficialTournaments, ScheduleView, and TrainingStatsView to use dedicated dialog components for improved maintainability and user experience. Update styles and structure for consistency across the application.

This commit is contained in:
Torsten Schulz (local)
2025-10-08 12:49:42 +02:00
parent bd338b86df
commit 40dcd0e54c
13 changed files with 1776 additions and 225 deletions

View File

@@ -0,0 +1,221 @@
<template>
<BaseDialog
:model-value="modelValue"
@update:model-value="$emit('update:modelValue', $event)"
title="Unfall melden"
size="medium"
@close="handleClose"
>
<form @submit.prevent="handleSubmit" class="accident-form">
<div class="form-group">
<label for="memberId">Mitglied:</label>
<select id="memberId" v-model="localAccident.memberId" class="form-select">
<option value="">Bitte wählen</option>
<option
v-for="member in availableMembers"
:key="member.id"
:value="member.id"
>
{{ member.firstName }} {{ member.lastName }}
</option>
</select>
</div>
<div class="form-group">
<label for="accident">Unfall:</label>
<textarea
id="accident"
v-model="localAccident.accident"
required
class="form-textarea"
placeholder="Beschreibung des Unfalls..."
></textarea>
</div>
<div v-if="accidents.length > 0" class="accidents-list">
<h4>Gemeldete Unfälle</h4>
<ul>
<li v-for="accident in accidents" :key="accident.id" class="accident-item">
<strong>{{ accident.firstName }} {{ accident.lastName }}:</strong> {{ accident.accident }}
</li>
</ul>
</div>
</form>
<template #footer>
<button type="button" @click="handleClose" class="btn-secondary">Schließen</button>
<button type="button" @click="handleSubmit" class="btn-primary" :disabled="!isValid">Eintragen</button>
</template>
</BaseDialog>
</template>
<script>
import BaseDialog from './BaseDialog.vue';
export default {
name: 'AccidentFormDialog',
components: {
BaseDialog
},
props: {
modelValue: {
type: Boolean,
default: false
},
accident: {
type: Object,
default: () => ({ memberId: '', accident: '' })
},
members: {
type: Array,
default: () => []
},
participants: {
type: Array,
default: () => []
},
accidents: {
type: Array,
default: () => []
}
},
emits: ['update:modelValue', 'close', 'submit', 'update:accident'],
data() {
return {
localAccident: { ...this.accident }
};
},
computed: {
availableMembers() {
return this.members.filter(m => this.participants.indexOf(m.id) >= 0);
},
isValid() {
return this.localAccident.memberId && this.localAccident.accident && this.localAccident.accident.trim() !== '';
}
},
watch: {
accident: {
handler(newVal) {
this.localAccident = { ...newVal };
},
deep: true
},
localAccident: {
handler(newVal) {
this.$emit('update:accident', newVal);
},
deep: true
}
},
methods: {
handleClose() {
this.$emit('update:modelValue', false);
this.$emit('close');
},
handleSubmit() {
if (this.isValid) {
this.$emit('submit', this.localAccident);
}
}
}
};
</script>
<style scoped>
.accident-form {
display: flex;
flex-direction: column;
gap: 1rem;
}
.form-group {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.form-group label {
font-weight: 600;
color: var(--text-color);
}
.form-select,
.form-textarea {
width: 100%;
padding: 0.5rem;
border: 1px solid var(--border-color);
border-radius: 4px;
font-family: inherit;
}
.form-textarea {
resize: vertical;
min-height: 100px;
}
.form-select:focus,
.form-textarea:focus {
outline: none;
border-color: var(--primary-color);
box-shadow: 0 0 0 2px var(--primary-light);
}
.accidents-list {
margin-top: 1rem;
padding-top: 1rem;
border-top: 1px solid var(--border-color);
}
.accidents-list h4 {
margin: 0 0 0.5rem 0;
font-size: 1rem;
}
.accidents-list ul {
list-style: none;
padding: 0;
margin: 0;
}
.accident-item {
padding: 0.5rem;
margin-bottom: 0.5rem;
background: var(--background-light);
border-radius: 4px;
font-size: 0.9rem;
}
.btn-primary,
.btn-secondary {
padding: 0.5rem 1.5rem;
border: none;
border-radius: 4px;
font-size: 0.875rem;
font-weight: 600;
cursor: pointer;
transition: all 0.2s ease;
}
.btn-primary {
background: linear-gradient(135deg, var(--primary-color), var(--primary-hover));
color: white;
}
.btn-primary:hover:not(:disabled) {
opacity: 0.9;
}
.btn-primary:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.btn-secondary {
background: #6c757d;
color: white;
}
.btn-secondary:hover {
background: #5a6268;
}
</style>

View File

@@ -294,7 +294,7 @@ export default {
.dialog-header {
background: linear-gradient(135deg, var(--primary-color), var(--primary-hover));
color: white;
padding: 12px 16px;
padding: 4px 16px;
display: flex;
justify-content: space-between;
align-items: center;
@@ -353,7 +353,7 @@ export default {
/* Footer */
.dialog-footer {
padding: 12px 16px;
padding: 4px 16px;
border-top: 1px solid var(--border-color);
display: flex;
justify-content: flex-end;

View File

@@ -0,0 +1,185 @@
<template>
<BaseDialog
:model-value="modelValue"
@update:model-value="$emit('update:modelValue', $event)"
title="Spielplan importieren"
size="small"
@close="handleClose"
>
<form @submit.prevent="handleSubmit" class="import-form">
<div class="form-group">
<label for="csvFile">CSV-Datei hochladen:</label>
<input
type="file"
id="csvFile"
@change="handleFileChange"
accept=".csv"
required
class="file-input"
ref="fileInput"
/>
<div v-if="selectedFile" class="file-info">
<span class="file-icon">📄</span>
<span class="file-name">{{ selectedFile.name }}</span>
<span class="file-size">({{ formatFileSize(selectedFile.size) }})</span>
</div>
</div>
</form>
<template #footer>
<button @click="handleClose" class="btn-secondary">Abbrechen</button>
<button @click="handleSubmit" class="btn-primary" :disabled="!selectedFile">Importieren</button>
</template>
</BaseDialog>
</template>
<script>
import BaseDialog from './BaseDialog.vue';
export default {
name: 'CsvImportDialog',
components: {
BaseDialog
},
props: {
modelValue: {
type: Boolean,
default: false
}
},
emits: ['update:modelValue', 'close', 'import'],
data() {
return {
selectedFile: null
};
},
methods: {
handleClose() {
this.selectedFile = null;
if (this.$refs.fileInput) {
this.$refs.fileInput.value = '';
}
this.$emit('update:modelValue', false);
this.$emit('close');
},
handleFileChange(event) {
this.selectedFile = event.target.files && event.target.files[0] ? event.target.files[0] : null;
},
handleSubmit() {
if (this.selectedFile) {
this.$emit('import', this.selectedFile);
}
},
formatFileSize(bytes) {
if (bytes < 1024) return bytes + ' B';
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB';
return (bytes / (1024 * 1024)).toFixed(1) + ' MB';
}
},
watch: {
modelValue(newVal) {
if (!newVal) {
// Dialog geschlossen - Reset
this.selectedFile = null;
}
}
}
};
</script>
<style scoped>
.import-form {
display: flex;
flex-direction: column;
gap: 1rem;
}
.form-group {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.form-group label {
font-weight: 600;
color: var(--text-color);
}
.file-input {
padding: 0.5rem;
border: 1px solid var(--border-color);
border-radius: 4px;
cursor: pointer;
}
.file-input::-webkit-file-upload-button {
padding: 0.375rem 0.75rem;
border: 1px solid var(--border-color);
border-radius: 4px;
background: white;
cursor: pointer;
margin-right: 0.5rem;
}
.file-input::-webkit-file-upload-button:hover {
background: var(--background-light);
}
.file-info {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem;
background: var(--background-light);
border-radius: 4px;
font-size: 0.875rem;
}
.file-icon {
font-size: 1.5rem;
}
.file-name {
flex: 1;
font-weight: 500;
}
.file-size {
color: var(--text-muted);
}
.btn-primary,
.btn-secondary {
padding: 0.5rem 1.5rem;
border: none;
border-radius: 4px;
font-size: 0.875rem;
font-weight: 600;
cursor: pointer;
transition: all 0.2s ease;
}
.btn-primary {
background: linear-gradient(135deg, var(--primary-color), var(--primary-hover));
color: white;
}
.btn-primary:hover:not(:disabled) {
opacity: 0.9;
}
.btn-primary:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.btn-secondary {
background: #6c757d;
color: white;
}
.btn-secondary:hover {
background: #5a6268;
}
</style>

View File

@@ -0,0 +1,249 @@
<template>
<BaseDialog
:model-value="modelValue"
@update:model-value="$emit('update:modelValue', $event)"
:title="`Notizen für ${member ? member.firstName + ' ' + member.lastName : ''}`"
size="large"
@close="handleClose"
>
<div v-if="member" class="notes-modal-content">
<div class="notes-header-info">
Telefon-Nr.: {{ member.phone }}
</div>
<div class="notes-body">
<div class="notes-left">
<img v-if="member.imageUrl" :src="member.imageUrl" alt="Mitgliedsbild"
class="member-image" />
</div>
<div class="notes-right">
<div class="form-group">
<label>Tags</label>
<multiselect
v-model="localSelectedTags"
:options="availableTags"
placeholder="Tags auswählen"
label="name"
track-by="id"
multiple
:close-on-select="false"
@tag="$emit('add-tag', $event)"
@remove="$emit('remove-tag', $event)"
:allow-empty="false"
/>
</div>
<div class="form-group">
<label>Neue Notiz</label>
<textarea v-model="localNoteContent" placeholder="Neue Notiz" rows="4" class="note-textarea"></textarea>
<button @click="handleAddNote" class="btn-primary">Hinzufügen</button>
</div>
<div class="notes-list">
<h4>Notizen</h4>
<ul>
<li v-for="note in notes" :key="note.id" class="note-item">
<button @click="$emit('delete-note', note.id)" class="trash-btn">🗑</button>
<span class="note-content">{{ note.content }}</span>
</li>
</ul>
</div>
</div>
</div>
</div>
</BaseDialog>
</template>
<script>
import BaseDialog from './BaseDialog.vue';
import Multiselect from 'vue-multiselect';
export default {
name: 'MemberNotesDialog',
components: {
BaseDialog,
Multiselect
},
props: {
modelValue: {
type: Boolean,
default: false
},
member: {
type: Object,
default: null
},
notes: {
type: Array,
default: () => []
},
selectedTags: {
type: Array,
default: () => []
},
availableTags: {
type: Array,
default: () => []
},
noteContent: {
type: String,
default: ''
}
},
emits: ['update:modelValue', 'close', 'add-note', 'delete-note', 'add-tag', 'remove-tag', 'update:noteContent', 'update:selectedTags'],
data() {
return {
localNoteContent: this.noteContent,
localSelectedTags: this.selectedTags
};
},
watch: {
noteContent(newVal) {
this.localNoteContent = newVal;
},
selectedTags(newVal) {
this.localSelectedTags = newVal;
},
localNoteContent(newVal) {
this.$emit('update:noteContent', newVal);
},
localSelectedTags(newVal) {
this.$emit('update:selectedTags', newVal);
}
},
methods: {
handleClose() {
this.$emit('update:modelValue', false);
this.$emit('close');
},
handleAddNote() {
this.$emit('add-note', this.localNoteContent);
this.localNoteContent = '';
}
}
};
</script>
<style scoped>
.notes-modal-content {
display: flex;
flex-direction: column;
gap: 1rem;
}
.notes-header-info {
padding: 0.5rem;
background: var(--background-light);
border-radius: 4px;
font-size: 0.9rem;
color: var(--text-muted);
}
.notes-body {
display: flex;
gap: 1.5rem;
}
.notes-left {
flex-shrink: 0;
}
.member-image {
width: 250px;
height: 250px;
object-fit: cover;
border-radius: 8px;
border: 1px solid var(--border-color);
}
.notes-right {
flex: 1;
display: flex;
flex-direction: column;
gap: 1rem;
}
.form-group {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.form-group label {
font-weight: 600;
color: var(--text-color);
}
.note-textarea {
width: 100%;
padding: 0.5rem;
border: 1px solid var(--border-color);
border-radius: 4px;
font-family: inherit;
resize: vertical;
}
.notes-list h4 {
margin: 0 0 0.5rem 0;
font-size: 1rem;
}
.notes-list ul {
list-style: none;
padding: 0;
margin: 0;
}
.note-item {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem;
margin-bottom: 0.5rem;
background: var(--background-light);
border-radius: 4px;
}
.note-content {
flex: 1;
}
.trash-btn {
background: none;
border: none;
cursor: pointer;
font-size: 1.2rem;
padding: 0.25rem;
transition: transform 0.2s;
}
.trash-btn:hover {
transform: scale(1.2);
}
.btn-primary {
align-self: flex-start;
padding: 0.5rem 1rem;
border: none;
border-radius: 4px;
background: linear-gradient(135deg, var(--primary-color), var(--primary-hover));
color: white;
font-weight: 600;
cursor: pointer;
transition: opacity 0.2s;
}
.btn-primary:hover {
opacity: 0.9;
}
@media (max-width: 768px) {
.notes-body {
flex-direction: column;
}
.member-image {
width: 100%;
height: auto;
max-height: 300px;
}
}
</style>

View File

@@ -0,0 +1,260 @@
<template>
<BaseDialog
:model-value="modelValue"
@update:model-value="$emit('update:modelValue', $event)"
title="Mitglieder auswählen"
size="large"
:close-on-overlay="false"
@close="handleClose"
>
<div class="member-selection-content">
<div class="controls-bar">
<button class="btn-secondary" @click="$emit('select-all')">Alle auswählen</button>
<button class="btn-secondary" @click="$emit('deselect-all')">Alle abwählen</button>
</div>
<div class="selection-layout">
<div class="members-column">
<h4>Mitglieder</h4>
<div class="checkbox-list">
<label v-for="m in members" :key="m.id" class="checkbox-item">
<input
type="checkbox"
:value="m.id"
:checked="selectedIds.includes(m.id)"
@change="handleMemberToggle(m.id, $event.target.checked)"
/>
<span :class="{ active: activeMemberId === m.id }">
{{ m.firstName }} {{ m.lastName }}
</span>
</label>
</div>
</div>
<div class="recommendations-column" v-if="activeMember && showRecommendations">
<h4>Empfehlungen</h4>
<div v-if="recommendations && recommendations.length" class="checkbox-list">
<label v-for="rec in recommendations" :key="rec.key" class="checkbox-item">
<input
type="checkbox"
:checked="isRecommended(rec.key)"
@change="handleRecommendationToggle(rec.key, $event.target.checked)"
/>
<span>{{ rec.name }} {{ rec.date }} {{ rec.time }}</span>
</label>
</div>
<div v-else class="no-data">
<em>Keine passenden Empfehlungen gefunden.</em>
</div>
</div>
</div>
</div>
<template #footer>
<button class="btn-secondary" @click="handleClose">Schließen</button>
<button
class="btn-primary"
:disabled="selectedIds.length === 0"
@click="$emit('generate-pdf')"
>
PDF erzeugen
</button>
</template>
</BaseDialog>
</template>
<script>
import BaseDialog from './BaseDialog.vue';
export default {
name: 'MemberSelectionDialog',
components: {
BaseDialog
},
props: {
modelValue: {
type: Boolean,
default: false
},
members: {
type: Array,
default: () => []
},
selectedIds: {
type: Array,
default: () => []
},
activeMemberId: {
type: [Number, String],
default: null
},
recommendations: {
type: Array,
default: () => []
},
recommendedKeys: {
type: Array,
default: () => []
},
showRecommendations: {
type: Boolean,
default: true
}
},
emits: [
'update:modelValue',
'close',
'select-all',
'deselect-all',
'toggle-member',
'toggle-recommendation',
'generate-pdf',
'update:selectedIds',
'update:activeMemberId'
],
computed: {
activeMember() {
return this.members.find(m => m.id === this.activeMemberId);
}
},
methods: {
handleClose() {
this.$emit('update:modelValue', false);
this.$emit('close');
},
handleMemberToggle(memberId, checked) {
this.$emit('update:activeMemberId', memberId);
this.$emit('toggle-member', { memberId, checked });
},
handleRecommendationToggle(key, checked) {
this.$emit('toggle-recommendation', { memberId: this.activeMemberId, key, checked });
},
isRecommended(key) {
return this.recommendedKeys.includes(key);
}
}
};
</script>
<style scoped>
.member-selection-content {
display: flex;
flex-direction: column;
gap: 1rem;
}
.controls-bar {
display: flex;
gap: 0.5rem;
padding-bottom: 1rem;
border-bottom: 1px solid var(--border-color);
}
.selection-layout {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1.5rem;
min-height: 400px;
}
.members-column,
.recommendations-column {
display: flex;
flex-direction: column;
}
.members-column h4,
.recommendations-column h4 {
margin: 0 0 0.75rem 0;
padding-bottom: 0.5rem;
border-bottom: 2px solid var(--primary-color);
color: var(--primary-color);
}
.checkbox-list {
flex: 1;
overflow-y: auto;
max-height: 500px;
border: 1px solid var(--border-color);
border-radius: 4px;
padding: 0.5rem;
}
.checkbox-item {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem;
cursor: pointer;
border-radius: 4px;
transition: background-color 0.2s;
}
.checkbox-item:hover {
background: var(--background-light);
}
.checkbox-item input[type="checkbox"] {
cursor: pointer;
}
.checkbox-item span {
flex: 1;
}
.checkbox-item span.active {
font-weight: 600;
color: var(--primary-color);
}
.no-data {
padding: 2rem;
text-align: center;
color: var(--text-muted);
}
.btn-primary,
.btn-secondary {
padding: 0.5rem 1.5rem;
border: none;
border-radius: 4px;
font-size: 0.875rem;
font-weight: 600;
cursor: pointer;
transition: all 0.2s ease;
}
.btn-primary {
background: linear-gradient(135deg, var(--primary-color), var(--primary-hover));
color: white;
}
.btn-primary:hover:not(:disabled) {
opacity: 0.9;
}
.btn-primary:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.btn-secondary {
background: #6c757d;
color: white;
}
.btn-secondary:hover {
background: #5a6268;
}
@media (max-width: 768px) {
.selection-layout {
grid-template-columns: 1fr;
}
.checkbox-list {
max-height: 300px;
}
}
</style>

View File

@@ -0,0 +1,208 @@
<template>
<BaseDialog
:model-value="modelValue"
@update:model-value="$emit('update:modelValue', $event)"
title="Neues Mitglied hinzufügen"
size="medium"
:close-on-overlay="false"
@close="handleClose"
>
<div class="quick-add-form">
<div class="form-row">
<div class="form-group">
<label for="firstName">Vorname:</label>
<input
type="text"
id="firstName"
v-model="localMember.firstName"
required
class="form-input"
placeholder="Vorname"
/>
</div>
<div class="form-group">
<label for="lastName">Nachname (optional):</label>
<input
type="text"
id="lastName"
v-model="localMember.lastName"
class="form-input"
placeholder="Nachname"
/>
</div>
</div>
<div class="form-row">
<div class="form-group">
<label for="birthDate">Geburtsdatum:</label>
<input
type="date"
id="birthDate"
v-model="localMember.birthDate"
class="form-input"
/>
</div>
<div class="form-group">
<label for="gender">Geschlecht:</label>
<select id="gender" v-model="localMember.gender" class="form-select">
<option value="">Bitte wählen</option>
<option value="male">Männlich</option>
<option value="female">Weiblich</option>
<option value="diverse">Divers</option>
</select>
</div>
</div>
</div>
<template #footer>
<button class="btn-secondary" @click="handleClose">Abbrechen</button>
<button
class="btn-primary"
@click="handleSubmit"
:disabled="!isValid"
>
Erstellen & Hinzufügen
</button>
</template>
</BaseDialog>
</template>
<script>
import BaseDialog from './BaseDialog.vue';
export default {
name: 'QuickAddMemberDialog',
components: {
BaseDialog
},
props: {
modelValue: {
type: Boolean,
default: false
},
member: {
type: Object,
default: () => ({ firstName: '', lastName: '', birthDate: '', gender: '' })
}
},
emits: ['update:modelValue', 'close', 'submit', 'update:member'],
data() {
return {
localMember: { ...this.member }
};
},
computed: {
isValid() {
return this.localMember.firstName && this.localMember.firstName.trim() !== '';
}
},
watch: {
member: {
handler(newVal) {
this.localMember = { ...newVal };
},
deep: true
},
localMember: {
handler(newVal) {
this.$emit('update:member', newVal);
},
deep: true
}
},
methods: {
handleClose() {
this.$emit('update:modelValue', false);
this.$emit('close');
},
handleSubmit() {
if (this.isValid) {
this.$emit('submit', this.localMember);
}
}
}
};
</script>
<style scoped>
.quick-add-form {
display: flex;
flex-direction: column;
gap: 1rem;
}
.form-row {
display: flex;
gap: 1rem;
}
.form-group {
flex: 1;
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.form-group label {
font-weight: 600;
color: var(--text-color);
font-size: 0.9rem;
}
.form-input,
.form-select {
width: 100%;
padding: 0.5rem;
border: 1px solid var(--border-color);
border-radius: 4px;
font-family: inherit;
font-size: 0.9rem;
}
.form-input:focus,
.form-select:focus {
outline: none;
border-color: var(--primary-color);
box-shadow: 0 0 0 2px var(--primary-light);
}
.btn-primary,
.btn-secondary {
padding: 0.5rem 1.5rem;
border: none;
border-radius: 4px;
font-size: 0.875rem;
font-weight: 600;
cursor: pointer;
transition: all 0.2s ease;
}
.btn-primary {
background: linear-gradient(135deg, var(--primary-color), var(--primary-hover));
color: white;
}
.btn-primary:hover:not(:disabled) {
opacity: 0.9;
}
.btn-primary:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.btn-secondary {
background: #6c757d;
color: white;
}
.btn-secondary:hover {
background: #5a6268;
}
@media (max-width: 768px) {
.form-row {
flex-direction: column;
}
}
</style>

View File

@@ -0,0 +1,154 @@
<template>
<BaseDialog
:model-value="modelValue"
@update:model-value="$emit('update:modelValue', $event)"
:title="`Tag-Historie ${member ? member.firstName + ' ' + member.lastName : ''}`"
size="medium"
@close="handleClose"
>
<div class="tag-history-content">
<div class="form-group">
<label>Tags auswählen</label>
<multiselect
v-model="localSelectedTags"
:options="activityTags"
placeholder="Tags auswählen"
label="name"
track-by="id"
multiple
:close-on-select="false"
:taggable="false"
@select="$emit('select-tag', $event)"
:allow-empty="false"
/>
</div>
<div v-if="tagHistory && tagHistory.length" class="tag-history-list">
<div v-for="tag in tagHistory" :key="tag.id" class="tag-history-item">
<div class="tag-header">{{ tag.name }}</div>
<ul class="tag-list">
<li v-for="entry in tag.diaryMemberTags" :key="entry.id">
{{ formatDate(entry.diaryDates.date) }}
</li>
</ul>
</div>
</div>
<div v-else class="no-history">
<em>Keine Tag-Historie vorhanden</em>
</div>
</div>
</BaseDialog>
</template>
<script>
import BaseDialog from './BaseDialog.vue';
import Multiselect from 'vue-multiselect';
export default {
name: 'TagHistoryDialog',
components: {
BaseDialog,
Multiselect
},
props: {
modelValue: {
type: Boolean,
default: false
},
member: {
type: Object,
default: null
},
tagHistory: {
type: Array,
default: () => []
},
selectedTags: {
type: Array,
default: () => []
},
activityTags: {
type: Array,
default: () => []
}
},
emits: ['update:modelValue', 'close', 'select-tag', 'update:selectedTags'],
data() {
return {
localSelectedTags: this.selectedTags
};
},
watch: {
selectedTags(newVal) {
this.localSelectedTags = newVal;
},
localSelectedTags(newVal) {
this.$emit('update:selectedTags', newVal);
}
},
methods: {
handleClose() {
this.$emit('update:modelValue', false);
this.$emit('close');
},
formatDate(dateString) {
if (!dateString) return '';
const date = new Date(dateString);
return date.toLocaleDateString('de-DE');
}
}
};
</script>
<style scoped>
.tag-history-content {
display: flex;
flex-direction: column;
gap: 1rem;
}
.form-group {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.form-group label {
font-weight: 600;
color: var(--text-color);
}
.tag-history-list {
margin-top: 1rem;
}
.tag-history-item {
margin-bottom: 1rem;
padding: 0.75rem;
background: var(--background-light);
border-radius: 4px;
}
.tag-header {
font-weight: 600;
margin-bottom: 0.5rem;
color: var(--primary-color);
}
.tag-list {
list-style: none;
padding-left: 1rem;
margin: 0;
}
.tag-list li {
padding: 0.25rem 0;
color: var(--text-muted);
}
.no-history {
padding: 2rem;
text-align: center;
color: var(--text-muted);
}
</style>

View File

@@ -0,0 +1,222 @@
<template>
<BaseDialog
:model-value="modelValue"
@update:model-value="$emit('update:modelValue', $event)"
:title="`Trainings-Details: ${member ? member.firstName + ' ' + member.lastName : ''}`"
size="large"
@close="handleClose"
>
<div v-if="member" class="details-modal-content">
<div class="member-info">
<div class="info-item">
<strong>Geburtsdatum:</strong> {{ formatBirthdate(member.birthDate) }}
</div>
<div class="info-item">
<strong>Geburtsjahr:</strong> {{ getBirthYear(member.birthDate) }}
</div>
</div>
<div class="participation-summary">
<div class="summary-item">
<span class="label">Letzte 12 Monate:</span>
<span class="value">{{ member.participation12Months }}</span>
</div>
<div class="summary-item">
<span class="label">Letzte 3 Monate:</span>
<span class="value">{{ member.participation3Months }}</span>
</div>
<div class="summary-item">
<span class="label">Gesamt:</span>
<span class="value">{{ member.participationTotal }}</span>
</div>
</div>
<div class="training-details">
<h4>Trainingsteilnahmen (absteigend sortiert)</h4>
<div class="training-list">
<div
v-for="training in member.trainingDetails"
:key="training.id"
class="training-item"
>
<div class="training-date">{{ formatDate(training.date) }}</div>
<div class="training-activity">{{ training.activityName }}</div>
<div class="training-time">{{ training.startTime }} - {{ training.endTime }}</div>
</div>
</div>
<div v-if="!member.trainingDetails || member.trainingDetails.length === 0" class="no-trainings">
<em>Keine Trainingsteilnahmen vorhanden</em>
</div>
</div>
</div>
</BaseDialog>
</template>
<script>
import BaseDialog from './BaseDialog.vue';
export default {
name: 'TrainingDetailsDialog',
components: {
BaseDialog
},
props: {
modelValue: {
type: Boolean,
default: false
},
member: {
type: Object,
default: null
}
},
emits: ['update:modelValue', 'close'],
methods: {
handleClose() {
this.$emit('update:modelValue', false);
this.$emit('close');
},
formatBirthdate(dateString) {
if (!dateString) return '-';
const date = new Date(dateString);
return date.toLocaleDateString('de-DE');
},
getBirthYear(dateString) {
if (!dateString) return '-';
const date = new Date(dateString);
return date.getFullYear();
},
formatDate(dateString) {
if (!dateString) return '';
const date = new Date(dateString);
return date.toLocaleDateString('de-DE', {
weekday: 'short',
year: 'numeric',
month: '2-digit',
day: '2-digit'
});
}
}
};
</script>
<style scoped>
.details-modal-content {
display: flex;
flex-direction: column;
gap: 1.5rem;
}
.member-info {
display: flex;
gap: 2rem;
padding: 1rem;
background: var(--background-light);
border-radius: 4px;
}
.info-item {
display: flex;
gap: 0.5rem;
}
.info-item strong {
color: var(--text-color);
}
.participation-summary {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
gap: 1rem;
}
.summary-item {
padding: 1rem;
background: var(--background-light);
border-radius: 4px;
text-align: center;
}
.summary-item .label {
display: block;
font-size: 0.875rem;
color: var(--text-muted);
margin-bottom: 0.5rem;
}
.summary-item .value {
display: block;
font-size: 1.5rem;
font-weight: 700;
color: var(--primary-color);
}
.training-details h4 {
margin: 0 0 1rem 0;
color: var(--text-color);
}
.training-list {
max-height: 400px;
overflow-y: auto;
border: 1px solid var(--border-color);
border-radius: 4px;
}
.training-item {
display: flex;
gap: 1rem;
padding: 0.75rem 1rem;
border-bottom: 1px solid var(--border-color);
transition: background-color 0.2s;
}
.training-item:last-child {
border-bottom: none;
}
.training-item:hover {
background: var(--background-light);
}
.training-date {
font-weight: 600;
min-width: 120px;
}
.training-activity {
flex: 1;
}
.training-time {
color: var(--text-muted);
font-size: 0.875rem;
}
.no-trainings {
padding: 2rem;
text-align: center;
color: var(--text-muted);
}
@media (max-width: 768px) {
.member-info {
flex-direction: column;
gap: 0.5rem;
}
.participation-summary {
grid-template-columns: 1fr;
}
.training-item {
flex-direction: column;
gap: 0.25rem;
}
.training-date {
min-width: auto;
}
}
</style>

View File

@@ -289,55 +289,31 @@
</div>
</div>
<div v-if="showTagHistoryModal" class="modal">
<div class="modal-content">
<span class="close" @click="closeTagHistoryModal">&times;</span>
<h3>Tag-Historie {{ tagHistoryMember.firstName }} {{ tagHistoryMember.lastName }}</h3>
<div>
<multiselect v-model="selectedMemberDayTags" :options="selectedActivityTags"
placeholder="Tags auswählen" label="name" track-by="id" multiple :close-on-select="false"
:taggable="false" @select="addNewTagForDay" :allow-empty="false" />
</div>
<div v-if="tagHistory">
<div v-for="tag in tagHistory" :key="tag.id">
<div class="tag-headerplement">{{ tag.name }}</div>
<ul class="tag-list">
<li v-for="entry in tag.diaryMemberTags" :key="entry.id">{{
getFormattedDate(entry.diaryDates.date) }}</li>
</ul>
</div>
</div>
</div>
</div>
<!-- Tag History Modal -->
<TagHistoryDialog
v-model="showTagHistoryModal"
:member="tagHistoryMember"
:tag-history="tagHistory"
v-model:selected-tags="selectedMemberDayTags"
:activity-tags="selectedActivityTags"
@select-tag="addNewTagForDay"
@close="closeTagHistoryModal"
/>
<div v-if="showNotesModal" class="modal">
<div class="modal-content">
<span class="close" @click="closeNotesModal">&times;</span>
<h3>Notizen für {{ noteMember.firstName }} {{ noteMember.lastName }}</h3>
<div>Telefon-Nr.: {{ noteMember.phone }}</div>
<div class="modal-body">
<div class="modal-left">
<img v-if="noteMember.imageUrl" :src="noteMember.imageUrl" alt="Mitgliedsbild"
style="width: 250px; height: 250px; object-fit: cover;" />
</div>
<div class="modal-right">
<multiselect v-model="selectedMemberTags" :options="availableTags" placeholder="Tags auswählen"
label="name" track-by="id" multiple :close-on-select="false"
@tag="addNewTagForMemberFromSelection" @remove="removeMemberDayTag" allow-empty="false" />
<div>
<textarea v-model="newNoteContent" placeholder="Neue Notiz" rows="4" cols="30"></textarea>
<button @click="addMemberNote">Hinzufügen</button>
</div>
<ul>
<li v-for="note in notes" :key="note.id">
<button @click="deleteNote(note.id)" class="trash-btn">🗑</button>
{{ note.content }}
</li>
</ul>
</div>
</div>
</div>
</div>
<!-- Notizen Modal -->
<MemberNotesDialog
v-model="showNotesModal"
:member="noteMember"
:notes="notes"
v-model:selected-tags="selectedMemberTags"
:available-tags="availableTags"
v-model:note-content="newNoteContent"
@add-note="addMemberNote"
@delete-note="deleteNote"
@add-tag="addNewTagForMemberFromSelection"
@remove-tag="removeMemberDayTag"
@close="closeNotesModal"
/>
<!-- Image Dialog für normale Bilder -->
<ImageDialog
v-model="showImage"
@@ -355,74 +331,24 @@
<CourtDrawingRender :drawing-data="renderModalData" :width="560" :height="360" />
</div>
</BaseDialog>
<div v-if="showAccidentForm" class="accidentForm">
<form @submit.prevent="submitAccident">
<div>
<label for="memberId">Mitglied:</label>
<select id="memberId" v-model="accident.memberId">
<template v-for="member in members" :key="member.id" :value="member.id">
<option v-if="participants.indexOf(member.id) >= 0" :value="member.id">{{ member.firstName +
' '
+ member.lastName }}</option>
</template>
</select>
</div>
<div>
<label for="accident">Unfall:</label>
<textarea id="accident" v-model="accident.accident" required></textarea>
</div>
<button type="button" @click="saveAccident">Eintragen</button>
<button type="button" @click="closeAccidentForm">Schießen</button>
<ul>
<li v-for="accident in accidents" :key="accident.id">{{ accident.firstName + ' ' + accident.lastName
+
': '
+ accident.accident}}</li>
</ul>
</form>
</div>
<!-- Accident Form Modal -->
<AccidentFormDialog
v-model="showAccidentForm"
v-model:accident="accident"
:members="members"
:participants="participants"
:accidents="accidents"
@submit="saveAccident"
@close="closeAccidentForm"
/>
<!-- Schnell hinzufügen Dialog -->
<div v-if="showQuickAddDialog" class="modal-overlay" @click.self="closeQuickAddDialog">
<div class="modal">
<div class="modal-header">
<h3>Neues Mitglied hinzufügen</h3>
</div>
<div class="modal-body">
<div class="form-row">
<div class="form-group">
<label for="firstName">Vorname:</label>
<input type="text" id="firstName" v-model="newMember.firstName" required />
</div>
<div class="form-group">
<label for="lastName">Nachname (optional):</label>
<input type="text" id="lastName" v-model="newMember.lastName" />
</div>
</div>
<div class="form-row">
<div class="form-group">
<label for="birthDate">Geburtsdatum:</label>
<input type="date" id="birthDate" v-model="newMember.birthDate" />
</div>
<div class="form-group">
<label for="gender">Geschlecht:</label>
<select id="gender" v-model="newMember.gender">
<option value="">Bitte wählen</option>
<option value="male">Männlich</option>
<option value="female">Weiblich</option>
<option value="diverse">Divers</option>
</select>
</div>
</div>
<div class="form-row">
<button class="btn-secondary" @click="closeQuickAddDialog">Abbrechen</button>
<button class="btn-primary" @click="createAndAddMember" :disabled="!isNewMemberValid">
Erstellen & Hinzufügen
</button>
</div>
</div>
</div>
</div>
<QuickAddMemberDialog
v-model="showQuickAddDialog"
v-model:member="newMember"
@submit="createAndAddMember"
@close="closeQuickAddDialog"
/>
</div>
<!-- Info Dialog -->
@@ -457,6 +383,10 @@ import InfoDialog from '../components/InfoDialog.vue';
import ConfirmDialog from '../components/ConfirmDialog.vue';
import ImageDialog from '../components/ImageDialog.vue';
import BaseDialog from '../components/BaseDialog.vue';
import MemberNotesDialog from '../components/MemberNotesDialog.vue';
import TagHistoryDialog from '../components/TagHistoryDialog.vue';
import AccidentFormDialog from '../components/AccidentFormDialog.vue';
import QuickAddMemberDialog from '../components/QuickAddMemberDialog.vue';
export default {
name: 'DiaryView',
@@ -466,7 +396,11 @@ export default {
InfoDialog,
ConfirmDialog,
ImageDialog,
BaseDialog
BaseDialog,
MemberNotesDialog,
TagHistoryDialog,
AccidentFormDialog,
QuickAddMemberDialog
},
data() {
return {
@@ -2426,4 +2360,99 @@ img {
cursor: not-allowed !important;
opacity: 0.6 !important;
}
/* Notes Modal Styles */
.notes-modal-content {
display: flex;
flex-direction: column;
gap: 1rem;
}
.notes-header-info {
padding: 0.5rem;
background: var(--background-light);
border-radius: 4px;
font-size: 0.9rem;
color: var(--text-muted);
}
.notes-body {
display: flex;
gap: 1.5rem;
}
.notes-left {
flex-shrink: 0;
}
.member-image {
width: 250px;
height: 250px;
object-fit: cover;
border-radius: 8px;
border: 1px solid var(--border-color);
}
.notes-right {
flex: 1;
display: flex;
flex-direction: column;
gap: 1rem;
}
.note-textarea {
width: 100%;
padding: 0.5rem;
border: 1px solid var(--border-color);
border-radius: 4px;
font-family: inherit;
resize: vertical;
}
.notes-list h4 {
margin: 0 0 0.5rem 0;
font-size: 1rem;
}
.notes-list ul {
list-style: none;
padding: 0;
margin: 0;
}
.note-item {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem;
margin-bottom: 0.5rem;
background: var(--background-light);
border-radius: 4px;
}
.note-content {
flex: 1;
}
@media (max-width: 768px) {
.notes-body {
flex-direction: column;
}
.member-image {
width: 100%;
height: auto;
max-height: 300px;
}
}
/* Render Container */
.render-container {
display: flex;
justify-content: center;
align-items: center;
padding: 1rem;
background: #f5f5f5;
border-radius: 4px;
}
</style>

View File

@@ -138,22 +138,18 @@
@rotate="handleRotate"
/>
<div v-if="showNotesModal" class="modal">
<div class="modal-content">
<span class="close" @click="closeNotesModal">&times;</span>
<h3>Notizen für {{ memberToEdit.firstName }} {{ memberToEdit.lastName }}</h3>
<div>
<textarea v-model="newNoteContent" placeholder="Neue Notiz" rows="4" cols="30"></textarea>
<button @click="addNote">Hinzufügen</button>
</div>
<ul>
<li v-for="note in notes" :key="note.id">
<button @click="deleteNote(note.id)" class="trash-btn">🗑</button>
{{ note.content }}
</li>
</ul>
</div>
</div>
<!-- Notes Modal -->
<MemberNotesDialog
v-model="showNotesModal"
:member="memberToEdit"
:notes="notes"
v-model:note-content="newNoteContent"
:selected-tags="[]"
:available-tags="[]"
@add-note="addNote"
@delete-note="deleteNote"
@close="closeNotesModal"
/>
</div>
@@ -186,12 +182,16 @@ import PDFGenerator from '../components/PDFGenerator.js';
import InfoDialog from '../components/InfoDialog.vue';
import ConfirmDialog from '../components/ConfirmDialog.vue';
import ImageViewerDialog from '../components/ImageViewerDialog.vue';
import BaseDialog from '../components/BaseDialog.vue';
import MemberNotesDialog from '../components/MemberNotesDialog.vue';
export default {
name: 'MembersView',
components: {
InfoDialog,
ConfirmDialog,
ImageViewerDialog
ImageViewerDialog,
BaseDialog,
MemberNotesDialog
},
computed: {
...mapGetters(['isAuthenticated', 'currentClub']),
@@ -790,4 +790,24 @@ table td {
.rotate-btn:active {
transform: translateY(0);
}
/* Dialog-spezifische Styles */
.member-notes-content {
min-height: 200px;
}
.btn-primary {
padding: 0.5rem 1rem;
border: none;
border-radius: 4px;
background: linear-gradient(135deg, var(--primary-color), var(--primary-hover));
color: white;
font-weight: 600;
cursor: pointer;
transition: opacity 0.2s;
}
.btn-primary:hover {
opacity: 0.9;
}
</style>

View File

@@ -381,44 +381,21 @@
</div>
</div>
<div v-if="showMemberDialog" class="modal-overlay" @click.self="closeMemberDialog">
<div class="modal">
<div class="modal-header">
<h3>Mitglieder auswählen</h3>
</div>
<div class="modal-controls">
<button class="btn-secondary" @click="selectAllMembers">Alle auswählen</button>
<button class="btn-secondary" @click="deselectAllMembers">Alle abwählen</button>
<div style="flex:1;"></div>
<button class="btn-secondary" @click="closeMemberDialog">Schließen</button>
<button class="btn-primary" :disabled="!selectedMemberIds.length" @click="generateMembersPdfAndClose">PDF erzeugen</button>
</div>
<div class="modal-body">
<div class="dialog-layout">
<div class="dialog-col members-col">
<div class="checkbox-column">
<label v-for="m in activeMembers" :key="m.id" class="check-item">
<input type="checkbox" :value="m.id" v-model="selectedMemberIds" @change="selectedMemberIdForDialog = m.id" />
<span :class="{ active: selectedMemberIdForDialog === m.id }">{{ m.firstName }} {{ m.lastName }}</span>
</label>
</div>
</div>
<div class="dialog-col recommendations-col" v-if="selectedMemberInDialog">
<h4>Empfehlungen</h4>
<div v-if="selectedMemberCompetitions.length">
<label v-for="row in selectedMemberCompetitions" :key="row.key" class="check-item">
<input type="checkbox" :checked="isRecommended(selectedMemberInDialog.id, row.key)" @change="toggleRecommendation(selectedMemberInDialog.id, row.key)" />
<span>{{ row.name }} {{ row.date }} {{ row.time }}</span>
</label>
</div>
<div v-else>
<em>Keine passenden Wettbewerbe gefunden.</em>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Member Selection Dialog -->
<MemberSelectionDialog
v-model="showMemberDialog"
:members="activeMembers"
v-model:selected-ids="selectedMemberIds"
v-model:active-member-id="selectedMemberIdForDialog"
:recommendations="selectedMemberCompetitions"
:recommended-keys="getRecommendedKeys()"
@select-all="selectAllMembers"
@deselect-all="deselectAllMembers"
@toggle-member="handleMemberToggle"
@toggle-recommendation="handleRecommendationToggle"
@generate-pdf="generateMembersPdfAndClose"
@close="closeMemberDialog"
/>
</div>
@@ -450,8 +427,16 @@ import PDFGenerator from '../components/PDFGenerator.js';
import InfoDialog from '../components/InfoDialog.vue';
import ConfirmDialog from '../components/ConfirmDialog.vue';
import BaseDialog from '../components/BaseDialog.vue';
import MemberSelectionDialog from '../components/MemberSelectionDialog.vue';
export default {
name: 'OfficialTournaments',
components: {
InfoDialog,
ConfirmDialog,
BaseDialog,
MemberSelectionDialog
},
data() {
return {
// Dialog States
@@ -1032,6 +1017,32 @@ export default {
},
selectAllMembers() { this.selectedMemberIds = this.activeMembers.map(m => m.id); },
deselectAllMembers() { this.selectedMemberIds = []; },
handleMemberToggle({ memberId, checked }) {
this.selectedMemberIdForDialog = memberId;
if (checked && !this.selectedMemberIds.includes(memberId)) {
this.selectedMemberIds.push(memberId);
} else if (!checked) {
this.selectedMemberIds = this.selectedMemberIds.filter(id => id !== memberId);
}
},
handleRecommendationToggle({ memberId, key, checked }) {
if (checked) {
if (!this.memberRecommendations[memberId]) {
this.$set ? this.$set(this.memberRecommendations, memberId, new Set()) : (this.memberRecommendations[memberId] = new Set());
}
this.memberRecommendations[memberId].add(key);
} else {
if (this.memberRecommendations[memberId]) {
this.memberRecommendations[memberId].delete(key);
}
}
},
getRecommendedKeys() {
if (!this.selectedMemberIdForDialog || !this.memberRecommendations[this.selectedMemberIdForDialog]) {
return [];
}
return Array.from(this.memberRecommendations[this.selectedMemberIdForDialog]);
},
splitDateTime(str) {
if (!str) return { date: '', time: '' };
const s = String(str);
@@ -1464,6 +1475,25 @@ th, td { border-bottom: 1px solid var(--border-color); padding: 0.5rem; text-ali
font-weight: 700;
color: #28a745;
}
/* Dialog-spezifische Styles */
.member-dialog-controls {
display: flex;
gap: 0.5rem;
margin-bottom: 1rem;
}
.member-dialog-layout {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1.5rem;
}
@media (max-width: 768px) {
.member-dialog-layout {
grid-template-columns: 1fr;
}
}
</style>

View File

@@ -74,17 +74,12 @@
</div>
</div>
<div v-if="showImportModal" class="modal">
<div class="modal-content">
<span class="close" @click="closeImportModal">&times;</span>
<h3>Spielplan importieren</h3>
<form @submit.prevent="importCSV">
<label for="csvFile">CSV-Datei hochladen:</label>
<input type="file" id="csvFile" @change="onFileSelected" accept=".csv" required />
<button type="submit">Importieren</button>
</form>
</div>
</div>
<!-- Import Modal -->
<CsvImportDialog
v-model="showImportModal"
@import="handleCsvImport"
@close="closeImportModal"
/>
</div>
@@ -118,14 +113,18 @@ import MatchReportDialog from '../components/MatchReportDialog.vue';
import InfoDialog from '../components/InfoDialog.vue';
import ConfirmDialog from '../components/ConfirmDialog.vue';
import BaseDialog from '../components/BaseDialog.vue';
import CsvImportDialog from '../components/CsvImportDialog.vue';
export default {
name: 'ScheduleView',
components: {
SeasonSelector,
MatchReportDialog
,
MatchReportDialog,
InfoDialog,
ConfirmDialog},
ConfirmDialog,
BaseDialog,
CsvImportDialog
},
computed: {
...mapGetters(['isAuthenticated', 'currentClub', 'clubs', 'currentClubName']),
},
@@ -198,8 +197,9 @@ export default {
this.showImportModal = false;
this.selectedFile = null;
},
onFileSelected(event) {
this.selectedFile = event.target.files[0];
handleCsvImport(file) {
this.selectedFile = file;
this.importCSV();
},
async importCSV() {
if (!this.selectedFile) return;

View File

@@ -113,52 +113,25 @@
</div>
<!-- Modal für Mitgliedsdetails -->
<div v-if="showDetailsModal" class="modal">
<div class="modal-content">
<span class="close" @click="closeDetailsModal">&times;</span>
<h3>Trainings-Details: {{ selectedMember.firstName }} {{ selectedMember.lastName }}</h3>
<div class="member-info">
<p><strong>Geburtsdatum:</strong> {{ formatBirthdate(selectedMember.birthDate) }}</p>
<p><strong>Geburtsjahr:</strong> {{ getBirthYear(selectedMember.birthDate) }}</p>
</div>
<div class="participation-summary">
<div class="summary-item">
<span class="label">Letzte 12 Monate:</span>
<span class="value">{{ selectedMember.participation12Months }}</span>
</div>
<div class="summary-item">
<span class="label">Letzte 3 Monate:</span>
<span class="value">{{ selectedMember.participation3Months }}</span>
</div>
<div class="summary-item">
<span class="label">Gesamt:</span>
<span class="value">{{ selectedMember.participationTotal }}</span>
</div>
</div>
<div class="training-details">
<h4>Trainingsteilnahmen (absteigend sortiert)</h4>
<div class="training-list">
<div v-for="training in selectedMember.trainingDetails" :key="training.id" class="training-item">
<div class="training-date">{{ formatDate(training.date) }}</div>
<div class="training-activity">{{ training.activityName }}</div>
<div class="training-time">{{ training.startTime }} - {{ training.endTime }}</div>
</div>
</div>
</div>
</div>
</div>
<!-- Training Details Modal -->
<TrainingDetailsDialog
v-model="showDetailsModal"
:member="selectedMember"
@close="closeDetailsModal"
/>
</div>
</template>
<script>
import { mapGetters } from 'vuex';
import apiClient from '../apiClient';
import TrainingDetailsDialog from '../components/TrainingDetailsDialog.vue';
export default {
name: 'TrainingStatsView',
components: {
TrainingDetailsDialog
},
computed: {
...mapGetters(['isAuthenticated', 'currentClub']),