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,65 @@
import memberGroupPhotoService from '../services/memberGroupPhotoService.js';
export const listMemberGroupPhotos = async (req, res) => {
try {
const { authcode: userToken } = req.headers;
const { clubId } = req.params;
const photos = await memberGroupPhotoService.list(userToken, clubId);
res.status(200).json({ success: true, photos });
} catch (error) {
console.error('[listMemberGroupPhotos] error:', error);
res.status(500).json({ success: false, error: 'Failed to list group photos' });
}
};
export const createMemberGroupPhoto = async (req, res) => {
try {
const { authcode: userToken } = req.headers;
const { clubId } = req.params;
const result = await memberGroupPhotoService.create(userToken, clubId, req.file, req.body);
res.status(result.status).json(result.response);
} catch (error) {
console.error('[createMemberGroupPhoto] error:', error);
res.status(500).json({ success: false, error: 'Failed to save group photo' });
}
};
export const updateMemberGroupPhoto = async (req, res) => {
try {
const { authcode: userToken } = req.headers;
const { clubId, photoId } = req.params;
const result = await memberGroupPhotoService.update(userToken, clubId, photoId, req.body);
res.status(result.status).json(result.response);
} catch (error) {
console.error('[updateMemberGroupPhoto] error:', error);
res.status(500).json({ success: false, error: 'Failed to update group photo' });
}
};
export const deleteMemberGroupPhoto = async (req, res) => {
try {
const { authcode: userToken } = req.headers;
const { clubId, photoId } = req.params;
const result = await memberGroupPhotoService.remove(userToken, clubId, photoId);
res.status(result.status).json(result.response);
} catch (error) {
console.error('[deleteMemberGroupPhoto] error:', error);
res.status(500).json({ success: false, error: 'Failed to delete group photo' });
}
};
export const getMemberGroupPhotoImage = async (req, res) => {
try {
const { authcode: userToken } = req.headers;
const { clubId, photoId } = req.params;
const result = await memberGroupPhotoService.getImage(userToken, clubId, photoId);
if (result.status === 200) {
res.setHeader('Content-Type', 'image/jpeg');
return res.sendFile(result.imagePath);
}
return res.status(result.status).json({ success: false, error: result.error });
} catch (error) {
console.error('[getMemberGroupPhotoImage] error:', error);
res.status(500).json({ success: false, error: 'Failed to load group photo' });
}
};

View File

@@ -0,0 +1,28 @@
-- Migration: Store group photos for later member photo cropping.
CREATE TABLE IF NOT EXISTS `member_group_photo` (
`id` INT NOT NULL AUTO_INCREMENT,
`club_id` INT NOT NULL,
`title` VARCHAR(255) NOT NULL,
`description` TEXT NULL,
`file_name` VARCHAR(255) NOT NULL,
`original_file_name` VARCHAR(255) NULL,
`mime_type` VARCHAR(100) NULL,
`file_size` INT NULL,
`width` INT NULL,
`height` INT NULL,
`taken_at` DATETIME NULL,
`created_by_user_id` INT NULL,
`created_at` DATETIME NOT NULL,
`updated_at` DATETIME NOT NULL,
PRIMARY KEY (`id`),
INDEX `idx_member_group_photo_club_id` (`club_id`),
CONSTRAINT `fk_member_group_photo_club`
FOREIGN KEY (`club_id`) REFERENCES `clubs` (`id`)
ON DELETE CASCADE
ON UPDATE CASCADE,
CONSTRAINT `fk_member_group_photo_created_by`
FOREIGN KEY (`created_by_user_id`) REFERENCES `user` (`id`)
ON DELETE SET NULL
ON UPDATE CASCADE
);

View File

