Implement Court Drawing Dialog and enhance activity management in DiaryView and PredefinedActivities

Added a Court Drawing Dialog component to facilitate the creation and editing of drawing data for activities. Updated DiaryView.vue to include a button for opening the drawing dialog and handling the resulting data. Enhanced PredefinedActivities.vue to allow users to create or edit drawing data directly from the activity form, improving the overall user experience. Refactored related styles and logic for better integration and usability.
This commit is contained in:
Torsten Schulz (local)
2025-10-31 15:05:40 +01:00
parent 6a333f198d
commit 91fc3e9d13
4 changed files with 386 additions and 42 deletions

View File

@@ -0,0 +1,168 @@
<template>
<BaseDialog
:model-value="modelValue"
@update:model-value="$emit('update:modelValue', $event)"
title="Tischtennis-Übung konfigurieren"
size="large"
:close-on-overlay="false"
@close="handleClose"
>
<CourtDrawingTool
ref="drawingTool"
:drawing-data="initialDrawingData"
:allow-image-upload="false"
@update-drawing-data="handleDrawingDataUpdate"
@update-fields="handleFieldsUpdate"
/>
<template #footer>
<button class="btn-secondary" @click="handleClose">Abbrechen</button>
<button
class="btn-primary"
@click="handleOk"
:disabled="!isValid"
>
OK
</button>
</template>
</BaseDialog>
</template>
<script>
import BaseDialog from './BaseDialog.vue';
import CourtDrawingTool from './CourtDrawingTool.vue';
export default {
name: 'CourtDrawingDialog',
components: {
BaseDialog,
CourtDrawingTool
},
props: {
modelValue: {
type: Boolean,
default: false
},
initialDrawingData: {
type: Object,
default: null
}
},
emits: ['update:modelValue', 'close', 'ok'],
data() {
return {
currentDrawingData: null,
currentFields: null
};
},
computed: {
isValid() {
// Mindestens Aufschlag und Zielposition müssen gesetzt sein
return this.currentDrawingData &&
this.currentDrawingData.selectedStartPosition &&
this.currentDrawingData.strokeType &&
this.currentDrawingData.spinType &&
this.currentDrawingData.targetPosition;
}
},
watch: {
initialDrawingData: {
handler(newVal) {
if (newVal) {
this.currentDrawingData = { ...newVal };
}
},
deep: true,
immediate: true
},
modelValue(newVal) {
// Wenn Dialog geöffnet wird, stelle sicher dass Tool neu gezeichnet wird
if (newVal) {
this.$nextTick(() => {
// Warte bis der Dialog vollständig gerendert ist
setTimeout(() => {
if (this.$refs.drawingTool && this.$refs.drawingTool.drawCourt) {
this.$refs.drawingTool.drawCourt(true);
}
}, 100);
});
}
}
},
mounted() {
// Wenn Dialog bereits beim Mount geöffnet ist
if (this.modelValue) {
this.$nextTick(() => {
setTimeout(() => {
if (this.$refs.drawingTool && this.$refs.drawingTool.drawCourt) {
this.$refs.drawingTool.drawCourt(true);
}
}, 100);
});
}
},
methods: {
handleDrawingDataUpdate(data) {
this.currentDrawingData = { ...data };
},
handleFieldsUpdate(fields) {
this.currentFields = { ...fields };
},
handleClose() {
this.$emit('update:modelValue', false);
this.$emit('close');
},
handleOk() {
if (this.isValid && this.currentDrawingData) {
// Sammle alle Daten zusammen
const result = {
drawingData: { ...this.currentDrawingData },
fields: this.currentFields ? { ...this.currentFields } : null,
code: this.currentDrawingData.code || (this.currentFields ? this.currentFields.code : ''),
name: this.currentFields ? this.currentFields.name : '',
description: this.currentFields ? this.currentFields.description : ''
};
this.$emit('ok', result);
this.handleClose();
}
}
}
};
</script>
<style scoped>
.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

