diff --git a/backend/controllers/predefinedActivityController.js b/backend/controllers/predefinedActivityController.js index a5d27e9..3fee1f7 100644 --- a/backend/controllers/predefinedActivityController.js +++ b/backend/controllers/predefinedActivityController.js @@ -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' }); + } +}; diff --git a/backend/controllers/predefinedActivityImageController.js b/backend/controllers/predefinedActivityImageController.js new file mode 100644 index 0000000..820228c --- /dev/null +++ b/backend/controllers/predefinedActivityImageController.js @@ -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' }); + } +}; + + diff --git a/backend/models/PredefinedActivity.js b/backend/models/PredefinedActivity.js index 89f1253..749c6a5 100644 --- a/backend/models/PredefinedActivity.js +++ b/backend/models/PredefinedActivity.js @@ -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, diff --git a/backend/models/PredefinedActivityImage.js b/backend/models/PredefinedActivityImage.js new file mode 100644 index 0000000..c385931 --- /dev/null +++ b/backend/models/PredefinedActivityImage.js @@ -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; + + diff --git a/backend/models/index.js b/backend/models/index.js index 3f74910..f870a69 100644 --- a/backend/models/index.js +++ b/backend/models/index.js @@ -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, diff --git a/backend/routes/predefinedActivityRoutes.js b/backend/routes/predefinedActivityRoutes.js index 9da53f9..e4ef704 100644 --- a/backend/routes/predefinedActivityRoutes.js +++ b/backend/routes/predefinedActivityRoutes.js @@ -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; diff --git a/backend/server.js b/backend/server.js index 28f28f4..e17301b 100644 --- a/backend/server.js +++ b/backend/server.js @@ -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 }); diff --git a/backend/services/predefinedActivityService.js b/backend/services/predefinedActivityService.js index aa6a688..5a023da 100644 --- a/backend/services/predefinedActivityService.js +++ b/backend/services/predefinedActivityService.js @@ -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(); diff --git a/frontend/src/App.vue b/frontend/src/App.vue index 01fbe93..9bf1732 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -52,6 +52,10 @@ 🏆 Turniere + + ⚙️ + Vordefinierte Aktivitäten + diff --git a/frontend/src/router.js b/frontend/src/router.js index cace9c4..4e2b143 100644 --- a/frontend/src/router.js +++ b/frontend/src/router.js @@ -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({ diff --git a/frontend/src/views/DiaryView.vue b/frontend/src/views/DiaryView.vue index 49908eb..d686ae7 100644 --- a/frontend/src/views/DiaryView.vue +++ b/frontend/src/views/DiaryView.vue @@ -45,7 +45,7 @@ -
+

Gruppenverwaltung

@@ -84,7 +84,7 @@

Trainingsplan

-
+
@@ -104,11 +104,11 @@ @@ -134,7 +142,13 @@ - + @@ -152,8 +166,17 @@ @click="addGroupActivity">Gruppen-Aktivität
Zeitblock -
+
+
- - {{ item.predefinedActivity ? item.predefinedActivity.name : item.activity }} + + {{ (item.predefinedActivity && item.predefinedActivity.code && item.predefinedActivity.code.trim() !== '') + ? item.predefinedActivity.code + : (item.predefinedActivity ? item.predefinedActivity.name : item.activity) }}
{{ item.groupActivity ? item.groupActivity.name : '' }}
{{ groupItem.groupPredefinedActivity.name }} + + {{ (groupItem.groupPredefinedActivity && groupItem.groupPredefinedActivity.code && groupItem.groupPredefinedActivity.code.trim() !== '') + ? groupItem.groupPredefinedActivity.code + : groupItem.groupPredefinedActivity.name }} + + {{ groupItem.groupsGroupActivity.name }} - +
+ + +
Zeitblock @@ -188,14 +211,16 @@
-

Aktivitäten

- - -
    -
  • - {{ activity.description }} -
  • -
+

Aktivitäten {{ showActivitiesBox ? '-' : '+' }}

+
+ + +
    +
  • + {{ activity.description }} +
  • +
+
@@ -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); +} diff --git a/frontend/src/views/PredefinedActivities.vue b/frontend/src/views/PredefinedActivities.vue new file mode 100644 index 0000000..fff4a04 --- /dev/null +++ b/frontend/src/views/PredefinedActivities.vue @@ -0,0 +1,192 @@ +