@@ -0,0 +1,80 @@
import { DataTypes } from 'sequelize';
import sequelize from '../database.js';
const MemberGroupPhoto = sequelize.define('MemberGroupPhoto', {
id: {
type: DataTypes.INTEGER,
autoIncrement: true,
primaryKey: true,
allowNull: false
},
clubId: {
type: DataTypes.INTEGER,
allowNull: false,
field: 'club_id',
references: {
model: 'clubs',
key: 'id'
},
onDelete: 'CASCADE',
onUpdate: 'CASCADE'
},
title: {
type: DataTypes.STRING(255),
allowNull: false
},
description: {
type: DataTypes.TEXT,
allowNull: true
},
fileName: {
type: DataTypes.STRING(255),
allowNull: false,
field: 'file_name'
},
originalFileName: {
type: DataTypes.STRING(255),
allowNull: true,
field: 'original_file_name'
},
mimeType: {
type: DataTypes.STRING(100),
allowNull: true,
field: 'mime_type'
},
fileSize: {
type: DataTypes.INTEGER,
allowNull: true,
field: 'file_size'
},
width: {
type: DataTypes.INTEGER,
allowNull: true
},
height: {
type: DataTypes.INTEGER,
allowNull: true
},
takenAt: {
type: DataTypes.DATE,
allowNull: true,
field: 'taken_at'
},
createdByUserId: {
type: DataTypes.INTEGER,
allowNull: true,
field: 'created_by_user_id',
references: {
model: 'user',
key: 'id'
},
onDelete: 'SET NULL',
onUpdate: 'CASCADE'
}
}, {
tableName: 'member_group_photo',
underscored: true,
timestamps: true
});
export default MemberGroupPhoto;

View File

