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:
@@ -1,9 +1,12 @@
|
||||
import predefinedActivityService from '../services/predefinedActivityService.js';
|
||||
import PredefinedActivityImage from '../models/PredefinedActivityImage.js';
|
||||
import path from 'path';
|
||||
import fs from 'fs';
|
||||
|
||||
export const createPredefinedActivity = async (req, res) => {
|
||||
try {
|
||||
const { name, description, durationText, duration } = req.body;
|
||||
const predefinedActivity = await predefinedActivityService.createPredefinedActivity({ name, description, durationText, duration });
|
||||
const { name, code, description, durationText, duration, imageLink } = req.body;
|
||||
const predefinedActivity = await predefinedActivityService.createPredefinedActivity({ name, code, description, durationText, duration, imageLink });
|
||||
res.status(201).json(predefinedActivity);
|
||||
} catch (error) {
|
||||
console.error('[createPredefinedActivity] - Error:', error);
|
||||
@@ -25,10 +28,11 @@ export const getPredefinedActivityById = async (req, res) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const predefinedActivity = await predefinedActivityService.getPredefinedActivityById(id);
|
||||
const images = await PredefinedActivityImage.findAll({ where: { predefinedActivityId: id } });
|
||||
if (!predefinedActivity) {
|
||||
return res.status(404).json({ error: 'Predefined activity not found' });
|
||||
}
|
||||
res.status(200).json(predefinedActivity);
|
||||
res.status(200).json({ ...predefinedActivity.toJSON(), images });
|
||||
} catch (error) {
|
||||
console.error('[getPredefinedActivityById] - Error:', error);
|
||||
res.status(500).json({ error: 'Error fetching predefined activity' });
|
||||
@@ -38,11 +42,22 @@ export const getPredefinedActivityById = async (req, res) => {
|
||||
export const updatePredefinedActivity = async (req, res) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const { name, description, durationText, duration } = req.body;
|
||||
const updatedActivity = await predefinedActivityService.updatePredefinedActivity(id, { name, description, durationText, duration });
|
||||
const { name, code, description, durationText, duration, imageLink } = req.body;
|
||||
const updatedActivity = await predefinedActivityService.updatePredefinedActivity(id, { name, code, description, durationText, duration, imageLink });
|
||||
res.status(200).json(updatedActivity);
|
||||
} catch (error) {
|
||||
console.error('[updatePredefinedActivity] - Error:', error);
|
||||
res.status(500).json({ error: 'Error updating predefined activity' });
|
||||
}
|
||||
};
|
||||
|
||||
export const searchPredefinedActivities = async (req, res) => {
|
||||
try {
|
||||
const { q, limit } = req.query;
|
||||
const result = await predefinedActivityService.searchPredefinedActivities(q, limit);
|
||||
res.status(200).json(result);
|
||||
} catch (error) {
|
||||
console.error('[searchPredefinedActivities] - Error:', error);
|
||||
res.status(500).json({ error: 'Error searching predefined activities' });
|
||||
}
|
||||
};
|
||||
|
||||
53
backend/controllers/predefinedActivityImageController.js
Normal file
53
backend/controllers/predefinedActivityImageController.js
Normal file
@@ -0,0 +1,53 @@
|
||||
import PredefinedActivity from '../models/PredefinedActivity.js';
|
||||
import PredefinedActivityImage from '../models/PredefinedActivityImage.js';
|
||||
import { checkAccess } from '../utils/userUtils.js';
|
||||
import path from 'path';
|
||||
import fs from 'fs';
|
||||
import sharp from 'sharp';
|
||||
|
||||
export const uploadPredefinedActivityImage = async (req, res) => {
|
||||
try {
|
||||
const { id } = req.params; // predefinedActivityId
|
||||
const { authcode: userToken } = req.headers;
|
||||
await checkAccess(userToken); // Club-Kontext ist hier nicht zwingend, falls gewünscht kann erweitert werden
|
||||
|
||||
const activity = await PredefinedActivity.findByPk(id);
|
||||
if (!activity) {
|
||||
return res.status(404).json({ error: 'Predefined activity not found' });
|
||||
}
|
||||
|
||||
if (!req.file || !req.file.buffer) {
|
||||
return res.status(400).json({ error: 'No image uploaded' });
|
||||
}
|
||||
|
||||
const imagesDir = path.join('images', 'predefined');
|
||||
if (!fs.existsSync(imagesDir)) {
|
||||
fs.mkdirSync(imagesDir, { recursive: true });
|
||||
}
|
||||
|
||||
const fileName = `${id}-${Date.now()}.jpg`;
|
||||
const filePath = path.join(imagesDir, fileName);
|
||||
|
||||
await sharp(req.file.buffer)
|
||||
.resize(800, 800, { fit: 'inside' })
|
||||
.jpeg({ quality: 85 })
|
||||
.toFile(filePath);
|
||||
|
||||
const imageRecord = await PredefinedActivityImage.create({
|
||||
predefinedActivityId: id,
|
||||
imagePath: filePath,
|
||||
mimeType: 'image/jpeg',
|
||||
});
|
||||
|
||||
// Optional: als imageLink am Activity-Datensatz setzen
|
||||
activity.imageLink = `/api/predefined-activities/${id}/image/${imageRecord.id}`;
|
||||
await activity.save();
|
||||
|
||||
res.status(201).json({ id: imageRecord.id, imageLink: activity.imageLink });
|
||||
} catch (error) {
|
||||
console.error('[uploadPredefinedActivityImage] - Error:', error);
|
||||
res.status(500).json({ error: 'Failed to upload image' });
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -11,6 +11,10 @@ const PredefinedActivity = sequelize.define('PredefinedActivity', {
|
||||
type: DataTypes.STRING,
|
||||
allowNull: false,
|
||||
},
|
||||
code: {
|
||||
type: DataTypes.STRING,
|
||||
allowNull: true,
|
||||
},
|
||||
description: {
|
||||
type: DataTypes.TEXT,
|
||||
allowNull: true,
|
||||
@@ -23,6 +27,10 @@ const PredefinedActivity = sequelize.define('PredefinedActivity', {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: true,
|
||||
},
|
||||
imageLink: {
|
||||
type: DataTypes.STRING,
|
||||
allowNull: true,
|
||||
},
|
||||
}, {
|
||||
tableName: 'predefined_activities',
|
||||
timestamps: true,
|
||||
|
||||
30
backend/models/PredefinedActivityImage.js
Normal file
30
backend/models/PredefinedActivityImage.js
Normal file
@@ -0,0 +1,30 @@
|
||||
import { DataTypes } from 'sequelize';
|
||||
import sequelize from '../database.js';
|
||||
|
||||
const PredefinedActivityImage = sequelize.define('PredefinedActivityImage', {
|
||||
id: {
|
||||
type: DataTypes.INTEGER,
|
||||
primaryKey: true,
|
||||
autoIncrement: true,
|
||||
},
|
||||
predefinedActivityId: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: false,
|
||||
},
|
||||
imagePath: {
|
||||
type: DataTypes.STRING,
|
||||
allowNull: false,
|
||||
},
|
||||
mimeType: {
|
||||
type: DataTypes.STRING,
|
||||
allowNull: true,
|
||||
},
|
||||
}, {
|
||||
tableName: 'predefined_activity_images',
|
||||
timestamps: true,
|
||||
underscored: true,
|
||||
});
|
||||
|
||||
export default PredefinedActivityImage;
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ import DiaryDateTag from './DiaryDateTag.js';
|
||||
import DiaryMemberNote from './DiaryMemberNote.js';
|
||||
import DiaryMemberTag from './DiaryMemberTag.js';
|
||||
import PredefinedActivity from './PredefinedActivity.js';
|
||||
import PredefinedActivityImage from './PredefinedActivityImage.js';
|
||||
import DiaryDateActivity from './DiaryDateActivity.js';
|
||||
import Match from './Match.js';
|
||||
import League from './League.js';
|
||||
@@ -76,6 +77,9 @@ DiaryDateActivity.belongsTo(DiaryDate, { foreignKey: 'diaryDateId', as: 'diaryDa
|
||||
|
||||
PredefinedActivity.hasMany(DiaryDateActivity, { foreignKey: 'predefinedActivityId', as: 'predefinedActivities' });
|
||||
DiaryDateActivity.belongsTo(PredefinedActivity, { foreignKey: 'predefinedActivityId', as: 'predefinedActivity' });
|
||||
// PredefinedActivity Images
|
||||
PredefinedActivity.hasMany(PredefinedActivityImage, { foreignKey: 'predefinedActivityId', as: 'images' });
|
||||
PredefinedActivityImage.belongsTo(PredefinedActivity, { foreignKey: 'predefinedActivityId', as: 'predefinedActivity' });
|
||||
|
||||
Club.hasMany(Match, { foreignKey: 'clubId', as: 'matches' });
|
||||
Match.belongsTo(Club, { foreignKey: 'clubId', as: 'club' });
|
||||
@@ -198,6 +202,7 @@ export {
|
||||
DiaryMemberNote,
|
||||
DiaryMemberTag,
|
||||
PredefinedActivity,
|
||||
PredefinedActivityImage,
|
||||
DiaryDateActivity,
|
||||
Match,
|
||||
League,
|
||||
|
||||
@@ -4,13 +4,35 @@ import {
|
||||
getAllPredefinedActivities,
|
||||
getPredefinedActivityById,
|
||||
updatePredefinedActivity,
|
||||
searchPredefinedActivities,
|
||||
} from '../controllers/predefinedActivityController.js';
|
||||
import multer from 'multer';
|
||||
import { authenticate } from '../middleware/authMiddleware.js';
|
||||
import { uploadPredefinedActivityImage } from '../controllers/predefinedActivityImageController.js';
|
||||
import PredefinedActivityImage from '../models/PredefinedActivityImage.js';
|
||||
import path from 'path';
|
||||
import fs from 'fs';
|
||||
|
||||
const router = express.Router();
|
||||
const upload = multer({ storage: multer.memoryStorage() });
|
||||
|
||||
router.post('/', createPredefinedActivity);
|
||||
router.get('/', getAllPredefinedActivities);
|
||||
router.get('/:id', getPredefinedActivityById);
|
||||
router.put('/:id', updatePredefinedActivity);
|
||||
router.post('/', authenticate, createPredefinedActivity);
|
||||
router.get('/', authenticate, getAllPredefinedActivities);
|
||||
router.get('/:id', authenticate, getPredefinedActivityById);
|
||||
router.put('/:id', authenticate, updatePredefinedActivity);
|
||||
router.post('/:id/image', authenticate, upload.single('image'), uploadPredefinedActivityImage);
|
||||
router.get('/search/query', authenticate, searchPredefinedActivities);
|
||||
router.get('/:id/image/:imageId', authenticate, async (req, res) => {
|
||||
try {
|
||||
const { id, imageId } = req.params;
|
||||
const image = await PredefinedActivityImage.findOne({ where: { id: imageId, predefinedActivityId: id } });
|
||||
if (!image) return res.status(404).json({ error: 'Image not found' });
|
||||
if (!fs.existsSync(image.imagePath)) return res.status(404).json({ error: 'Image file missing' });
|
||||
res.sendFile(path.resolve(image.imagePath));
|
||||
} catch (e) {
|
||||
console.error('[getPredefinedActivityImage] - Error:', e);
|
||||
res.status(500).json({ error: 'Failed to fetch image' });
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
|
||||
@@ -6,7 +6,7 @@ import cors from 'cors';
|
||||
import {
|
||||
User, Log, Club, UserClub, Member, DiaryDate, Participant, Activity, MemberNote,
|
||||
DiaryNote, DiaryTag, MemberDiaryTag, DiaryDateTag, DiaryMemberNote, DiaryMemberTag,
|
||||
PredefinedActivity, DiaryDateActivity, Match, League, Team, Group,
|
||||
PredefinedActivity, PredefinedActivityImage, DiaryDateActivity, Match, League, Team, Group,
|
||||
GroupActivity, Tournament, TournamentGroup, TournamentMatch, TournamentResult,
|
||||
TournamentMember, Accident, UserToken
|
||||
} from './models/index.js';
|
||||
@@ -88,6 +88,7 @@ app.get('*', (req, res) => {
|
||||
await DiaryMemberTag.sync({ alter: true });
|
||||
await DiaryMemberNote.sync({ alter: true });
|
||||
await PredefinedActivity.sync({ alter: true });
|
||||
await PredefinedActivityImage.sync({ alter: true });
|
||||
await DiaryDateActivity.sync({ alter: true });
|
||||
await Season.sync({ alter: true });
|
||||
await League.sync({ alter: true });
|
||||
|
||||
@@ -1,13 +1,16 @@
|
||||
import PredefinedActivity from '../models/PredefinedActivity.js';
|
||||
import { Op } from 'sequelize';
|
||||
|
||||
class PredefinedActivityService {
|
||||
async createPredefinedActivity(data) {
|
||||
console.log('[PredefinedActivityService::createPredefinedActivity] - Creating predefined activity');
|
||||
return await PredefinedActivity.create({
|
||||
name: data.name,
|
||||
code: data.code,
|
||||
description: data.description,
|
||||
durationText: data.durationText,
|
||||
duration: data.duration,
|
||||
imageLink: data.imageLink,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -20,9 +23,11 @@ class PredefinedActivityService {
|
||||
}
|
||||
return await activity.update({
|
||||
name: data.name,
|
||||
code: data.code,
|
||||
description: data.description,
|
||||
durationText: data.durationText,
|
||||
duration: data.duration,
|
||||
imageLink: data.imageLink,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -40,6 +45,23 @@ class PredefinedActivityService {
|
||||
}
|
||||
return activity;
|
||||
}
|
||||
|
||||
async searchPredefinedActivities(query, limit = 20) {
|
||||
const q = (query || '').trim();
|
||||
if (!q || q.length < 2) {
|
||||
return [];
|
||||
}
|
||||
return await PredefinedActivity.findAll({
|
||||
where: {
|
||||
[Op.or]: [
|
||||
{ name: { [Op.like]: `%${q}%` } },
|
||||
{ code: { [Op.like]: `%${q}%` } },
|
||||
],
|
||||
},
|
||||
order: [['name', 'ASC']],
|
||||
limit: Math.min(parseInt(limit || 20, 10), 50),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export default new PredefinedActivityService();
|
||||
|
||||
@@ -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