@@ -96,7 +96,7 @@
<span class="group-label">Zielposition:</span>
<div class="target-grid">
<button
v-for="n in 9"
v-for="n in mainTargetPositions"
:key="`main-target-${n}`"
type="button"
:class="['grid-btn', { 'is-active': targetPosition === String(n) }]"
@@ -205,7 +205,7 @@
<span>Zielposition:</span>
<div class="target-grid">
<button
v-for="n in 9"
v-for="n in additionalTargetPositions"
:key="`next-target-${n}`"
type="button"
:class="['grid-btn', { 'is-active': nextStrokeTargetPosition === String(n) }]"
@@ -379,6 +379,28 @@ export default {
nextStrokeTargetPosition: ''
}
},
computed: {
// Reihenfolge der Positionen für Hauptschlag basierend auf Richtung
mainTargetPositions() {
const isLeftToRight = this.isMainStrokeLeftToRight();
// Links nach rechts: 7 4 1 / 8 5 2 / 9 6 3
// Rechts nach links: 3 6 9 / 2 5 8 / 1 4 7
if (isLeftToRight) {
return [7, 4, 1, 8, 5, 2, 9, 6, 3];
} else {
return [3, 6, 9, 2, 5, 8, 1, 4, 7];
}
},
// Reihenfolge der Positionen für zusätzliche Schläge basierend auf Richtung
additionalTargetPositions() {
const isLeftToRight = this.isAdditionalStrokeLeftToRight();
if (isLeftToRight) {
return [7, 4, 1, 8, 5, 2, 9, 6, 3];
} else {
return [3, 6, 9, 2, 5, 8, 1, 4, 7];
}
}
},
watch: {
strokeType() {
this.emitDrawingData();
@@ -905,6 +927,53 @@ export default {
return idx % 2 === 0 ? 'left' : 'right';
},
// Bestimmt ob der Hauptschlag von links nach rechts geht
isMainStrokeLeftToRight() {
// Bei Aufschlag immer links nach rechts
if (this.selectedStartPosition && this.selectedStartPosition.startsWith('AS')) {
return true;
}
// Wenn von links kommt, dann links nach rechts
if (this.selectedCirclePosition === 'left') {
return true;
}
// Wenn von rechts kommt, dann rechts nach links
if (this.selectedCirclePosition === 'right') {
return false;
}
// Standard: links nach rechts
return true;
},
// Bestimmt ob der nächste zusätzliche Schlag von links nach rechts geht
isAdditionalStrokeLeftToRight() {
// Wenn der Hauptschlag ein Aufschlag ist, endet er rechts
// Dann kommt der erste zusätzliche Schlag von rechts → rechts nach links → false
// Der zweite zusätzliche Schlag kommt dann von links → links nach rechts → true
// etc.
// Wenn der Hauptschlag kein Aufschlag ist und von links kommt, endet er rechts
// Dann kommt der erste zusätzliche Schlag von rechts → rechts nach links → false
// Wenn der Hauptschlag von rechts kommt, endet er links
// Dann kommt der erste zusätzliche Schlag von links → links nach rechts → true
const nextIdx = this.additionalStrokes.length;
const isMainStrokeEndingRight = this.isMainStrokeLeftToRight();
// Wenn der Hauptschlag rechts endet (links nach rechts), dann:
// - erster zusätzlicher Schlag kommt von rechts → rechts nach links → false
// - zweiter zusätzlicher Schlag kommt von links → links nach rechts → true
if (isMainStrokeEndingRight) {
return nextIdx % 2 === 1; // ungerade Index = links nach rechts
} else {
// Wenn der Hauptschlag links endet (rechts nach links), dann:
// - erster zusätzlicher Schlag kommt von links → links nach rechts → true
// - zweiter zusätzlicher Schlag kommt von rechts → rechts nach links → false
return nextIdx % 2 === 0; // gerade Index = links nach rechts
}
},
// Berechne Mittelpunkt eines rechten Zielkreises (1..9)
computeRightTargetCenter(tableX, tableY, tableWidth, tableHeight, number) {
const cfg = this.config.targetCircles;

View File

@@ -244,10 +244,20 @@
@click="addGroupActivity">Gruppen-Aktivität</button>
</td>
<td v-if="addNewItem || addNewGroupActivity">
<div v-if="addtype === 'activity'" style="position: relative;">
<div v-if="addtype === 'activity'" style="position: relative; display: flex; align-items: center; gap: 0.5rem;">
<button
type="button"
class="btn-palette"
@click="showDrawingDialog = true"
title="Übungszeichnung erstellen"
style="margin: 0; margin-left: 0;"
>
🎨
</button>
<input type="text" v-model="newPlanItem.activity"
placeholder="Aktivität / Zeitblock" required
@input="onNewItemInputChange" />
@input="onNewItemInputChange"
style="flex: 1;" />
<div v-if="newItemShowDropdown && newItemSearchResults.length"
class="dropdown" style="max-height: 9.5em;">
<div v-for="s in newItemSearchResults" :key="s.id"
@@ -400,6 +410,13 @@
@submit="createAndAddMember"
@close="closeQuickAddDialog"
/>
<!-- Court Drawing Dialog -->
<CourtDrawingDialog
v-model="showDrawingDialog"
:initial-drawing-data="null"
@ok="handleDrawingDialogOkForDiary"
/>
</div>
<!-- Info Dialog -->
@@ -430,6 +447,7 @@ import Multiselect from 'vue-multiselect';
import Sortable from 'sortablejs';
import PDFGenerator from '../components/PDFGenerator.js';
import CourtDrawingRender from '../components/CourtDrawingRender.vue';
import CourtDrawingDialog from '../components/CourtDrawingDialog.vue';
import InfoDialog from '../components/InfoDialog.vue';
import ConfirmDialog from '../components/ConfirmDialog.vue';
import ImageDialog from '../components/ImageDialog.vue';
@@ -444,6 +462,7 @@ export default {
components: {
Multiselect,
CourtDrawingRender,
CourtDrawingDialog,
InfoDialog,
ConfirmDialog,
ImageDialog,
@@ -558,7 +577,9 @@ export default {
lastName: '',
birthDate: '',
gender: ''
}
},
// Court Drawing Dialog
showDrawingDialog: false
};
},
watch: {
@@ -1731,10 +1752,58 @@ export default {
chooseNewItemSuggestion(s) {
this.newPlanItem.activity = (s.code && s.code.trim() !== '') ? s.code : s.name;
this.newPlanItem.durationText = s.durationText || '';
this.newPlanItem.duration = s.duration || '';
this.newItemShowDropdown = false;
this.newItemSearchResults = [];
},
async handleDrawingDialogOkForDiary(result) {
if (!result || !result.code) {
this.showInfo('Fehler', 'Keine Übungsdaten erhalten', '', 'error');
return;
}
try {
const code = result.code.trim();
// Suche nach existierender Aktivität mit diesem Code
const searchResults = await this.searchPredefinedActivities(code);
const existing = searchResults.find(a =>
a.code && a.code.trim().toLowerCase() === code.toLowerCase()
);
let activityToUse;
if (existing) {
// Aktivität existiert bereits - verwende diese
activityToUse = existing;
} else {
// Erstelle neue PredefinedActivity
const newActivity = {
name: result.name || result.fields?.name || '',
code: code,
description: result.description || result.fields?.description || '',
drawingData: result.drawingData || null
};
const response = await apiClient.post('/predefined-activities', newActivity);
activityToUse = response.data;
}
// Setze die Aktivität im Formular
this.chooseNewItemSuggestion(activityToUse);
// Erstelle automatisch den Plan-Eintrag
await this.addPlanItem();
} catch (error) {
console.error('Fehler beim Verarbeiten der Übungszeichnung:', error);
const msg = error.response?.data?.error || 'Fehler beim Erstellen der Aktivität';
this.showInfo('Fehler', msg, '', 'error');
}
},
async loadTrainingPlan() {
try {
this.trainingPlan = await apiClient.get(`/diary-date-activities/${this.currentClub}/${this.date.id}`).then(response => response.data);
@@ -2651,6 +2720,29 @@ img {
font-size: 0.9rem !important;
}
.btn-palette {
background: linear-gradient(135deg, #4CAF50, #45a049);
color: white;
border: none;
border-radius: 4px;
padding: 0.25rem;
font-size: 1rem;
cursor: pointer;
transition: all 0.2s ease;
width: 1.75rem;
height: 0.75rem;
margin: 0 !important;
margin-left: 0 !important;
display: flex;
align-items: center;
justify-content: center;
}
.btn-palette:hover {
background: linear-gradient(135deg, #45a049, #3d8b40);
transform: translateY(-1px);
}
.modal .btn-primary:hover {
background-color: #0056b3 !important;
}

View File

@@ -47,6 +47,11 @@
<div class="detail" v-if="editModel">
<h3>{{ editModel.id ? 'Aktivität bearbeiten' : 'Neue Aktivität' }}</h3>
<div class="drawing-button-section">
<button type="button" class="btn-secondary" @click="showDrawingDialog = true">
{{ editModel.drawingData ? 'Übungszeichnung bearbeiten' : 'Übungszeichnung erstellen' }}
</button>
</div>
<form @submit.prevent="save">
<label>Name
<input type="text" v-model="editModel.name" required />
@@ -65,7 +70,7 @@
</label>
<div class="image-section">
<h4>Bild hinzufügen</h4>
<p class="image-help">Du kannst entweder einen Link zu einem Bild eingeben, ein Bild hochladen oder eine Übungszeichnung erstellen:</p>
<p class="image-help">Du kannst entweder einen Link zu einem Bild eingeben oder ein Bild hochladen:</p>
<label>Bild-Link (optional)
<input type="text" v-model="editModel.imageLink" placeholder="z.B. https://example.com/bild.jpg oder /api/predefined-activities/:id/image/:imageId" />
@@ -83,18 +88,6 @@
</p>
</div>
<!-- Zeichen-Tool -->
<div class="drawing-section">
<h5>Übungszeichnung erstellen</h5>
<CourtDrawingTool
:activity-id="editModel.id"
:drawing-data="editModel.drawingData"
:allow-image-upload="false"
@update-fields="onUpdateFields"
@update-drawing-data="onUpdateDrawingData"
/>
</div>
<div class="image-list" v-if="images && images.length">
<h5>Hochgeladene Bilder:</h5>
<div class="image-grid">
@@ -135,19 +128,25 @@
@confirm="handleConfirmResult(true)"
@cancel="handleConfirmResult(false)"
/>
<!-- Court Drawing Dialog -->
<CourtDrawingDialog
v-model="showDrawingDialog"
:initial-drawing-data="editModel ? editModel.drawingData : null"
@ok="handleDrawingDialogOk"
/>
</template>
<script>
import apiClient from '../apiClient.js';
import CourtDrawingTool from '../components/CourtDrawingTool.vue';
import CourtDrawingDialog from '../components/CourtDrawingDialog.vue';
import InfoDialog from '../components/InfoDialog.vue';
import ConfirmDialog from '../components/ConfirmDialog.vue';
export default {
name: 'PredefinedActivities',
components: {
CourtDrawingTool
,
CourtDrawingDialog,
InfoDialog,
ConfirmDialog},
data() {
@@ -179,6 +178,7 @@ export default {
searchQuery: '',
searchResults: [],
isSearching: false,
showDrawingDialog: false,
};
},
computed: {
@@ -333,6 +333,25 @@ export default {
async save() {
if (!this.editModel) return;
// Prüfe auf Duplikate nur bei neuen Aktivitäten (nicht bei Updates)
if (!this.editModel.id && this.editModel.code && this.editModel.code.trim()) {
const codeToCheck = this.editModel.code.trim().toLowerCase();
const existing = this.activities.find(a =>
a.code && a.code.trim().toLowerCase() === codeToCheck
);
if (existing) {
const confirmed = await this.showConfirm(
'Aktivität existiert bereits',
`Eine Aktivität mit dem Kürzel "${this.editModel.code}" existiert bereits:\n${this.formatItem(existing)}\n\nTrotzdem speichern?`,
'',
'warning'
);
if (!confirmed) {
return; // Speichern abbrechen
}
}
}
if (this.editModel.id) {
const { id, ...payload } = this.editModel;
@@ -424,16 +443,22 @@ export default {
}
// Nicht automatisch speichern, nur wenn User explizit "Speichern" klickt
},
onUpdateFields(fields) {
if (this.editModel) {
this.editModel.code = fields.code;
this.editModel.name = fields.name;
this.editModel.description = fields.description;
}
},
onUpdateDrawingData(data) {
if (this.editModel) {
this.editModel.drawingData = data;
handleDrawingDialogOk(result) {
if (this.editModel && result) {
// Übernehme alle Daten vom Dialog
if (result.drawingData) {
this.editModel.drawingData = result.drawingData;
}
if (result.fields) {
this.editModel.code = result.fields.code || result.code || '';
this.editModel.name = result.fields.name || result.name || '';
this.editModel.description = result.fields.description || result.description || '';
} else {
// Fallback falls fields nicht vorhanden
if (result.code) this.editModel.code = result.code;
if (result.name) this.editModel.name = result.name;
if (result.description) this.editModel.description = result.description;
}
}
},
@@ -586,18 +611,8 @@ input[type="text"], input[type="number"], textarea { width: 100%; }
background: #c82333;
}
.drawing-section {
.drawing-button-section {
margin: 1rem 0;
padding: 1rem;
background: white;
border-radius: var(--border-radius);
border: 1px solid #ddd;
}
.drawing-section h5 {
margin: 0 0 1rem 0;
color: #333;
font-size: 1rem;
}
/* Suchfeld Styles */