@@ -49,6 +49,7 @@ import ApiLog from './ApiLog.js';
import MemberTransferConfig from './MemberTransferConfig.js';
import MemberContact from './MemberContact.js';
import MemberImage from './MemberImage.js';
import MemberGroupPhoto from './MemberGroupPhoto.js';
import MemberTtrHistory from './MemberTtrHistory.js';
import MemberPlayInterest from './MemberPlayInterest.js';
import MemberOrder from './MemberOrder.js';
@@ -102,6 +103,11 @@ MemberPlayInterest.belongsTo(Member, { as: 'member', foreignKey: 'memberId' });
Club.hasMany(MemberPlayInterest, { as: 'memberPlayInterests', foreignKey: 'clubId' });
MemberPlayInterest.belongsTo(Club, { as: 'club', foreignKey: 'clubId' });
Club.hasMany(MemberGroupPhoto, { as: 'memberGroupPhotos', foreignKey: 'clubId' });
MemberGroupPhoto.belongsTo(Club, { as: 'club', foreignKey: 'clubId' });
User.hasMany(MemberGroupPhoto, { as: 'createdMemberGroupPhotos', foreignKey: 'createdByUserId' });
MemberGroupPhoto.belongsTo(User, { as: 'createdByUser', foreignKey: 'createdByUserId' });
Member.hasMany(MemberOrder, { as: 'orders', foreignKey: 'memberId' });
MemberOrder.belongsTo(Member, { as: 'member', foreignKey: 'memberId' });
Club.hasMany(MemberOrder, { as: 'memberOrders', foreignKey: 'clubId' });
@@ -442,6 +448,7 @@ export {
MemberTransferConfig,
MemberContact,
MemberImage,
MemberGroupPhoto,
MemberTtrHistory,
MemberPlayInterest,
MemberOrder,

View File

@@ -0,0 +1,25 @@
import express from 'express';
import multer from 'multer';
import { authenticate } from '../middleware/authMiddleware.js';
import { authorize } from '../middleware/authorizationMiddleware.js';
import {
listMemberGroupPhotos,
createMemberGroupPhoto,
updateMemberGroupPhoto,
deleteMemberGroupPhoto,
getMemberGroupPhotoImage
} from '../controllers/memberGroupPhotoController.js';
const router = express.Router();
const upload = multer({
storage: multer.memoryStorage(),
limits: { fileSize: 15 * 1024 * 1024 }
});
router.get('/:clubId', authenticate, authorize('members', 'read'), listMemberGroupPhotos);
router.post('/:clubId', authenticate, authorize('members', 'write'), upload.single('image'), createMemberGroupPhoto);
router.get('/:clubId/:photoId/image', authenticate, authorize('members', 'read'), getMemberGroupPhotoImage);
router.put('/:clubId/:photoId', authenticate, authorize('members', 'write'), updateMemberGroupPhoto);
router.delete('/:clubId/:photoId', authenticate, authorize('members', 'write'), deleteMemberGroupPhoto);
export default router;

View File

@@ -14,7 +14,7 @@ import {
PredefinedActivity, PredefinedActivityImage, DiaryDateActivity, DiaryMemberActivity, Match, League, Team, ClubTeam, ClubTeamMember, TeamDocument, Group,
GroupActivity, Tournament, TournamentGroup, TournamentMatch, TournamentResult,
TournamentMember, Accident, UserToken, OfficialTournament, OfficialCompetition, OfficialCompetitionMember, MyTischtennis, ClickTtAccount, MyTischtennisUpdateHistory, MyTischtennisFetchLog, ApiLog, MemberTransferConfig, MemberContact, MemberTtrHistory, MemberPlayInterest
, MemberOrder, MemberOrderHistory
, MemberOrder, MemberOrderHistory, MemberGroupPhoto
} from './models/index.js';
import authRoutes from './routes/authRoutes.js';
import clubRoutes from './routes/clubRoutes.js';
@@ -56,6 +56,7 @@ import memberTransferConfigRoutes from './routes/memberTransferConfigRoutes.js';
import trainingGroupRoutes from './routes/trainingGroupRoutes.js';
import trainingTimeRoutes from './routes/trainingTimeRoutes.js';
import memberOrderRoutes from './routes/memberOrderRoutes.js';
import memberGroupPhotoRoutes from './routes/memberGroupPhotoRoutes.js';
import schedulerService from './services/schedulerService.js';
import { requestLoggingMiddleware } from './middleware/requestLoggingMiddleware.js';
import HttpError from './exceptions/HttpError.js';
@@ -300,6 +301,7 @@ app.use('/api/member-transfer-config', memberTransferConfigRoutes);
app.use('/api/training-groups', trainingGroupRoutes);
app.use('/api/training-times', trainingTimeRoutes);
app.use('/api/member-orders', memberOrderRoutes);
app.use('/api/member-group-photos', memberGroupPhotoRoutes);
// Middleware für dynamischen kanonischen Tag (vor express.static)
const setCanonicalTag = (req, res, next) => {
@@ -542,6 +544,7 @@ app.use((err, req, res, next) => {
await safeSync(ApiLog);
await safeSync(MemberTransferConfig);
await safeSync(MemberContact);
await safeSync(MemberGroupPhoto);
await safeSync(MemberTtrHistory);
await safeSync(MemberPlayInterest);
await safeSync(MemberOrder);

View File

@@ -0,0 +1,177 @@
import fs from 'fs';
import path from 'path';
import sharp from 'sharp';
import MemberGroupPhoto from '../models/MemberGroupPhoto.js';
import { checkAccess, getUserByToken } from '../utils/userUtils.js';
class MemberGroupPhotoService {
async list(userToken, clubId) {
await checkAccess(userToken, clubId);
const photos = await MemberGroupPhoto.findAll({
where: { clubId },
order: [['createdAt', 'DESC']]
});
return photos.map(photo => this.mapPhoto(photo));
}
async create(userToken, clubId, file, payload = {}) {
await checkAccess(userToken, clubId);
if (!file?.buffer) {
return { status: 400, response: { success: false, error: 'No image provided' } };
}
if (!String(file.mimetype || '').startsWith('image/')) {
return { status: 400, response: { success: false, error: 'File must be an image' } };
}
const user = await getUserByToken(userToken);
const title = this.sanitizeText(payload.title, 255) || this.defaultTitle();
const description = this.sanitizeText(payload.description, 5000) || null;
const takenAt = this.parseDate(payload.takenAt);
let photo = null;
try {
photo = await MemberGroupPhoto.create({
clubId,
title,
description,
fileName: 'pending.jpg',
originalFileName: this.sanitizeText(file.originalname, 255) || null,
mimeType: 'image/jpeg',
fileSize: file.size || file.buffer.length || null,
takenAt,
createdByUserId: user?.id || null
});
const directory = await this.ensurePhotoDirectory(clubId);
const fileName = `${photo.id}.jpg`;
const targetPath = path.join(directory, fileName);
const info = await sharp(file.buffer)
.rotate()
.resize(2400, 2400, { fit: 'inside', withoutEnlargement: true })
.jpeg({ quality: 88 })
.toFile(targetPath);
await photo.update({
fileName,
fileSize: info.size || file.size || file.buffer.length || null,
width: info.width || null,
height: info.height || null
});
return { status: 200, response: { success: true, photo: this.mapPhoto(photo) } };
} catch (error) {
if (photo?.id) {
await photo.destroy().catch(() => {});
await fs.promises.unlink(this.getPhotoPath(clubId, `${photo.id}.jpg`)).catch(() => {});
}
console.error('[MemberGroupPhotoService.create] error:', error);
return { status: 500, response: { success: false, error: 'Failed to save group photo' } };
}
}
async update(userToken, clubId, photoId, payload = {}) {
await checkAccess(userToken, clubId);
const photo = await this.findPhoto(clubId, photoId);
if (!photo) {
return { status: 404, response: { success: false, error: 'Group photo not found' } };
}
const updates = {};
if (payload.title !== undefined) {
updates.title = this.sanitizeText(payload.title, 255) || photo.title;
}
if (payload.description !== undefined) {
updates.description = this.sanitizeText(payload.description, 5000) || null;
}
if (payload.takenAt !== undefined) {
updates.takenAt = this.parseDate(payload.takenAt);
}
await photo.update(updates);
return { status: 200, response: { success: true, photo: this.mapPhoto(photo) } };
}
async remove(userToken, clubId, photoId) {
await checkAccess(userToken, clubId);
const photo = await this.findPhoto(clubId, photoId);
if (!photo) {
return { status: 404, response: { success: false, error: 'Group photo not found' } };
}
const imagePath = this.getPhotoPath(clubId, photo.fileName);
await photo.destroy();
await fs.promises.unlink(imagePath).catch((error) => {
console.warn('[MemberGroupPhotoService.remove] file removal failed:', error.message || error);
});
return { status: 200, response: { success: true } };
}
async getImage(userToken, clubId, photoId) {
await checkAccess(userToken, clubId);
const photo = await this.findPhoto(clubId, photoId);
if (!photo) {
return { status: 404, error: 'Group photo not found' };
}
const imagePath = this.getPhotoPath(clubId, photo.fileName);
if (!fs.existsSync(imagePath)) {
return { status: 404, error: 'Group photo file not found' };
}
return { status: 200, imagePath: path.resolve(imagePath) };
}
async findPhoto(clubId, photoId) {
return MemberGroupPhoto.findOne({ where: { id: photoId, clubId } });
}
mapPhoto(photo) {
const data = typeof photo?.toJSON === 'function' ? photo.toJSON() : photo;
return {
id: data.id,
clubId: data.clubId,
title: data.title,
description: data.description || '',
fileName: data.fileName,
originalFileName: data.originalFileName || '',
mimeType: data.mimeType || 'image/jpeg',
fileSize: data.fileSize || null,
width: data.width || null,
height: data.height || null,
takenAt: data.takenAt || null,
createdByUserId: data.createdByUserId || null,
createdAt: data.createdAt || null,
updatedAt: data.updatedAt || null,
imageUrl: `/api/member-group-photos/${data.clubId}/${data.id}/image?t=${new Date(data.updatedAt || Date.now()).getTime()}`
};
}
async ensurePhotoDirectory(clubId) {
const directory = path.join('images', 'member-group-photos', String(clubId));
await fs.promises.mkdir(directory, { recursive: true });
return directory;
}
getPhotoPath(clubId, fileName) {
return path.join('images', 'member-group-photos', String(clubId), fileName);
}
sanitizeText(value, maxLength) {
const text = String(value || '').trim();
if (!text) return '';
return text.slice(0, maxLength);
}
parseDate(value) {
if (!value) return null;
const date = new Date(value);
return Number.isNaN(date.getTime()) ? null : date;
}
defaultTitle() {
return `Gruppenfoto ${new Date().toLocaleDateString('de-DE')}`;
}
}
export default new MemberGroupPhotoService();

View File

@@ -0,0 +1,311 @@
# Feature-Konzept: Gruppenfoto zu Mitgliedsfotos
## Ziel
Trainer sollen ein Gruppenfoto hochladen oder mit der Kamera aufnehmen koennen und daraus per manuell gezogenem Rahmen einzelne Mitgliedsfotos erstellen. Die ausgeschnittenen Bilder werden wie normale Mitgliedsfotos gespeichert.
Zusaetzlich soll ein Gruppenfoto fuer spaetere Bearbeitung gespeichert werden koennen. So kann der Trainer im Training schnell ein Foto aufnehmen und die eigentliche Zuordnung und Bearbeitung spaeter, zum Beispiel zuhause, erledigen.
## Nutzerfluss
1. Trainer oeffnet `/members`.
2. Trainer klickt auf `Gruppenfoto verarbeiten`.
3. Ein Dialog oeffnet sich mit drei Einstiegen:
- `Foto aufnehmen`
- `Foto hochladen`
- `Gespeichertes Foto oeffnen`
4. Bei einem neuen Foto entscheidet der Trainer:
- `Nur jetzt verarbeiten`
- `Fuer spaetere Bearbeitung speichern`
5. Das Gruppenfoto wird gross angezeigt.
6. Trainer zieht einen Rahmen um eine Person oder ein Gesicht.
7. Trainer waehlt das passende Mitglied aus.
8. Trainer klickt `Als Mitgliedsfoto speichern`.
9. Der Ausschnitt wird gespeichert.
10. Trainer kann direkt den naechsten Ausschnitt aus demselben Gruppenfoto markieren.
## Erster Funktionsumfang
- Gruppenfoto hochladen.
- Gruppenfoto mobil per Kamera aufnehmen.
- Gruppenfoto optional dauerhaft fuer spaetere Bearbeitung speichern.
- Gespeicherte Gruppenfotos pro Verein auflisten.
- Gespeichertes Gruppenfoto spaeter wieder oeffnen.
- Manuelle Rahmenauswahl auf dem Gruppenfoto.
- Mitgliedsauswahl mit Suche.
- Ausschnitt-Vorschau vor dem Speichern.
- Ausschnitt als neues Mitgliedsfoto speichern.
- Optionaler Schalter `Als Hauptfoto verwenden`.
- Mehrere Ausschnitte nacheinander aus demselben Gruppenfoto erstellen.
- Gruppenfoto loeschen, ohne bereits erzeugte Mitgliedsfotos zu loeschen.
## Nicht im ersten Wurf
- Automatische Gesichtserkennung.
- Automatische Zuordnung zu Mitgliedern.
- Mehrfachrahmen gleichzeitig.
- Speichern unfertiger Crop-Rahmen als Entwurf.
- Statusverwaltung pro markiertem Ausschnitt.
- Nachtraegliche Bearbeitung bereits erzeugter Mitgliedsfotos.
Diese Punkte koennen spaeter ergaenzt werden. Fuer die erste Version bleibt das Feature bewusst manuell und kontrollierbar.
## UI-Konzept
Auf `/members` wird ein neuer Button im Aktionsbereich ergaenzt:
```text
Gruppenfoto verarbeiten
```
Der Dialog startet mit:
```text
Gruppenfoto verarbeiten
[Foto aufnehmen]
[Foto hochladen]
[Gespeichertes Foto oeffnen]
```
Nach Fotoauswahl:
```text
[ ] Fuer spaetere Bearbeitung speichern
Titel: Training 15.04.2026
Datum: 15.04.2026
Notiz: optional
```
Der Bearbeitungsdialog besteht aus:
- links: grosses Gruppenfoto mit ziehbarem Crop-Rahmen
- rechts: Ausschnitt-Vorschau
- rechts: Mitgliedersuche
- rechts: Checkbox `Als Hauptfoto verwenden`
- rechts: Button `Als Mitgliedsfoto speichern`
- rechts: Statusmeldung, zum Beispiel `Foto gespeichert fuer Max Mustermann`
Fuer mobile Ansicht:
- Bild oben
- Vorschau darunter
- Mitgliedersuche darunter
- Speichern-Aktion am unteren Rand
## Gespeicherte Gruppenfotos
Unter `Gespeichertes Foto oeffnen` wird eine Liste angezeigt:
- Titel
- Aufnahmedatum oder Erstelldatum
- optional Notiz
- Button `Oeffnen`
- Button `Loeschen`
Ein geloeschtes Gruppenfoto entfernt nur das Original-Gruppenfoto. Bereits erstellte Mitgliedsfotos bleiben erhalten.
## Crop-Verhalten
Empfehlung fuer Mitgliedsfotos:
- Standard-Seitenverhaeltnis `1:1`
- Mindestgroesse zum Beispiel `160 x 160 px`
- Exportgroesse zum Beispiel `600 x 600 px`
- JPEG-Qualitaet etwa `0.9`
- Warnung bei sehr kleinen Ausschnitten:
```text
Der Ausschnitt ist sehr klein und kann unscharf wirken.
```
Optional spaeter:
- Umschalter `Freier Ausschnitt`
- Zoom und Pan fuer grosse Gruppenfotos
- Tastatursteuerung fuer Feinkorrektur
## Technische Umsetzung
### Frontend
Neue Komponente:
```text
frontend/src/components/members/GroupPhotoCropDialog.vue
```
Einbindung in:
```text
frontend/src/views/MembersView.vue
```
Technischer Ablauf:
1. Bilddatei per Dateiinput oder Kamera laden.
2. Bild im Browser anzeigen.
3. Crop-Koordinaten erfassen.
4. Ausschnitt per Canvas in ein neues Canvas zeichnen.
5. Canvas als `Blob` exportieren.
6. Blob per `FormData` an die bestehende Mitgliedsfoto-API hochladen.
7. Optional danach das Bild als Hauptfoto setzen.
Kameraaufnahme kann pragmatisch ueber einen Dateiinput starten:
```html
<input type="file" accept="image/*" capture>
```
### Bestehender Mitgliedsfoto-Upload
Wenn moeglich wird die bestehende Route genutzt:
```text
POST /api/clubmembers/image/:clubId/:memberId
```
Falls `Als Hauptfoto verwenden` aktiv ist, wird danach die bestehende Primary-Route genutzt:
```text
POST /api/clubmembers/image/:clubId/:memberId/:imageId/primary
```
Der Blob kann als Datei `group-photo-crop.jpg` gesendet werden.
## Backend fuer gespeicherte Gruppenfotos
Fuer dauerhaft gespeicherte Gruppenfotos wird eine neue Tabelle empfohlen:
```text
member_group_photo
```
Minimal benoetigte Felder:
- `id`
- `club_id`
- `title`
- `photo_path`
- `taken_at`
- `created_by_user_id`
- `created_at`
- `updated_at`
Sinnvolle weitere Felder:
- `description`
- `original_file_name`
- `mime_type`
- `file_size`
- `width`
- `height`
- `status`, zum Beispiel `open`, `in_progress`, `done`, `archived`
- `processed_at`
- `archived_at`
## API-Vorschlag
```text
GET /api/member-group-photos/:clubId
POST /api/member-group-photos/:clubId
GET /api/member-group-photos/:clubId/:photoId/image
PUT /api/member-group-photos/:clubId/:photoId
DELETE /api/member-group-photos/:clubId/:photoId
```
Beschreibung:
- `GET /:clubId`: listet gespeicherte Gruppenfotos des Vereins.
- `POST /:clubId`: speichert ein neues Gruppenfoto.
- `GET /:clubId/:photoId/image`: liefert das Originalbild fuer spaetere Bearbeitung.
- `PUT /:clubId/:photoId`: aktualisiert Titel, Datum oder Notiz.
- `DELETE /:clubId/:photoId`: loescht das gespeicherte Gruppenfoto.
Alle Routen muessen authentifiziert sein und Vereinszugriff pruefen.
## Speicherort
Empfohlener Speicherort, getrennt von normalen Mitgliedsbildern:
```text
uploads/member-group-photos/:clubId/:photoId/original.jpg
```
Falls das Projekt bereits andere Upload-Konventionen nutzt, sollte der Pfad daran angepasst werden.
## Datenschutz und Berechtigungen
Wenn komplette Gruppenfotos gespeichert werden, koennen darauf auch Personen zu sehen sein, deren Einzelbild nicht gespeichert werden soll. Deshalb sollte im Dialog ein klarer Hinweis stehen:
```text
Dieses Gruppenfoto wird gespeichert und kann andere Personen enthalten. Bitte nur speichern, wenn die Nutzung im Verein zulaessig ist.
```
Empfehlungen:
- Gruppenfotos nur fuer berechtigte Vereinsnutzer sichtbar machen.
- Keine oeffentliche Bild-URL ohne Authentifizierung.
- Bildauslieferung nur ueber geschuetzte API.
- Loeschfunktion direkt in der Gruppenfoto-Liste anbieten.
- Das Speichern eines Gruppenfotos aendert keine bestehenden Einwilligungen, zum Beispiel `picsInInternetAllowed`.
- Die Zuordnung eines Ausschnitts zu einem Mitglied bleibt eine bewusste Trainerentscheidung.
## Spaeterer Ausbau: Crop-Entwuerfe
Wenn spaeter auch unfertige Rahmen gespeichert werden sollen, kann eine zweite Tabelle ergaenzt werden:
```text
member_group_photo_crop
```
Moegliche Felder:
- `id`
- `group_photo_id`
- `member_id`
- `x`
- `y`
- `width`
- `height`
- `status`
- `created_at`
- `updated_at`
Moegliche Statuswerte:
- `open`
- `assigned`
- `saved`
- `discarded`
Das ist nicht Teil der ersten Version, waere aber die Grundlage fuer Mehrfachrahmen und spaetere Weiterbearbeitung.
## Fehlerfaelle
- Kein Foto gewaehlt: Bearbeitung nicht moeglich.
- Kein Rahmen gezogen: Speichern deaktivieren.
- Kein Mitglied gewaehlt: Speichern deaktivieren.
- Ausschnitt zu klein: Warnung anzeigen oder Speichern deaktivieren.
- Upload schlaegt fehl: Fehlermeldung anzeigen, Auswahl beibehalten.
- Gruppenfoto kann nicht geladen werden: Fehlermeldung und Rueckkehr zur Liste.
- Gruppenfoto wurde geloescht: Liste aktualisieren.
## Akzeptanzkriterien
- Trainer kann ein Gruppenfoto hochladen.
- Trainer kann ein Gruppenfoto mobil aufnehmen.
- Trainer kann ein Gruppenfoto fuer spaetere Bearbeitung speichern.
- Gespeicherte Gruppenfotos werden pro Verein aufgelistet.
- Ein gespeichertes Gruppenfoto kann spaeter geoeffnet und bearbeitet werden.
- Trainer kann per Rahmen einen Ausschnitt markieren.
- Ausschnitt wird als Vorschau angezeigt.
- Trainer kann ein Mitglied suchen und auswaehlen.
- Ausschnitt wird als Mitgliedsfoto gespeichert.
- Optional kann der Ausschnitt als Hauptfoto gesetzt werden.
- Mehrere Mitglieder koennen nacheinander aus demselben Gruppenfoto versorgt werden.
- Gruppenfoto kann geloescht werden.
- Loeschen eines Gruppenfotos loescht keine bereits erstellten Mitgliedsfotos.
- Gruppenfoto ist nur fuer berechtigte Nutzer des Vereins abrufbar.
- Das vollstaendige Gruppenfoto wird nur gespeichert, wenn der Trainer dies bewusst auswaehlt.
- Frontend-Build laeuft ohne Fehler.

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)) : [];