feat(MemberGroupPhoto): implement group photo management functionality
All checks were successful
Deploy tt-tagebuch / deploy (push) Successful in 38s

- Added MemberGroupPhoto model and established relationships with Club and User models.
- Introduced new routes for managing group photos in the backend.
- Enhanced frontend components to support group photo cropping and member image updates.
- Updated localization files to include new terms related to group photo processing across multiple languages.
- Refactored server.js to include MemberGroupPhoto in the synchronization process.
This commit is contained in:
Torsten Schulz (local)
2026-04-15 22:45:35 +02:00
parent 5fa34637ba
commit 1dd7bb24ea
26 changed files with 1384 additions and 2 deletions

View File

@@ -0,0 +1,636 @@
<template>
<BaseDialog
:model-value="modelValue"
title="Gruppenfoto verarbeiten"
size="large"
:width="1180"
max-width="96vw"
@update:model-value="emitClose"
@close="emitClose"
>
<div class="group-photo-dialog">
<section class="group-photo-start">
<div class="group-photo-actions">
<input ref="fileInput" type="file" accept="image/*" class="hidden-input" @change="handleFileChange">
<input ref="cameraInput" type="file" accept="image/*" capture="environment" class="hidden-input" @change="handleFileChange">
<button type="button" @click="$refs.cameraInput?.click()">Foto aufnehmen</button>
<button type="button" @click="$refs.fileInput?.click()">Foto hochladen</button>
<button type="button" @click="loadSavedPhotos" :disabled="loadingSaved">
{{ loadingSaved ? 'Lade ...' : 'Gespeicherte Fotos laden' }}
</button>
</div>
<p class="group-photo-hint">
Das Gruppenfoto kann nur lokal verarbeitet oder fuer spaetere Bearbeitung im Verein gespeichert werden.
</p>
</section>
<section v-if="savedPhotos.length" class="saved-photo-list">
<h3>Gespeicherte Gruppenfotos</h3>
<div class="saved-photo-grid">
<article v-for="photo in savedPhotos" :key="photo.id" class="saved-photo-item">
<div>
<strong>{{ photo.title }}</strong>
<span>{{ formatDate(photo.takenAt || photo.createdAt) }}</span>
</div>
<div class="saved-photo-actions">
<button type="button" @click="openSavedPhoto(photo)">Oeffnen</button>
<button type="button" class="danger-button" @click="deleteSavedPhoto(photo)">Loeschen</button>
</div>
</article>
</div>
</section>
<section v-if="sourceUrl" class="save-source-panel">
<label class="checkbox-line">
<input type="checkbox" v-model="saveForLater">
Fuer spaetere Bearbeitung speichern
</label>
<div v-if="saveForLater" class="save-source-fields">
<label>
<span>Titel</span>
<input v-model="sourceTitle" type="text" placeholder="Training 15.04.2026">
</label>
<label>
<span>Datum</span>
<input v-model="sourceTakenAt" type="date">
</label>
<label>
<span>Notiz</span>
<input v-model="sourceDescription" type="text" placeholder="optional">
</label>
<button type="button" class="btn-primary" :disabled="savingSource || !sourceFile || currentSavedPhoto" @click="saveSourcePhoto">
{{ currentSavedPhoto ? 'Gruppenfoto gespeichert' : savingSource ? 'Speichere ...' : 'Gruppenfoto speichern' }}
</button>
</div>
<p v-if="saveForLater" class="privacy-note">
Dieses Gruppenfoto wird gespeichert und kann andere Personen enthalten. Bitte nur speichern, wenn die Nutzung im Verein zulaessig ist.
</p>
</section>
<section v-if="sourceUrl" class="crop-workspace">
<div class="crop-stage">
<div
ref="imageFrame"
class="crop-image-frame"
@mousedown="startSelection"
@mousemove="moveSelection"
@mouseup="finishSelection"
@mouseleave="finishSelection"
@touchstart.prevent="startSelection"
@touchmove.prevent="moveSelection"
@touchend.prevent="finishSelection"
>
<img
ref="sourceImage"
:src="sourceUrl"
alt="Gruppenfoto"
draggable="false"
@load="handleImageLoad"
>
<div
v-if="selection"
class="crop-selection"
:style="selectionStyle"
></div>
</div>
<p class="group-photo-hint">Rahmen um eine Person ziehen. Der Ausschnitt wird quadratisch als Mitgliedsfoto gespeichert.</p>
</div>
<aside class="crop-side">
<div>
<h3>Ausschnitt</h3>
<div class="crop-preview">
<img v-if="previewUrl" :src="previewUrl" alt="Ausschnitt Vorschau">
<span v-else>Kein Ausschnitt</span>
</div>
<p v-if="smallCropWarning" class="warning-text">Der Ausschnitt ist sehr klein und kann unscharf wirken.</p>
</div>
<label class="member-search">
<span>Mitglied suchen</span>
<input v-model="memberQuery" type="search" placeholder="Name eingeben">
</label>
<select v-model="selectedMemberId" class="member-select">
<option value="">Mitglied auswaehlen</option>
<option v-for="member in filteredMembers" :key="member.id" :value="member.id">
{{ member.lastName }}, {{ member.firstName }}
</option>
</select>
<label class="checkbox-line">
<input type="checkbox" v-model="makePrimary">
Als Hauptfoto verwenden
</label>
<button type="button" class="btn-primary" :disabled="!canSaveCrop || savingCrop" @click="saveCrop">
{{ savingCrop ? 'Speichere ...' : 'Als Mitgliedsfoto speichern' }}
</button>
<button type="button" @click="resetSelection">Auswahl zuruecksetzen</button>
<p v-if="statusMessage" class="status-message">{{ statusMessage }}</p>
<p v-if="errorMessage" class="error-message">{{ errorMessage }}</p>
</aside>
</section>
</div>
</BaseDialog>
</template>
<script>
import BaseDialog from '../BaseDialog.vue';
import apiClient from '../../apiClient.js';
export default {
name: 'GroupPhotoCropDialog',
components: { BaseDialog },
props: {
modelValue: { type: Boolean, required: true },
clubId: { type: [String, Number], required: true },
members: { type: Array, required: true }
},
emits: ['update:modelValue', 'member-image-updated'],
data() {
return {
sourceUrl: '',
sourceFile: null,
sourceTitle: '',
sourceTakenAt: '',
sourceDescription: '',
saveForLater: false,
currentSavedPhoto: null,
savedPhotos: [],
loadingSaved: false,
savingSource: false,
naturalSize: { width: 0, height: 0 },
selection: null,
selecting: false,
selectionStart: null,
previewUrl: '',
previewBlob: null,
memberQuery: '',
selectedMemberId: '',
makePrimary: true,
savingCrop: false,
statusMessage: '',
errorMessage: ''
};
},
computed: {
filteredMembers() {
const query = this.memberQuery.trim().toLowerCase();
return [...this.members]
.filter(member => {
if (!query) return true;
return [member.firstName, member.lastName].filter(Boolean).join(' ').toLowerCase().includes(query);
})
.sort((a, b) => String(a.lastName || '').localeCompare(String(b.lastName || ''), 'de-DE'));
},
selectionStyle() {
if (!this.selection) return {};
const frameRect = this.$refs.imageFrame?.getBoundingClientRect();
const imageRect = this.$refs.sourceImage?.getBoundingClientRect();
const offsetX = frameRect && imageRect ? imageRect.left - frameRect.left : 0;
const offsetY = frameRect && imageRect ? imageRect.top - frameRect.top : 0;
return {
left: `${this.selection.x + offsetX}px`,
top: `${this.selection.y + offsetY}px`,
width: `${this.selection.size}px`,
height: `${this.selection.size}px`
};
},
canSaveCrop() {
return !!this.previewBlob && !!this.selectedMemberId;
},
smallCropWarning() {
return this.selection && this.selection.size > 0 && this.selection.size < 80;
}
},
watch: {
modelValue(isOpen) {
if (isOpen && !this.savedPhotos.length) {
this.loadSavedPhotos();
}
}
},
beforeUnmount() {
this.revokeSourceUrl();
this.revokePreviewUrl();
},
methods: {
emitClose() {
this.$emit('update:modelValue', false);
},
async handleFileChange(event) {
const file = event.target.files?.[0];
event.target.value = '';
if (!file) return;
if (!file.type.startsWith('image/')) {
this.errorMessage = 'Bitte eine Bilddatei auswaehlen.';
return;
}
this.revokeSourceUrl();
this.revokePreviewUrl();
this.sourceFile = file;
this.sourceUrl = URL.createObjectURL(file);
this.sourceTitle = `Training ${new Date().toLocaleDateString('de-DE')}`;
this.sourceTakenAt = new Date().toISOString().slice(0, 10);
this.sourceDescription = '';
this.currentSavedPhoto = null;
this.selection = null;
this.previewBlob = null;
this.statusMessage = '';
this.errorMessage = '';
},
handleImageLoad() {
const image = this.$refs.sourceImage;
this.naturalSize = {
width: image?.naturalWidth || 0,
height: image?.naturalHeight || 0
};
},
getPointer(event) {
const point = event.touches?.[0] || event.changedTouches?.[0] || event;
const rect = this.$refs.imageFrame.getBoundingClientRect();
const image = this.$refs.sourceImage;
const imageRect = image.getBoundingClientRect();
return {
x: Math.max(0, Math.min(point.clientX - imageRect.left, imageRect.width)),
y: Math.max(0, Math.min(point.clientY - imageRect.top, imageRect.height)),
maxWidth: imageRect.width,
maxHeight: imageRect.height
};
},
startSelection(event) {
if (!this.sourceUrl) return;
const pointer = this.getPointer(event);
this.selecting = true;
this.selectionStart = pointer;
this.selection = { x: pointer.x, y: pointer.y, size: 0 };
this.revokePreviewUrl();
},
moveSelection(event) {
if (!this.selecting || !this.selectionStart) return;
const pointer = this.getPointer(event);
const dx = pointer.x - this.selectionStart.x;
const dy = pointer.y - this.selectionStart.y;
const size = Math.min(Math.abs(dx), Math.abs(dy));
const x = dx < 0 ? this.selectionStart.x - size : this.selectionStart.x;
const y = dy < 0 ? this.selectionStart.y - size : this.selectionStart.y;
this.selection = {
x: Math.max(0, Math.min(x, pointer.maxWidth - size)),
y: Math.max(0, Math.min(y, pointer.maxHeight - size)),
size
};
},
async finishSelection() {
if (!this.selecting) return;
this.selecting = false;
if (!this.selection || this.selection.size < 20) {
this.resetSelection();
return;
}
await this.updatePreview();
},
async updatePreview() {
const blob = await this.createCropBlob();
if (!blob) return;
this.revokePreviewUrl();
this.previewBlob = blob;
this.previewUrl = URL.createObjectURL(blob);
},
async createCropBlob() {
const image = this.$refs.sourceImage;
if (!image || !this.selection || !this.naturalSize.width || !this.naturalSize.height) return null;
const imageRect = image.getBoundingClientRect();
const scaleX = this.naturalSize.width / imageRect.width;
const scaleY = this.naturalSize.height / imageRect.height;
const sx = this.selection.x * scaleX;
const sy = this.selection.y * scaleY;
const sourceSize = this.selection.size * Math.min(scaleX, scaleY);
const canvas = document.createElement('canvas');
canvas.width = 600;
canvas.height = 600;
const ctx = canvas.getContext('2d');
ctx.drawImage(image, sx, sy, sourceSize, sourceSize, 0, 0, 600, 600);
return new Promise(resolve => canvas.toBlob(resolve, 'image/jpeg', 0.9));
},
resetSelection() {
this.selection = null;
this.selectionStart = null;
this.selecting = false;
this.previewBlob = null;
this.revokePreviewUrl();
},
async saveCrop() {
if (!this.canSaveCrop) return;
this.savingCrop = true;
this.errorMessage = '';
this.statusMessage = '';
try {
const formData = new FormData();
formData.append('image', this.previewBlob, 'group-photo-crop.jpg');
if (this.makePrimary) {
formData.append('makePrimary', 'true');
}
const response = await apiClient.post(`/clubmembers/image/${this.clubId}/${this.selectedMemberId}`, formData, {
headers: { 'Content-Type': 'multipart/form-data' }
});
if (!response.data?.success) {
throw new Error(response.data?.error || 'Ausschnitt konnte nicht gespeichert werden.');
}
this.$emit('member-image-updated', {
memberId: this.selectedMemberId,
payload: response.data
});
const member = this.members.find(entry => String(entry.id) === String(this.selectedMemberId));
this.statusMessage = `Foto gespeichert${member ? ` fuer ${member.firstName} ${member.lastName}` : ''}.`;
this.resetSelection();
} catch (error) {
this.errorMessage = error?.message || 'Ausschnitt konnte nicht gespeichert werden.';
} finally {
this.savingCrop = false;
}
},
async saveSourcePhoto() {
if (!this.sourceFile || this.currentSavedPhoto) return;
this.savingSource = true;
this.errorMessage = '';
try {
const formData = new FormData();
formData.append('image', this.sourceFile);
formData.append('title', this.sourceTitle || `Training ${new Date().toLocaleDateString('de-DE')}`);
if (this.sourceTakenAt) formData.append('takenAt', this.sourceTakenAt);
if (this.sourceDescription) formData.append('description', this.sourceDescription);
const response = await apiClient.post(`/member-group-photos/${this.clubId}`, formData, {
headers: { 'Content-Type': 'multipart/form-data' }
});
if (!response.data?.success) {
throw new Error(response.data?.error || 'Gruppenfoto konnte nicht gespeichert werden.');
}
this.currentSavedPhoto = response.data.photo;
this.savedPhotos = [response.data.photo, ...this.savedPhotos.filter(photo => photo.id !== response.data.photo.id)];
this.statusMessage = 'Gruppenfoto gespeichert.';
} catch (error) {
this.errorMessage = error?.message || 'Gruppenfoto konnte nicht gespeichert werden.';
} finally {
this.savingSource = false;
}
},
async loadSavedPhotos() {
if (!this.clubId) return;
this.loadingSaved = true;
try {
const response = await apiClient.get(`/member-group-photos/${this.clubId}`);
this.savedPhotos = Array.isArray(response.data?.photos) ? response.data.photos : [];
} catch (error) {
this.errorMessage = 'Gespeicherte Gruppenfotos konnten nicht geladen werden.';
} finally {
this.loadingSaved = false;
}
},
async openSavedPhoto(photo) {
try {
const apiPath = String(photo.imageUrl || '').replace(/^\/?api\//i, '').replace(/^\//, '');
const response = await apiClient.get(apiPath, { responseType: 'blob' });
this.revokeSourceUrl();
this.revokePreviewUrl();
this.sourceUrl = URL.createObjectURL(response.data);
this.sourceFile = null;
this.currentSavedPhoto = photo;
this.sourceTitle = photo.title || '';
this.sourceTakenAt = photo.takenAt ? String(photo.takenAt).slice(0, 10) : '';
this.sourceDescription = photo.description || '';
this.saveForLater = true;
this.selection = null;
this.previewBlob = null;
this.statusMessage = `Gruppenfoto "${photo.title}" geoeffnet.`;
this.errorMessage = '';
} catch (error) {
this.errorMessage = 'Gruppenfoto konnte nicht geoeffnet werden.';
}
},
async deleteSavedPhoto(photo) {
if (!window.confirm(`Gruppenfoto "${photo.title}" wirklich loeschen? Bereits erstellte Mitgliedsfotos bleiben erhalten.`)) {
return;
}
try {
await apiClient.delete(`/member-group-photos/${this.clubId}/${photo.id}`);
this.savedPhotos = this.savedPhotos.filter(entry => entry.id !== photo.id);
if (this.currentSavedPhoto?.id === photo.id) {
this.currentSavedPhoto = null;
this.revokeSourceUrl();
this.sourceUrl = '';
this.sourceFile = null;
this.resetSelection();
}
} catch (error) {
this.errorMessage = 'Gruppenfoto konnte nicht geloescht werden.';
}
},
formatDate(value) {
if (!value) return '';
const date = new Date(value);
if (Number.isNaN(date.getTime())) return '';
return date.toLocaleDateString('de-DE');
},
revokeSourceUrl() {
if (this.sourceUrl) {
URL.revokeObjectURL(this.sourceUrl);
}
},
revokePreviewUrl() {
if (this.previewUrl) {
URL.revokeObjectURL(this.previewUrl);
this.previewUrl = '';
}
}
}
};
</script>
<style scoped>
.group-photo-dialog {
display: flex;
flex-direction: column;
gap: 1rem;
}
.hidden-input {
display: none;
}
.group-photo-actions,
.saved-photo-actions,
.save-source-fields {
display: flex;
flex-wrap: wrap;
gap: 0.6rem;
align-items: end;
}
.group-photo-hint,
.privacy-note {
margin: 0.5rem 0 0;
color: var(--text-muted);
font-size: 0.88rem;
}
.privacy-note {
color: #856404;
}
.saved-photo-list,
.save-source-panel {
border: 1px solid var(--border-color);
border-radius: 8px;
padding: 0.9rem;
background: var(--background-light);
}
.saved-photo-list h3,
.crop-side h3 {
margin: 0 0 0.7rem;
}
.saved-photo-grid {
display: grid;
gap: 0.7rem;
}
.saved-photo-item {
display: flex;
justify-content: space-between;
gap: 1rem;
align-items: center;
padding: 0.7rem;
border: 1px solid var(--border-color);
border-radius: 8px;
background: #fff;
}
.saved-photo-item span {
display: block;
color: var(--text-muted);
font-size: 0.82rem;
}
.save-source-fields label,
.member-search {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.save-source-fields input,
.member-search input,
.member-select {
border: 1px solid var(--border-color);
border-radius: 6px;
padding: 0.5rem;
}
.checkbox-line {
display: flex;
gap: 0.45rem;
align-items: center;
}
.crop-workspace {
display: grid;
grid-template-columns: minmax(0, 1fr) 300px;
gap: 1rem;
align-items: start;
}
.crop-image-frame {
position: relative;
min-height: 320px;
max-height: 68vh;
border: 1px solid var(--border-color);
border-radius: 8px;
background: #111;
overflow: hidden;
display: flex;
align-items: center;
justify-content: center;
touch-action: none;
user-select: none;
}
.crop-image-frame img {
max-width: 100%;
max-height: 68vh;
display: block;
}
.crop-selection {
position: absolute;
border: 2px solid #fff;
outline: 9999px solid rgba(0, 0, 0, 0.38);
box-shadow: 0 0 0 1px var(--primary-color);
pointer-events: none;
}
.crop-side {
display: flex;
flex-direction: column;
gap: 0.8rem;
border: 1px solid var(--border-color);
border-radius: 8px;
padding: 0.9rem;
background: #fff;
}
.crop-preview {
width: 100%;
aspect-ratio: 1;
border: 1px dashed var(--border-color);
border-radius: 8px;
display: flex;
align-items: center;
justify-content: center;
color: var(--text-muted);
overflow: hidden;
background: var(--background-light);
}
.crop-preview img {
width: 100%;
height: 100%;
object-fit: cover;
}
.btn-primary {
background: var(--primary-color);
color: var(--text-on-primary);
}
.danger-button {
color: #b42318;
}
.warning-text {
margin: 0.4rem 0 0;
color: #856404;
font-size: 0.84rem;
}
.status-message {
color: #1f7a3f;
font-weight: 600;
}
.error-message {
color: #b42318;
font-weight: 600;
}
@media (max-width: 900px) {
.crop-workspace {
grid-template-columns: 1fr;
}
.saved-photo-item {
align-items: stretch;
flex-direction: column;
}
}
</style>

View File

@@ -9,6 +9,7 @@
{{ memberFormIsOpen ? $t('members.closeEditor') : $t('members.newMember') }}
</button>
<button @click="$emit('create-phone-list')">{{ $t('members.generatePhoneList') }}</button>
<button @click="$emit('open-group-photo-crop')">{{ $t('members.groupPhotoCrop') }}</button>
<button @click="$emit('update-ratings')" class="btn-update-ratings" :disabled="isUpdatingRatings">
{{ isUpdatingRatings ? $t('members.updating') : $t('members.updateRatings') }}
</button>
@@ -263,6 +264,7 @@ export default {
emits: [
'toggle-new-member',
'create-phone-list',
'open-group-photo-crop',
'update-ratings',
'open-transfer-dialog',
'update:search-query',

View File

@@ -450,6 +450,8 @@
"testMembers": "Testmitglieder",
"inactiveMembers": "Inaktive Mitglieder",
"generatePhoneList": "Telefonliste generieren",
"groupPhotoCrop": "Gruppenfoto verarbeiten",
"groupPhotoCropSaved": "Mitgliedsfoto aus Gruppenfoto gespeichert.",
"onlyActiveMembers": "Es werden nur aktive Mitglieder ausgegeben",
"updateRatings": "TTR/QTTR von myTischtennis aktualisieren",
"updating": "Aktualisiere...",

View File

@@ -174,6 +174,8 @@
"testMembers": "Testmitglieder",
"inactiveMembers": "Inaktive Mitglieder",
"generatePhoneList": "Telefonliste generieren",
"groupPhotoCrop": "Gruppenfoto verarbeiten",
"groupPhotoCropSaved": "Mitgliedsfoto aus Gruppenfoto gespeichert.",
"onlyActiveMembers": "Es werden nur aktive Mitglieder ausgegeben",
"updateRatings": "TTR/QTTR von myTischtennis aktualisieren",
"updating": "Aktualisiere...",

View File

@@ -198,6 +198,8 @@
"testMembers": "Testmitglieder",
"inactiveMembers": "Inaktive Mitglieder",
"generatePhoneList": "Telefonliste generieren",
"groupPhotoCrop": "Gruppenfoto verarbeiten",
"groupPhotoCropSaved": "Mitgliedsfoto aus Gruppenfoto gespeichert.",
"onlyActiveMembers": "Es werden nur aktive Mitglieder ausgegeben",
"updateRatings": "TTR/QTTR von myTischtennis aktualisieren",
"updating": "Aktualisiere...",

View File

@@ -448,6 +448,8 @@
"testMembers": "Trial members",
"inactiveMembers": "Inactive members",
"generatePhoneList": "Generate phone list",
"groupPhotoCrop": "Process group photo",
"groupPhotoCropSaved": "Member photo saved from group photo.",
"onlyActiveMembers": "Only active members are included",
"updateRatings": "Update TTR/QTTR from myTischtennis",
"updating": "Updating...",

View File

@@ -723,6 +723,8 @@
"testMembers": "Trial members",
"inactiveMembers": "Inactive members",
"generatePhoneList": "Generate phone list",
"groupPhotoCrop": "Process group photo",
"groupPhotoCropSaved": "Member photo saved from group photo.",
"onlyActiveMembers": "Only active members are included",
"updateRatings": "Update TTR/QTTR from myTischtennis",
"updating": "Updating...",

View File

@@ -448,6 +448,8 @@
"testMembers": "Trial members",
"inactiveMembers": "Inactive members",
"generatePhoneList": "Generate phone list",
"groupPhotoCrop": "Process group photo",
"groupPhotoCropSaved": "Member photo saved from group photo.",
"onlyActiveMembers": "Only active members are included",
"updateRatings": "Update TTR/QTTR from myTischtennis",
"updating": "Updating...",

View File

@@ -413,6 +413,8 @@
"testMembers": "Miembros de prueba",
"inactiveMembers": "Miembros inactivos",
"generatePhoneList": "Generar lista telefónica",
"groupPhotoCrop": "Procesar foto de grupo",
"groupPhotoCropSaved": "Foto del miembro guardada desde la foto de grupo.",
"onlyActiveMembers": "Solo se incluyen miembros activos",
"updateRatings": "Actualizar TTR/QTTR desde myTischtennis",
"updating": "Actualizando...",

View File

@@ -413,6 +413,8 @@
"testMembers": "Mga kasaping pansubok",
"inactiveMembers": "Mga hindi aktibong kasapi",
"generatePhoneList": "Gumawa ng listahan ng telepono",
"groupPhotoCrop": "Iproseso ang group photo",
"groupPhotoCropSaved": "Na-save ang larawan ng miyembro mula sa group photo.",
"onlyActiveMembers": "Tanging mga aktibong miyembro lamang ang ilalabas",
"updateRatings": "I-update ang TTR/QTTR mula sa myTischtennis",
"updating": "Nag-a-update...",

View File

@@ -413,6 +413,8 @@
"testMembers": "Membres à l'essai",
"inactiveMembers": "Membres inactifs",
"generatePhoneList": "Générer la liste téléphonique",
"groupPhotoCrop": "Traiter la photo de groupe",
"groupPhotoCropSaved": "Photo du membre enregistrée depuis la photo de groupe.",
"onlyActiveMembers": "Seuls les membres actifs sont inclus",
"updateRatings": "Mettre à jour TTR/QTTR depuis myTischtennis",
"updating": "Mise à jour...",

View File

@@ -413,6 +413,8 @@
"testMembers": "Membri in prova",
"inactiveMembers": "Membri inattivi",
"generatePhoneList": "Genera elenco telefonico",
"groupPhotoCrop": "Elabora foto di gruppo",
"groupPhotoCropSaved": "Foto del membro salvata dalla foto di gruppo.",
"onlyActiveMembers": "Sono inclusi solo i membri attivi",
"updateRatings": "Aggiorna TTR/QTTR da myTischtennis",
"updating": "Aggiornamento in corso...",

View File

@@ -413,6 +413,8 @@
"testMembers": "体験メンバー",
"inactiveMembers": "非アクティブメンバー",
"generatePhoneList": "電話リストを作成",
"groupPhotoCrop": "集合写真を処理",
"groupPhotoCropSaved": "集合写真から会員写真を保存しました。",
"onlyActiveMembers": "アクティブメンバーのみ出力されます",
"updateRatings": "myTischtennis から TTR/QTTR を更新",
"updating": "更新中...",

View File

@@ -413,6 +413,8 @@
"testMembers": "Członkowie próbni",
"inactiveMembers": "Nieaktywni członkowie",
"generatePhoneList": "Generuj listę telefoniczną",
"groupPhotoCrop": "Przetwórz zdjęcie grupowe",
"groupPhotoCropSaved": "Zdjęcie członka zapisane ze zdjęcia grupowego.",
"onlyActiveMembers": "Uwzględniani są tylko aktywni członkowie",
"updateRatings": "Aktualizuj TTR/QTTR z myTischtennis",
"updating": "Aktualizowanie...",

View File

@@ -413,6 +413,8 @@
"testMembers": "สมาชิกทดลอง",
"inactiveMembers": "สมาชิกที่ไม่ใช้งาน",
"generatePhoneList": "สร้างรายการโทรศัพท์",
"groupPhotoCrop": "ประมวลผลรูปกลุ่ม",
"groupPhotoCropSaved": "บันทึกรูปสมาชิกจากรูปกลุ่มแล้ว",
"onlyActiveMembers": "จะแสดงเฉพาะสมาชิกที่ใช้งานอยู่",
"updateRatings": "อัปเดต TTR/QTTR จาก myTischtennis",
"updating": "กำลังอัปเดต...",

View File

@@ -413,6 +413,8 @@
"testMembers": "Mga trial na miyembro",
"inactiveMembers": "Mga hindi aktibong miyembro",
"generatePhoneList": "Gumawa ng listahan ng telepono",
"groupPhotoCrop": "Iproseso ang group photo",
"groupPhotoCropSaved": "Na-save ang larawan ng miyembro mula sa group photo.",
"onlyActiveMembers": "Tanging mga aktibong miyembro lamang ang ilalabas",
"updateRatings": "I-update ang TTR/QTTR mula sa myTischtennis",
"updating": "Nag-a-update...",

View File

@@ -413,6 +413,8 @@
"testMembers": "试用成员",
"inactiveMembers": "非活跃成员",
"generatePhoneList": "生成电话列表",
"groupPhotoCrop": "处理集体照",
"groupPhotoCropSaved": "已从集体照保存会员照片。",
"onlyActiveMembers": "仅导出活跃成员",
"updateRatings": "从 myTischtennis 更新 TTR/QTTR",
"updating": "更新中...",

View File

@@ -30,6 +30,7 @@
:export-preview-names="exportPreviewNames"
@toggle-new-member="toggleNewMember"
@create-phone-list="createPhoneList"
@open-group-photo-crop="showGroupPhotoCropDialog = true"
@update-ratings="updateRatingsFromMyTischtennis"
@open-transfer-dialog="openTransferDialog"
@update:search-query="searchQuery = $event"
@@ -584,6 +585,13 @@
:club-id="currentClub"
@close="closeOrdersDialog"
/>
<GroupPhotoCropDialog
v-model="showGroupPhotoCropDialog"
:club-id="currentClub"
:members="members"
@member-image-updated="handleGroupPhotoMemberImageUpdated"
/>
</div>
</template>
@@ -610,6 +618,7 @@ import MemberTransferDialog from '../components/MemberTransferDialog.vue';
import MemberTtrHistoryDialog from '../components/MemberTtrHistoryDialog.vue';
import MemberOrdersDialog from '../components/MemberOrdersDialog.vue';
import MembersOverviewSection from '../components/members/MembersOverviewSection.vue';
import GroupPhotoCropDialog from '../components/members/GroupPhotoCropDialog.vue';
import { debounce } from '../utils/debounce.js';
import { buildInfoConfig, buildConfirmConfig, safeErrorMessage, sanitizeText } from '../utils/dialogUtils.js';
@@ -633,7 +642,8 @@ export default {
MemberTransferDialog,
MemberTtrHistoryDialog,
MemberOrdersDialog,
MembersOverviewSection
MembersOverviewSection,
GroupPhotoCropDialog
},
computed: {
...mapGetters(['isAuthenticated', 'currentClub', 'token']),
@@ -1005,6 +1015,7 @@ export default {
showActivitiesModal: false,
showMemberTtrHistoryDialog: false,
showMemberOrdersDialog: false,
showGroupPhotoCropDialog: false,
selectedMemberForActivities: null,
selectedMemberForTtrHistory: null,
selectedMemberForOrders: null,
@@ -2081,6 +2092,13 @@ export default {
}
}
},
async handleGroupPhotoMemberImageUpdated(event) {
const { memberId, payload } = event || {};
const member = this.members.find(m => String(m.id) === String(memberId));
if (!member) return;
await this.applyMemberImageUpdate(member, payload);
await this.showInfo(this.$t('messages.success'), this.$t('members.groupPhotoCropSaved'), '', 'success');
},
async applyMemberImageUpdate(member, payload) {
if (!member || !payload) return;
const images = Array.isArray(payload.images) ? payload.images.map(image => this.createImageObject(image)) : [];