Fügt Unterstützung für vordefinierte Aktivitäten hinzu, einschließlich der Möglichkeit, Bilder hochzuladen und zu suchen. Aktualisiert die Datenbankmodelle und -routen entsprechend. Verbessert die Benutzeroberfläche zur Anzeige und Bearbeitung von Aktivitäten in DiaryView.vue.
This commit is contained in:
@@ -52,6 +52,10 @@
|
||||
<span class="nav-icon">🏆</span>
|
||||
Turniere
|
||||
</a>
|
||||
<a href="/predefined-activities" class="nav-link">
|
||||
<span class="nav-icon">⚙️</span>
|
||||
Vordefinierte Aktivitäten
|
||||
</a>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ import PendingApprovalsView from './views/PendingApprovalsView.vue';
|
||||
import ScheduleView from './views/ScheduleView.vue';
|
||||
import TournamentsView from './views/TournamentsView.vue';
|
||||
import TrainingStatsView from './views/TrainingStatsView.vue';
|
||||
import PredefinedActivities from './views/PredefinedActivities.vue';
|
||||
|
||||
const routes = [
|
||||
{ path: '/register', component: Register },
|
||||
@@ -25,6 +26,7 @@ const routes = [
|
||||
{ path: '/schedule', component: ScheduleView},
|
||||
{ path: '/tournaments', component: TournamentsView },
|
||||
{ path: '/training-stats', component: TrainingStatsView },
|
||||
{ path: '/predefined-activities', component: PredefinedActivities },
|
||||
];
|
||||
|
||||
const router = createRouter({
|
||||
|
||||
@@ -45,7 +45,7 @@
|
||||
<button type="submit">Zeiten aktualisieren</button>
|
||||
</form>
|
||||
</div>
|
||||
<div v-if="date !== 'new' && date !== null" style="overflow:hidden">
|
||||
<div v-if="date !== 'new' && date !== null" style="overflow: visible">
|
||||
<div class="columns">
|
||||
<div class="column">
|
||||
<h3 v-if="showGeneralData">Gruppenverwaltung</h3>
|
||||
@@ -84,7 +84,7 @@
|
||||
</div>
|
||||
</div>
|
||||
<h3>Trainingsplan</h3>
|
||||
<div style="overflow: auto;">
|
||||
<div style="overflow-x: auto; overflow-y: visible; position: relative;">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
@@ -104,11 +104,11 @@
|
||||
<td>
|
||||
<span v-if="item.isTimeblock"><i>Zeitblock</i></span>
|
||||
<span v-else-if="editingActivityId === item.id">
|
||||
<div style="display: flex; gap: 5px; align-items: center;">
|
||||
<div style="display: flex; gap: 5px; align-items: center; position: relative;">
|
||||
<input
|
||||
type="text"
|
||||
:value="item.predefinedActivity ? item.predefinedActivity.name : item.activity"
|
||||
@input="item.activity = $event.target.value"
|
||||
v-model="editingActivityText"
|
||||
@input="onEditInputChangeText(item)"
|
||||
@keyup.enter="saveActivityEdit(item)"
|
||||
@keyup.esc="cancelActivityEdit"
|
||||
ref="activityInput"
|
||||
@@ -116,10 +116,18 @@
|
||||
/>
|
||||
<button @click="saveActivityEdit(item)" class="btn-primary" style="padding: 2px 8px; font-size: 12px;">✓</button>
|
||||
<button @click="cancelActivityEdit" class="btn-secondary" style="padding: 2px 8px; font-size: 12px;">✗</button>
|
||||
<div v-if="editShowDropdown && editSearchForId === item.id && editSearchResults.length" class="dropdown" style="max-height: 9.5em;">
|
||||
<div v-for="s in editSearchResults" :key="s.id" @click="chooseEditSuggestion(s, item)">
|
||||
<span v-if="s.code && s.code.trim() !== ''"><strong>[{{ s.name }}]</strong> </span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</span>
|
||||
<span v-else @click="startActivityEdit(item)" class="clickable">
|
||||
{{ item.predefinedActivity ? item.predefinedActivity.name : item.activity }}
|
||||
<span v-else @click="startActivityEdit(item)" class="clickable activity-label"
|
||||
:title="item.predefinedActivity && item.predefinedActivity.name ? item.predefinedActivity.name : ''">
|
||||
{{ (item.predefinedActivity && item.predefinedActivity.code && item.predefinedActivity.code.trim() !== '')
|
||||
? item.predefinedActivity.code
|
||||
: (item.predefinedActivity ? item.predefinedActivity.name : item.activity) }}
|
||||
</span>
|
||||
</td>
|
||||
<td>{{ item.groupActivity ? item.groupActivity.name : '' }}</td>
|
||||
@@ -134,7 +142,13 @@
|
||||
<tr>
|
||||
<td></td>
|
||||
<td></td>
|
||||
<td>{{ groupItem.groupPredefinedActivity.name }}</td>
|
||||
<td>
|
||||
<span class="activity-label" :title="(groupItem.groupPredefinedActivity && groupItem.groupPredefinedActivity.name) ? groupItem.groupPredefinedActivity.name : ''">
|
||||
{{ (groupItem.groupPredefinedActivity && groupItem.groupPredefinedActivity.code && groupItem.groupPredefinedActivity.code.trim() !== '')
|
||||
? groupItem.groupPredefinedActivity.code
|
||||
: groupItem.groupPredefinedActivity.name }}
|
||||
</span>
|
||||
</td>
|
||||
<td>{{ groupItem.groupsGroupActivity.name }}</td>
|
||||
<td></td>
|
||||
<td></td>
|
||||
@@ -152,8 +166,17 @@
|
||||
@click="addGroupActivity">Gruppen-Aktivität</button>
|
||||
</td>
|
||||
<td v-if="addNewItem || addNewGroupActivity">
|
||||
<input v-if="addtype === 'activity'" type="text" v-model="newPlanItem.activity"
|
||||
placeholder="Aktivität / Zeitblock" required />
|
||||
<div v-if="addtype === 'activity'" style="position: relative;">
|
||||
<input type="text" v-model="newPlanItem.activity"
|
||||
placeholder="Aktivität / Zeitblock" required
|
||||
@input="onNewItemInputChange"
|
||||
/>
|
||||
<div v-if="newItemShowDropdown && newItemSearchResults.length" class="dropdown" style="max-height: 9.5em;">
|
||||
<div v-for="s in newItemSearchResults" :key="s.id" @click="chooseNewItemSuggestion(s)">
|
||||
<span v-if="s.code && s.code.trim() !== ''"><strong>[{{ s.code }}]</strong> </span>{{ s.name }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td v-else-if="addNewTimeblock">Zeitblock</td>
|
||||
<td v-if="addNewGroupActivity" colspan="2">
|
||||
@@ -188,14 +211,16 @@
|
||||
<div v-if="accidents.length > 0">
|
||||
</div>
|
||||
</div>
|
||||
<h3>Aktivitäten</h3>
|
||||
<textarea v-model="newActivity"></textarea>
|
||||
<button @click="addActivity">Aktivität hinzufügen</button>
|
||||
<ul>
|
||||
<li v-for="activity in activities" :key="activity.id">
|
||||
{{ activity.description }}
|
||||
</li>
|
||||
</ul>
|
||||
<h3 class="clickable" @click="toggleActivitiesBox">Aktivitäten <span>{{ showActivitiesBox ? '-' : '+' }}</span></h3>
|
||||
<div v-if="showActivitiesBox" class="collapsible-box">
|
||||
<textarea v-model="newActivity"></textarea>
|
||||
<button @click="addActivity">Aktivität hinzufügen</button>
|
||||
<ul>
|
||||
<li v-for="activity in activities" :key="activity.id">
|
||||
{{ activity.description }}
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<multiselect v-model="selectedActivityTags" :options="availableTags" placeholder="Tags auswählen"
|
||||
label="name" track-by="id" multiple :close-on-select="true" @tag="addNewTag"
|
||||
@remove="removeActivityTag" :allow-empty="false" @keydown.enter.prevent="addNewTagFromInput" />
|
||||
@@ -371,6 +396,16 @@ export default {
|
||||
},
|
||||
accidents: [],
|
||||
editingActivityId: null, // ID der Aktivität, die gerade bearbeitet wird
|
||||
// Suche für Inline-Edit
|
||||
editShowDropdown: false,
|
||||
editSearchResults: [],
|
||||
editSearchForId: null,
|
||||
editingActivityText: '',
|
||||
// Suche für Neue-Item-Eingabe
|
||||
newItemShowDropdown: false,
|
||||
newItemSearchResults: [],
|
||||
// Aktivitäten-Box (rechts)
|
||||
showActivitiesBox: false,
|
||||
};
|
||||
},
|
||||
watch: {
|
||||
@@ -1141,12 +1176,13 @@ export default {
|
||||
this.$nextTick(() => {
|
||||
this.$refs.activityInput.focus();
|
||||
});
|
||||
this.editingActivityText = item.predefinedActivity ? item.predefinedActivity.name : item.activity || '';
|
||||
},
|
||||
|
||||
async saveActivityEdit(item) {
|
||||
try {
|
||||
await apiClient.put(`/diary-date-activities/${this.currentClub}/${item.id}`, {
|
||||
customActivityName: item.activity,
|
||||
customActivityName: this.editingActivityText,
|
||||
duration: item.duration,
|
||||
durationText: item.durationText,
|
||||
groupId: item.groupId,
|
||||
@@ -1156,11 +1192,61 @@ export default {
|
||||
await this.loadTrainingPlan();
|
||||
|
||||
this.editingActivityId = null;
|
||||
this.editingActivityText = '';
|
||||
} catch (error) {
|
||||
alert('Ein Fehler ist aufgetreten. Bitte versuchen Sie es erneut.');
|
||||
}
|
||||
},
|
||||
|
||||
async searchPredefinedActivities(term) {
|
||||
if (!term || term.trim().length < 2) {
|
||||
return [];
|
||||
}
|
||||
try {
|
||||
const r = await apiClient.get('/predefined-activities/search/query', { params: { q: term, limit: 10 } });
|
||||
return r.data || [];
|
||||
} catch (e) {
|
||||
return [];
|
||||
}
|
||||
},
|
||||
|
||||
async onEditInputChangeText(item) {
|
||||
const term = this.editingActivityText;
|
||||
this.editSearchForId = item.id;
|
||||
if (!term || term.trim().length < 2) {
|
||||
this.editShowDropdown = false;
|
||||
this.editSearchResults = [];
|
||||
return;
|
||||
}
|
||||
const results = await this.searchPredefinedActivities(term);
|
||||
this.editSearchResults = results;
|
||||
this.editShowDropdown = results.length > 0;
|
||||
},
|
||||
|
||||
chooseEditSuggestion(s, item) {
|
||||
this.editingActivityText = (s.code && s.code.trim() !== '') ? s.code : s.name;
|
||||
this.editShowDropdown = false;
|
||||
this.editSearchResults = [];
|
||||
},
|
||||
|
||||
async onNewItemInputChange() {
|
||||
const term = this.newPlanItem.activity;
|
||||
if (!term || term.trim().length < 2) {
|
||||
this.newItemShowDropdown = false;
|
||||
this.newItemSearchResults = [];
|
||||
return;
|
||||
}
|
||||
const results = await this.searchPredefinedActivities(term);
|
||||
this.newItemSearchResults = results;
|
||||
this.newItemShowDropdown = results.length > 0;
|
||||
},
|
||||
|
||||
chooseNewItemSuggestion(s) {
|
||||
this.newPlanItem.activity = (s.code && s.code.trim() !== '') ? s.code : s.name;
|
||||
this.newItemShowDropdown = false;
|
||||
this.newItemSearchResults = [];
|
||||
},
|
||||
|
||||
async loadTrainingPlan() {
|
||||
try {
|
||||
this.trainingPlan = await apiClient.get(`/diary-date-activities/${this.currentClub}/${this.date.id}`).then(response => response.data);
|
||||
@@ -1173,6 +1259,9 @@ export default {
|
||||
cancelActivityEdit() {
|
||||
this.editingActivityId = null;
|
||||
},
|
||||
toggleActivitiesBox() {
|
||||
this.showActivitiesBox = !this.showActivitiesBox;
|
||||
},
|
||||
},
|
||||
async mounted() {
|
||||
await this.init();
|
||||
@@ -1222,13 +1311,13 @@ h3 {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
width: calc(100% - 1em);
|
||||
overflow: hidden;
|
||||
overflow: visible;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.column:first-child {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
overflow: visible;
|
||||
height: 100%;
|
||||
justify-self: start;
|
||||
display: flex;
|
||||
@@ -1305,9 +1394,30 @@ li {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.collapsible-box {
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--border-radius);
|
||||
padding: 0.5rem;
|
||||
background: white;
|
||||
}
|
||||
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
thead, tbody, tr, td, th {
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
td {
|
||||
position: static;
|
||||
}
|
||||
|
||||
/* Bearbeitungszelle soll relativer Kontext sein */
|
||||
.clickable, td > div[style*="position: relative"] {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
th,
|
||||
@@ -1339,7 +1449,7 @@ input[type="number"] {
|
||||
overflow-y: auto;
|
||||
position: absolute;
|
||||
background-color: white;
|
||||
z-index: 1000;
|
||||
z-index: 9999;
|
||||
width: calc(100% - 20px);
|
||||
box-shadow: 0px 4px 6px rgba(0, 0, 0, 0.1);
|
||||
max-width: 30em;
|
||||
@@ -1465,7 +1575,7 @@ img {
|
||||
.diary {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
overflow: visible;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
@@ -1491,4 +1601,33 @@ img {
|
||||
padding: 3px;
|
||||
background-color: #fff;
|
||||
}
|
||||
|
||||
.collapsible-box {
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--border-radius);
|
||||
padding: 0.5rem;
|
||||
background: white;
|
||||
}
|
||||
|
||||
.collapsible-box h3 {
|
||||
margin-top: 0;
|
||||
margin-bottom: 10px;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.collapsible-box h3 span {
|
||||
font-size: 1.2em;
|
||||
transition: transform 0.3s ease;
|
||||
}
|
||||
|
||||
.collapsible-box.collapsed h3 span {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
|
||||
.collapsible-box.expanded h3 span {
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
</style>
|
||||
|
||||
192
frontend/src/views/PredefinedActivities.vue
Normal file
192
frontend/src/views/PredefinedActivities.vue
Normal file
@@ -0,0 +1,192 @@
|
||||
<template>
|
||||
<div class="predef-activities">
|
||||
<h2>Vordefinierte Aktivitäten</h2>
|
||||
<div class="grid">
|
||||
<div class="list">
|
||||
<div class="toolbar">
|
||||
<button @click="startCreate" class="btn-primary">Neu</button>
|
||||
<button @click="reload" class="btn-secondary">Neu laden</button>
|
||||
</div>
|
||||
<ul class="items">
|
||||
<li v-for="a in activities" :key="a.id" :class="{ active: selectedActivity && selectedActivity.id === a.id }" @click="select(a)">
|
||||
<div class="title">
|
||||
<strong>{{ a.code ? '[' + a.code + '] ' : '' }}{{ a.name }}</strong>
|
||||
</div>
|
||||
<div class="meta">
|
||||
<span v-if="a.duration">{{ a.duration }} min</span>
|
||||
<span v-if="a.durationText"> ({{ a.durationText }})</span>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="detail" v-if="editModel">
|
||||
<h3>{{ editModel.id ? 'Aktivität bearbeiten' : 'Neue Aktivität' }}</h3>
|
||||
<form @submit.prevent="save">
|
||||
<label>Name
|
||||
<input type="text" v-model="editModel.name" required />
|
||||
</label>
|
||||
<label>Kürzel
|
||||
<input type="text" v-model="editModel.code" />
|
||||
</label>
|
||||
<label>Dauer (Minuten)
|
||||
<input type="number" v-model.number="editModel.duration" min="0" />
|
||||
</label>
|
||||
<label>Dauer (Text)
|
||||
<input type="text" v-model="editModel.durationText" placeholder="z.B. 2x7" />
|
||||
</label>
|
||||
<label>Beschreibung
|
||||
<textarea v-model="editModel.description" rows="4" />
|
||||
</label>
|
||||
<label>Bild-Link (optional)
|
||||
<input type="text" v-model="editModel.imageLink" placeholder="/api/predefined-activities/:id/image/:imageId oder extern" />
|
||||
</label>
|
||||
|
||||
<div class="actions">
|
||||
<button type="submit" class="btn-primary">Speichern</button>
|
||||
<button type="button" class="btn-secondary" @click="cancel">Abbrechen</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div v-if="editModel.id" class="images">
|
||||
<h4>Bild hochladen</h4>
|
||||
<input type="file" accept="image/*" @change="onFileChange" />
|
||||
<button class="btn-secondary" :disabled="!selectedFile" @click="uploadImage">Hochladen</button>
|
||||
|
||||
<div class="image-list" v-if="images && images.length">
|
||||
<div v-for="img in images" :key="img.id" class="image-item">
|
||||
<img :src="imageUrl(img)" alt="Activity Image" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import apiClient from '../apiClient.js';
|
||||
|
||||
export default {
|
||||
name: 'PredefinedActivities',
|
||||
data() {
|
||||
return {
|
||||
activities: [],
|
||||
selectedActivity: null,
|
||||
editModel: null,
|
||||
images: [],
|
||||
selectedFile: null,
|
||||
};
|
||||
},
|
||||
methods: {
|
||||
async reload() {
|
||||
const r = await apiClient.get('/predefined-activities');
|
||||
this.activities = r.data;
|
||||
},
|
||||
async select(a) {
|
||||
this.selectedActivity = a;
|
||||
const r = await apiClient.get(`/predefined-activities/${a.id}`);
|
||||
const { images, ...activity } = r.data;
|
||||
this.images = images || [];
|
||||
this.editModel = { ...activity };
|
||||
},
|
||||
startCreate() {
|
||||
this.selectedActivity = null;
|
||||
this.images = [];
|
||||
this.editModel = {
|
||||
name: '',
|
||||
code: '',
|
||||
description: '',
|
||||
duration: null,
|
||||
durationText: '',
|
||||
imageLink: '',
|
||||
};
|
||||
},
|
||||
cancel() {
|
||||
this.editModel = null;
|
||||
this.selectedActivity = null;
|
||||
this.images = [];
|
||||
},
|
||||
async save() {
|
||||
if (!this.editModel) return;
|
||||
if (this.editModel.id) {
|
||||
const { id, ...payload } = this.editModel;
|
||||
const r = await apiClient.put(`/predefined-activities/${id}`, payload);
|
||||
this.editModel = r.data;
|
||||
} else {
|
||||
const r = await apiClient.post('/predefined-activities', this.editModel);
|
||||
this.editModel = r.data;
|
||||
}
|
||||
await this.reload();
|
||||
},
|
||||
onFileChange(e) {
|
||||
this.selectedFile = e.target.files && e.target.files[0] ? e.target.files[0] : null;
|
||||
},
|
||||
imageUrl(img) {
|
||||
return `/api/predefined-activities/${this.editModel.id}/image/${img.id}`;
|
||||
},
|
||||
async uploadImage() {
|
||||
if (!this.editModel || !this.editModel.id || !this.selectedFile) return;
|
||||
const fd = new FormData();
|
||||
fd.append('image', this.selectedFile);
|
||||
await apiClient.post(`/predefined-activities/${this.editModel.id}/image`, fd, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' }
|
||||
});
|
||||
// Nach Upload Details neu laden
|
||||
await this.select(this.editModel);
|
||||
this.selectedFile = null;
|
||||
}
|
||||
},
|
||||
async mounted() {
|
||||
await this.reload();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.predef-activities {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: 320px 1fr;
|
||||
gap: 1rem;
|
||||
}
|
||||
.list {
|
||||
background: white;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--border-radius);
|
||||
padding: 0.5rem;
|
||||
}
|
||||
.toolbar {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
.items {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
.items li {
|
||||
padding: 0.5rem;
|
||||
border-radius: var(--border-radius-small);
|
||||
cursor: pointer;
|
||||
}
|
||||
.items li:hover { background: var(--primary-light);
|
||||
}
|
||||
.items li.active { background: var(--primary-light); color: var(--primary-color); }
|
||||
.detail {
|
||||
background: white;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--border-radius);
|
||||
padding: 0.75rem;
|
||||
}
|
||||
label { display: block; margin-bottom: 0.5rem; }
|
||||
input[type="text"], input[type="number"], textarea { width: 100%; }
|
||||
.actions { margin-top: 0.75rem; display: flex; gap: 0.5rem; }
|
||||
.image-list { display: flex; gap: 0.5rem; flex-wrap: wrap; margin-top: 0.5rem; }
|
||||
.image-item img { max-height: 120px; border: 1px solid var(--border-color); border-radius: var(--border-radius-small); }
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user