implementierung der ersten schritte eine komplett-suite

This commit is contained in:
Torsten Schulz (local)
2026-06-19 15:47:32 +02:00
parent 111b37b287
commit 542fae089c
62 changed files with 11924 additions and 669 deletions

View File

@@ -0,0 +1,60 @@
import clubAccountService from '../services/clubAccountService.js';
class ClubAccountController {
async listClubAccounts(req, res) {
try {
const { clubId } = req.params;
const accounts = await clubAccountService.listClubAccounts(Number(clubId));
res.json({ accounts });
} catch (error) {
console.error('[listClubAccounts] - Error:', error);
res.status(error?.status || 500).json({ error: error?.message || 'Konten konnten nicht geladen werden.' });
}
}
async createClubAccount(req, res) {
try {
const { clubId } = req.params;
const account = await clubAccountService.createClubAccount(Number(clubId), req.body || {});
res.status(201).json({ account });
} catch (error) {
console.error('[createClubAccount] - Error:', error);
res.status(error?.status || 500).json({ error: error?.message || 'Konto konnte nicht gespeichert werden.' });
}
}
async updateClubAccount(req, res) {
try {
const { clubId, accountId } = req.params;
const account = await clubAccountService.updateClubAccount(Number(clubId), Number(accountId), req.body || {});
res.json({ account });
} catch (error) {
console.error('[updateClubAccount] - Error:', error);
res.status(error?.status || 500).json({ error: error?.message || 'Konto konnte nicht gespeichert werden.' });
}
}
async updateClubAccountStatus(req, res) {
try {
const { clubId, accountId } = req.params;
const account = await clubAccountService.updateClubAccountStatus(Number(clubId), Number(accountId), String(req.body?.status || ''));
res.json({ account });
} catch (error) {
console.error('[updateClubAccountStatus] - Error:', error);
res.status(error?.status || 500).json({ error: error?.message || 'Kontostatus konnte nicht gespeichert werden.' });
}
}
async deleteClubAccount(req, res) {
try {
const { clubId, accountId } = req.params;
await clubAccountService.deleteClubAccount(Number(clubId), Number(accountId));
res.json({ success: true });
} catch (error) {
console.error('[deleteClubAccount] - Error:', error);
res.status(error?.status || 500).json({ error: error?.message || 'Konto konnte nicht gelöscht werden.' });
}
}
}
export default new ClubAccountController();

View File

@@ -0,0 +1,19 @@
import clubArchiveService from '../services/clubArchiveService.js';
class ClubArchiveController {
async getClubArchive(req, res) {
try {
const { clubId } = req.params;
const archive = await clubArchiveService.getClubArchive(clubId);
res.json(archive);
} catch (error) {
console.error('[getClubArchive] - Error:', error);
if (error?.status) {
return res.status(error.status).json({ error: error.message });
}
res.status(500).json({ error: 'Fehler beim Laden des Vereinsarchivs' });
}
}
}
export default new ClubArchiveController();

View File

@@ -0,0 +1,557 @@
import { Op } from 'sequelize';
import sequelize from '../database.js';
import {
CalendarEvent,
ClubPaymentClaim,
ClubRequest,
ClubSepaMandate,
ClubTask,
Match,
Member,
TrainingGroup,
} from '../models/index.js';
import { getSafeErrorMessage } from '../utils/errorUtils.js';
function formatRequestWorkflowStage(stage) {
return {
contact_replied: 'Kontakt beantwortet',
trial_training_scheduled: 'Probetraining terminiert',
trial_training_feedback_recorded: 'Probetraining nachbereitet',
membership_reviewed: 'Mitgliedsanfrage geprüft',
admission_prepared: 'Aufnahme vorbereitet',
member_record_created: 'Mitglied angelegt',
sepa_pending: 'SEPA ausstehend',
onboarding_completed: 'Onboarding abgeschlossen',
sponsoring_contacted: 'Sponsoring kontaktiert',
}[stage] || stage;
}
function formatTaskType(taskType) {
return {
request_contact_reply: 'Kontaktanfrage beantworten',
request_schedule_trial_training: 'Probetraining organisieren',
request_trial_training_follow_up: 'Probetraining nachbereiten',
request_membership_review: 'Mitgliedsanfrage prüfen',
membership_prepare_admission: 'Aufnahme vorbereiten',
membership_create_member_record: 'Mitglied anlegen',
membership_collect_sepa_mandate: 'SEPA organisieren',
membership_assign_fee: 'Beitrag zuordnen',
request_sponsoring_reply: 'Sponsoring nachfassen',
member_missing_email: 'E-Mail ergänzen',
member_missing_birthdate: 'Geburtsdatum ergänzen',
member_missing_sepa_mandate: 'SEPA-Mandat einholen',
payment_claim_due_soon: 'Fällige Zahlung vorbereiten',
payment_claim_overdue: 'Überfällige Zahlung nachfassen',
payment_claim_reminder: 'Mahnstufe prüfen',
calendar_event_prepare: 'Termin vorbereiten',
calendar_event_deadline_check: 'Terminfrist prüfen',
}[taskType] || taskType || 'Freie Aufgabe';
}
function formatEventDateRange(event) {
if (!event?.startDate) {
return null;
}
const formatter = new Intl.DateTimeFormat('de-DE', { dateStyle: 'medium' });
const start = formatter.format(new Date(event.startDate));
const end = event.endDate ? formatter.format(new Date(event.endDate)) : start;
return start === end ? start : `${start} bis ${end}`;
}
function formatDate(value, options = { dateStyle: 'medium' }) {
if (!value) {
return null;
}
return new Intl.DateTimeFormat('de-DE', options).format(new Date(value));
}
function formatTime(value) {
if (!value) {
return null;
}
return String(value).slice(0, 5);
}
function formatWeekday(weekday) {
return ['Sonntag', 'Montag', 'Dienstag', 'Mittwoch', 'Donnerstag', 'Freitag', 'Samstag'][Number(weekday)] || 'Unbekannt';
}
function formatConfiguredTrainingLabel(entry) {
const start = formatTime(entry.startTime);
const end = formatTime(entry.endTime);
const timeRange = start && end ? `${start} bis ${end} Uhr` : start ? `${start} Uhr` : null;
return [entry.groupName, formatWeekday(entry.weekday), timeRange].filter(Boolean).join(' · ');
}
function formatMatchLabel(match) {
const date = formatDate(match.date);
const time = formatTime(match.time);
const homeTeam = match.homeTeam?.name || 'Heimteam';
const guestTeam = match.guestTeam?.name || 'Gastteam';
const league = match.leagueDetails?.name || null;
return [
`${homeTeam} gegen ${guestTeam}`,
date,
time ? `${time} Uhr` : null,
league,
].filter(Boolean).join(' · ');
}
function createDashboardItem(label, to, extra = {}) {
return { label, to, ...extra };
}
function buildMemberRoute(memberId, scope = 'active', extraQuery = {}) {
return {
path: '/members',
query: {
scope,
memberId: String(memberId),
mode: 'edit',
...extraQuery,
},
};
}
function buildRequestRoute(requestId, status = '') {
const query = { requestId: String(requestId) };
if (status) {
query.status = status;
}
return {
path: '/club-requests',
query,
};
}
function buildTaskRoute(taskId, status = '') {
const query = { taskId: String(taskId) };
if (status) {
query.status = status;
}
return {
path: '/club-tasks',
query,
};
}
async function loadAvailableTables() {
const tables = await sequelize.getQueryInterface().showAllTables();
return new Set(
tables
.map((table) => (typeof table === 'string' ? table : Object.values(table || {})[0]))
.filter(Boolean)
.map((table) => String(table).toLowerCase())
);
}
async function loadOptionalTableData(availableTables, tableName, loader, fallbackValue = []) {
if (!availableTables.has(String(tableName).toLowerCase())) {
return fallbackValue;
}
return loader();
}
function countMissingMemberFields(members) {
const missing = {
email: 0,
birthDate: 0,
};
for (const member of members) {
if (!String(member.email || '').trim()) {
missing.email += 1;
}
if (!String(member.birthDate || '').trim()) {
missing.birthDate += 1;
}
}
return missing;
}
function toNextOccurrenceDate(weekday, startTime) {
const now = new Date();
const result = new Date(now);
const targetWeekday = Number(weekday);
const daysUntilWeekday = (targetWeekday - result.getDay() + 7) % 7;
result.setDate(result.getDate() + daysUntilWeekday);
const [hours = '0', minutes = '0'] = String(startTime || '00:00').split(':');
result.setHours(Number(hours), Number(minutes), 0, 0);
if (result < now) {
result.setDate(result.getDate() + 7);
}
return result;
}
function buildUpcomingTrainingSlots(groups, limit = 5) {
return groups
.flatMap((group) => (Array.isArray(group.trainingTimes) ? group.trainingTimes.map((time) => ({
id: time.id,
weekday: time.weekday,
startTime: time.startTime,
endTime: time.endTime,
sortOrder: time.sortOrder,
groupName: group.name,
nextOccurrence: toNextOccurrenceDate(time.weekday, time.startTime),
})) : []))
.sort((left, right) => {
const timeDiff = left.nextOccurrence.getTime() - right.nextOccurrence.getTime();
if (timeDiff !== 0) return timeDiff;
return String(left.groupName || '').localeCompare(String(right.groupName || ''));
})
.slice(0, limit);
}
export const getClubDashboard = async (req, res) => {
try {
const clubId = Number(req.params.clubId);
const currentUserId = Number(req.user?.id) || null;
const today = new Date();
today.setHours(0, 0, 0, 0);
const todayIso = today.toISOString().slice(0, 10);
const availableTables = await loadAvailableTables();
const [
requests,
tasks,
members,
mandates,
paymentClaims,
upcomingEvents,
trainingGroups,
upcomingMatches,
] = await Promise.all([
loadOptionalTableData(availableTables, 'club_requests', () => ClubRequest.findAll({
where: {
clubId,
status: { [Op.notIn]: ['archived'] },
},
order: [['receivedAt', 'DESC']],
})),
loadOptionalTableData(availableTables, 'club_tasks', () => ClubTask.findAll({
where: {
clubId,
status: { [Op.notIn]: ['archived'] },
},
order: [['dueAt', 'ASC'], ['updatedAt', 'DESC']],
})),
Member.findAll({
where: {
clubId,
active: true,
},
order: [['createdAt', 'DESC']],
}),
loadOptionalTableData(availableTables, 'club_sepa_mandates', () => ClubSepaMandate.findAll({
where: {
clubId,
status: 'active',
revokedAt: null,
memberId: { [Op.ne]: null },
},
attributes: ['memberId'],
})),
loadOptionalTableData(availableTables, 'club_payment_claims', () => ClubPaymentClaim.findAll({
where: {
clubId,
status: { [Op.in]: ['open', 'partially_paid'] },
archivedAt: null,
},
order: [['dueOn', 'ASC']],
})),
loadOptionalTableData(availableTables, 'calendar_events', () => CalendarEvent.findAll({
where: {
clubId,
endDate: { [Op.gte]: todayIso },
},
order: [['startDate', 'ASC']],
limit: 5,
})),
loadOptionalTableData(availableTables, 'training_group', () => TrainingGroup.findAll({
where: {
clubId,
},
include: [
{
association: 'trainingTimes',
required: false,
},
],
order: [['isPreset', 'DESC'], ['sortOrder', 'ASC'], ['name', 'ASC']],
})),
loadOptionalTableData(availableTables, 'match', () => Match.findAll({
where: {
clubId,
date: { [Op.gte]: today },
},
include: [
{ association: 'homeTeam', attributes: ['id', 'name'] },
{ association: 'guestTeam', attributes: ['id', 'name'] },
{ association: 'leagueDetails', attributes: ['id', 'name'] },
],
order: [['date', 'ASC'], ['time', 'ASC']],
limit: 5,
})),
]);
const visibleDashboardTasks = tasks.filter((task) => !task.assignedUserId || Number(task.assignedUserId) === currentUserId);
const membersById = new Map(members.map((member) => [Number(member.id), member]));
const paymentClaimsById = new Map(paymentClaims.map((claim) => [Number(claim.id), claim]));
const missingFields = countMissingMemberFields(members);
const openTasks = visibleDashboardTasks.filter((task) => task.status === 'open');
const inProgressTasks = visibleDashboardTasks.filter((task) => task.status === 'in_progress');
const automatedTasks = visibleDashboardTasks.filter((task) => Boolean(task.automationKey));
const automatedOpenTasks = automatedTasks.filter((task) => ['open', 'in_progress', 'waiting'].includes(task.status));
const overdueTaskCount = visibleDashboardTasks.filter((task) => {
if (!task.dueAt || ['done', 'cancelled', 'archived'].includes(task.status)) {
return false;
}
return new Date(task.dueAt) < today;
}).length;
const memberIdsWithMandate = new Set(mandates.map((mandate) => Number(mandate.memberId)).filter(Boolean));
const missingMandateCount = members.filter((member) => !memberIdsWithMandate.has(Number(member.id))).length;
const openRequestCount = requests.filter((request) => request.status === 'open').length;
const inProgressRequestCount = requests.filter((request) => request.status === 'in_progress').length;
const trialTrainingCount = requests.filter((request) => request.requestType === 'trial_training' && request.status !== 'archived').length;
const workflowStageCounts = requests.reduce((accumulator, request) => {
if (!request.workflowStage) return accumulator;
accumulator[request.workflowStage] = (accumulator[request.workflowStage] || 0) + 1;
return accumulator;
}, {});
const onboardingCount =
(workflowStageCounts.membership_reviewed || 0) +
(workflowStageCounts.admission_prepared || 0) +
(workflowStageCounts.member_record_created || 0) +
(workflowStageCounts.sepa_pending || 0);
const duePaymentCount = paymentClaims.filter((claim) => claim.status === 'open').length;
const reminderCount = paymentClaims.filter((claim) => Number(claim.reminderLevel || 0) > 0).length;
const recentMembers = members.slice(0, 4);
const upcomingTrainings = buildUpcomingTrainingSlots(trainingGroups);
const paidRatio = paymentClaims.length === 0
? null
: Math.max(
0,
Math.round(
((paymentClaims.length - duePaymentCount) / paymentClaims.length) * 100
)
);
function taskDetailTarget(task) {
if (task.relatedEntityType === 'member' && task.relatedEntityId) {
return buildMemberRoute(task.relatedEntityId, 'active');
}
if (task.relatedEntityType === 'club_request' && task.relatedEntityId) {
const request = requests.find((entry) => Number(entry.id) === Number(task.relatedEntityId));
return buildRequestRoute(task.relatedEntityId, request?.status || '');
}
if (task.relatedEntityType === 'club_payment_claim' && task.relatedEntityId) {
const claim = paymentClaimsById.get(Number(task.relatedEntityId));
if (claim?.memberId) {
return buildMemberRoute(claim.memberId, 'active');
}
}
return buildTaskRoute(task.id, task.status || '');
}
function taskDashboardItem(task, label) {
return createDashboardItem(label, taskDetailTarget(task), {
isAssignedToCurrentUser: Boolean(task.assignedUserId) && Number(task.assignedUserId) === currentUserId,
});
}
const sections = [
{
id: 'action-needed',
title: 'Handlungsbedarf',
cards: [
{
title: 'Neue Anfragen',
value: `${openRequestCount + inProgressRequestCount}`,
meta: trialTrainingCount > 0 ? `${trialTrainingCount} Probetrainings` : null,
to: '/club-requests',
items: [
createDashboardItem(`${openRequestCount} offen`, '/club-requests'),
createDashboardItem(`${inProgressRequestCount} in Bearbeitung`, '/club-requests'),
],
},
{
title: 'Anfrage-Workflows',
value: `${onboardingCount}`,
meta: onboardingCount > 0 ? 'im Aufnahme- und Onboardingprozess' : 'Keine aktiven Onboarding-Fälle',
to: '/club-requests',
items: [
createDashboardItem(`${workflowStageCounts.trial_training_scheduled || 0} Probetrainings terminiert`, '/club-requests'),
createDashboardItem(`${workflowStageCounts.membership_reviewed || 0} Mitgliedsanfragen geprüft`, '/club-requests'),
createDashboardItem(`${workflowStageCounts.sepa_pending || 0} Fälle mit ausstehendem SEPA`, '/club-requests'),
],
},
{
title: 'Offene Zahlungen',
value: `${paymentClaims.length}`,
meta: reminderCount > 0 ? `${reminderCount} mit Mahnstufe` : 'Keine Mahnungen aktiv',
to: '/club-tasks',
items: paymentClaims.slice(0, 3).map((claim) => {
const amount = `${(Number(claim.amountCents) / 100).toFixed(2)} ${claim.currencyCode || 'EUR'}`;
return createDashboardItem(
`${amount} fällig am ${claim.dueOn}`,
claim.memberId ? buildMemberRoute(claim.memberId, 'active') : '/club-tasks'
);
}),
},
{
title: 'Fehlende Daten',
value: `${missingFields.email + missingFields.birthDate + missingMandateCount}`,
to: '/members',
items: [
createDashboardItem(`${missingFields.email} Mitglieder ohne E-Mail`, { path: '/members', query: { scope: 'dataIncomplete' } }),
createDashboardItem(`${missingFields.birthDate} Mitglieder ohne Geburtsdatum`, { path: '/members', query: { scope: 'dataIncomplete' } }),
createDashboardItem(`${missingMandateCount} Mitglieder ohne SEPA-Mandat`, { path: '/members', query: { scope: 'dataIncomplete' } }),
],
},
{
title: 'Offene Aufgaben',
value: `${openTasks.length + inProgressTasks.length}`,
meta: overdueTaskCount > 0 ? `${overdueTaskCount} überfällig` : 'Keine überfälligen Aufgaben',
to: '/club-tasks',
items: [
createDashboardItem(`${automatedOpenTasks.length} automatisch erzeugte Schritte`, '/club-tasks'),
...visibleDashboardTasks.slice(0, 3).map((task) => taskDashboardItem(task, task.title)),
],
},
],
},
{
id: 'appointments',
title: 'Aktuelle Termine',
cards: [
{
title: 'Nächste Trainings',
value: `${upcomingTrainings.length}`,
to: {
path: '/club-settings',
query: { tab: 'training-times' },
},
items: upcomingTrainings.map((training) => createDashboardItem(
formatConfiguredTrainingLabel(training),
{
path: '/club-settings',
query: { tab: 'training-times' },
}
)),
},
{
title: 'Nächste Spiele',
value: `${upcomingMatches.length}`,
to: '/schedule',
items: upcomingMatches.map((match) => createDashboardItem(formatMatchLabel(match), '/schedule')),
},
{
title: 'Kalendertermine',
value: `${upcomingEvents.length}`,
to: '/calendar',
items: upcomingEvents.map((event) => createDashboardItem(`${event.title} · ${formatEventDateRange(event)}`, '/calendar')),
},
],
},
{
id: 'club-status',
title: 'Vereinsstatus',
cards: [
{
title: 'Mitglieder',
value: `${members.length} aktiv`,
meta: members.length > 0 ? `${members.filter((member) => {
const createdAt = new Date(member.createdAt);
return createdAt.getFullYear() === today.getFullYear();
}).length} dieses Jahr angelegt` : null,
to: '/members',
items: recentMembers.map((member) => {
const name = [member.firstName, member.lastName].filter(Boolean).join(' ').trim() || member.email || `Mitglied ${member.id}`;
return createDashboardItem(name, buildMemberRoute(member.id, 'active'));
}),
},
{
title: 'Anfragen',
value: `${requests.length}`,
meta: `${openRequestCount} offen, ${inProgressRequestCount} in Bearbeitung`,
to: '/club-requests',
},
{
title: 'Workflow-Fortschritt',
value: `${workflowStageCounts.onboarding_completed || 0}`,
meta: 'Onboardings abgeschlossen',
to: '/club-requests',
items: [
createDashboardItem(`${workflowStageCounts.admission_prepared || 0} Aufnahmen vorbereitet`, '/club-requests'),
createDashboardItem(`${workflowStageCounts.member_record_created || 0} Mitglieder angelegt`, '/club-requests'),
createDashboardItem(`${workflowStageCounts.sepa_pending || 0} warten auf SEPA`, '/club-requests'),
],
},
{
title: 'Finanzen',
value: paidRatio === null ? 'Keine Daten' : `${paidRatio} % erledigt`,
meta: paymentClaims.length > 0 ? `${paymentClaims.length} offene oder teilweise offene Forderungen` : 'Noch keine Beitragsforderungen erfasst',
to: '/club-tasks',
},
],
},
{
id: 'recent-activity',
title: 'Letzte Aktivitäten',
cards: [
{
title: 'Zuletzt eingegangen',
to: '/club-requests',
items: requests.slice(0, 4).map((request) => {
const name = [request.firstName, request.lastName].filter(Boolean).join(' ').trim() || request.email || 'Unbekannt';
const workflow = request.workflowStage ? ` · ${formatRequestWorkflowStage(request.workflowStage)}` : '';
return createDashboardItem(
`${name} · ${request.subject || request.requestType}${workflow}`,
buildRequestRoute(request.id, request.status || '')
);
}),
},
{
title: 'Aktuelle Aufgaben',
to: '/club-tasks',
items: visibleDashboardTasks.slice(0, 4).map((task) => taskDashboardItem(task, `${formatTaskType(task.taskType)} · ${task.status}`)),
},
{
title: 'Automatik zuletzt aktiv',
to: '/club-tasks',
items: automatedTasks.slice(0, 4).map((task) => {
const sourceLabel = task.automationSource === 'club_requests'
? 'Anfrage'
: task.automationSource === 'club_payment_claims'
? 'Zahlung'
: task.automationSource === 'calendar_events'
? 'Termin'
: 'Workflow';
return taskDashboardItem(task, `${formatTaskType(task.taskType)} · ${sourceLabel}`);
}),
},
],
},
];
res.status(200).json({ sections });
} catch (error) {
console.error('[getClubDashboard] - Error:', error);
res.status(error.statusCode || 500).json({
error: getSafeErrorMessage(error, 'Dashboard konnte nicht geladen werden.'),
});
}
};

View File

@@ -0,0 +1,174 @@
import { ClubRequest, ClubRequestNote } from '../models/index.js';
import { getSafeErrorMessage } from '../utils/errorUtils.js';
const TERMINAL_REQUEST_STATUSES = new Set(['converted', 'rejected', 'archived']);
function isMissingRequestTableError(error) {
return error?.original?.code === 'ER_NO_SUCH_TABLE'
&& /club_requests|club_request_notes/.test(String(error?.original?.sqlMessage || ''));
}
function normalizeRequestPayload(payload = {}) {
return {
requestType: payload.requestType || 'contact',
subject: payload.subject?.trim() || null,
firstName: payload.firstName?.trim() || null,
lastName: payload.lastName?.trim() || null,
email: payload.email?.trim() || null,
phone: payload.phone?.trim() || null,
message: payload.message?.trim() || null,
};
}
async function loadRequestOrThrow(clubId, requestId) {
const request = await ClubRequest.findOne({
where: {
id: requestId,
clubId,
},
include: [
{
model: ClubRequestNote,
as: 'notes',
required: false,
},
],
order: [[{ model: ClubRequestNote, as: 'notes' }, 'createdAt', 'DESC']],
});
if (!request) {
const error = new Error('Anfrage wurde nicht gefunden.');
error.statusCode = 404;
throw error;
}
return request;
}
export const listClubRequests = async (req, res) => {
try {
const { clubId } = req.params;
let requests = [];
try {
requests = await ClubRequest.findAll({
where: { clubId },
include: [
{
model: ClubRequestNote,
as: 'notes',
required: false,
},
],
order: [
['receivedAt', 'DESC'],
[{ model: ClubRequestNote, as: 'notes' }, 'createdAt', 'DESC'],
],
});
} catch (error) {
if (!isMissingRequestTableError(error)) {
throw error;
}
}
res.status(200).json({ requests });
} catch (error) {
console.error('[listClubRequests] - Error:', error);
res.status(error.statusCode || 500).json({
error: getSafeErrorMessage(error, 'Anfragen konnten nicht geladen werden.'),
});
}
};
export const createClubRequest = async (req, res) => {
try {
const { clubId } = req.params;
const payload = normalizeRequestPayload(req.body);
if (!payload.subject && !payload.message) {
return res.status(400).json({ error: 'Betreff oder Nachricht sind erforderlich.' });
}
const request = await ClubRequest.create({
clubId,
...payload,
receivedAt: new Date(),
});
const created = await loadRequestOrThrow(clubId, request.id);
res.status(201).json({ request: created });
} catch (error) {
console.error('[createClubRequest] - Error:', error);
res.status(error.statusCode || 500).json({
error: getSafeErrorMessage(error, 'Anfrage konnte nicht gespeichert werden.'),
});
}
};
export const updateClubRequest = async (req, res) => {
try {
const { clubId, requestId } = req.params;
const request = await loadRequestOrThrow(clubId, requestId);
const payload = normalizeRequestPayload(req.body);
await request.update(payload);
const updated = await loadRequestOrThrow(clubId, requestId);
res.status(200).json({ request: updated });
} catch (error) {
console.error('[updateClubRequest] - Error:', error);
res.status(error.statusCode || 500).json({
error: getSafeErrorMessage(error, 'Anfrage konnte nicht gespeichert werden.'),
});
}
};
export const updateClubRequestStatus = async (req, res) => {
try {
const { clubId, requestId } = req.params;
const { status } = req.body || {};
if (!status) {
return res.status(400).json({ error: 'Status fehlt.' });
}
const request = await loadRequestOrThrow(clubId, requestId);
await request.update({
status,
closedAt: TERMINAL_REQUEST_STATUSES.has(status) ? new Date() : null,
});
const updated = await loadRequestOrThrow(clubId, requestId);
res.status(200).json({ request: updated });
} catch (error) {
console.error('[updateClubRequestStatus] - Error:', error);
res.status(error.statusCode || 500).json({
error: getSafeErrorMessage(error, 'Status konnte nicht gespeichert werden.'),
});
}
};
export const addClubRequestNote = async (req, res) => {
try {
const { clubId, requestId } = req.params;
const body = String(req.body?.body || '').trim();
if (!body) {
return res.status(400).json({ error: 'Notiztext fehlt.' });
}
await loadRequestOrThrow(clubId, requestId);
await ClubRequestNote.create({
clubRequestId: requestId,
createdByUserId: req.user?.id || null,
body,
});
const updated = await loadRequestOrThrow(clubId, requestId);
res.status(201).json({ request: updated });
} catch (error) {
console.error('[addClubRequestNote] - Error:', error);
res.status(error.statusCode || 500).json({
error: getSafeErrorMessage(error, 'Notiz konnte nicht gespeichert werden.'),
});
}
};

View File

@@ -0,0 +1,19 @@
import clubStatisticsService from '../services/clubStatisticsService.js';
class ClubStatisticsController {
async getClubStatistics(req, res) {
try {
const { clubId } = req.params;
const statistics = await clubStatisticsService.getClubStatistics(clubId);
res.json(statistics);
} catch (error) {
console.error('[getClubStatistics] - Error:', error);
if (error?.status) {
return res.status(error.status).json({ error: error.message });
}
res.status(500).json({ error: 'Fehler beim Laden der Vereinsstatistiken' });
}
}
}
export default new ClubStatisticsController();

View File

@@ -0,0 +1,274 @@
import { ClubTask, User, UserClub } from '../models/index.js';
import { getSafeErrorMessage } from '../utils/errorUtils.js';
import clubTaskAutomationService from '../services/clubTaskAutomationService.js';
import clubWorkflowSourceService from '../services/clubWorkflowSourceService.js';
const TERMINAL_TASK_STATUSES = new Set(['done', 'cancelled', 'archived']);
function isMissingTaskTableError(error) {
return error?.original?.code === 'ER_NO_SUCH_TABLE'
&& /club_tasks/.test(String(error?.original?.sqlMessage || ''));
}
function isMissingTaskSuppressionTableError(error) {
return error?.original?.code === 'ER_NO_SUCH_TABLE'
&& /club_task_suppressions/.test(String(error?.original?.sqlMessage || ''));
}
function normalizeTaskPayload(payload = {}) {
return {
title: String(payload.title || '').trim(),
taskType: payload.taskType?.trim() || null,
description: payload.description?.trim() || null,
status: payload.status || 'open',
priority: payload.priority || 'normal',
dueAt: payload.dueAt || null,
remindAt: payload.remindAt || null,
assignedUserId: payload.assignedUserId ? Number(payload.assignedUserId) : null,
automationSource: payload.automationSource?.trim() || null,
automationKey: payload.automationKey?.trim() || null,
relatedEntityType: payload.relatedEntityType?.trim() || null,
relatedEntityId: payload.relatedEntityId ? Number(payload.relatedEntityId) : null,
sourceSnapshot: payload.sourceSnapshot || null,
};
}
async function loadAssignableUsers(clubId) {
const entries = await UserClub.findAll({
where: { clubId },
include: [{ model: User, as: 'user', attributes: ['id', 'email'] }],
order: [[{ model: User, as: 'user' }, 'email', 'ASC']],
});
return entries
.filter((entry) => entry.user)
.filter((entry) => entry.approved || entry.isOwner)
.map((entry) => ({
userId: entry.userId,
email: entry.user.email,
isOwner: Boolean(entry.isOwner),
approved: Boolean(entry.approved),
}));
}
async function validateAssignedUser(clubId, assignedUserId) {
if (!assignedUserId) {
return null;
}
const userClub = await UserClub.findOne({
where: {
clubId,
userId: assignedUserId,
},
});
if (!userClub || (!userClub.approved && !userClub.isOwner)) {
const error = new Error('Der zugewiesene Benutzer gehört nicht zu diesem Verein.');
error.statusCode = 400;
throw error;
}
return assignedUserId;
}
async function loadTaskOrThrow(clubId, taskId) {
const task = await ClubTask.findOne({
where: {
id: taskId,
clubId,
},
});
if (!task) {
const error = new Error('Aufgabe wurde nicht gefunden.');
error.statusCode = 404;
throw error;
}
return task;
}
export const listClubTasks = async (req, res) => {
try {
const { clubId } = req.params;
let tasks = [];
let automationOverview = { definitions: [], suggestions: [] };
try {
[tasks, automationOverview] = await Promise.all([
ClubTask.findAll({
where: { clubId },
include: [{ model: User, as: 'assignedUser', attributes: ['id', 'email'], required: false }],
order: [
['status', 'ASC'],
['dueAt', 'ASC'],
['updatedAt', 'DESC'],
],
}),
clubTaskAutomationService.buildAutomationOverview(clubId),
]);
} catch (error) {
if (!isMissingTaskTableError(error)) {
throw error;
}
}
const assignableUsers = await loadAssignableUsers(clubId);
res.status(200).json({
tasks,
taskDefinitions: automationOverview.definitions,
taskSuggestions: automationOverview.suggestions,
assignableUsers,
});
} catch (error) {
console.error('[listClubTasks] - Error:', error);
res.status(error.statusCode || 500).json({
error: getSafeErrorMessage(error, 'Aufgaben konnten nicht geladen werden.'),
});
}
};
export const createClubTask = async (req, res) => {
try {
const { clubId } = req.params;
const payload = normalizeTaskPayload(req.body);
if (!payload.title) {
return res.status(400).json({ error: 'Titel ist erforderlich.' });
}
payload.assignedUserId = await validateAssignedUser(clubId, payload.assignedUserId);
const task = await ClubTask.create({
clubId,
...payload,
createdByUserId: req.user?.id || null,
});
res.status(201).json({ task });
} catch (error) {
console.error('[createClubTask] - Error:', error);
res.status(error.statusCode || 500).json({
error: getSafeErrorMessage(error, 'Aufgabe konnte nicht gespeichert werden.'),
});
}
};
export const updateClubTask = async (req, res) => {
try {
const { clubId, taskId } = req.params;
const task = await loadTaskOrThrow(clubId, taskId);
const payload = normalizeTaskPayload(req.body);
const wasDoneBefore = task.status === 'done';
if (!payload.title) {
return res.status(400).json({ error: 'Titel ist erforderlich.' });
}
payload.assignedUserId = await validateAssignedUser(clubId, payload.assignedUserId);
await task.update({
...payload,
completedAt: TERMINAL_TASK_STATUSES.has(payload.status) ? (task.completedAt || new Date()) : null,
archivedAt: payload.status === 'archived' ? (task.archivedAt || new Date()) : null,
});
const followUpTasks = !wasDoneBefore && payload.status === 'done'
? await clubTaskAutomationService.materializeWorkflowFollowUps(task, req.user?.id || null)
: [];
const sourceUpdate = !wasDoneBefore && payload.status === 'done'
? await clubWorkflowSourceService.syncSourceStateForCompletedTask(task)
: null;
res.status(200).json({ task, followUpTasks, sourceUpdate });
} catch (error) {
console.error('[updateClubTask] - Error:', error);
res.status(error.statusCode || 500).json({
error: getSafeErrorMessage(error, 'Aufgabe konnte nicht gespeichert werden.'),
});
}
};
export const updateClubTaskStatus = async (req, res) => {
try {
const { clubId, taskId } = req.params;
const { status } = req.body || {};
if (!status) {
return res.status(400).json({ error: 'Status fehlt.' });
}
const task = await loadTaskOrThrow(clubId, taskId);
const wasDoneBefore = task.status === 'done';
await task.update({
status,
completedAt: TERMINAL_TASK_STATUSES.has(status) ? (task.completedAt || new Date()) : null,
archivedAt: status === 'archived' ? (task.archivedAt || new Date()) : null,
});
const followUpTasks = !wasDoneBefore && status === 'done'
? await clubTaskAutomationService.materializeWorkflowFollowUps(task, req.user?.id || null)
: [];
const sourceUpdate = !wasDoneBefore && status === 'done'
? await clubWorkflowSourceService.syncSourceStateForCompletedTask(task)
: null;
res.status(200).json({ task, followUpTasks, sourceUpdate });
} catch (error) {
console.error('[updateClubTaskStatus] - Error:', error);
res.status(error.statusCode || 500).json({
error: getSafeErrorMessage(error, 'Status konnte nicht gespeichert werden.'),
});
}
};
export const materializeAutomatedClubTasks = async (req, res) => {
try {
const { clubId } = req.params;
const automationKeys = Array.isArray(req.body?.automationKeys) ? req.body.automationKeys : [];
if (automationKeys.length === 0) {
return res.status(400).json({ error: 'Es wurden keine Automatik-Schlüssel übergeben.' });
}
const tasks = await clubTaskAutomationService.materializeSuggestions(clubId, req.user?.id || null, automationKeys);
res.status(201).json({ tasks });
} catch (error) {
console.error('[materializeAutomatedClubTasks] - Error:', error);
res.status(error.statusCode || 500).json({
error: getSafeErrorMessage(error, 'Automatische Aufgaben konnten nicht erstellt werden.'),
});
}
};
export const dismissAutomatedClubTaskSuggestion = async (req, res) => {
try {
const { clubId } = req.params;
const payload = req.body || {};
const suppression = await clubTaskAutomationService.dismissSuggestion(clubId, req.user?.id || null, payload);
res.status(200).json({ success: true, suppression });
} catch (error) {
console.error('[dismissAutomatedClubTaskSuggestion] - Error:', error);
if (isMissingTaskSuppressionTableError(error)) {
return res.status(500).json({
error: 'Die Tabelle club_task_suppressions fehlt noch. Bitte die aktuelle SQL-Datei auf dem System ausführen.',
});
}
res.status(error.statusCode || 500).json({
error: getSafeErrorMessage(error, 'Vorschlag konnte nicht ausgeblendet werden.'),
});
}
};
export const deleteClubTask = async (req, res) => {
try {
const { clubId, taskId } = req.params;
const task = await loadTaskOrThrow(clubId, taskId);
await task.destroy();
res.status(200).json({ success: true });
} catch (error) {
console.error('[deleteClubTask] - Error:', error);
res.status(error.statusCode || 500).json({
error: getSafeErrorMessage(error, 'Aufgabe konnte nicht gelöscht werden.'),
});
}
};

View File

@@ -28,7 +28,7 @@ const getWaitingApprovals = async(req, res) => {
const setClubMembers = async (req, res) => {
try {
const { id: memberId, firstname: firstName, lastname: lastName, street, city, postalCode, birthdate, phone, email, active,
const { id: memberId, firstname: firstName, lastname: lastName, street, city, postalCode, birthdate, phone, email, active,
testMembership, picsInInternetAllowed, gender, ttr, qttr, memberFormHandedOver, adultReleaseApproved, adultReserveApproved, contacts } = req.body;
const { id: clubId } = req.params;
const { authcode: userToken } = req.headers;
@@ -47,6 +47,33 @@ const setClubMembers = async (req, res) => {
}
}
const getMemberSepaMandate = async (req, res) => {
try {
const { clubId, memberId } = req.params;
const { authcode: userToken } = req.headers;
const result = await MemberService.getMemberSepaMandate(userToken, Number(clubId), Number(memberId));
res.status(result.status || 500).json(result.response);
} catch (error) {
console.error('[getMemberSepaMandate] - Error:', error);
res.status(500).json({ success: false, error: 'SEPA-Mandat konnte nicht geladen werden.' });
}
};
const saveMemberSepaMandate = async (req, res) => {
try {
const { clubId, memberId } = req.params;
const { authcode: userToken } = req.headers;
const result = await MemberService.saveMemberSepaMandate(userToken, Number(clubId), Number(memberId), req.body || {});
if (result.status === 200) {
emitMemberChanged(clubId);
}
res.status(result.status || 500).json(result.response);
} catch (error) {
console.error('[saveMemberSepaMandate] - Error:', error);
res.status(500).json({ success: false, error: 'SEPA-Mandat konnte nicht gespeichert werden.' });
}
};
const getMemberPlayInterests = async (req, res) => {
try {
const { clubId } = req.params;
@@ -327,6 +354,8 @@ export {
getClubMembers,
getWaitingApprovals,
setClubMembers,
getMemberSepaMandate,
saveMemberSepaMandate,
getMemberPlayInterests,
setMemberPlayInterest,
uploadMemberImage,

View File

@@ -76,6 +76,29 @@ export const updateUserRole = async (req, res) => {
}
};
export const updateUserRoles = async (req, res) => {
try {
const { clubId, userId: targetUserId } = req.params;
const { roleIds } = req.body;
const updatingUserId = req.user.id;
const result = await permissionService.setUserRoles(
parseInt(targetUserId),
parseInt(clubId),
Array.isArray(roleIds) ? roleIds : [],
updatingUserId
);
res.json(result);
} catch (error) {
console.error('Error updating user roles:', error);
if (error.message && error.message.toLowerCase().includes('keine berechtigung')) {
return res.status(403).json({ error: error.message });
}
res.status(400).json({ error: error.message });
}
};
/**
* Update user custom permissions
*/
@@ -128,6 +151,62 @@ export const getPermissionStructure = async (req, res) => {
}
};
export const getClubRoles = async (req, res) => {
try {
const { clubId } = req.params;
const roles = await permissionService.getClubRoles(parseInt(clubId, 10), req.user.id);
res.json(roles);
} catch (error) {
console.error('Error getting club roles:', error);
if (error.message && error.message.toLowerCase().includes('keine berechtigung')) {
return res.status(403).json({ error: error.message });
}
res.status(400).json({ error: error.message });
}
};
export const createClubRole = async (req, res) => {
try {
const { clubId } = req.params;
const role = await permissionService.createClubRole(parseInt(clubId, 10), req.body || {}, req.user.id);
res.status(201).json(role);
} catch (error) {
console.error('Error creating club role:', error);
if (error.message && error.message.toLowerCase().includes('keine berechtigung')) {
return res.status(403).json({ error: error.message });
}
res.status(400).json({ error: error.message });
}
};
export const updateClubRole = async (req, res) => {
try {
const { clubId, roleId } = req.params;
const role = await permissionService.updateClubRole(parseInt(clubId, 10), parseInt(roleId, 10), req.body || {}, req.user.id);
res.json(role);
} catch (error) {
console.error('Error updating club role:', error);
if (error.message && error.message.toLowerCase().includes('keine berechtigung')) {
return res.status(403).json({ error: error.message });
}
res.status(400).json({ error: error.message });
}
};
export const deleteClubRole = async (req, res) => {
try {
const { clubId, roleId } = req.params;
const result = await permissionService.deleteClubRole(parseInt(clubId, 10), parseInt(roleId, 10), req.user.id);
res.json(result);
} catch (error) {
console.error('Error deleting club role:', error);
if (error.message && error.message.toLowerCase().includes('keine berechtigung')) {
return res.status(403).json({ error: error.message });
}
res.status(400).json({ error: error.message });
}
};
/**
* Update user status (activate/deactivate)
*/
@@ -158,10 +237,14 @@ export default {
getUserPermissions,
getClubMembersWithPermissions,
updateUserRole,
updateUserRoles,
updateUserPermissions,
updateUserStatus,
getAvailableRoles,
getPermissionStructure
getPermissionStructure,
getClubRoles,
createClubRole,
updateClubRole,
deleteClubRole,
};

View File

@@ -142,7 +142,7 @@ export const requireAdmin = () => {
parseInt(clubId)
);
if (!userPermissions || (userPermissions.role !== 'admin' && !userPermissions.isOwner)) {
if (!userPermissions || (!userPermissions.isAdmin && !userPermissions.isOwner)) {
return res.status(403).json({
error: 'Keine Berechtigung',
details: 'Administrator-Rechte erforderlich'
@@ -190,7 +190,10 @@ export const requireRole = (roles) => {
parseInt(clubId)
);
if (!userPermissions || !roles.includes(userPermissions.role)) {
const assignedRoleKeys = Array.isArray(userPermissions?.roles)
? userPermissions.roles.map((role) => role.roleKey)
: [];
if (!userPermissions || (!roles.includes(userPermissions.role) && !assignedRoleKeys.some((roleKey) => roles.includes(roleKey)))) {
return res.status(403).json({
error: 'Keine Berechtigung',
details: `Erforderliche Rolle: ${roles.join(', ')}`
@@ -212,4 +215,3 @@ export default {
requireAdmin,
requireRole
};

View File

@@ -0,0 +1,94 @@
import { DataTypes } from 'sequelize';
import sequelize from '../database.js';
const ClubAccount = sequelize.define('ClubAccount', {
clubId: {
type: DataTypes.INTEGER,
allowNull: false,
field: 'club_id',
},
name: {
type: DataTypes.STRING(160),
allowNull: false,
},
accountHolder: {
type: DataTypes.STRING(255),
allowNull: true,
field: 'account_holder',
},
bankName: {
type: DataTypes.STRING(160),
allowNull: true,
field: 'bank_name',
},
iban: {
type: DataTypes.STRING(34),
allowNull: true,
},
bic: {
type: DataTypes.STRING(11),
allowNull: true,
},
accountType: {
type: DataTypes.ENUM('bank', 'cash', 'virtual'),
allowNull: false,
defaultValue: 'bank',
field: 'account_type',
},
usageType: {
type: DataTypes.ENUM('general', 'membership_fees', 'donations', 'expenses', 'reserve', 'petty_cash'),
allowNull: false,
defaultValue: 'general',
field: 'usage_type',
},
currencyCode: {
type: DataTypes.STRING(3),
allowNull: false,
defaultValue: 'EUR',
field: 'currency_code',
},
allowSepaCollections: {
type: DataTypes.BOOLEAN,
allowNull: false,
defaultValue: false,
field: 'allow_sepa_collections',
},
allowOutgoingPayments: {
type: DataTypes.BOOLEAN,
allowNull: false,
defaultValue: true,
field: 'allow_outgoing_payments',
},
isDefault: {
type: DataTypes.BOOLEAN,
allowNull: false,
defaultValue: false,
field: 'is_default',
},
status: {
type: DataTypes.ENUM('active', 'inactive', 'archived'),
allowNull: false,
defaultValue: 'active',
},
notes: {
type: DataTypes.TEXT,
allowNull: true,
},
archivedAt: {
type: DataTypes.DATE,
allowNull: true,
field: 'archived_at',
},
sortOrder: {
type: DataTypes.INTEGER,
allowNull: false,
defaultValue: 0,
field: 'sort_order',
},
}, {
tableName: 'club_accounts',
underscored: true,
timestamps: true,
});
export default ClubAccount;

View File

@@ -0,0 +1,78 @@
import { DataTypes } from 'sequelize';
import sequelize from '../database.js';
const ClubPaymentClaim = sequelize.define('ClubPaymentClaim', {
clubId: {
type: DataTypes.INTEGER,
allowNull: false,
field: 'club_id'
},
memberId: {
type: DataTypes.INTEGER,
allowNull: true,
field: 'member_id'
},
feeRuleId: {
type: DataTypes.INTEGER,
allowNull: true,
field: 'fee_rule_id'
},
claimType: {
type: DataTypes.STRING(32),
allowNull: false,
defaultValue: 'membership_fee',
field: 'claim_type'
},
status: {
type: DataTypes.ENUM('open', 'partially_paid', 'paid', 'written_off', 'cancelled'),
allowNull: false,
defaultValue: 'open'
},
dueOn: {
type: DataTypes.DATEONLY,
allowNull: false,
field: 'due_on'
},
amountCents: {
type: DataTypes.BIGINT,
allowNull: false,
field: 'amount_cents'
},
currencyCode: {
type: DataTypes.STRING(3),
allowNull: false,
defaultValue: 'EUR',
field: 'currency_code'
},
reminderLevel: {
type: DataTypes.INTEGER,
allowNull: false,
defaultValue: 0,
field: 'reminder_level'
},
lastReminderAt: {
type: DataTypes.DATE,
allowNull: true,
field: 'last_reminder_at'
},
notes: {
type: DataTypes.TEXT,
allowNull: true
},
settledAt: {
type: DataTypes.DATE,
allowNull: true,
field: 'settled_at'
},
archivedAt: {
type: DataTypes.DATE,
allowNull: true,
field: 'archived_at'
}
}, {
tableName: 'club_payment_claims',
underscored: true,
timestamps: true
});
export default ClubPaymentClaim;

View File

@@ -0,0 +1,94 @@
import { DataTypes } from 'sequelize';
import sequelize from '../database.js';
const ClubRequest = sequelize.define('ClubRequest', {
clubId: {
type: DataTypes.INTEGER,
allowNull: false,
field: 'club_id'
},
requestType: {
type: DataTypes.ENUM('contact', 'trial_training', 'membership', 'sponsoring'),
allowNull: false,
defaultValue: 'contact',
field: 'request_type'
},
status: {
type: DataTypes.ENUM('open', 'in_progress', 'waiting', 'converted', 'rejected', 'archived'),
allowNull: false,
defaultValue: 'open'
},
workflowStage: {
type: DataTypes.STRING(64),
allowNull: true,
field: 'workflow_stage'
},
priority: {
type: DataTypes.ENUM('low', 'normal', 'high', 'urgent'),
allowNull: false,
defaultValue: 'normal'
},
subject: {
type: DataTypes.STRING,
allowNull: true
},
firstName: {
type: DataTypes.STRING,
allowNull: true,
field: 'first_name'
},
lastName: {
type: DataTypes.STRING,
allowNull: true,
field: 'last_name'
},
email: {
type: DataTypes.STRING,
allowNull: true
},
phone: {
type: DataTypes.STRING,
allowNull: true
},
message: {
type: DataTypes.TEXT,
allowNull: true
},
sourceSystem: {
type: DataTypes.STRING,
allowNull: true,
field: 'source_system'
},
receivedAt: {
type: DataTypes.DATE,
allowNull: false,
defaultValue: DataTypes.NOW,
field: 'received_at'
},
assignedUserId: {
type: DataTypes.INTEGER,
allowNull: true,
field: 'assigned_user_id'
},
assignedMemberId: {
type: DataTypes.INTEGER,
allowNull: true,
field: 'assigned_member_id'
},
convertedMemberId: {
type: DataTypes.INTEGER,
allowNull: true,
field: 'converted_member_id'
},
closedAt: {
type: DataTypes.DATE,
allowNull: true,
field: 'closed_at'
}
}, {
tableName: 'club_requests',
underscored: true,
timestamps: true
});
export default ClubRequest;

View File

@@ -0,0 +1,32 @@
import { DataTypes } from 'sequelize';
import sequelize from '../database.js';
const ClubRequestNote = sequelize.define('ClubRequestNote', {
clubRequestId: {
type: DataTypes.INTEGER,
allowNull: false,
field: 'club_request_id'
},
noteType: {
type: DataTypes.STRING(32),
allowNull: false,
defaultValue: 'internal',
field: 'note_type'
},
createdByUserId: {
type: DataTypes.INTEGER,
allowNull: true,
field: 'created_by_user_id'
},
body: {
type: DataTypes.TEXT,
allowNull: false
}
}, {
tableName: 'club_request_notes',
underscored: true,
timestamps: true,
updatedAt: false
});
export default ClubRequestNote;

View File

@@ -0,0 +1,58 @@
import { DataTypes } from 'sequelize';
import sequelize from '../database.js';
const ClubRole = sequelize.define('ClubRole', {
id: {
type: DataTypes.INTEGER,
primaryKey: true,
autoIncrement: true,
allowNull: false,
},
clubId: {
type: DataTypes.INTEGER,
allowNull: false,
field: 'club_id',
},
roleKey: {
type: DataTypes.STRING(64),
allowNull: false,
field: 'role_key',
},
name: {
type: DataTypes.STRING(120),
allowNull: false,
},
description: {
type: DataTypes.TEXT,
allowNull: true,
},
permissions: {
type: DataTypes.JSON,
allowNull: false,
defaultValue: {},
},
isSystemRole: {
type: DataTypes.BOOLEAN,
allowNull: false,
defaultValue: false,
field: 'is_system_role',
},
sortOrder: {
type: DataTypes.INTEGER,
allowNull: false,
defaultValue: 0,
field: 'sort_order',
},
}, {
tableName: 'club_roles',
underscored: true,
timestamps: true,
indexes: [
{
unique: true,
fields: ['club_id', 'role_key'],
},
],
});
export default ClubRole;

View File

@@ -0,0 +1,64 @@
import { DataTypes } from 'sequelize';
import sequelize from '../database.js';
const ClubSepaMandate = sequelize.define('ClubSepaMandate', {
clubId: {
type: DataTypes.INTEGER,
allowNull: false,
field: 'club_id'
},
memberId: {
type: DataTypes.INTEGER,
allowNull: true,
field: 'member_id'
},
debtorName: {
type: DataTypes.STRING,
allowNull: false,
field: 'debtor_name'
},
iban: {
type: DataTypes.STRING(34),
allowNull: false
},
bic: {
type: DataTypes.STRING(11),
allowNull: true
},
mandateReference: {
type: DataTypes.STRING(80),
allowNull: false,
field: 'mandate_reference'
},
signedOn: {
type: DataTypes.DATEONLY,
allowNull: true,
field: 'signed_on'
},
validFrom: {
type: DataTypes.DATEONLY,
allowNull: true,
field: 'valid_from'
},
revokedAt: {
type: DataTypes.DATE,
allowNull: true,
field: 'revoked_at'
},
status: {
type: DataTypes.STRING(32),
allowNull: false,
defaultValue: 'active'
},
historyNote: {
type: DataTypes.TEXT,
allowNull: true,
field: 'history_note'
}
}, {
tableName: 'club_sepa_mandates',
underscored: true,
timestamps: true
});
export default ClubSepaMandate;

View File

@@ -0,0 +1,94 @@
import { DataTypes } from 'sequelize';
import sequelize from '../database.js';
const ClubTask = sequelize.define('ClubTask', {
clubId: {
type: DataTypes.INTEGER,
allowNull: false,
field: 'club_id'
},
title: {
type: DataTypes.STRING(255),
allowNull: false
},
taskType: {
type: DataTypes.STRING(64),
allowNull: true,
field: 'task_type'
},
description: {
type: DataTypes.TEXT,
allowNull: true
},
status: {
type: DataTypes.ENUM('open', 'in_progress', 'waiting', 'done', 'cancelled', 'archived'),
allowNull: false,
defaultValue: 'open'
},
priority: {
type: DataTypes.ENUM('low', 'normal', 'high', 'urgent'),
allowNull: false,
defaultValue: 'normal'
},
dueAt: {
type: DataTypes.DATE,
allowNull: true,
field: 'due_at'
},
remindAt: {
type: DataTypes.DATE,
allowNull: true,
field: 'remind_at'
},
createdByUserId: {
type: DataTypes.INTEGER,
allowNull: true,
field: 'created_by_user_id'
},
assignedUserId: {
type: DataTypes.INTEGER,
allowNull: true,
field: 'assigned_user_id'
},
automationSource: {
type: DataTypes.STRING(64),
allowNull: true,
field: 'automation_source'
},
automationKey: {
type: DataTypes.STRING(255),
allowNull: true,
field: 'automation_key'
},
relatedEntityType: {
type: DataTypes.STRING(32),
allowNull: true,
field: 'related_entity_type'
},
relatedEntityId: {
type: DataTypes.INTEGER,
allowNull: true,
field: 'related_entity_id'
},
completedAt: {
type: DataTypes.DATE,
allowNull: true,
field: 'completed_at'
},
archivedAt: {
type: DataTypes.DATE,
allowNull: true,
field: 'archived_at'
},
sourceSnapshot: {
type: DataTypes.JSON,
allowNull: true,
field: 'source_snapshot'
}
}, {
tableName: 'club_tasks',
underscored: true,
timestamps: true
});
export default ClubTask;

View File

@@ -0,0 +1,31 @@
import { DataTypes } from 'sequelize';
import sequelize from '../database.js';
const ClubTaskSuppression = sequelize.define('ClubTaskSuppression', {
clubId: {
type: DataTypes.INTEGER,
allowNull: false,
field: 'club_id'
},
automationKey: {
type: DataTypes.STRING(255),
allowNull: false,
field: 'automation_key'
},
suppressionToken: {
type: DataTypes.STRING(255),
allowNull: false,
field: 'suppression_token'
},
dismissedByUserId: {
type: DataTypes.INTEGER,
allowNull: true,
field: 'dismissed_by_user_id'
}
}, {
tableName: 'club_task_suppressions',
underscored: true,
timestamps: true
});
export default ClubTaskSuppression;

View File

@@ -0,0 +1,44 @@
import { DataTypes } from 'sequelize';
import sequelize from '../database.js';
const ClubUserRole = sequelize.define('ClubUserRole', {
id: {
type: DataTypes.INTEGER,
primaryKey: true,
autoIncrement: true,
allowNull: false,
},
clubId: {
type: DataTypes.INTEGER,
allowNull: false,
field: 'club_id',
},
userId: {
type: DataTypes.INTEGER,
allowNull: false,
field: 'user_id',
},
clubRoleId: {
type: DataTypes.INTEGER,
allowNull: false,
field: 'club_role_id',
},
isPrimary: {
type: DataTypes.BOOLEAN,
allowNull: false,
defaultValue: false,
field: 'is_primary',
},
}, {
tableName: 'club_user_roles',
underscored: true,
timestamps: true,
indexes: [
{
unique: true,
fields: ['club_id', 'user_id', 'club_role_id'],
},
],
});
export default ClubUserRole;

View File

@@ -70,6 +70,15 @@ import MemberTrainingGroup from './MemberTrainingGroup.js';
import ClubDisabledPresetGroup from './ClubDisabledPresetGroup.js';
import TrainingTime from './TrainingTime.js';
import ClubVenue from './ClubVenue.js';
import ClubRequest from './ClubRequest.js';
import ClubRequestNote from './ClubRequestNote.js';
import ClubSepaMandate from './ClubSepaMandate.js';
import ClubPaymentClaim from './ClubPaymentClaim.js';
import ClubAccount from './ClubAccount.js';
import ClubTask from './ClubTask.js';
import ClubTaskSuppression from './ClubTaskSuppression.js';
import ClubRole from './ClubRole.js';
import ClubUserRole from './ClubUserRole.js';
// Official tournaments relations
OfficialTournament.hasMany(OfficialCompetition, { foreignKey: 'tournamentId', as: 'competitions' });
OfficialCompetition.belongsTo(OfficialTournament, { foreignKey: 'tournamentId', as: 'tournament' });
@@ -172,6 +181,10 @@ ClubTeam.belongsTo(League, { foreignKey: 'leagueId', as: 'league' });
Season.hasMany(ClubTeam, { foreignKey: 'seasonId', as: 'clubTeams' });
ClubTeam.belongsTo(Season, { foreignKey: 'seasonId', as: 'season' });
ClubTeam.hasMany(ClubTeamMember, { foreignKey: 'clubTeamId', as: 'lineupMembers' });
ClubTeamMember.belongsTo(ClubTeam, { foreignKey: 'clubTeamId', as: 'clubTeam' });
Member.hasMany(ClubTeamMember, { foreignKey: 'memberId', as: 'clubTeamAssignments' });
ClubTeamMember.belongsTo(Member, { foreignKey: 'memberId', as: 'member' });
// TeamDocument relationships
ClubTeam.hasMany(TeamDocument, { foreignKey: 'clubTeamId', as: 'documents' });
@@ -447,6 +460,50 @@ FriendlyMatchInvitation.hasOne(FriendlyMatchShared, {
Club.hasMany(ClubVenue, { foreignKey: 'clubId', as: 'venues' });
ClubVenue.belongsTo(Club, { foreignKey: 'clubId', as: 'club' });
Club.hasMany(ClubRequest, { foreignKey: 'clubId', as: 'clubRequests' });
ClubRequest.belongsTo(Club, { foreignKey: 'clubId', as: 'club' });
ClubRequest.hasMany(ClubRequestNote, { foreignKey: 'clubRequestId', as: 'notes' });
ClubRequestNote.belongsTo(ClubRequest, { foreignKey: 'clubRequestId', as: 'request' });
User.hasMany(ClubRequest, { foreignKey: 'assignedUserId', as: 'assignedClubRequests' });
ClubRequest.belongsTo(User, { foreignKey: 'assignedUserId', as: 'assignedUser', constraints: false });
Member.hasMany(ClubRequest, { foreignKey: 'assignedMemberId', as: 'assignedRequests' });
ClubRequest.belongsTo(Member, { foreignKey: 'assignedMemberId', as: 'assignedMember', constraints: false });
User.hasMany(ClubRequestNote, { foreignKey: 'createdByUserId', as: 'clubRequestNotes' });
ClubRequestNote.belongsTo(User, { foreignKey: 'createdByUserId', as: 'createdByUser', constraints: false });
Club.hasMany(ClubSepaMandate, { foreignKey: 'clubId', as: 'sepaMandates' });
ClubSepaMandate.belongsTo(Club, { foreignKey: 'clubId', as: 'club' });
Member.hasMany(ClubSepaMandate, { foreignKey: 'memberId', as: 'sepaMandates' });
ClubSepaMandate.belongsTo(Member, { foreignKey: 'memberId', as: 'member', constraints: false });
Club.hasMany(ClubPaymentClaim, { foreignKey: 'clubId', as: 'paymentClaims' });
ClubPaymentClaim.belongsTo(Club, { foreignKey: 'clubId', as: 'club' });
Member.hasMany(ClubPaymentClaim, { foreignKey: 'memberId', as: 'paymentClaims' });
ClubPaymentClaim.belongsTo(Member, { foreignKey: 'memberId', as: 'member', constraints: false });
Club.hasMany(ClubAccount, { foreignKey: 'clubId', as: 'accounts' });
ClubAccount.belongsTo(Club, { foreignKey: 'clubId', as: 'club' });
Club.hasMany(ClubTask, { foreignKey: 'clubId', as: 'clubTasks' });
ClubTask.belongsTo(Club, { foreignKey: 'clubId', as: 'club' });
User.hasMany(ClubTask, { foreignKey: 'createdByUserId', as: 'createdClubTasks' });
ClubTask.belongsTo(User, { foreignKey: 'createdByUserId', as: 'createdByUser', constraints: false });
User.hasMany(ClubTask, { foreignKey: 'assignedUserId', as: 'assignedClubTasksWork' });
ClubTask.belongsTo(User, { foreignKey: 'assignedUserId', as: 'assignedUser', constraints: false });
Club.hasMany(ClubTaskSuppression, { foreignKey: 'clubId', as: 'taskSuppressions' });
ClubTaskSuppression.belongsTo(Club, { foreignKey: 'clubId', as: 'club' });
User.hasMany(ClubTaskSuppression, { foreignKey: 'dismissedByUserId', as: 'dismissedTaskSuggestions' });
ClubTaskSuppression.belongsTo(User, { foreignKey: 'dismissedByUserId', as: 'dismissedByUser', constraints: false });
Club.hasMany(ClubRole, { foreignKey: 'clubId', as: 'clubRoles' });
ClubRole.belongsTo(Club, { foreignKey: 'clubId', as: 'club' });
ClubRole.hasMany(ClubUserRole, { foreignKey: 'clubRoleId', as: 'assignments' });
ClubUserRole.belongsTo(ClubRole, { foreignKey: 'clubRoleId', as: 'role' });
Club.hasMany(ClubUserRole, { foreignKey: 'clubId', as: 'clubUserRoles' });
ClubUserRole.belongsTo(Club, { foreignKey: 'clubId', as: 'club', constraints: false });
User.hasMany(ClubUserRole, { foreignKey: 'userId', as: 'clubRoleAssignments' });
ClubUserRole.belongsTo(User, { foreignKey: 'userId', as: 'user', constraints: false });
export {
User,
Log,
@@ -517,4 +574,13 @@ export {
ClubDisabledPresetGroup,
TrainingTime,
ClubVenue,
ClubRequest,
ClubRequestNote,
ClubSepaMandate,
ClubPaymentClaim,
ClubAccount,
ClubTask,
ClubTaskSuppression,
ClubRole,
ClubUserRole,
};

View File

@@ -0,0 +1,16 @@
import express from 'express';
import clubAccountController from '../controllers/clubAccountController.js';
import { authenticate } from '../middleware/authMiddleware.js';
import { authorize } from '../middleware/authorizationMiddleware.js';
const router = express.Router();
router.use(authenticate);
router.get('/:clubId', authorize('members', 'read'), clubAccountController.listClubAccounts);
router.post('/:clubId', authorize('members', 'write'), clubAccountController.createClubAccount);
router.put('/:clubId/:accountId', authorize('members', 'write'), clubAccountController.updateClubAccount);
router.patch('/:clubId/:accountId/status', authorize('members', 'write'), clubAccountController.updateClubAccountStatus);
router.delete('/:clubId/:accountId', authorize('members', 'write'), clubAccountController.deleteClubAccount);
export default router;

View File

@@ -0,0 +1,11 @@
import express from 'express';
import clubArchiveController from '../controllers/clubArchiveController.js';
import { authenticate } from '../middleware/authMiddleware.js';
import { authorize } from '../middleware/authorizationMiddleware.js';
const router = express.Router();
router.use(authenticate);
router.get('/:clubId', authorize('settings', 'read'), clubArchiveController.getClubArchive);
export default router;

View File

@@ -0,0 +1,10 @@
import express from 'express';
import { authenticate } from '../middleware/authMiddleware.js';
import { authorize } from '../middleware/authorizationMiddleware.js';
import { getClubDashboard } from '../controllers/clubDashboardController.js';
const router = express.Router();
router.get('/:clubId', authenticate, authorize('members', 'read'), getClubDashboard);
export default router;

View File

@@ -0,0 +1,20 @@
import express from 'express';
import { authenticate } from '../middleware/authMiddleware.js';
import { authorize } from '../middleware/authorizationMiddleware.js';
import {
addClubRequestNote,
createClubRequest,
listClubRequests,
updateClubRequest,
updateClubRequestStatus,
} from '../controllers/clubRequestController.js';
const router = express.Router();
router.get('/:clubId', authenticate, authorize('members', 'read'), listClubRequests);
router.post('/:clubId', authenticate, authorize('members', 'write'), createClubRequest);
router.put('/:clubId/:requestId', authenticate, authorize('members', 'write'), updateClubRequest);
router.patch('/:clubId/:requestId/status', authenticate, authorize('members', 'write'), updateClubRequestStatus);
router.post('/:clubId/:requestId/notes', authenticate, authorize('members', 'write'), addClubRequestNote);
export default router;

View File

@@ -0,0 +1,11 @@
import express from 'express';
import clubStatisticsController from '../controllers/clubStatisticsController.js';
import { authenticate } from '../middleware/authMiddleware.js';
import { authorize } from '../middleware/authorizationMiddleware.js';
const router = express.Router();
router.use(authenticate);
router.get('/:clubId', authorize('statistics', 'read'), clubStatisticsController.getClubStatistics);
export default router;

View File

@@ -0,0 +1,24 @@
import express from 'express';
import { authenticate } from '../middleware/authMiddleware.js';
import { authorize } from '../middleware/authorizationMiddleware.js';
import {
createClubTask,
deleteClubTask,
dismissAutomatedClubTaskSuggestion,
listClubTasks,
materializeAutomatedClubTasks,
updateClubTask,
updateClubTaskStatus,
} from '../controllers/clubTaskController.js';
const router = express.Router();
router.get('/:clubId', authenticate, authorize('members', 'read'), listClubTasks);
router.post('/:clubId', authenticate, authorize('members', 'write'), createClubTask);
router.post('/:clubId/materialize', authenticate, authorize('members', 'write'), materializeAutomatedClubTasks);
router.post('/:clubId/dismiss-suggestion', authenticate, authorize('members', 'write'), dismissAutomatedClubTaskSuggestion);
router.put('/:clubId/:taskId', authenticate, authorize('members', 'write'), updateClubTask);
router.patch('/:clubId/:taskId/status', authenticate, authorize('members', 'write'), updateClubTaskStatus);
router.delete('/:clubId/:taskId', authenticate, authorize('members', 'write'), deleteClubTask);
export default router;

View File

@@ -2,6 +2,8 @@ import {
getClubMembers,
getWaitingApprovals,
setClubMembers,
getMemberSepaMandate,
saveMemberSepaMandate,
getMemberPlayInterests,
setMemberPlayInterest,
uploadMemberImage,
@@ -37,6 +39,8 @@ router.post('/image/:clubId/:memberId/:imageId/primary', authenticate, authorize
router.get('/get/:id/:showAll', authenticate, authorize('members', 'read'), getClubMembers);
router.get('/gallery/:clubId', authenticate, authorize('members', 'read'), generateMemberGallery);
router.post('/set/:id', authenticate, authorize('members', 'write'), setClubMembers);
router.get('/sepa/:clubId/:memberId', authenticate, authorize('members', 'read'), getMemberSepaMandate);
router.put('/sepa/:clubId/:memberId', authenticate, authorize('members', 'write'), saveMemberSepaMandate);
router.get('/play-interest/:clubId', authenticate, authorize('members', 'read'), getMemberPlayInterests);
router.post('/play-interest/:clubId', authenticate, authorize('members', 'write'), setMemberPlayInterest);
router.get('/notapproved/:id', authenticate, authorize('members', 'read'), getWaitingApprovals);

View File

@@ -22,6 +22,12 @@ router.get('/roles/available', authenticate, permissionController.getAvailableRo
// Get permission structure (no club context needed)
router.get('/structure/all', authenticate, permissionController.getPermissionStructure);
// Get and manage club roles
router.get('/:clubId/roles', authenticate, authorize('permissions', 'read'), permissionController.getClubRoles);
router.post('/:clubId/roles', authenticate, authorize('permissions', 'write'), permissionController.createClubRole);
router.put('/:clubId/roles/:roleId', authenticate, authorize('permissions', 'write'), permissionController.updateClubRole);
router.delete('/:clubId/roles/:roleId', authenticate, authorize('permissions', 'write'), permissionController.deleteClubRole);
// Get current user's permissions for a club (no authorization check - needed to load permissions)
router.get('/:clubId', authenticate, permissionController.getUserPermissions);
@@ -30,6 +36,7 @@ router.get('/:clubId/members', authenticate, authorize('permissions', 'read'), p
// Update user role (admin only)
router.put('/:clubId/user/:userId/role', authenticate, authorize('permissions', 'write'), permissionController.updateUserRole);
router.put('/:clubId/user/:userId/roles', authenticate, authorize('permissions', 'write'), permissionController.updateUserRoles);
// Update user permissions (admin only)
router.put('/:clubId/user/:userId/permissions', authenticate, authorize('permissions', 'write'), permissionController.updateUserPermissions);
@@ -38,4 +45,3 @@ router.put('/:clubId/user/:userId/permissions', authenticate, authorize('permiss
router.put('/:clubId/user/:userId/status', authenticate, authorize('permissions', 'write'), permissionController.updateUserStatus);
export default router;

View File

@@ -16,7 +16,7 @@ import {
TournamentMember, Accident, UserToken, OfficialTournament, OfficialCompetition, OfficialCompetitionMember, MyTischtennis, ClickTtAccount, MyTischtennisUpdateHistory, MyTischtennisFetchLog, ApiLog, MemberTransferConfig, MemberContact, MemberTtrHistory, MemberPlayInterest,
MemberOrder, MemberOrderHistory, MemberGroupPhoto, BillingTemplate, BillingTemplateField, BillingRun, BillingDocument, BillingDocumentValue, BillingUserSetting, FriendlyMatch, TrainingCancellation
, FriendlyMatchShared, FriendlyMatchInvitation
, CalendarEvent, ClubVenue
, CalendarEvent, ClubVenue, ClubRequest, ClubRequestNote, ClubSepaMandate, ClubPaymentClaim, ClubAccount, ClubRole, ClubUserRole
} from './models/index.js';
import authRoutes from './routes/authRoutes.js';
import clubRoutes from './routes/clubRoutes.js';
@@ -68,6 +68,12 @@ import friendlyMatchInvitationRoutes from './routes/friendlyMatchInvitationRoute
import calendarRoutes from './routes/calendarRoutes.js';
import calendarEventRoutes from './routes/calendarEventRoutes.js';
import mobileFeedbackRoutes from './routes/mobileFeedbackRoutes.js';
import clubRequestRoutes from './routes/clubRequestRoutes.js';
import clubDashboardRoutes from './routes/clubDashboardRoutes.js';
import clubTaskRoutes from './routes/clubTaskRoutes.js';
import clubStatisticsRoutes from './routes/clubStatisticsRoutes.js';
import clubArchiveRoutes from './routes/clubArchiveRoutes.js';
import clubAccountRoutes from './routes/clubAccountRoutes.js';
import schedulerService from './services/schedulerService.js';
import { requestLoggingMiddleware } from './middleware/requestLoggingMiddleware.js';
import HttpError from './exceptions/HttpError.js';
@@ -368,6 +374,12 @@ app.use('/api/friendly-match-invitations', friendlyMatchInvitationRoutes);
app.use('/api/calendar', calendarRoutes);
app.use('/api/calendar-events', calendarEventRoutes);
app.use('/api/mobile-feedback', mobileFeedbackRoutes);
app.use('/api/club-requests', clubRequestRoutes);
app.use('/api/club-dashboard', clubDashboardRoutes);
app.use('/api/club-tasks', clubTaskRoutes);
app.use('/api/club-statistics', clubStatisticsRoutes);
app.use('/api/club-archive', clubArchiveRoutes);
app.use('/api/club-accounts', clubAccountRoutes);
// Middleware für dynamischen kanonischen Tag (vor express.static)
const setCanonicalTag = (req, res, next) => {
@@ -571,6 +583,9 @@ app.use((err, req, res, next) => {
await safeSync(Club);
await safeSync(ClubVenue);
await safeSync(UserClub);
await safeSync(ClubRole);
await safeSync(ClubUserRole);
await safeSync(ClubAccount);
await safeSync(Log);
await safeSync(Member);
await safeSync(DiaryDate);

View File

@@ -0,0 +1,228 @@
import { Op } from 'sequelize';
import sequelize from '../database.js';
import ClubAccount from '../models/ClubAccount.js';
const ACCOUNT_TYPES = new Set(['bank', 'cash', 'virtual']);
const ACCOUNT_USAGE_TYPES = new Set(['general', 'membership_fees', 'donations', 'expenses', 'reserve', 'petty_cash']);
const ACCOUNT_STATUSES = new Set(['active', 'inactive', 'archived']);
function trimText(value, maxLength = null) {
const normalized = String(value || '').trim();
if (!normalized) return null;
return maxLength ? normalized.slice(0, maxLength) : normalized;
}
function normalizeIban(value) {
const normalized = String(value || '').replace(/\s+/g, '').trim().toUpperCase();
return normalized || null;
}
function normalizeBic(value) {
const normalized = String(value || '').replace(/\s+/g, '').trim().toUpperCase();
return normalized || null;
}
function normalizePayload(payload = {}) {
const accountType = ACCOUNT_TYPES.has(payload.accountType) ? payload.accountType : 'bank';
const usageType = ACCOUNT_USAGE_TYPES.has(payload.usageType) ? payload.usageType : 'general';
const status = ACCOUNT_STATUSES.has(payload.status) ? payload.status : 'active';
return {
name: trimText(payload.name, 160),
accountHolder: trimText(payload.accountHolder, 255),
bankName: trimText(payload.bankName, 160),
iban: normalizeIban(payload.iban),
bic: normalizeBic(payload.bic),
accountType,
usageType,
currencyCode: trimText(payload.currencyCode, 3)?.toUpperCase() || 'EUR',
allowSepaCollections: Boolean(payload.allowSepaCollections),
allowOutgoingPayments: Boolean(payload.allowOutgoingPayments),
isDefault: Boolean(payload.isDefault),
status,
notes: trimText(payload.notes),
sortOrder: Number.isFinite(Number(payload.sortOrder)) ? Number(payload.sortOrder) : 0,
};
}
function validatePayload(payload) {
if (!payload.name) {
const error = new Error('Kontobezeichnung ist erforderlich.');
error.status = 400;
throw error;
}
if (payload.allowSepaCollections && payload.accountType !== 'bank') {
const error = new Error('SEPA-Einzüge sind nur für Bankkonten möglich.');
error.status = 400;
throw error;
}
if (payload.allowSepaCollections && !payload.iban) {
const error = new Error('Für SEPA-Einzüge muss eine IBAN hinterlegt sein.');
error.status = 400;
throw error;
}
if (payload.status === 'archived' && payload.isDefault) {
const error = new Error('Ein archiviertes Konto kann nicht das Standardkonto sein.');
error.status = 400;
throw error;
}
}
async function ensureSingleDefault(clubId, accountId, transaction) {
await ClubAccount.update(
{ isDefault: false },
{
where: {
clubId,
id: { [Op.ne]: accountId },
},
transaction,
}
);
}
async function ensureFallbackDefault(clubId, transaction) {
const existingDefault = await ClubAccount.findOne({
where: {
clubId,
isDefault: true,
status: { [Op.ne]: 'archived' },
},
transaction,
});
if (existingDefault) {
return;
}
const fallback = await ClubAccount.findOne({
where: {
clubId,
status: { [Op.ne]: 'archived' },
},
order: [['sortOrder', 'ASC'], ['createdAt', 'ASC']],
transaction,
});
if (fallback) {
await fallback.update({ isDefault: true }, { transaction });
}
}
class ClubAccountService {
async listClubAccounts(clubId) {
return ClubAccount.findAll({
where: { clubId },
order: [
['isDefault', 'DESC'],
['status', 'ASC'],
['sortOrder', 'ASC'],
['name', 'ASC'],
],
});
}
async createClubAccount(clubId, payload) {
const normalized = normalizePayload(payload);
validatePayload(normalized);
return sequelize.transaction(async (transaction) => {
const account = await ClubAccount.create({
clubId,
...normalized,
archivedAt: normalized.status === 'archived' ? new Date() : null,
}, { transaction });
if (normalized.isDefault) {
await ensureSingleDefault(clubId, account.id, transaction);
} else {
await ensureFallbackDefault(clubId, transaction);
}
return account.reload({ transaction });
});
}
async updateClubAccount(clubId, accountId, payload) {
const account = await ClubAccount.findOne({
where: { id: accountId, clubId },
});
if (!account) {
const error = new Error('Konto wurde nicht gefunden.');
error.status = 404;
throw error;
}
const normalized = normalizePayload(payload);
validatePayload(normalized);
return sequelize.transaction(async (transaction) => {
await account.update({
...normalized,
archivedAt: normalized.status === 'archived'
? (account.archivedAt || new Date())
: null,
}, { transaction });
if (normalized.isDefault) {
await ensureSingleDefault(clubId, account.id, transaction);
}
await ensureFallbackDefault(clubId, transaction);
return account.reload({ transaction });
});
}
async updateClubAccountStatus(clubId, accountId, status) {
if (!ACCOUNT_STATUSES.has(status)) {
const error = new Error('Ungültiger Kontostatus.');
error.status = 400;
throw error;
}
const account = await ClubAccount.findOne({
where: { id: accountId, clubId },
});
if (!account) {
const error = new Error('Konto wurde nicht gefunden.');
error.status = 404;
throw error;
}
return sequelize.transaction(async (transaction) => {
const updatePayload = {
status,
archivedAt: status === 'archived' ? (account.archivedAt || new Date()) : null,
};
if (status === 'archived') {
updatePayload.isDefault = false;
}
await account.update(updatePayload, { transaction });
await ensureFallbackDefault(clubId, transaction);
return account.reload({ transaction });
});
}
async deleteClubAccount(clubId, accountId) {
const account = await ClubAccount.findOne({
where: { id: accountId, clubId },
});
if (!account) {
const error = new Error('Konto wurde nicht gefunden.');
error.status = 404;
throw error;
}
await sequelize.transaction(async (transaction) => {
await account.destroy({ transaction });
await ensureFallbackDefault(clubId, transaction);
});
}
}
export default new ClubAccountService();

View File

@@ -0,0 +1,155 @@
import { Op } from 'sequelize';
import sequelize from '../database.js';
import { ClubPaymentClaim, ClubRequest, ClubTask, Member } from '../models/index.js';
const DEFAULT_LIMIT = 50;
function formatMemberName(member) {
return [member?.firstName, member?.lastName].filter(Boolean).join(' ').trim() || `Mitglied #${member?.id}`;
}
async function loadAvailableTables() {
const tables = await sequelize.getQueryInterface().showAllTables();
return new Set(
tables
.map((table) => (typeof table === 'string' ? table : Object.values(table || {})[0]))
.filter(Boolean)
.map((table) => String(table).toLowerCase())
);
}
async function loadOptionalTableData(availableTables, tableName, loader, fallbackValue = []) {
if (!availableTables.has(String(tableName).toLowerCase())) {
return fallbackValue;
}
return loader();
}
class ClubArchiveService {
async getClubArchive(clubIdRaw) {
const clubId = Number.parseInt(clubIdRaw, 10);
if (!Number.isFinite(clubId)) {
const error = new Error('Ungültige clubId');
error.status = 400;
throw error;
}
const availableTables = await loadAvailableTables();
const members = await Member.findAll({
where: { clubId },
order: [['updatedAt', 'DESC'], ['createdAt', 'DESC']],
});
const memberMap = new Map(members.map((member) => [Number(member.id), member]));
const archivedRequests = await loadOptionalTableData(
availableTables,
'club_requests',
() => ClubRequest.findAll({
where: {
clubId,
status: 'archived',
},
order: [['updatedAt', 'DESC'], ['createdAt', 'DESC']],
limit: DEFAULT_LIMIT,
})
);
const archivedTasks = await loadOptionalTableData(
availableTables,
'club_tasks',
() => ClubTask.findAll({
where: {
clubId,
status: 'archived',
},
order: [['archivedAt', 'DESC'], ['updatedAt', 'DESC']],
limit: DEFAULT_LIMIT,
})
);
const archivedClaims = await loadOptionalTableData(
availableTables,
'club_payment_claims',
() => ClubPaymentClaim.findAll({
where: {
clubId,
[Op.or]: [
{ archivedAt: { [Op.not]: null } },
{ status: { [Op.in]: ['written_off', 'cancelled'] } },
],
},
order: [['archivedAt', 'DESC'], ['updatedAt', 'DESC']],
limit: DEFAULT_LIMIT,
})
);
const inactiveMembers = members
.filter((member) => !member.active)
.slice(0, DEFAULT_LIMIT)
.map((member) => ({
id: member.id,
firstName: member.firstName || '',
lastName: member.lastName || '',
displayName: formatMemberName(member),
email: member.email || '',
city: member.city || '',
createdAt: member.createdAt || null,
updatedAt: member.updatedAt || null,
}));
return {
summary: {
inactiveMembers: members.filter((member) => !member.active).length,
archivedRequests: archivedRequests.length,
archivedTasks: archivedTasks.length,
archivedClaims: archivedClaims.length,
},
inactiveMembers,
archivedRequests: archivedRequests.map((entry) => ({
id: entry.id,
requestType: entry.requestType,
status: entry.status,
subject: entry.subject || '',
personName: [entry.firstName, entry.lastName].filter(Boolean).join(' ').trim(),
email: entry.email || '',
updatedAt: entry.updatedAt || null,
createdAt: entry.createdAt || null,
})),
archivedTasks: archivedTasks.map((entry) => ({
id: entry.id,
title: entry.title || '',
taskType: entry.taskType || '',
status: entry.status,
priority: entry.priority || 'normal',
dueAt: entry.dueAt || null,
archivedAt: entry.archivedAt || null,
updatedAt: entry.updatedAt || null,
})),
archivedClaims: archivedClaims.map((entry) => {
const member = memberMap.get(Number(entry.memberId));
return {
id: entry.id,
memberId: entry.memberId || null,
memberName: member ? formatMemberName(member) : '',
claimType: entry.claimType || 'membership_fee',
status: entry.status,
dueOn: entry.dueOn || null,
amountCents: Number(entry.amountCents || 0),
currencyCode: entry.currencyCode || 'EUR',
settledAt: entry.settledAt || null,
archivedAt: entry.archivedAt || null,
updatedAt: entry.updatedAt || null,
};
}),
notes: [
'Das Vereinsarchiv zeigt inaktive Mitglieder sowie archivierte oder abgeschlossene Vereinsvorgänge.',
'Ein zentrales Dokumentenarchiv wird ergänzt, sobald Dokumente und Rechnungen produktiv archiviert werden.',
],
};
}
}
export default new ClubArchiveService();

View File

@@ -0,0 +1,285 @@
import { ClubPaymentClaim, ClubRequest, Member } from '../models/index.js';
function isMissingTableError(error, tableName) {
return error?.original?.code === 'ER_NO_SUCH_TABLE'
&& String(error?.original?.sqlMessage || '').includes(tableName);
}
function getMonthKey(dateLike) {
const date = new Date(dateLike);
if (Number.isNaN(date.getTime())) return null;
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}`;
}
function buildLastMonthsTemplate(monthCount = 12) {
const months = [];
const cursor = new Date();
cursor.setDate(1);
for (let i = monthCount - 1; i >= 0; i -= 1) {
const monthDate = new Date(cursor.getFullYear(), cursor.getMonth() - i, 1);
months.push({
key: getMonthKey(monthDate),
label: `${String(monthDate.getMonth() + 1).padStart(2, '0')}.${monthDate.getFullYear()}`,
newMembers: 0,
memberCountSnapshot: 0,
claimOpenAmountCents: 0,
claimPaidAmountCents: 0,
claimOpenCount: 0,
claimPaidCount: 0,
sponsorRequests: 0,
sponsorOpenRequests: 0,
sponsorConvertedRequests: 0,
});
}
return months;
}
function getAgeFromBirthDate(birthDate) {
if (!birthDate || typeof birthDate !== 'string') return null;
const value = birthDate.trim();
if (!value) return null;
let year;
let month;
let day;
const iso = value.match(/^(\d{4})-(\d{2})-(\d{2})$/);
const german = value.match(/^(\d{1,2})\.(\d{1,2})\.(\d{4})$/);
if (iso) {
year = Number(iso[1]);
month = Number(iso[2]) - 1;
day = Number(iso[3]);
} else if (german) {
year = Number(german[3]);
month = Number(german[2]) - 1;
day = Number(german[1]);
} else {
const parsed = new Date(value);
if (Number.isNaN(parsed.getTime())) return null;
year = parsed.getFullYear();
month = parsed.getMonth();
day = parsed.getDate();
}
const birth = new Date(year, month, day);
if (Number.isNaN(birth.getTime())) return null;
const today = new Date();
let age = today.getFullYear() - birth.getFullYear();
const monthDiff = today.getMonth() - birth.getMonth();
if (monthDiff < 0 || (monthDiff === 0 && today.getDate() < birth.getDate())) {
age -= 1;
}
return age >= 0 ? age : null;
}
function getAgeBucket(age) {
if (age == null) return null;
if (age < 12) return 'under_12';
if (age < 18) return '12_17';
if (age < 27) return '18_26';
if (age < 41) return '27_40';
if (age < 61) return '41_60';
return '61_plus';
}
const AGE_BUCKET_LABELS = {
under_12: 'Unter 12',
'12_17': '12 bis 17',
'18_26': '18 bis 26',
'27_40': '27 bis 40',
'41_60': '41 bis 60',
'61_plus': '61+',
};
class ClubStatisticsService {
async getClubStatistics(clubIdRaw) {
const clubId = Number.parseInt(clubIdRaw, 10);
if (!Number.isFinite(clubId)) {
const error = new Error('Ungültige clubId');
error.status = 400;
throw error;
}
const months = buildLastMonthsTemplate(12);
const monthMap = new Map(months.map((entry) => [entry.key, entry]));
const currentYear = new Date().getFullYear();
const members = await Member.findAll({
where: { clubId },
order: [['createdAt', 'ASC']],
});
let paymentClaims = [];
try {
paymentClaims = await ClubPaymentClaim.findAll({
where: { clubId },
order: [['dueOn', 'ASC']],
});
} catch (error) {
if (!isMissingTableError(error, 'club_payment_claims')) {
throw error;
}
}
let sponsorRequests = [];
try {
sponsorRequests = await ClubRequest.findAll({
where: {
clubId,
requestType: 'sponsoring',
},
order: [['receivedAt', 'ASC']],
});
} catch (error) {
if (!isMissingTableError(error, 'club_requests')) {
throw error;
}
}
const memberOverview = {
activeMembers: members.filter((member) => member.active && !member.testMembership).length,
inactiveMembers: members.filter((member) => !member.active).length,
testMembers: members.filter((member) => member.testMembership).length,
createdThisYear: members.filter((member) => new Date(member.createdAt).getFullYear() === currentYear).length,
};
const cumulativeMembers = [];
let runningTotal = 0;
for (const month of months) {
const monthDate = new Date(`${month.key}-01T00:00:00`);
const monthEnd = new Date(monthDate.getFullYear(), monthDate.getMonth() + 1, 0, 23, 59, 59, 999);
const newMembers = members.filter((member) => {
const createdAt = new Date(member.createdAt);
return createdAt >= monthDate && createdAt <= monthEnd;
}).length;
month.newMembers = newMembers;
runningTotal += newMembers;
month.memberCountSnapshot = runningTotal;
cumulativeMembers.push(month);
}
const ageCounts = {
under_12: 0,
'12_17': 0,
'18_26': 0,
'27_40': 0,
'41_60': 0,
'61_plus': 0,
};
let missingBirthdates = 0;
let knownBirthdates = 0;
for (const member of members.filter((entry) => entry.active)) {
const age = getAgeFromBirthDate(member.birthDate);
const bucket = getAgeBucket(age);
if (!bucket) {
missingBirthdates += 1;
continue;
}
knownBirthdates += 1;
ageCounts[bucket] += 1;
}
const paymentTotals = {
openCount: 0,
openAmountCents: 0,
paidCount: 0,
paidAmountCents: 0,
overdueCount: 0,
overdueAmountCents: 0,
};
const today = new Date();
for (const claim of paymentClaims) {
const amount = Number(claim.amountCents || 0);
const dueDate = claim.dueOn ? new Date(claim.dueOn) : null;
const monthKey = getMonthKey(claim.dueOn || claim.createdAt);
const monthEntry = monthKey ? monthMap.get(monthKey) : null;
if (claim.status === 'paid') {
paymentTotals.paidCount += 1;
paymentTotals.paidAmountCents += amount;
if (monthEntry) {
monthEntry.claimPaidCount += 1;
monthEntry.claimPaidAmountCents += amount;
}
} else if (['open', 'partially_paid'].includes(claim.status)) {
paymentTotals.openCount += 1;
paymentTotals.openAmountCents += amount;
if (dueDate && dueDate < today) {
paymentTotals.overdueCount += 1;
paymentTotals.overdueAmountCents += amount;
}
if (monthEntry) {
monthEntry.claimOpenCount += 1;
monthEntry.claimOpenAmountCents += amount;
}
}
}
const sponsorTotals = {
totalRequests: sponsorRequests.length,
openRequests: sponsorRequests.filter((entry) => ['open', 'in_progress', 'waiting'].includes(entry.status)).length,
convertedRequests: sponsorRequests.filter((entry) => entry.status === 'converted').length,
archivedRequests: sponsorRequests.filter((entry) => ['archived', 'rejected'].includes(entry.status)).length,
};
for (const request of sponsorRequests) {
const monthKey = getMonthKey(request.receivedAt || request.createdAt);
const monthEntry = monthKey ? monthMap.get(monthKey) : null;
if (!monthEntry) continue;
monthEntry.sponsorRequests += 1;
if (['open', 'in_progress', 'waiting'].includes(request.status)) {
monthEntry.sponsorOpenRequests += 1;
}
if (request.status === 'converted') {
monthEntry.sponsorConvertedRequests += 1;
}
}
return {
overview: memberOverview,
memberDevelopment: {
monthly: cumulativeMembers.map((entry) => ({
key: entry.key,
label: entry.label,
newMembers: entry.newMembers,
memberCountSnapshot: entry.memberCountSnapshot,
})),
},
ageStructure: {
knownBirthdates,
missingBirthdates,
buckets: Object.entries(ageCounts).map(([key, count]) => ({
key,
label: AGE_BUCKET_LABELS[key] || key,
count,
})),
},
contributionDevelopment: {
totals: paymentTotals,
monthly: months.map((entry) => ({
key: entry.key,
label: entry.label,
openAmountCents: entry.claimOpenAmountCents,
paidAmountCents: entry.claimPaidAmountCents,
openCount: entry.claimOpenCount,
paidCount: entry.claimPaidCount,
})),
},
sponsorDevelopment: {
totals: sponsorTotals,
monthly: months.map((entry) => ({
key: entry.key,
label: entry.label,
totalRequests: entry.sponsorRequests,
openRequests: entry.sponsorOpenRequests,
convertedRequests: entry.sponsorConvertedRequests,
})),
},
};
}
}
export default new ClubStatisticsService();

View File

@@ -0,0 +1,556 @@
import { Op } from 'sequelize';
import {
CalendarEvent,
ClubPaymentClaim,
ClubRequest,
ClubSepaMandate,
ClubTask,
ClubTaskSuppression,
Member,
} from '../models/index.js';
import { CLUB_TASK_DEFINITIONS, getClubTaskDefinitionMap } from './clubTaskDefinitions.js';
const definitionMap = getClubTaskDefinitionMap();
function activeTask(task) {
return !['done', 'cancelled', 'archived'].includes(task.status);
}
function buildAutomationKey(taskType, entityType, entityId, suffix = '') {
return [taskType, entityType, entityId, suffix].filter(Boolean).join(':');
}
function todayStart() {
const date = new Date();
date.setHours(0, 0, 0, 0);
return date;
}
function addDays(baseDate, days) {
const date = new Date(baseDate);
date.setDate(date.getDate() + days);
return date;
}
function daysUntil(targetDate, today) {
return Math.floor((targetDate.getTime() - today.getTime()) / 86400000);
}
function derivePriority(days) {
if (days < 0) return 'urgent';
if (days <= 2) return 'high';
if (days <= 7) return 'normal';
return 'low';
}
function personName(entity) {
return [entity?.firstName, entity?.lastName].filter(Boolean).join(' ').trim() || entity?.email || 'Unbekannt';
}
function wrapSuggestion(suggestion) {
return {
...suggestion,
definition: definitionMap[suggestion.taskType] || null,
};
}
function suggestionToken(parts = []) {
return parts
.map((part) => (part == null ? '' : String(part).trim()))
.join('|');
}
function hasExistingSourceTask(tasks, sourceEntityType, sourceEntityId, automationSource) {
return tasks.some((task) =>
task.relatedEntityType === sourceEntityType &&
Number(task.relatedEntityId) === Number(sourceEntityId) &&
task.automationSource === automationSource
);
}
function requestSuggestionFor(request, today) {
const requestDate = new Date(request.receivedAt || today);
const name = personName(request);
if (request.requestType === 'trial_training') {
return {
taskType: 'request_schedule_trial_training',
title: `Probetraining für ${name} organisieren`,
description: 'Anfrage prüfen, Trainingsgruppe auswählen und einen passenden Termin abstimmen.',
priority: 'high',
dueAt: addDays(requestDate, 2),
};
}
if (request.requestType === 'membership') {
return {
taskType: 'request_membership_review',
title: `Mitgliedsanfrage von ${name} prüfen`,
description: 'Unterlagen, Rückfragen und Aufnahmeentscheidung im Vereinskontext bearbeiten.',
priority: 'high',
dueAt: addDays(requestDate, 3),
};
}
if (request.requestType === 'sponsoring') {
return {
taskType: 'request_sponsoring_reply',
title: `Sponsoringanfrage von ${name} nachfassen`,
description: 'Sponsoringanfrage bewerten und nächsten Vereinskontakt vorbereiten.',
priority: 'normal',
dueAt: addDays(requestDate, 4),
};
}
return {
taskType: 'request_contact_reply',
title: `Kontaktanfrage von ${name} beantworten`,
description: 'Rückmeldung an die anfragende Person geben und Zuständigkeit festlegen.',
priority: 'normal',
dueAt: addDays(requestDate, 3),
};
}
class ClubTaskAutomationService {
async buildAutomationOverview(clubId) {
const today = todayStart();
const [currentTasks, requests, members, mandates, paymentClaims, events, suppressions] = await Promise.all([
ClubTask.findAll({
where: {
clubId,
automationKey: { [Op.ne]: null },
},
}),
ClubRequest.findAll({
where: {
clubId,
status: { [Op.in]: ['open', 'in_progress', 'waiting'] },
},
order: [['receivedAt', 'ASC']],
}),
Member.findAll({
where: {
clubId,
active: true,
},
order: [['lastName', 'ASC'], ['firstName', 'ASC']],
}),
ClubSepaMandate.findAll({
where: {
clubId,
status: 'active',
revokedAt: null,
memberId: { [Op.ne]: null },
},
attributes: ['memberId'],
}),
ClubPaymentClaim.findAll({
where: {
clubId,
status: { [Op.in]: ['open', 'partially_paid'] },
archivedAt: null,
},
order: [['dueOn', 'ASC']],
}),
CalendarEvent.findAll({
where: {
clubId,
endDate: { [Op.gte]: today.toISOString().slice(0, 10) },
},
order: [['startDate', 'ASC']],
limit: 20,
}),
ClubTaskSuppression.findAll({
where: { clubId },
attributes: ['automationKey', 'suppressionToken'],
}).catch((error) => {
if (error?.original?.code === 'ER_NO_SUCH_TABLE'
&& /club_task_suppressions/.test(String(error?.original?.sqlMessage || ''))) {
return [];
}
throw error;
}),
]);
const existingKeys = new Set(currentTasks.filter(activeTask).map((task) => task.automationKey).filter(Boolean));
const memberIdsWithMandate = new Set(mandates.map((mandate) => Number(mandate.memberId)).filter(Boolean));
const suppressionMap = new Map(
suppressions
.filter((entry) => entry?.automationKey && entry?.suppressionToken)
.map((entry) => [String(entry.automationKey), String(entry.suppressionToken)])
);
const suggestions = [];
const pushSuggestion = (suggestion) => {
const key = String(suggestion.automationKey || '');
const token = String(suggestion.suppressionToken || '');
if (key && token && suppressionMap.get(key) === token) {
return;
}
suggestions.push(wrapSuggestion(suggestion));
};
for (const request of requests) {
const requestSuggestion = requestSuggestionFor(request, today);
if (hasExistingSourceTask(currentTasks, 'club_request', request.id, 'club_requests')) continue;
const key = buildAutomationKey(requestSuggestion.taskType, 'club_request', request.id);
if (existingKeys.has(key)) continue;
pushSuggestion({
...requestSuggestion,
automationSource: 'club_requests',
automationKey: key,
suppressionToken: suggestionToken([
request.updatedAt,
request.status,
request.workflowStage,
request.convertedMemberId
]),
sourceEntityType: 'club_request',
sourceEntityId: request.id,
sourceSnapshot: {
requestType: request.requestType,
status: request.status,
subject: request.subject || null,
person: personName(request),
convertedMemberId: request.convertedMemberId || null,
},
});
}
for (const member of members) {
const name = personName(member);
if (!String(member.email || '').trim()) {
const key = buildAutomationKey('member_missing_email', 'member', member.id, 'email');
if (!existingKeys.has(key)) {
pushSuggestion({
taskType: 'member_missing_email',
title: `E-Mail für ${name} ergänzen`,
description: 'Im Mitgliedsdatensatz fehlt eine E-Mail-Adresse.',
priority: 'normal',
dueAt: addDays(today, 7),
automationSource: 'members',
automationKey: key,
suppressionToken: suggestionToken([member.updatedAt, 'email']),
sourceEntityType: 'member',
sourceEntityId: member.id,
sourceSnapshot: { memberName: name, missingField: 'email' },
});
}
}
if (!String(member.birthDate || '').trim()) {
const key = buildAutomationKey('member_missing_birthdate', 'member', member.id, 'birthdate');
if (!existingKeys.has(key)) {
pushSuggestion({
taskType: 'member_missing_birthdate',
title: `Geburtsdatum für ${name} ergänzen`,
description: 'Im Mitgliedsdatensatz fehlt das Geburtsdatum.',
priority: 'normal',
dueAt: addDays(today, 7),
automationSource: 'members',
automationKey: key,
suppressionToken: suggestionToken([member.updatedAt, 'birthdate']),
sourceEntityType: 'member',
sourceEntityId: member.id,
sourceSnapshot: { memberName: name, missingField: 'birthdate' },
});
}
}
if (!memberIdsWithMandate.has(Number(member.id))) {
const key = buildAutomationKey('member_missing_sepa_mandate', 'member', member.id);
if (!existingKeys.has(key)) {
pushSuggestion({
taskType: 'member_missing_sepa_mandate',
title: `SEPA-Mandat für ${name} einholen`,
description: 'Für dieses aktive Mitglied liegt noch kein aktives SEPA-Mandat vor.',
priority: 'high',
dueAt: addDays(today, 5),
automationSource: 'club_sepa_mandates',
automationKey: key,
suppressionToken: suggestionToken([member.updatedAt, 'sepa-mandate-missing']),
sourceEntityType: 'member',
sourceEntityId: member.id,
sourceSnapshot: { memberName: name },
});
}
}
}
for (const claim of paymentClaims) {
const dueDate = claim.dueOn ? new Date(claim.dueOn) : today;
const claimTaskType = Number(claim.reminderLevel || 0) > 0
? 'payment_claim_reminder'
: daysUntil(dueDate, today) < 0
? 'payment_claim_overdue'
: 'payment_claim_due_soon';
const key = buildAutomationKey(claimTaskType, 'club_payment_claim', claim.id);
if (existingKeys.has(key)) continue;
pushSuggestion({
taskType: claimTaskType,
title:
claimTaskType === 'payment_claim_reminder'
? `Mahnfall ${claim.id} prüfen`
: claimTaskType === 'payment_claim_overdue'
? `Überfällige Zahlung ${claim.id} nachfassen`
: `Fällige Zahlung ${claim.id} vorbereiten`,
description:
claimTaskType === 'payment_claim_reminder'
? `Offene Forderung über ${(Number(claim.amountCents) / 100).toFixed(2)} ${claim.currencyCode || 'EUR'} mit bestehender Mahnstufe prüfen.`
: claimTaskType === 'payment_claim_overdue'
? `Überfällige Forderung über ${(Number(claim.amountCents) / 100).toFixed(2)} ${claim.currencyCode || 'EUR'} priorisiert nachverfolgen.`
: `Forderung über ${(Number(claim.amountCents) / 100).toFixed(2)} ${claim.currencyCode || 'EUR'} vor Fälligkeit organisatorisch vorbereiten.`,
priority: derivePriority(daysUntil(dueDate, today)),
dueAt: dueDate,
automationSource: 'club_payment_claims',
automationKey: key,
suppressionToken: suggestionToken([claim.updatedAt, claim.status, claim.reminderLevel, claim.dueOn]),
sourceEntityType: 'club_payment_claim',
sourceEntityId: claim.id,
sourceSnapshot: {
amountCents: Number(claim.amountCents),
currencyCode: claim.currencyCode,
dueOn: claim.dueOn,
status: claim.status,
reminderLevel: claim.reminderLevel,
},
});
}
for (const event of events) {
if (hasExistingSourceTask(currentTasks, 'calendar_event', event.id, 'calendar_events')) continue;
const dueDate = event.startDate ? addDays(new Date(event.startDate), -3) : addDays(today, 7);
const eventTaskType = daysUntil(dueDate, today) <= 1 ? 'calendar_event_deadline_check' : 'calendar_event_prepare';
const key = buildAutomationKey(eventTaskType, 'calendar_event', event.id);
if (existingKeys.has(key)) continue;
pushSuggestion({
taskType: eventTaskType,
title:
eventTaskType === 'calendar_event_deadline_check'
? `Letzte Prüfung für Termin: ${event.title}`
: `Termin vorbereiten: ${event.title}`,
description:
eventTaskType === 'calendar_event_deadline_check'
? 'Kurz vor dem Termin noch einmal Kommunikation, Teilnehmerstand und letzte Freigaben prüfen.'
: 'Termin organisatorisch prüfen, Verantwortliche abstimmen und offene Punkte schließen.',
priority: derivePriority(daysUntil(dueDate, today)),
dueAt: dueDate,
automationSource: 'calendar_events',
automationKey: key,
suppressionToken: suggestionToken([event.updatedAt, event.startDate, event.endDate, event.category]),
sourceEntityType: 'calendar_event',
sourceEntityId: event.id,
sourceSnapshot: {
title: event.title,
startDate: event.startDate,
endDate: event.endDate,
category: event.category || null,
},
});
}
suggestions.sort((left, right) => {
const leftDue = left.dueAt ? new Date(left.dueAt).getTime() : Number.MAX_SAFE_INTEGER;
const rightDue = right.dueAt ? new Date(right.dueAt).getTime() : Number.MAX_SAFE_INTEGER;
return leftDue - rightDue;
});
return {
definitions: CLUB_TASK_DEFINITIONS,
suggestions,
};
}
async materializeSuggestions(clubId, userId, automationKeys = []) {
const overview = await this.buildAutomationOverview(clubId);
const matches = overview.suggestions.filter((suggestion) => automationKeys.includes(suggestion.automationKey));
const tasks = [];
for (const suggestion of matches) {
const task = await ClubTask.create({
clubId,
title: suggestion.title,
taskType: suggestion.taskType,
description: suggestion.description,
status: 'open',
priority: suggestion.priority,
dueAt: suggestion.dueAt,
createdByUserId: userId || null,
automationSource: suggestion.automationSource,
automationKey: suggestion.automationKey,
relatedEntityType: suggestion.sourceEntityType,
relatedEntityId: suggestion.sourceEntityId,
sourceSnapshot: suggestion.sourceSnapshot,
});
tasks.push(task);
}
return tasks;
}
buildFollowUpSuggestionFromTask(task, nextTaskType) {
const definition = definitionMap[nextTaskType];
if (!definition) {
return null;
}
const snapshot = task.sourceSnapshot || {};
const person = snapshot.person || snapshot.memberName || 'Unbekannt';
const sourceDate = task.dueAt ? new Date(task.dueAt) : todayStart();
switch (nextTaskType) {
case 'request_trial_training_follow_up':
return wrapSuggestion({
taskType: nextTaskType,
title: `Rückmeldung zu Probetraining von ${person} einholen`,
description: 'Nach dem vereinbarten Probetraining Trainerfeedback und Rückmeldung des Interessenten einsammeln.',
priority: 'high',
dueAt: addDays(sourceDate, 2),
automationSource: task.automationSource,
automationKey: buildAutomationKey(nextTaskType, task.relatedEntityType, task.relatedEntityId),
sourceEntityType: task.relatedEntityType,
sourceEntityId: task.relatedEntityId,
sourceSnapshot: snapshot,
});
case 'membership_prepare_admission':
return wrapSuggestion({
taskType: nextTaskType,
title: `Aufnahme für ${person} vorbereiten`,
description: 'Aufnahmeentscheidung vorbereiten, fehlende Freigaben klären und Übernahme in den Verein anstoßen.',
priority: 'high',
dueAt: addDays(sourceDate, 2),
automationSource: task.automationSource,
automationKey: buildAutomationKey(nextTaskType, task.relatedEntityType, task.relatedEntityId),
sourceEntityType: task.relatedEntityType,
sourceEntityId: task.relatedEntityId,
sourceSnapshot: snapshot,
});
case 'membership_create_member_record':
return wrapSuggestion({
taskType: nextTaskType,
title: `Mitgliedsdatensatz für ${person} anlegen`,
description: 'Mitglied im System anlegen, Stammdaten prüfen und Vereinsstatus sauber setzen.',
priority: 'high',
dueAt: addDays(sourceDate, 1),
automationSource: task.automationSource,
automationKey: buildAutomationKey(nextTaskType, task.relatedEntityType, task.relatedEntityId),
sourceEntityType: task.relatedEntityType,
sourceEntityId: task.relatedEntityId,
sourceSnapshot: snapshot,
});
case 'membership_collect_sepa_mandate':
return wrapSuggestion({
taskType: nextTaskType,
title: `SEPA-Mandat für ${person} organisieren`,
description: 'Für das neue Mitglied das SEPA-Mandat einholen oder auf Vollständigkeit prüfen.',
priority: 'high',
dueAt: addDays(sourceDate, 3),
automationSource: task.automationSource,
automationKey: buildAutomationKey(nextTaskType, task.relatedEntityType, task.relatedEntityId),
sourceEntityType: task.relatedEntityType,
sourceEntityId: task.relatedEntityId,
sourceSnapshot: snapshot,
});
case 'membership_assign_fee':
return wrapSuggestion({
taskType: nextTaskType,
title: `Beitragszuordnung für ${person} prüfen`,
description: 'Beitragssatz, Ermäßigung oder Familienlogik für das neue Mitglied verbindlich festlegen.',
priority: 'normal',
dueAt: addDays(sourceDate, 2),
automationSource: task.automationSource,
automationKey: buildAutomationKey(nextTaskType, task.relatedEntityType, task.relatedEntityId),
sourceEntityType: task.relatedEntityType,
sourceEntityId: task.relatedEntityId,
sourceSnapshot: snapshot,
});
case 'calendar_event_deadline_check':
return wrapSuggestion({
taskType: nextTaskType,
title: `Letzte Prüfung für Termin: ${snapshot.title || task.title}`,
description: 'Kurz vor dem Termin noch einmal Kommunikation, Teilnehmerstand und letzte Freigaben prüfen.',
priority: 'high',
dueAt: addDays(sourceDate, 1),
automationSource: task.automationSource,
automationKey: buildAutomationKey(nextTaskType, task.relatedEntityType, task.relatedEntityId),
sourceEntityType: task.relatedEntityType,
sourceEntityId: task.relatedEntityId,
sourceSnapshot: snapshot,
});
default:
return null;
}
}
async materializeWorkflowFollowUps(task, userId) {
const definition = definitionMap[task.taskType];
const nextTaskTypes = Array.isArray(definition?.nextTaskTypes) ? definition.nextTaskTypes : [];
if (nextTaskTypes.length === 0) {
return [];
}
const createdTasks = [];
for (const nextTaskType of nextTaskTypes) {
const followUp = this.buildFollowUpSuggestionFromTask(task, nextTaskType);
if (!followUp) continue;
const existingTask = await ClubTask.findOne({
where: {
clubId: task.clubId,
automationKey: followUp.automationKey,
},
});
if (existingTask) continue;
const createdTask = await ClubTask.create({
clubId: task.clubId,
title: followUp.title,
taskType: followUp.taskType,
description: followUp.description,
status: 'open',
priority: followUp.priority,
dueAt: followUp.dueAt,
createdByUserId: userId || null,
automationSource: followUp.automationSource,
automationKey: followUp.automationKey,
relatedEntityType: followUp.sourceEntityType,
relatedEntityId: followUp.sourceEntityId,
sourceSnapshot: followUp.sourceSnapshot,
});
createdTasks.push(createdTask);
}
return createdTasks;
}
async dismissSuggestion(clubId, userId, suggestionPayload = {}) {
const automationKey = String(suggestionPayload.automationKey || '').trim();
const suppressionTokenValue = String(suggestionPayload.suppressionToken || '').trim();
if (!automationKey || !suppressionTokenValue) {
const error = new Error('Automatik-Schlüssel und Unterdrückungs-Token sind erforderlich.');
error.statusCode = 400;
throw error;
}
const [row] = await ClubTaskSuppression.findOrCreate({
where: { clubId, automationKey },
defaults: {
clubId,
automationKey,
suppressionToken: suppressionTokenValue,
dismissedByUserId: userId || null,
},
});
if (row.suppressionToken !== suppressionTokenValue || Number(row.dismissedByUserId || 0) !== Number(userId || 0)) {
row.suppressionToken = suppressionTokenValue;
row.dismissedByUserId = userId || null;
await row.save();
}
return row;
}
}
export default new ClubTaskAutomationService();

View File

@@ -0,0 +1,196 @@
export const CLUB_TASK_DEFINITIONS = [
{
key: 'request_contact_reply',
label: 'Kontaktanfrage beantworten',
source: 'club_requests',
category: 'Anfragen',
workflow: 'Anfragebearbeitung',
trigger: 'Kontaktanfrage ist offen oder wartet auf Rückmeldung.',
description: 'Antwort an eine allgemeine Kontaktanfrage vorbereiten und den nächsten Vereinskontakt sichern.',
suggestedAction: 'Antwort verfassen und zuständige Person festlegen.',
nextTaskTypes: [],
},
{
key: 'request_schedule_trial_training',
label: 'Probetraining organisieren',
source: 'club_requests',
category: 'Anfragen',
workflow: 'Probetraining',
trigger: 'Probetraining wurde angefragt und noch nicht konkret terminiert.',
description: 'Probetraining terminieren, Ansprechpartner bestimmen und Rückmeldung an den Interessenten senden.',
suggestedAction: 'Termin abstimmen, Trainingsgruppe auswählen, Einladung senden.',
nextTaskTypes: ['request_trial_training_follow_up'],
},
{
key: 'request_trial_training_follow_up',
label: 'Nach Probetraining Rückmeldung einholen',
source: 'club_requests',
category: 'Anfragen',
workflow: 'Probetraining',
trigger: 'Probetraining-Anfrage ist in Bearbeitung oder wartet auf Entscheidung.',
description: 'Nach dem Probetraining Rückmeldung einholen und über Mitgliedsantrag oder Absage entscheiden.',
suggestedAction: 'Trainerfeedback holen und nächsten Schritt festlegen.',
nextTaskTypes: [],
},
{
key: 'request_membership_review',
label: 'Mitgliedsanfrage prüfen',
source: 'club_requests',
category: 'Anfragen',
workflow: 'Mitgliedschaft',
trigger: 'Mitgliedsanfrage ist offen oder unvollständig.',
description: 'Mitgliedsantrag prüfen, fehlende Unterlagen nachfordern und Aufnahme vorbereiten.',
suggestedAction: 'Unterlagen prüfen und Aufnahmeprozess anstoßen.',
nextTaskTypes: ['membership_prepare_admission'],
},
{
key: 'membership_prepare_admission',
label: 'Aufnahme vorbereiten',
source: 'club_requests',
category: 'Anfragen',
workflow: 'Mitgliedschaft',
trigger: 'Mitgliedsanfrage wurde fachlich geprüft und soll in die Aufnahme überführt werden.',
description: 'Aufnahmeentscheidung vorbereiten, Freigaben einholen und die formale Übernahme in den Verein anstoßen.',
suggestedAction: 'Aufnahmestatus klären und Übergang in die Mitgliedsdaten vorbereiten.',
nextTaskTypes: ['membership_create_member_record'],
},
{
key: 'membership_create_member_record',
label: 'Mitgliedsdatensatz anlegen',
source: 'club_requests',
category: 'Mitglieder',
workflow: 'Mitgliedschaft',
trigger: 'Aufnahme ist entschieden und der Datensatz muss im Verein sauber angelegt oder geprüft werden.',
description: 'Mitglied im System anlegen, Stammdaten prüfen und den Vereinskontext vollständig herstellen.',
suggestedAction: 'Mitgliedsnummer, Status und Basisdaten vervollständigen.',
nextTaskTypes: ['membership_collect_sepa_mandate'],
},
{
key: 'membership_collect_sepa_mandate',
label: 'SEPA für neues Mitglied einholen',
source: 'club_requests',
category: 'Finanzen',
workflow: 'Mitgliedschaft',
trigger: 'Neues Mitglied ist angelegt, aber der Beitragseinzug muss noch vorbereitet werden.',
description: 'SEPA-Mandat für das neu aufgenommene Mitglied organisieren und den Beitragseinzug vorbereiten.',
suggestedAction: 'Mandatsformular anfordern, prüfen oder zur Unterschrift versenden.',
nextTaskTypes: ['membership_assign_fee'],
},
{
key: 'membership_assign_fee',
label: 'Beitragszuordnung prüfen',
source: 'club_requests',
category: 'Finanzen',
workflow: 'Mitgliedschaft',
trigger: 'Mitglied ist angelegt und finanzseitig in die richtige Beitragslogik einzuordnen.',
description: 'Passenden Beitragssatz, Ermäßigung oder Familienbeitrag für das neue Mitglied prüfen.',
suggestedAction: 'Beitragsregel festlegen und Zuordnung kontrollieren.',
nextTaskTypes: [],
},
{
key: 'request_sponsoring_reply',
label: 'Sponsoringanfrage nachfassen',
source: 'club_requests',
category: 'Anfragen',
workflow: 'Sponsoring',
trigger: 'Sponsoringanfrage ist offen oder wartet auf Vereinsreaktion.',
description: 'Erstkontakt zu Sponsoringanfragen strukturieren und den nächsten Gesprächstermin vorbereiten.',
suggestedAction: 'Ansprechpartner festlegen und Antwort mit weiterem Vorgehen senden.',
nextTaskTypes: [],
},
{
key: 'member_missing_email',
label: 'Mitgliedsdaten ergänzen: E-Mail',
source: 'members',
category: 'Mitglieder',
workflow: 'Datenqualität',
trigger: 'Aktives Mitglied ohne E-Mail-Adresse.',
description: 'Entsteht, wenn bei einem aktiven Mitglied keine E-Mail-Adresse gepflegt ist.',
suggestedAction: 'Kontakt aufnehmen und E-Mail nachpflegen.',
nextTaskTypes: [],
},
{
key: 'member_missing_birthdate',
label: 'Mitgliedsdaten ergänzen: Geburtsdatum',
source: 'members',
category: 'Mitglieder',
workflow: 'Datenqualität',
trigger: 'Aktives Mitglied ohne Geburtsdatum.',
description: 'Entsteht, wenn bei einem aktiven Mitglied kein Geburtsdatum gepflegt ist.',
suggestedAction: 'Geburtsdatum verifizieren und im Datensatz ergänzen.',
nextTaskTypes: [],
},
{
key: 'member_missing_sepa_mandate',
label: 'SEPA-Mandat einholen',
source: 'club_sepa_mandates',
category: 'Finanzen',
workflow: 'Beitragseinzug',
trigger: 'Aktives Mitglied ohne aktives SEPA-Mandat.',
description: 'Entsteht, wenn ein aktives Mitglied noch kein aktives SEPA-Mandat hat.',
suggestedAction: 'Mandatsformular anfordern oder zur Unterschrift versenden.',
nextTaskTypes: [],
},
{
key: 'payment_claim_due_soon',
label: 'Fällige Zahlung vorbereiten',
source: 'club_payment_claims',
category: 'Finanzen',
workflow: 'Zahlungseingänge',
trigger: 'Forderung ist bald fällig, aber noch nicht überfällig.',
description: 'Vor Fälligkeit prüfen, ob der Beitragseinzug oder die Zahlungserinnerung vorbereitet ist.',
suggestedAction: 'Einzug oder Zahlungserinnerung vorbereiten.',
nextTaskTypes: [],
},
{
key: 'payment_claim_overdue',
label: 'Überfällige Zahlung nachfassen',
source: 'club_payment_claims',
category: 'Finanzen',
workflow: 'Zahlungseingänge',
trigger: 'Forderung ist überfällig.',
description: 'Überfällige Beiträge priorisiert nachverfolgen und den nächsten Mahn- oder Kontakt-Schritt auslösen.',
suggestedAction: 'Mitglied kontaktieren oder Mahnstufe erhöhen.',
nextTaskTypes: [],
},
{
key: 'payment_claim_reminder',
label: 'Mahnstufe prüfen',
source: 'club_payment_claims',
category: 'Finanzen',
workflow: 'Zahlungseingänge',
trigger: 'Offene Forderung hat bereits eine Mahnstufe.',
description: 'Bestehende Mahnfälle prüfen und entscheiden, ob eine weitere Eskalation oder Klärung nötig ist.',
suggestedAction: 'Mahnung, Rücksprache oder Teilzahlungsentscheidung vorbereiten.',
nextTaskTypes: [],
},
{
key: 'calendar_event_prepare',
label: 'Termin vorbereiten',
source: 'calendar_events',
category: 'Termine',
workflow: 'Terminorganisation',
trigger: 'Termin rückt näher.',
description: 'Entsteht vor anstehenden Vereins- und Kalenderterminen als organisatorische Wiedervorlage.',
suggestedAction: 'Verantwortliche, Räume, Kommunikation und offene Punkte prüfen.',
nextTaskTypes: ['calendar_event_deadline_check'],
},
{
key: 'calendar_event_deadline_check',
label: 'Terminfrist prüfen',
source: 'calendar_events',
category: 'Termine',
workflow: 'Terminorganisation',
trigger: 'Termin oder Frist steht kurzfristig bevor.',
description: 'Kurzfristige Frist oder Veranstaltung vor Durchführung auf Vollständigkeit und Kommunikation prüfen.',
suggestedAction: 'Teilnehmerstand, Erinnerungen und letzte Freigaben prüfen.',
nextTaskTypes: [],
},
];
export function getClubTaskDefinitionMap() {
return CLUB_TASK_DEFINITIONS.reduce((accumulator, definition) => {
accumulator[definition.key] = definition;
return accumulator;
}, {});
}

View File

@@ -0,0 +1,65 @@
import { ClubRequest } from '../models/index.js';
function completedRequestStateForTaskType(taskType) {
switch (taskType) {
case 'request_contact_reply':
return { status: 'waiting', workflowStage: 'contact_replied' };
case 'request_schedule_trial_training':
return { status: 'in_progress', workflowStage: 'trial_training_scheduled' };
case 'request_trial_training_follow_up':
return { status: 'waiting', workflowStage: 'trial_training_feedback_recorded' };
case 'request_membership_review':
return { status: 'in_progress', workflowStage: 'membership_reviewed' };
case 'membership_prepare_admission':
return { status: 'in_progress', workflowStage: 'admission_prepared' };
case 'membership_create_member_record':
return { status: 'in_progress', workflowStage: 'member_record_created' };
case 'membership_collect_sepa_mandate':
return { status: 'in_progress', workflowStage: 'sepa_pending' };
case 'membership_assign_fee':
return { status: 'converted', workflowStage: 'onboarding_completed', closedAt: new Date() };
case 'request_sponsoring_reply':
return { status: 'waiting', workflowStage: 'sponsoring_contacted' };
default:
return null;
}
}
class ClubWorkflowSourceService {
async syncSourceStateForCompletedTask(task) {
if (task.relatedEntityType !== 'club_request' || !task.relatedEntityId) {
return null;
}
const requestState = completedRequestStateForTaskType(task.taskType);
if (!requestState) {
return null;
}
const request = await ClubRequest.findOne({
where: {
id: task.relatedEntityId,
clubId: task.clubId,
},
});
if (!request) {
return null;
}
await request.update({
status: requestState.status,
workflowStage: requestState.workflowStage,
closedAt: requestState.closedAt || null,
});
return {
entityType: 'club_request',
entityId: request.id,
status: request.status,
workflowStage: request.workflowStage,
};
}
}
export default new ClubWorkflowSourceService();

View File

@@ -5,6 +5,7 @@ import Member from "../models/Member.js";
import MemberImage from "../models/MemberImage.js";
import MemberTtrHistory from "../models/MemberTtrHistory.js";
import MemberPlayInterest from "../models/MemberPlayInterest.js";
import ClubSepaMandate from "../models/ClubSepaMandate.js";
import Participant from "../models/Participant.js";
import DiaryDate from "../models/DiaryDates.js";
import { Op, fn, col } from 'sequelize';
@@ -15,6 +16,132 @@ import sharp from 'sharp';
import { devLog } from '../utils/logger.js';
import { standardizePhoneNumber } from '../utils/phoneUtils.js';
class MemberService {
normalizeSepaMandatePayload(payload = {}) {
const normalizeText = (value, maxLength = null) => {
if (value === null || value === undefined) return null;
const trimmed = String(value).trim();
if (!trimmed) return null;
return maxLength ? trimmed.slice(0, maxLength) : trimmed;
};
const normalizeDate = (value) => {
const normalized = normalizeText(value, 10);
return normalized || null;
};
const status = normalizeText(payload.status, 32) || 'active';
return {
debtorName: normalizeText(payload.debtorName, 255),
iban: normalizeText(payload.iban, 34),
bic: normalizeText(payload.bic, 11),
mandateReference: normalizeText(payload.mandateReference, 80),
signedOn: normalizeDate(payload.signedOn),
validFrom: normalizeDate(payload.validFrom),
status,
historyNote: normalizeText(payload.historyNote),
revokedAt: status === 'revoked'
? (normalizeText(payload.revokedAt) || new Date().toISOString())
: null
};
}
async getMemberSepaMandate(userToken, clubId, memberId) {
await checkAccess(userToken, clubId);
const member = await Member.findOne({ where: { id: memberId, clubId } });
if (!member) {
return {
status: 404,
response: { success: false, error: 'membernotfound' }
};
}
const mandate = await ClubSepaMandate.findOne({
where: { clubId, memberId },
order: [['updatedAt', 'DESC'], ['id', 'DESC']]
});
return {
status: 200,
response: {
success: true,
mandate: mandate ? mandate.toJSON() : null
}
};
}
async saveMemberSepaMandate(userToken, clubId, memberId, payload = {}) {
await checkAccess(userToken, clubId);
const member = await Member.findOne({ where: { id: memberId, clubId } });
if (!member) {
return {
status: 404,
response: { success: false, error: 'membernotfound' }
};
}
const normalizedPayload = this.normalizeSepaMandatePayload(payload);
const hasContent = Boolean(
normalizedPayload.debtorName
|| normalizedPayload.iban
|| normalizedPayload.bic
|| normalizedPayload.mandateReference
|| normalizedPayload.signedOn
|| normalizedPayload.validFrom
|| normalizedPayload.historyNote
);
let mandate = await ClubSepaMandate.findOne({
where: { clubId, memberId },
order: [['updatedAt', 'DESC'], ['id', 'DESC']]
});
if (!mandate && !hasContent) {
return {
status: 200,
response: { success: true, mandate: null }
};
}
if (!mandate) {
if (!normalizedPayload.debtorName || !normalizedPayload.iban || !normalizedPayload.mandateReference) {
return {
status: 400,
response: {
success: false,
code: 'missingrequiredsepafields',
error: 'Bitte Kontoinhaber, IBAN und Mandatsreferenz angeben.'
}
};
}
mandate = await ClubSepaMandate.create({
clubId,
memberId,
...normalizedPayload
});
} else {
mandate.debtorName = normalizedPayload.debtorName;
mandate.iban = normalizedPayload.iban;
mandate.bic = normalizedPayload.bic;
mandate.mandateReference = normalizedPayload.mandateReference;
mandate.signedOn = normalizedPayload.signedOn;
mandate.validFrom = normalizedPayload.validFrom;
mandate.status = normalizedPayload.status;
mandate.historyNote = normalizedPayload.historyNote;
mandate.revokedAt = normalizedPayload.revokedAt;
await mandate.save();
}
await mandate.reload();
return {
status: 200,
response: {
success: true,
mandate: mandate.toJSON()
}
};
}
async getMemberPlayInterests(userToken, clubId, seasonId, lineupHalf) {
await checkAccess(userToken, clubId);
if (!seasonId || !['first_half', 'second_half'].includes(String(lineupHalf || ''))) {

View File

@@ -1,13 +1,8 @@
import UserClub from '../models/UserClub.js';
import Club from '../models/Club.js';
import User from '../models/User.js';
import ClubRole from '../models/ClubRole.js';
import ClubUserRole from '../models/ClubUserRole.js';
/**
* Permission Service
* Handles all permission-related logic
*/
// Default permissions for each role
const ROLE_PERMISSIONS = {
admin: {
diary: { read: true, write: true, delete: true },
@@ -17,10 +12,10 @@ const ROLE_PERMISSIONS = {
tournaments: { read: true, write: true, delete: true },
statistics: { read: true, write: true },
settings: { read: true, write: true },
permissions: { read: true, write: true }, // Can manage other users' permissions
permissions: { read: true, write: true },
approvals: { read: true, write: true },
mytischtennis_admin: { read: true, write: true },
predefined_activities: { read: true, write: true, delete: true }
predefined_activities: { read: true, write: true, delete: true },
},
trainer: {
diary: { read: true, write: true, delete: true },
@@ -33,7 +28,7 @@ const ROLE_PERMISSIONS = {
permissions: { read: false, write: false },
approvals: { read: false, write: false },
mytischtennis_admin: { read: false, write: false },
predefined_activities: { read: true, write: true, delete: true }
predefined_activities: { read: true, write: true, delete: true },
},
team_manager: {
diary: { read: false, write: false, delete: false },
@@ -46,7 +41,7 @@ const ROLE_PERMISSIONS = {
permissions: { read: false, write: false },
approvals: { read: false, write: false },
mytischtennis_admin: { read: false, write: false },
predefined_activities: { read: false, write: false, delete: false }
predefined_activities: { read: false, write: false, delete: false },
},
tournament_manager: {
diary: { read: false, write: false, delete: false },
@@ -59,7 +54,7 @@ const ROLE_PERMISSIONS = {
permissions: { read: false, write: false },
approvals: { read: false, write: false },
mytischtennis_admin: { read: false, write: false },
predefined_activities: { read: false, write: false, delete: false }
predefined_activities: { read: false, write: false, delete: false },
},
member: {
diary: { read: false, write: false, delete: false },
@@ -72,306 +67,58 @@ const ROLE_PERMISSIONS = {
permissions: { read: false, write: false },
approvals: { read: false, write: false },
mytischtennis_admin: { read: false, write: false },
predefined_activities: { read: false, write: false, delete: false }
}
predefined_activities: { read: false, write: false, delete: false },
},
};
const DEFAULT_ROLE_TEMPLATES = [
{ roleKey: 'admin', name: 'Administrator', description: 'Vollzugriff auf alle Funktionen', permissions: ROLE_PERMISSIONS.admin, sortOrder: 10 },
{ roleKey: 'trainer', name: 'Trainer', description: 'Kann Trainingseinheiten, Mitglieder und Teams verwalten', permissions: ROLE_PERMISSIONS.trainer, sortOrder: 20 },
{ roleKey: 'team_manager', name: 'Mannschaftsführer', description: 'Kann Teams und Spielpläne verwalten', permissions: ROLE_PERMISSIONS.team_manager, sortOrder: 30 },
{ roleKey: 'tournament_manager', name: 'Turnierleiter', description: 'Kann Turniere verwalten', permissions: ROLE_PERMISSIONS.tournament_manager, sortOrder: 40 },
{ roleKey: 'member', name: 'Mitglied', description: 'Kann nur freigegebene Vereinsbereiche ansehen', permissions: ROLE_PERMISSIONS.member, sortOrder: 50 },
];
function cloneValue(value) {
return JSON.parse(JSON.stringify(value));
}
function normalizePermissions(value) {
if (!value) return {};
if (typeof value === 'string') {
try {
return JSON.parse(value);
} catch (_error) {
return {};
}
}
return cloneValue(value);
}
function slugifyRoleKey(value) {
return String(value || '')
.trim()
.toLowerCase()
.replace(/[^a-z0-9]+/g, '_')
.replace(/^_+|_+$/g, '')
.slice(0, 64) || 'rolle';
}
class PermissionService {
/**
* Get user's permissions for a specific club
*/
async getUserClubPermissions(userId, clubId) {
const userClub = await UserClub.findOne({
where: {
userId,
clubId,
approved: true
}
});
mergePermissions(basePermissions = {}, extraPermissions = {}) {
const merged = cloneValue(basePermissions);
const normalizedExtra = normalizePermissions(extraPermissions);
if (!userClub) {
return null;
}
// If user is owner, they have full admin rights
if (userClub.isOwner) {
return {
role: 'admin',
isOwner: true,
permissions: ROLE_PERMISSIONS.admin
};
}
// Get role from database, fallback to 'member' if null/undefined
const role = userClub.role || 'member';
// Get role-based permissions
const rolePermissions = ROLE_PERMISSIONS[role] || ROLE_PERMISSIONS.member;
// Merge with custom permissions if any
const customPermissions = userClub.permissions || {};
const mergedPermissions = this.mergePermissions(rolePermissions, customPermissions);
return {
role: role,
isOwner: false,
permissions: mergedPermissions
};
}
/**
* Check if user has specific permission
*/
async hasPermission(userId, clubId, resource, action) {
const userPermissions = await this.getUserClubPermissions(userId, clubId);
if (!userPermissions) {
return false;
}
// Owner always has permission
if (userPermissions.isOwner) {
return true;
}
// MyTischtennis settings are accessible to all approved members
if (resource === 'mytischtennis') {
return true;
}
const resourcePermissions = userPermissions.permissions[resource];
if (!resourcePermissions) {
return false;
}
return resourcePermissions[action] === true;
}
/**
* Set user role in club
*/
async setUserRole(userId, clubId, role, updatedByUserId) {
// Check if updater has permission
const canManagePermissions = await this.hasPermission(updatedByUserId, clubId, 'permissions', 'write');
if (!canManagePermissions) {
throw new Error('Keine Berechtigung zum Ändern von Rollen');
}
// Check if target user is owner
const targetUserClub = await UserClub.findOne({
where: { userId, clubId }
});
if (!targetUserClub) {
throw new Error('Benutzer ist kein Mitglied dieses Clubs');
}
if (targetUserClub.isOwner) {
throw new Error('Die Rolle des Club-Erstellers kann nicht geändert werden');
}
// Validate role
if (!ROLE_PERMISSIONS[role]) {
throw new Error('Ungültige Rolle');
}
await targetUserClub.update({ role });
return {
success: true,
message: 'Rolle erfolgreich aktualisiert'
};
}
/**
* Set custom permissions for user
*/
async setCustomPermissions(userId, clubId, customPermissions, updatedByUserId) {
// Check if updater has permission
const canManagePermissions = await this.hasPermission(updatedByUserId, clubId, 'permissions', 'write');
if (!canManagePermissions) {
throw new Error('Keine Berechtigung zum Ändern von Berechtigungen');
}
// Check if target user is owner
const targetUserClub = await UserClub.findOne({
where: { userId, clubId }
});
if (!targetUserClub) {
throw new Error('Benutzer ist kein Mitglied dieses Clubs');
}
if (targetUserClub.isOwner) {
throw new Error('Die Berechtigungen des Club-Erstellers können nicht geändert werden');
}
await targetUserClub.update({ permissions: customPermissions });
return {
success: true,
message: 'Berechtigungen erfolgreich aktualisiert'
};
}
/**
* Set user status (activate/deactivate)
*/
async setUserStatus(userId, clubId, approved, updatedByUserId) {
// Check if updater has permission
const canManagePermissions = await this.hasPermission(updatedByUserId, clubId, 'permissions', 'write');
if (!canManagePermissions) {
throw new Error('Keine Berechtigung zum Ändern des Status');
}
// Check if target user is owner
const targetUserClub = await UserClub.findOne({
where: { userId, clubId }
});
if (!targetUserClub) {
throw new Error('Benutzer ist kein Mitglied dieses Clubs');
}
if (targetUserClub.isOwner) {
throw new Error('Der Status des Club-Erstellers kann nicht geändert werden');
}
await targetUserClub.update({ approved });
return {
success: true,
message: approved ? 'Benutzer erfolgreich aktiviert' : 'Benutzer erfolgreich deaktiviert'
};
}
/**
* Get all club members with their permissions
*/
async getClubMembersWithPermissions(clubId, requestingUserId) {
// Check if requester has permission to read permissions
const canReadPermissions = await this.hasPermission(requestingUserId, clubId, 'permissions', 'read');
if (!canReadPermissions) {
throw new Error('Keine Berechtigung zum Anzeigen von Berechtigungen');
}
const userClubs = await UserClub.findAll({
where: {
clubId
},
include: [{
model: User,
as: 'user',
attributes: ['id', 'email']
}]
});
return userClubs.map(uc => {
// Parse permissions JSON string to object
let parsedPermissions = null;
if (uc.permissions) {
try {
parsedPermissions = typeof uc.permissions === 'string'
? JSON.parse(uc.permissions)
: uc.permissions;
} catch (err) {
console.error('Error parsing permissions JSON:', err);
parsedPermissions = null;
}
}
return {
userId: uc.userId,
user: uc.user,
role: uc.role,
isOwner: uc.isOwner,
approved: uc.approved,
permissions: parsedPermissions,
effectivePermissions: this.getEffectivePermissions(uc)
};
});
}
/**
* Get effective permissions (role + custom)
*/
getEffectivePermissions(userClub) {
if (userClub.isOwner) {
return ROLE_PERMISSIONS.admin;
}
const rolePermissions = ROLE_PERMISSIONS[userClub.role] || ROLE_PERMISSIONS.member;
// Parse permissions JSON string to object
let customPermissions = {};
if (userClub.permissions) {
try {
customPermissions = typeof userClub.permissions === 'string'
? JSON.parse(userClub.permissions)
: userClub.permissions;
} catch (err) {
console.error('Error parsing permissions JSON in getEffectivePermissions:', err);
customPermissions = {};
}
}
return this.mergePermissions(rolePermissions, customPermissions);
}
/**
* Merge role permissions with custom permissions
*/
mergePermissions(rolePermissions, customPermissions) {
const merged = { ...rolePermissions };
for (const resource in customPermissions) {
if (!merged[resource]) {
merged[resource] = {};
}
for (const resource of Object.keys(normalizedExtra)) {
merged[resource] = {
...merged[resource],
...customPermissions[resource]
...(merged[resource] || {}),
...(normalizedExtra[resource] || {}),
};
}
return merged;
}
/**
* Mark user as club owner (used when creating a club)
*/
async setClubOwner(userId, clubId) {
const userClub = await UserClub.findOne({
where: { userId, clubId }
});
if (!userClub) {
throw new Error('UserClub relationship not found');
}
await userClub.update({
isOwner: true,
role: 'admin',
approved: true
});
}
/**
* Get all available roles
*/
getAvailableRoles() {
return [
{ value: 'admin', label: 'Administrator', description: 'Vollzugriff auf alle Funktionen' },
{ value: 'trainer', label: 'Trainer', description: 'Kann Trainingseinheiten, Mitglieder und Teams verwalten' },
{ value: 'team_manager', label: 'Mannschaftsführer', description: 'Kann Teams und Spielpläne verwalten' },
{ value: 'tournament_manager', label: 'Turnierleiter', description: 'Kann Turniere verwalten' },
{ value: 'member', label: 'Mitglied', description: 'Kann nur Trainings-Statistiken ansehen' }
];
}
/**
* Get permission structure for frontend
*/
getPermissionStructure() {
return {
diary: { label: 'Trainingstagebuch', actions: ['read', 'write', 'delete'] },
@@ -384,10 +131,439 @@ class PermissionService {
permissions: { label: 'Berechtigungsverwaltung', actions: ['read', 'write'] },
approvals: { label: 'Freigaben (Mitgliedsanträge)', actions: ['read', 'write'] },
mytischtennis_admin: { label: 'MyTischtennis Admin', actions: ['read', 'write'] },
predefined_activities: { label: 'Vordefinierte Aktivitäten', actions: ['read', 'write', 'delete'] }
predefined_activities: { label: 'Vordefinierte Aktivitäten', actions: ['read', 'write', 'delete'] },
};
}
getAvailableRoles() {
return DEFAULT_ROLE_TEMPLATES.map((role) => ({
value: role.roleKey,
label: role.name,
description: role.description,
isSystemRole: true,
}));
}
isMissingRoleTableError(error) {
return error?.original?.code === 'ER_NO_SUCH_TABLE'
&& /club_roles|club_user_roles/.test(String(error?.original?.sqlMessage || ''));
}
async ensureDefaultRoles(clubId) {
const createdRoles = [];
for (const template of DEFAULT_ROLE_TEMPLATES) {
const [role] = await ClubRole.findOrCreate({
where: { clubId, roleKey: template.roleKey },
defaults: {
clubId,
roleKey: template.roleKey,
name: template.name,
description: template.description,
permissions: template.permissions,
isSystemRole: true,
sortOrder: template.sortOrder,
},
});
createdRoles.push(role);
}
return createdRoles;
}
async getRoleAssignments(clubId, userIds = null) {
const where = { clubId };
if (Array.isArray(userIds)) {
where.userId = userIds;
}
return ClubUserRole.findAll({
where,
include: [{
model: ClubRole,
as: 'role',
}],
order: [
['isPrimary', 'DESC'],
[{ model: ClubRole, as: 'role' }, 'sortOrder', 'ASC'],
[{ model: ClubRole, as: 'role' }, 'name', 'ASC'],
],
});
}
buildLegacyPermissionPayload(userClub) {
if (userClub.isOwner) {
return {
role: 'admin',
roles: [{ roleKey: 'admin', name: 'Administrator', isPrimary: true, isSystemRole: true }],
isOwner: true,
isAdmin: true,
permissions: cloneValue(ROLE_PERMISSIONS.admin),
};
}
const primaryRole = userClub.role || 'member';
const effectivePermissions = this.mergePermissions(
ROLE_PERMISSIONS[primaryRole] || ROLE_PERMISSIONS.member,
userClub.permissions
);
return {
role: primaryRole,
roles: [{
roleKey: primaryRole,
name: DEFAULT_ROLE_TEMPLATES.find((role) => role.roleKey === primaryRole)?.name || primaryRole,
isPrimary: true,
isSystemRole: true,
}],
isOwner: false,
isAdmin: primaryRole === 'admin',
permissions: effectivePermissions,
};
}
buildRolePermissionPayload(userClub, assignments) {
if (userClub.isOwner) {
return {
role: 'admin',
roles: [{ roleKey: 'admin', name: 'Administrator', isPrimary: true, isSystemRole: true }],
isOwner: true,
isAdmin: true,
permissions: cloneValue(ROLE_PERMISSIONS.admin),
};
}
const normalizedAssignments = assignments
.filter((assignment) => assignment?.role)
.map((assignment) => ({
id: assignment.role.id,
roleKey: assignment.role.roleKey,
name: assignment.role.name,
description: assignment.role.description,
isSystemRole: Boolean(assignment.role.isSystemRole),
isPrimary: Boolean(assignment.isPrimary),
assignedAt: assignment.createdAt || null,
assignmentUpdatedAt: assignment.updatedAt || null,
roleCreatedAt: assignment.role.createdAt || null,
roleUpdatedAt: assignment.role.updatedAt || null,
permissions: normalizePermissions(assignment.role.permissions),
}));
if (normalizedAssignments.length === 0) {
return this.buildLegacyPermissionPayload(userClub);
}
const primaryRole = normalizedAssignments.find((role) => role.isPrimary) || normalizedAssignments[0];
const rolePermissions = normalizedAssignments.reduce(
(accumulator, role) => this.mergePermissions(accumulator, role.permissions),
{}
);
const effectivePermissions = this.mergePermissions(rolePermissions, userClub.permissions);
return {
role: normalizedAssignments.some((role) => role.roleKey === 'admin') ? 'admin' : primaryRole.roleKey,
roles: normalizedAssignments.map(({ permissions, ...role }) => role),
isOwner: false,
isAdmin: normalizedAssignments.some((role) => role.roleKey === 'admin'),
permissions: effectivePermissions,
};
}
async getUserClubPermissions(userId, clubId) {
const userClub = await UserClub.findOne({
where: { userId, clubId, approved: true },
});
if (!userClub) {
return null;
}
try {
await this.ensureDefaultRoles(clubId);
const assignments = await this.getRoleAssignments(clubId, [userId]);
return this.buildRolePermissionPayload(userClub, assignments);
} catch (error) {
if (this.isMissingRoleTableError(error)) {
return this.buildLegacyPermissionPayload(userClub);
}
throw error;
}
}
async hasPermission(userId, clubId, resource, action) {
const userPermissions = await this.getUserClubPermissions(userId, clubId);
if (!userPermissions) {
return false;
}
if (userPermissions.isOwner) {
return true;
}
if (resource === 'mytischtennis') {
return true;
}
return userPermissions.permissions?.[resource]?.[action] === true;
}
async setUserRole(userId, clubId, roleKey, updatedByUserId) {
await this.ensureDefaultRoles(clubId);
const role = await ClubRole.findOne({ where: { clubId, roleKey } });
if (!role) {
throw new Error('Ungültige Rolle');
}
return this.setUserRoles(userId, clubId, [role.id], updatedByUserId);
}
async setUserRoles(userId, clubId, roleIds, updatedByUserId) {
const canManagePermissions = await this.hasPermission(updatedByUserId, clubId, 'permissions', 'write');
if (!canManagePermissions) {
throw new Error('Keine Berechtigung zum Ändern von Rollen');
}
const targetUserClub = await UserClub.findOne({ where: { userId, clubId } });
if (!targetUserClub) {
throw new Error('Benutzer ist kein Mitglied dieses Clubs');
}
if (targetUserClub.isOwner) {
throw new Error('Die Rollen des Club-Erstellers können nicht geändert werden');
}
await this.ensureDefaultRoles(clubId);
const normalizedRoleIds = [...new Set((roleIds || []).map((id) => Number(id)).filter(Boolean))];
const roles = normalizedRoleIds.length > 0
? await ClubRole.findAll({ where: { clubId, id: normalizedRoleIds } })
: [];
if (roles.length !== normalizedRoleIds.length) {
throw new Error('Mindestens eine Rolle gehört nicht zu diesem Verein');
}
try {
await ClubUserRole.destroy({ where: { clubId, userId } });
if (roles.length > 0) {
await ClubUserRole.bulkCreate(roles.map((role, index) => ({
clubId,
userId,
clubRoleId: role.id,
isPrimary: index === 0,
})));
}
await targetUserClub.update({ role: roles[0]?.roleKey || 'member' });
} catch (error) {
if (this.isMissingRoleTableError(error)) {
await targetUserClub.update({ role: roles[0]?.roleKey || 'member' });
} else {
throw error;
}
}
return { success: true, message: 'Rollen erfolgreich aktualisiert' };
}
async setCustomPermissions(userId, clubId, customPermissions, updatedByUserId) {
const canManagePermissions = await this.hasPermission(updatedByUserId, clubId, 'permissions', 'write');
if (!canManagePermissions) {
throw new Error('Keine Berechtigung zum Ändern von Berechtigungen');
}
const targetUserClub = await UserClub.findOne({ where: { userId, clubId } });
if (!targetUserClub) {
throw new Error('Benutzer ist kein Mitglied dieses Clubs');
}
if (targetUserClub.isOwner) {
throw new Error('Die Berechtigungen des Club-Erstellers können nicht geändert werden');
}
await targetUserClub.update({ permissions: customPermissions });
return { success: true, message: 'Berechtigungen erfolgreich aktualisiert' };
}
async setUserStatus(userId, clubId, approved, updatedByUserId) {
const canManagePermissions = await this.hasPermission(updatedByUserId, clubId, 'permissions', 'write');
if (!canManagePermissions) {
throw new Error('Keine Berechtigung zum Ändern des Status');
}
const targetUserClub = await UserClub.findOne({ where: { userId, clubId } });
if (!targetUserClub) {
throw new Error('Benutzer ist kein Mitglied dieses Clubs');
}
if (targetUserClub.isOwner) {
throw new Error('Der Status des Club-Erstellers kann nicht geändert werden');
}
await targetUserClub.update({ approved });
return { success: true, message: approved ? 'Benutzer erfolgreich aktiviert' : 'Benutzer erfolgreich deaktiviert' };
}
async getClubMembersWithPermissions(clubId, requestingUserId) {
const canReadPermissions = await this.hasPermission(requestingUserId, clubId, 'permissions', 'read');
if (!canReadPermissions) {
throw new Error('Keine Berechtigung zum Anzeigen von Berechtigungen');
}
const userClubs = await UserClub.findAll({
where: { clubId },
include: [{ model: User, as: 'user', attributes: ['id', 'email'] }],
order: [[{ model: User, as: 'user' }, 'email', 'ASC']],
});
try {
await this.ensureDefaultRoles(clubId);
const assignments = await this.getRoleAssignments(clubId, userClubs.map((entry) => entry.userId));
const assignmentsByUserId = assignments.reduce((accumulator, assignment) => {
const key = Number(assignment.userId);
if (!accumulator[key]) {
accumulator[key] = [];
}
accumulator[key].push(assignment);
return accumulator;
}, {});
return userClubs.map((userClub) => {
const payload = this.buildRolePermissionPayload(userClub, assignmentsByUserId[Number(userClub.userId)] || []);
return {
userId: userClub.userId,
user: userClub.user,
role: payload.role,
roles: payload.roles,
isAdmin: payload.isAdmin,
isOwner: userClub.isOwner,
approved: userClub.approved,
createdAt: userClub.createdAt,
updatedAt: userClub.updatedAt,
permissions: normalizePermissions(userClub.permissions),
effectivePermissions: payload.permissions,
};
});
} catch (error) {
if (this.isMissingRoleTableError(error)) {
return userClubs.map((userClub) => {
const payload = this.buildLegacyPermissionPayload(userClub);
return {
userId: userClub.userId,
user: userClub.user,
role: payload.role,
roles: payload.roles,
isAdmin: payload.isAdmin,
isOwner: userClub.isOwner,
approved: userClub.approved,
createdAt: userClub.createdAt,
updatedAt: userClub.updatedAt,
permissions: normalizePermissions(userClub.permissions),
effectivePermissions: payload.permissions,
};
});
}
throw error;
}
}
async getClubRoles(clubId, requestingUserId) {
const canReadPermissions = await this.hasPermission(requestingUserId, clubId, 'permissions', 'read');
if (!canReadPermissions) {
throw new Error('Keine Berechtigung zum Anzeigen von Rollen');
}
await this.ensureDefaultRoles(clubId);
return ClubRole.findAll({
where: { clubId },
order: [['sortOrder', 'ASC'], ['name', 'ASC']],
});
}
async createClubRole(clubId, payload, requestingUserId) {
const canManagePermissions = await this.hasPermission(requestingUserId, clubId, 'permissions', 'write');
if (!canManagePermissions) {
throw new Error('Keine Berechtigung zum Anlegen von Rollen');
}
const baseKey = slugifyRoleKey(payload.roleKey || payload.name);
const existingKeys = new Set((await ClubRole.findAll({
where: { clubId },
attributes: ['roleKey'],
})).map((role) => role.roleKey));
let roleKey = baseKey;
let suffix = 2;
while (existingKeys.has(roleKey)) {
roleKey = `${baseKey}_${suffix++}`;
}
const role = await ClubRole.create({
clubId,
roleKey,
name: String(payload.name || '').trim(),
description: String(payload.description || '').trim() || null,
permissions: normalizePermissions(payload.permissions),
isSystemRole: false,
sortOrder: Number(payload.sortOrder) || 100,
});
return role;
}
async updateClubRole(clubId, roleId, payload, requestingUserId) {
const canManagePermissions = await this.hasPermission(requestingUserId, clubId, 'permissions', 'write');
if (!canManagePermissions) {
throw new Error('Keine Berechtigung zum Ändern von Rollen');
}
const role = await ClubRole.findOne({ where: { id: roleId, clubId } });
if (!role) {
throw new Error('Rolle nicht gefunden');
}
await role.update({
name: String(payload.name || role.name).trim(),
description: payload.description === undefined ? role.description : (String(payload.description || '').trim() || null),
permissions: payload.permissions === undefined ? role.permissions : normalizePermissions(payload.permissions),
sortOrder: payload.sortOrder === undefined ? role.sortOrder : Number(payload.sortOrder) || 100,
});
return role;
}
async deleteClubRole(clubId, roleId, requestingUserId) {
const canManagePermissions = await this.hasPermission(requestingUserId, clubId, 'permissions', 'write');
if (!canManagePermissions) {
throw new Error('Keine Berechtigung zum Löschen von Rollen');
}
const role = await ClubRole.findOne({ where: { id: roleId, clubId } });
if (!role) {
throw new Error('Rolle nicht gefunden');
}
if (role.isSystemRole) {
throw new Error('Systemrollen können nicht gelöscht werden');
}
await ClubUserRole.destroy({ where: { clubId, clubRoleId: roleId } });
await role.destroy();
return { success: true, message: 'Rolle erfolgreich gelöscht' };
}
async setClubOwner(userId, clubId) {
const userClub = await UserClub.findOne({ where: { userId, clubId } });
if (!userClub) {
throw new Error('UserClub relationship not found');
}
await userClub.update({ isOwner: true, role: 'admin', approved: true });
try {
await this.ensureDefaultRoles(clubId);
const adminRole = await ClubRole.findOne({ where: { clubId, roleKey: 'admin' } });
if (adminRole) {
await ClubUserRole.destroy({ where: { clubId, userId } });
await ClubUserRole.create({
clubId,
userId,
clubRoleId: adminRole.id,
isPrimary: true,
});
}
} catch (error) {
if (!this.isMissingRoleTableError(error)) {
throw error;
}
}
}
}
export default new PermissionService();

View File

@@ -0,0 +1,80 @@
# Multi-Product Umbau fuer `mein-tt.de` und `tt-verein.de`
## Summary
- Das Frontend wird von einer einheitlichen Vereins-App zu zwei klar getrennten Produkten auf gemeinsamer technischer Basis umgebaut:
- `tt-verein.de` fuer Vereinsarbeit
- `mein-tt.de` fuer einzelne Spieler
- Im ersten Schritt wird nur die Architektur und Produkttrennung umgesetzt, nicht der volle Ausbau neuer Spieler- oder Finanzmodule.
- Backend, Auth und Accounts bleiben vorerst gemeinsam; die Domain bestimmt Produktkontext, sichtbare Navigation, Standard-Startseite, zugelassene Routen und SEO.
## Implementation Changes
- Einen zentralen Produktkontext einfuehren, der beim App-Start den Host auf ein Produkt mapped.
- `tt-verein.de` => `club`
- `mein-tt.de` => `player`
- lokale Entwicklung zusaetzlich per Env-Override steuerbar, damit beide Produkte ohne DNS testbar bleiben
- Den Produktkontext zentral bereitstellen, statt `window.location` spaeter verteilt in Views zu pruefen.
- Store erweitert um `appProduct`, `appBrand`, `defaultHomeRoute`
- kleine Konfigurationsquelle fuer Produkt-Metadaten, erlaubte Routen, SEO-Basisdaten und Navigationsdefinitionen
- Routing auf Produktfaehigkeit umstellen.
- In `src/router.js` jede interne Route mit Produkt-Metadaten versehen, z. B. `products: ['club']`, `['player']`, `['club', 'player']`
- Globaler Router-Guard blockiert direkte URL-Zugriffe auf fachlich unpassende Bereiche
- Bei gesperrten Routen Umleitung auf produktpassende Startseite statt stiller Anzeige
- Produktzuordnung im ersten Schritt:
- `tt-verein.de`: bestehende Vereinsbereiche bleiben zugelassen, insbesondere Mitglieder, Tagebuch, Kalender, Freigaben, Statistiken, Turniere, Spielplaene, Vereinssettings, Teamverwaltung, Abrechnung
- `mein-tt.de`: zunaechst nur persoenliche Bereiche plus Kalender
- konkret freigegeben auf `mein-tt.de`: Startseite, Login/Register/Passwort-Flows, Kalender, persoenliche Einstellungen, MyTischtennis-/click-TT-Konto, Bestellungen, Impressum/Datenschutz/Konto loeschen
- alle klar vereinszentrierten Bereiche auf `mein-tt.de` sperren, einschliesslich Club-Auswahl als Primaernavigation, Mitgliederverwaltung, Freigaben, Teamverwaltung, Vereinssettings, Billing, Turnier- und Spielplan-Arbeitsflaechen
- falls einzelne heute technisch noch `currentClub` voraussetzen, bleiben sie auf `mein-tt.de` zunaechst ebenfalls gesperrt, bis sie produktneutral gemacht sind
- Navigation aus `App.vue` heraus in deklarative Produktnavigation ueberfuehren.
- keine fest verdrahteten Link-Bloecke mehr pro Template-Abschnitt
- Menue wird aus einer Konfigurationsliste gerendert: Label, Route, Icon, Permission-Regeln, Produktzuordnung
- `tt-verein.de` behaelt Club-Selektor und vereinszentrierte Sidebar
- `mein-tt.de` erhaelt eine reduzierte persoenliche Navigation ohne Club-Selektor als dominantes Element
- Onboarding/Startverhalten trennen.
- `tt-verein.de`: nach Login weiter club-zentriert; wenn kein Verein gewaehlt/verfuegbar, Club-Auswahl bzw. `createclub`
- `mein-tt.de`: nach Login auf persoenliche Startseite; kein erzwungener Club-Schritt
- bestehende Logik, die Navigation erst bei `selectedClub` sichtbar macht, wird fuer das Player-Produkt entkoppelt
- Public Surface und SEO pro Produkt trennen.
- `src/utils/seo.js` nicht mehr auf `https://tt-tagebuch.de` fest verdrahten
- produktabhaengige Canonical-URL, Seitentitel, OG-Daten und Standardbeschreibungen
- `index.html` ohne feste Canonical auf alte Domain
- oeffentliche Landingpages auf `tt-verein.de` vereinszentriert belassen
- `mein-tt.de` bekommt eine eigene reduzierte oeffentliche Positionierung fuer Spieler, auch wenn die neuen Spielerfeatures fachlich erst spaeter kommen
## Public Interfaces / Config Changes
- Neue zentrale Produktkonfiguration, z. B. in einer Datei wie `src/config/products.js`
- Hostname -> Produkt
- Produktname/Brand
- Default-Route
- erlaubte Routen
- SEO-Basiswerte
- Route-Meta wird erweitert um Produkt-Sichtbarkeit.
- Optionale Env-Variablen fuer lokale und Deployment-seitige Steuerung:
- Produkt-Override fuer lokale Entwicklung
- optionale Host-/Canonical-Basis-URLs je Produkt
## Test Plan
- Router-Guard:
- `mein-tt.de` blockiert direkte Aufrufe von `/members`, `/billing`, `/club-settings`, `/team-management`
- `tt-verein.de` laesst diese Routen bei Auth weiter zu
- Navigation:
- auf `mein-tt.de` erscheinen keine vereinszentrierten Menuepunkte
- auf `tt-verein.de` bleibt die bestehende Vereinsnavigation erhalten
- Onboarding:
- Login auf `mein-tt.de` landet ohne Club-Zwang auf persoenlicher Startseite
- Login auf `tt-verein.de` bleibt club-zentriert
- SEO:
- Canonical, Title und Description wechseln je Host korrekt
- oeffentliche Vereins-Landingpages referenzieren `tt-verein.de`, nicht mehr `tt-tagebuch.de`
- Regression:
- bestehende Auth-Flows, Club-Wechsel und Permission-basierte Vereinsnavigation funktionieren auf `tt-verein.de` unveraendert weiter
## Assumptions
- Gemeinsames Backend und gemeinsame Accounts bleiben im ersten Schritt bestehen.
- Die Domain-Trennung ist fachlich hart: unpassende Bereiche werden nicht nur im Menue versteckt, sondern per Routing gesperrt.
- `mein-tt.de` ist im ersten Schritt bewusst schmal und zeigt nur vorhandene persoenliche bzw. unkritische Bereiche plus Kalender.
- Neue Spielerfunktionen wie individuelles Trainingsprogramm und Ziele sowie neue Vereinsmodule wie Budget/Finanzuebersichten/Rechnungen werden erst im naechsten Ausbau auf diese Architektur aufgesetzt.
- Die bisherige Marke `tt-tagebuch.de` wird technisch nicht mehr als primaere oeffentliche Canonical-Basis behandelt, sobald die neuen Domains live sind.

View File

@@ -11,65 +11,48 @@
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
<link rel="manifest" href="/site.webmanifest" />
<title>Trainingstagebuch Vereinsverwaltung für Tischtennis, Trainingsplanung & Turniere</title>
<meta name="description" content="Trainingstagebuch: Vereinssoftware für Tischtennisvereine Mitgliederverwaltung und Mitgliederprofile, Trainingsplanung, Trainingstagebuch, Turniere, Mannschaften, Statistiken, MyTischtennis-Anbindung." />
<title>TT Verein und Mein TT</title>
<meta name="description" content="Tischtennis-Software fuer Vereine und Spieler." />
<meta name="robots" content="index,follow" />
<link rel="canonical" href="https://tt-tagebuch.de/" />
<!-- Open Graph -->
<meta property="og:type" content="website" />
<meta property="og:site_name" content="Trainingstagebuch" />
<meta property="og:title" content="Trainingstagebuch Vereinsverwaltung für Tischtennis, Trainingsplanung & Turniere" />
<meta property="og:description" content="Vereinssoftware für Tischtennisvereine: Mitgliederverwaltung, Mitgliederprofile, Trainingsplanung, Turniere, Mannschaften, Statistiken, MyTischtennis-Anbindung." />
<meta property="og:url" content="https://tt-tagebuch.de/" />
<meta property="og:image" content="https://tt-tagebuch.de/android-chrome-512x512.png" />
<meta property="og:site_name" content="TT Verein und Mein TT" />
<meta property="og:title" content="TT Verein und Mein TT" />
<meta property="og:description" content="Tischtennis-Software fuer Vereine und Spieler." />
<!-- Twitter -->
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:title" content="Trainingstagebuch Vereinsverwaltung für Tischtennis, Trainingsplanung & Turniere" />
<meta name="twitter:description" content="Vereinssoftware für Tischtennisvereine: Mitgliederverwaltung, Mitgliederprofile, Trainingsplanung, Turniere, Mannschaften, Statistiken, MyTischtennis-Anbindung." />
<meta name="twitter:image" content="https://tt-tagebuch.de/android-chrome-512x512.png" />
<meta name="twitter:title" content="TT Verein und Mein TT" />
<meta name="twitter:description" content="Tischtennis-Software fuer Vereine und Spieler." />
<!-- JSON-LD: Website + Organization -->
<script type="application/ld+json">
{
"@context": "https://schema.org",
"@type": "WebSite",
"name": "Trainingstagebuch",
"url": "https://tt-tagebuch.de/",
"potentialAction": {
"@type": "SearchAction",
"target": "https://tt-tagebuch.de/?q={search_term_string}",
"query-input": "required name=search_term_string"
}
"name": "TT Verein und Mein TT"
}
</script>
<script type="application/ld+json">
{
"@context": "https://schema.org",
"@type": "SoftwareApplication",
"name": "Trainingstagebuch",
"name": "TT Verein und Mein TT",
"applicationCategory": "SportsApplication",
"operatingSystem": "Web",
"description": "Umfassende Vereinsverwaltung mit Mitgliederverwaltung, Trainingsgruppen, Trainingszeiten, Trainingstagebuch, Turnierorganisation (intern, offen, offiziell), Team-Management, MyTischtennis-Integration, Statistiken und flexiblen Berechtigungssystemen DSGVOkonform und einfach zu bedienen.",
"description": "Tischtennis-Software fuer Vereine und Spieler mit getrennten Produktoberflaechen.",
"featureList": [
"Mitgliederverwaltung & Mitgliederprofile",
"Trainingsgruppen & Trainingszeiten",
"Trainingstagebuch & Dokumentation",
"Turniere (intern, offen, offiziell)",
"Team-Management & Ligen",
"MyTischtennis-Integration",
"Statistiken & Auswertungen",
"Rollen & Berechtigungssystem",
"PDF-Export",
"Aktivitätsprotokoll"
"Vereinsverwaltung fuer Tischtennisvereine",
"Persoenliche Spieleroberflaeche",
"Kalender und Turnierbezug",
"myTischtennis-Integration"
],
"offers": {
"@type": "Offer",
"price": "0",
"priceCurrency": "EUR"
},
"url": "https://tt-tagebuch.de/"
}
}
</script>
<script type="application/ld+json">

View File

@@ -0,0 +1,202 @@
ALTER TABLE `clubs`
ADD COLUMN IF NOT EXISTS `short_name` varchar(120) NULL,
ADD COLUMN IF NOT EXISTS `legal_name` varchar(255) NULL,
ADD COLUMN IF NOT EXISTS `club_number` varchar(64) NULL,
ADD COLUMN IF NOT EXISTS `email` varchar(255) NULL,
ADD COLUMN IF NOT EXISTS `phone` varchar(80) NULL,
ADD COLUMN IF NOT EXISTS `website` varchar(255) NULL,
ADD COLUMN IF NOT EXISTS `street` varchar(255) NULL,
ADD COLUMN IF NOT EXISTS `postal_code` varchar(24) NULL,
ADD COLUMN IF NOT EXISTS `city` varchar(120) NULL,
ADD COLUMN IF NOT EXISTS `country_code` varchar(2) NOT NULL DEFAULT 'DE',
ADD COLUMN IF NOT EXISTS `chairperson_name` varchar(255) NULL,
ADD COLUMN IF NOT EXISTS `treasurer_name` varchar(255) NULL,
ADD COLUMN IF NOT EXISTS `youth_manager_name` varchar(255) NULL,
ADD COLUMN IF NOT EXISTS `creditor_identifier` varchar(64) NULL,
ADD COLUMN IF NOT EXISTS `billing_email` varchar(255) NULL,
ADD COLUMN IF NOT EXISTS `iban` varchar(34) NULL,
ADD COLUMN IF NOT EXISTS `bic` varchar(11) NULL,
ADD COLUMN IF NOT EXISTS `is_archived` tinyint(1) NOT NULL DEFAULT 0,
ADD COLUMN IF NOT EXISTS `archived_at` datetime NULL;
ALTER TABLE `member`
ADD COLUMN IF NOT EXISTS `member_number` varchar(64) NULL,
ADD COLUMN IF NOT EXISTS `membership_status` varchar(32) NOT NULL DEFAULT 'active',
ADD COLUMN IF NOT EXISTS `membership_type` varchar(32) NOT NULL DEFAULT 'regular',
ADD COLUMN IF NOT EXISTS `joined_on` date NULL,
ADD COLUMN IF NOT EXISTS `left_on` date NULL,
ADD COLUMN IF NOT EXISTS `contribution_group_code` varchar(64) NULL,
ADD COLUMN IF NOT EXISTS `needs_sepa_mandate` tinyint(1) NOT NULL DEFAULT 0,
ADD COLUMN IF NOT EXISTS `sepa_mandate_reference` varchar(80) NULL,
ADD COLUMN IF NOT EXISTS `is_archived` tinyint(1) NOT NULL DEFAULT 0,
ADD COLUMN IF NOT EXISTS `archived_at` datetime NULL,
ADD COLUMN IF NOT EXISTS `archived_reason` text NULL;
CREATE TABLE IF NOT EXISTS `club_requests` (
`id` bigint NOT NULL AUTO_INCREMENT,
`club_id` bigint NOT NULL,
`request_type` varchar(32) NOT NULL,
`status` varchar(32) NOT NULL DEFAULT 'open',
`workflow_stage` varchar(64) NULL,
`priority` varchar(16) NOT NULL DEFAULT 'normal',
`subject` varchar(255) NULL,
`first_name` varchar(120) NULL,
`last_name` varchar(120) NULL,
`email` varchar(255) NULL,
`phone` varchar(80) NULL,
`message` text NULL,
`source_system` varchar(64) NULL,
`received_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
`assigned_user_id` bigint NULL,
`assigned_member_id` bigint NULL,
`converted_member_id` bigint NULL,
`closed_at` datetime NULL,
`created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
`updated_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
KEY `idx_club_requests_club_status` (`club_id`, `status`, `request_type`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS `club_request_notes` (
`id` bigint NOT NULL AUTO_INCREMENT,
`club_request_id` bigint NOT NULL,
`note_type` varchar(32) NOT NULL DEFAULT 'internal',
`body` text NOT NULL,
`created_by_user_id` bigint NULL,
`created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
KEY `idx_club_request_notes_request` (`club_request_id`, `created_at`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS `club_tasks` (
`id` bigint NOT NULL AUTO_INCREMENT,
`club_id` bigint NOT NULL,
`title` varchar(255) NOT NULL,
`task_type` varchar(64) NULL,
`description` text NULL,
`status` varchar(32) NOT NULL DEFAULT 'open',
`priority` varchar(16) NOT NULL DEFAULT 'normal',
`due_at` datetime NULL,
`remind_at` datetime NULL,
`created_by_user_id` bigint NULL,
`assigned_user_id` bigint NULL,
`automation_source` varchar(64) NULL,
`automation_key` varchar(255) NULL,
`related_entity_type` varchar(32) NULL,
`related_entity_id` bigint NULL,
`completed_at` datetime NULL,
`archived_at` datetime NULL,
`source_snapshot` json NULL,
`created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
`updated_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
KEY `idx_club_tasks_club_status_due` (`club_id`, `status`, `due_at`),
UNIQUE KEY `uq_club_tasks_automation_key` (`club_id`, `automation_key`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS `club_task_suppressions` (
`id` bigint NOT NULL AUTO_INCREMENT,
`club_id` bigint NOT NULL,
`automation_key` varchar(255) NOT NULL,
`suppression_token` varchar(255) NOT NULL,
`dismissed_by_user_id` bigint NULL,
`created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
`updated_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
UNIQUE KEY `uq_club_task_suppressions_key` (`club_id`, `automation_key`),
KEY `idx_club_task_suppressions_lookup` (`club_id`, `automation_key`, `suppression_token`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS `club_roles` (
`id` bigint NOT NULL AUTO_INCREMENT,
`club_id` bigint NOT NULL,
`role_key` varchar(64) NOT NULL,
`name` varchar(120) NOT NULL,
`description` text NULL,
`permissions` json NOT NULL,
`is_system_role` tinyint(1) NOT NULL DEFAULT 0,
`sort_order` int NOT NULL DEFAULT 0,
`created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
`updated_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
UNIQUE KEY `uq_club_roles_key` (`club_id`, `role_key`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS `club_user_roles` (
`id` bigint NOT NULL AUTO_INCREMENT,
`club_id` bigint NOT NULL,
`user_id` bigint NOT NULL,
`club_role_id` bigint NOT NULL,
`is_primary` tinyint(1) NOT NULL DEFAULT 0,
`created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
`updated_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
UNIQUE KEY `uq_club_user_roles_assignment` (`club_id`, `user_id`, `club_role_id`),
KEY `idx_club_user_roles_user` (`club_id`, `user_id`),
KEY `idx_club_user_roles_role` (`club_role_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS `club_sepa_mandates` (
`id` bigint NOT NULL AUTO_INCREMENT,
`club_id` bigint NOT NULL,
`member_id` bigint NULL,
`debtor_name` varchar(255) NOT NULL,
`iban` varchar(34) NOT NULL,
`bic` varchar(11) NULL,
`mandate_reference` varchar(80) NOT NULL,
`signed_on` date NULL,
`valid_from` date NULL,
`revoked_at` datetime NULL,
`status` varchar(32) NOT NULL DEFAULT 'active',
`history_note` text NULL,
`created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
`updated_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
KEY `idx_club_sepa_mandates_club_member` (`club_id`, `member_id`, `status`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS `club_payment_claims` (
`id` bigint NOT NULL AUTO_INCREMENT,
`club_id` bigint NOT NULL,
`member_id` bigint NULL,
`fee_rule_id` bigint NULL,
`claim_type` varchar(32) NOT NULL DEFAULT 'membership_fee',
`status` varchar(32) NOT NULL DEFAULT 'open',
`due_on` date NOT NULL,
`amount_cents` bigint NOT NULL,
`currency_code` varchar(3) NOT NULL DEFAULT 'EUR',
`reminder_level` int NOT NULL DEFAULT 0,
`last_reminder_at` datetime NULL,
`notes` text NULL,
`settled_at` datetime NULL,
`archived_at` datetime NULL,
`created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
`updated_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
KEY `idx_club_payment_claims_club_status_due` (`club_id`, `status`, `due_on`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS `club_accounts` (
`id` bigint NOT NULL AUTO_INCREMENT,
`club_id` bigint NOT NULL,
`name` varchar(160) NOT NULL,
`account_holder` varchar(255) NULL,
`bank_name` varchar(160) NULL,
`iban` varchar(34) NULL,
`bic` varchar(11) NULL,
`account_type` varchar(16) NOT NULL DEFAULT 'bank',
`usage_type` varchar(32) NOT NULL DEFAULT 'general',
`currency_code` varchar(3) NOT NULL DEFAULT 'EUR',
`allow_sepa_collections` tinyint(1) NOT NULL DEFAULT 0,
`allow_outgoing_payments` tinyint(1) NOT NULL DEFAULT 1,
`is_default` tinyint(1) NOT NULL DEFAULT 0,
`status` varchar(16) NOT NULL DEFAULT 'active',
`notes` text NULL,
`sort_order` int NOT NULL DEFAULT 0,
`archived_at` datetime NULL,
`created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
`updated_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
KEY `idx_club_accounts_club_status` (`club_id`, `status`, `account_type`),
KEY `idx_club_accounts_default` (`club_id`, `is_default`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;

View File

@@ -0,0 +1,650 @@
BEGIN;
SET client_encoding = 'UTF8';
SET standard_conforming_strings = on;
ALTER TABLE IF EXISTS clubs
ADD COLUMN IF NOT EXISTS short_name varchar(120),
ADD COLUMN IF NOT EXISTS legal_name varchar(255),
ADD COLUMN IF NOT EXISTS club_number varchar(64),
ADD COLUMN IF NOT EXISTS email varchar(255),
ADD COLUMN IF NOT EXISTS phone varchar(80),
ADD COLUMN IF NOT EXISTS website varchar(255),
ADD COLUMN IF NOT EXISTS street varchar(255),
ADD COLUMN IF NOT EXISTS postal_code varchar(24),
ADD COLUMN IF NOT EXISTS city varchar(120),
ADD COLUMN IF NOT EXISTS country_code varchar(2) DEFAULT 'DE',
ADD COLUMN IF NOT EXISTS chairperson_name varchar(255),
ADD COLUMN IF NOT EXISTS treasurer_name varchar(255),
ADD COLUMN IF NOT EXISTS youth_manager_name varchar(255),
ADD COLUMN IF NOT EXISTS creditor_identifier varchar(64),
ADD COLUMN IF NOT EXISTS billing_email varchar(255),
ADD COLUMN IF NOT EXISTS iban varchar(34),
ADD COLUMN IF NOT EXISTS bic varchar(11),
ADD COLUMN IF NOT EXISTS is_archived boolean NOT NULL DEFAULT false,
ADD COLUMN IF NOT EXISTS archived_at timestamptz;
ALTER TABLE IF EXISTS member
ADD COLUMN IF NOT EXISTS member_number varchar(64),
ADD COLUMN IF NOT EXISTS membership_status varchar(32) NOT NULL DEFAULT 'active',
ADD COLUMN IF NOT EXISTS membership_type varchar(32) NOT NULL DEFAULT 'regular',
ADD COLUMN IF NOT EXISTS joined_on date,
ADD COLUMN IF NOT EXISTS left_on date,
ADD COLUMN IF NOT EXISTS contribution_group_code varchar(64),
ADD COLUMN IF NOT EXISTS needs_sepa_mandate boolean NOT NULL DEFAULT false,
ADD COLUMN IF NOT EXISTS sepa_mandate_reference varchar(80),
ADD COLUMN IF NOT EXISTS is_archived boolean NOT NULL DEFAULT false,
ADD COLUMN IF NOT EXISTS archived_at timestamptz,
ADD COLUMN IF NOT EXISTS archived_reason text;
CREATE TABLE IF NOT EXISTS club_requests (
id bigserial PRIMARY KEY,
club_id bigint NOT NULL,
request_type varchar(32) NOT NULL,
status varchar(32) NOT NULL DEFAULT 'open',
workflow_stage varchar(64),
priority varchar(16) NOT NULL DEFAULT 'normal',
source_system varchar(64),
source_reference varchar(255),
subject varchar(255),
first_name varchar(120),
last_name varchar(120),
email varchar(255),
phone varchar(80),
birthdate date,
message text,
requested_membership_type varchar(32),
assigned_user_id bigint,
assigned_member_id bigint,
converted_member_id bigint,
received_at timestamptz NOT NULL DEFAULT now(),
closed_at timestamptz,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_club_requests_club_status
ON club_requests (club_id, status, request_type);
CREATE TABLE IF NOT EXISTS club_request_notes (
id bigserial PRIMARY KEY,
club_request_id bigint NOT NULL,
note_type varchar(32) NOT NULL DEFAULT 'internal',
body text NOT NULL,
created_by_user_id bigint,
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_club_request_notes_request
ON club_request_notes (club_request_id, created_at DESC);
CREATE TABLE IF NOT EXISTS club_distribution_groups (
id bigserial PRIMARY KEY,
club_id bigint NOT NULL,
name varchar(160) NOT NULL,
description text,
group_type varchar(32) NOT NULL DEFAULT 'manual',
is_system_group boolean NOT NULL DEFAULT false,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
CREATE UNIQUE INDEX IF NOT EXISTS uq_club_distribution_groups_name
ON club_distribution_groups (club_id, lower(name));
CREATE TABLE IF NOT EXISTS club_distribution_group_members (
id bigserial PRIMARY KEY,
distribution_group_id bigint NOT NULL,
member_id bigint,
user_id bigint,
email varchar(255),
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_club_distribution_group_members_group
ON club_distribution_group_members (distribution_group_id);
CREATE TABLE IF NOT EXISTS club_communication_threads (
id bigserial PRIMARY KEY,
club_id bigint NOT NULL,
thread_type varchar(32) NOT NULL,
subject varchar(255) NOT NULL,
status varchar(32) NOT NULL DEFAULT 'draft',
created_by_user_id bigint,
scheduled_at timestamptz,
sent_at timestamptz,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_club_communication_threads_club_status
ON club_communication_threads (club_id, status, thread_type);
CREATE TABLE IF NOT EXISTS club_communication_recipients (
id bigserial PRIMARY KEY,
thread_id bigint NOT NULL,
member_id bigint,
user_id bigint,
distribution_group_id bigint,
email varchar(255),
delivery_status varchar(32) NOT NULL DEFAULT 'pending',
delivered_at timestamptz,
read_at timestamptz,
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_club_communication_recipients_thread
ON club_communication_recipients (thread_id, delivery_status);
CREATE TABLE IF NOT EXISTS club_communication_messages (
id bigserial PRIMARY KEY,
thread_id bigint NOT NULL,
sender_user_id bigint,
sender_member_id bigint,
body text NOT NULL,
body_format varchar(16) NOT NULL DEFAULT 'plain',
sent_at timestamptz NOT NULL DEFAULT now(),
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_club_communication_messages_thread
ON club_communication_messages (thread_id, sent_at);
CREATE TABLE IF NOT EXISTS club_events (
id bigserial PRIMARY KEY,
club_id bigint NOT NULL,
event_type varchar(32) NOT NULL,
status varchar(32) NOT NULL DEFAULT 'planned',
title varchar(255) NOT NULL,
description text,
location varchar(255),
starts_at timestamptz NOT NULL,
ends_at timestamptz,
registration_deadline timestamptz,
organizer_user_id bigint,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
archived_at timestamptz
);
CREATE INDEX IF NOT EXISTS idx_club_events_club_starts
ON club_events (club_id, starts_at);
CREATE TABLE IF NOT EXISTS club_event_participants (
id bigserial PRIMARY KEY,
event_id bigint NOT NULL,
member_id bigint,
user_id bigint,
role_code varchar(32),
participation_status varchar(32) NOT NULL DEFAULT 'planned',
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_club_event_participants_event
ON club_event_participants (event_id, participation_status);
CREATE TABLE IF NOT EXISTS club_documents (
id bigserial PRIMARY KEY,
club_id bigint NOT NULL,
document_type varchar(32) NOT NULL,
title varchar(255) NOT NULL,
description text,
status varchar(32) NOT NULL DEFAULT 'active',
visibility_scope varchar(32) NOT NULL DEFAULT 'board',
owner_user_id bigint,
current_version_no integer NOT NULL DEFAULT 1,
archived_at timestamptz,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_club_documents_club_type
ON club_documents (club_id, document_type, status);
CREATE TABLE IF NOT EXISTS club_document_versions (
id bigserial PRIMARY KEY,
document_id bigint NOT NULL,
version_no integer NOT NULL,
file_name varchar(255) NOT NULL,
storage_path varchar(500) NOT NULL,
mime_type varchar(120),
file_size_bytes bigint,
checksum_sha256 varchar(64),
uploaded_by_user_id bigint,
uploaded_at timestamptz NOT NULL DEFAULT now(),
change_note text
);
CREATE UNIQUE INDEX IF NOT EXISTS uq_club_document_versions_document_version
ON club_document_versions (document_id, version_no);
CREATE TABLE IF NOT EXISTS club_document_links (
id bigserial PRIMARY KEY,
document_id bigint NOT NULL,
linked_entity_type varchar(32) NOT NULL,
linked_entity_id bigint NOT NULL,
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_club_document_links_entity
ON club_document_links (linked_entity_type, linked_entity_id);
CREATE TABLE IF NOT EXISTS club_tasks (
id bigserial PRIMARY KEY,
club_id bigint NOT NULL,
title varchar(255) NOT NULL,
task_type varchar(64),
description text,
status varchar(32) NOT NULL DEFAULT 'open',
priority varchar(16) NOT NULL DEFAULT 'normal',
due_at timestamptz,
remind_at timestamptz,
created_by_user_id bigint,
assigned_user_id bigint,
automation_source varchar(64),
automation_key varchar(255),
related_entity_type varchar(32),
related_entity_id bigint,
source_snapshot jsonb,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
completed_at timestamptz,
archived_at timestamptz
);
CREATE INDEX IF NOT EXISTS idx_club_tasks_club_status_due
ON club_tasks (club_id, status, due_at);
CREATE UNIQUE INDEX IF NOT EXISTS uq_club_tasks_automation_key
ON club_tasks (club_id, automation_key)
WHERE automation_key IS NOT NULL;
CREATE TABLE IF NOT EXISTS club_task_suppressions (
id bigserial PRIMARY KEY,
club_id bigint NOT NULL,
automation_key varchar(255) NOT NULL,
suppression_token varchar(255) NOT NULL,
dismissed_by_user_id bigint,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
CREATE UNIQUE INDEX IF NOT EXISTS uq_club_task_suppressions_key
ON club_task_suppressions (club_id, automation_key);
CREATE INDEX IF NOT EXISTS idx_club_task_suppressions_lookup
ON club_task_suppressions (club_id, automation_key, suppression_token);
CREATE TABLE IF NOT EXISTS club_sponsors (
id bigserial PRIMARY KEY,
club_id bigint NOT NULL,
name varchar(255) NOT NULL,
status varchar(32) NOT NULL DEFAULT 'lead',
website varchar(255),
email varchar(255),
phone varchar(80),
street varchar(255),
postal_code varchar(24),
city varchar(120),
notes text,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
archived_at timestamptz
);
CREATE UNIQUE INDEX IF NOT EXISTS uq_club_sponsors_name
ON club_sponsors (club_id, lower(name));
CREATE TABLE IF NOT EXISTS club_sponsor_contacts (
id bigserial PRIMARY KEY,
sponsor_id bigint NOT NULL,
first_name varchar(120),
last_name varchar(120),
role_title varchar(120),
email varchar(255),
phone varchar(80),
is_primary boolean NOT NULL DEFAULT false,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_club_sponsor_contacts_sponsor
ON club_sponsor_contacts (sponsor_id, is_primary DESC);
CREATE TABLE IF NOT EXISTS club_sponsor_contracts (
id bigserial PRIMARY KEY,
sponsor_id bigint NOT NULL,
title varchar(255) NOT NULL,
status varchar(32) NOT NULL DEFAULT 'draft',
starts_on date,
ends_on date,
annual_amount_cents bigint,
currency_code varchar(3) NOT NULL DEFAULT 'EUR',
notes text,
document_id bigint,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_club_sponsor_contracts_sponsor
ON club_sponsor_contracts (sponsor_id, status);
CREATE TABLE IF NOT EXISTS club_fee_rules (
id bigserial PRIMARY KEY,
club_id bigint NOT NULL,
code varchar(64) NOT NULL,
name varchar(255) NOT NULL,
category varchar(32) NOT NULL DEFAULT 'membership',
billing_cycle varchar(16) NOT NULL DEFAULT 'monthly',
base_amount_cents bigint NOT NULL,
currency_code varchar(3) NOT NULL DEFAULT 'EUR',
age_from integer,
age_to integer,
is_family_rule boolean NOT NULL DEFAULT false,
is_reduction_rule boolean NOT NULL DEFAULT false,
valid_from date NOT NULL DEFAULT CURRENT_DATE,
valid_to date,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
CREATE UNIQUE INDEX IF NOT EXISTS uq_club_fee_rules_code
ON club_fee_rules (club_id, code);
CREATE TABLE IF NOT EXISTS club_fee_rule_assignments (
id bigserial PRIMARY KEY,
fee_rule_id bigint NOT NULL,
member_id bigint NOT NULL,
valid_from date NOT NULL DEFAULT CURRENT_DATE,
valid_to date,
discount_percent numeric(5,2),
fixed_amount_cents bigint,
notes text,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_club_fee_rule_assignments_member
ON club_fee_rule_assignments (member_id, valid_from DESC);
CREATE TABLE IF NOT EXISTS club_payment_accounts (
id bigserial PRIMARY KEY,
club_id bigint NOT NULL,
account_name varchar(255) NOT NULL,
account_type varchar(32) NOT NULL DEFAULT 'bank',
iban varchar(34),
bic varchar(11),
bank_name varchar(255),
account_holder varchar(255),
is_default boolean NOT NULL DEFAULT false,
is_active boolean NOT NULL DEFAULT true,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_club_payment_accounts_club
ON club_payment_accounts (club_id, is_default DESC, is_active DESC);
CREATE TABLE IF NOT EXISTS club_sepa_mandates (
id bigserial PRIMARY KEY,
club_id bigint NOT NULL,
member_id bigint,
debtor_name varchar(255) NOT NULL,
iban varchar(34) NOT NULL,
bic varchar(11),
mandate_reference varchar(80) NOT NULL,
signed_on date,
valid_from date,
revoked_at timestamptz,
status varchar(32) NOT NULL DEFAULT 'active',
history_note text,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
CREATE UNIQUE INDEX IF NOT EXISTS uq_club_sepa_mandates_reference
ON club_sepa_mandates (club_id, mandate_reference);
CREATE TABLE IF NOT EXISTS club_payment_claims (
id bigserial PRIMARY KEY,
club_id bigint NOT NULL,
member_id bigint,
fee_rule_id bigint,
claim_type varchar(32) NOT NULL DEFAULT 'membership_fee',
status varchar(32) NOT NULL DEFAULT 'open',
due_on date NOT NULL,
amount_cents bigint NOT NULL,
currency_code varchar(3) NOT NULL DEFAULT 'EUR',
reminder_level integer NOT NULL DEFAULT 0,
last_reminder_at timestamptz,
notes text,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
settled_at timestamptz,
archived_at timestamptz
);
CREATE INDEX IF NOT EXISTS idx_club_payment_claims_club_status_due
ON club_payment_claims (club_id, status, due_on);
CREATE TABLE IF NOT EXISTS club_accounts (
id bigserial PRIMARY KEY,
club_id bigint NOT NULL,
name varchar(160) NOT NULL,
account_holder varchar(255),
bank_name varchar(160),
iban varchar(34),
bic varchar(11),
account_type varchar(16) NOT NULL DEFAULT 'bank',
usage_type varchar(32) NOT NULL DEFAULT 'general',
currency_code varchar(3) NOT NULL DEFAULT 'EUR',
allow_sepa_collections boolean NOT NULL DEFAULT false,
allow_outgoing_payments boolean NOT NULL DEFAULT true,
is_default boolean NOT NULL DEFAULT false,
status varchar(16) NOT NULL DEFAULT 'active',
notes text,
sort_order integer NOT NULL DEFAULT 0,
archived_at timestamptz,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_club_accounts_club_status
ON club_accounts (club_id, status, account_type);
CREATE INDEX IF NOT EXISTS idx_club_accounts_default
ON club_accounts (club_id, is_default);
CREATE TABLE IF NOT EXISTS club_payment_entries (
id bigserial PRIMARY KEY,
club_id bigint NOT NULL,
payment_claim_id bigint,
payment_account_id bigint,
entry_type varchar(32) NOT NULL,
status varchar(32) NOT NULL DEFAULT 'booked',
booked_on date NOT NULL DEFAULT CURRENT_DATE,
amount_cents bigint NOT NULL,
currency_code varchar(3) NOT NULL DEFAULT 'EUR',
reference_text varchar(255),
external_reference varchar(255),
created_by_user_id bigint,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_club_payment_entries_club_booked
ON club_payment_entries (club_id, booked_on DESC);
CREATE TABLE IF NOT EXISTS club_invoice_parties (
id bigserial PRIMARY KEY,
club_id bigint NOT NULL,
party_type varchar(32) NOT NULL,
name varchar(255) NOT NULL,
contact_name varchar(255),
email varchar(255),
phone varchar(80),
street varchar(255),
postal_code varchar(24),
city varchar(120),
country_code varchar(2) DEFAULT 'DE',
iban varchar(34),
bic varchar(11),
tax_identifier varchar(64),
notes text,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_club_invoice_parties_club_type
ON club_invoice_parties (club_id, party_type);
CREATE TABLE IF NOT EXISTS club_invoices (
id bigserial PRIMARY KEY,
club_id bigint NOT NULL,
invoice_direction varchar(16) NOT NULL,
invoice_type varchar(32) NOT NULL,
status varchar(32) NOT NULL DEFAULT 'draft',
invoice_number varchar(64),
external_reference varchar(255),
party_id bigint,
issued_on date,
due_on date,
paid_on date,
net_amount_cents bigint NOT NULL DEFAULT 0,
tax_amount_cents bigint NOT NULL DEFAULT 0,
gross_amount_cents bigint NOT NULL DEFAULT 0,
currency_code varchar(3) NOT NULL DEFAULT 'EUR',
description text,
document_id bigint,
created_by_user_id bigint,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
archived_at timestamptz
);
CREATE INDEX IF NOT EXISTS idx_club_invoices_club_direction_status
ON club_invoices (club_id, invoice_direction, status, due_on);
CREATE TABLE IF NOT EXISTS club_invoice_items (
id bigserial PRIMARY KEY,
invoice_id bigint NOT NULL,
line_no integer NOT NULL DEFAULT 1,
description text NOT NULL,
quantity numeric(12,2) NOT NULL DEFAULT 1,
unit_price_cents bigint NOT NULL DEFAULT 0,
tax_rate numeric(5,2) NOT NULL DEFAULT 0,
total_cents bigint NOT NULL DEFAULT 0,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
CREATE UNIQUE INDEX IF NOT EXISTS uq_club_invoice_items_line
ON club_invoice_items (invoice_id, line_no);
CREATE TABLE IF NOT EXISTS club_history_entries (
id bigserial PRIMARY KEY,
club_id bigint NOT NULL,
entity_type varchar(32) NOT NULL,
entity_id bigint NOT NULL,
action_type varchar(32) NOT NULL,
actor_user_id bigint,
actor_member_id bigint,
old_value_json jsonb,
new_value_json jsonb,
summary text,
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_club_history_entries_club_entity
ON club_history_entries (club_id, entity_type, entity_id, created_at DESC);
CREATE TABLE IF NOT EXISTS club_user_roles (
id bigserial PRIMARY KEY,
club_id bigint NOT NULL,
user_id bigint,
member_id bigint,
role_code varchar(32) NOT NULL,
valid_from date NOT NULL DEFAULT CURRENT_DATE,
valid_to date,
is_primary boolean NOT NULL DEFAULT false,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_club_user_roles_club_user
ON club_user_roles (club_id, user_id, role_code);
ALTER TABLE club_tasks
ADD COLUMN IF NOT EXISTS task_type varchar(64),
ADD COLUMN IF NOT EXISTS automation_source varchar(64),
ADD COLUMN IF NOT EXISTS automation_key varchar(255),
ADD COLUMN IF NOT EXISTS source_snapshot jsonb;
ALTER TABLE club_requests
ADD COLUMN IF NOT EXISTS workflow_stage varchar(64);
DO $$
BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'chk_club_requests_type') THEN
ALTER TABLE club_requests
ADD CONSTRAINT chk_club_requests_type
CHECK (request_type IN ('contact', 'trial_training', 'membership', 'sponsoring'));
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'chk_club_requests_status') THEN
ALTER TABLE club_requests
ADD CONSTRAINT chk_club_requests_status
CHECK (status IN ('open', 'in_progress', 'waiting', 'converted', 'rejected', 'archived'));
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'chk_club_events_type') THEN
ALTER TABLE club_events
ADD CONSTRAINT chk_club_events_type
CHECK (event_type IN ('training', 'match', 'club_event', 'meeting', 'deadline'));
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'chk_club_tasks_status') THEN
ALTER TABLE club_tasks
ADD CONSTRAINT chk_club_tasks_status
CHECK (status IN ('open', 'in_progress', 'waiting', 'done', 'cancelled', 'archived'));
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'chk_club_fee_rules_cycle') THEN
ALTER TABLE club_fee_rules
ADD CONSTRAINT chk_club_fee_rules_cycle
CHECK (billing_cycle IN ('monthly', 'quarterly', 'half_yearly', 'yearly', 'one_time'));
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'chk_club_payment_claims_status') THEN
ALTER TABLE club_payment_claims
ADD CONSTRAINT chk_club_payment_claims_status
CHECK (status IN ('open', 'partially_paid', 'paid', 'written_off', 'cancelled'));
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'chk_club_accounts_type') THEN
ALTER TABLE club_accounts
ADD CONSTRAINT chk_club_accounts_type
CHECK (account_type IN ('bank', 'cash', 'virtual'));
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'chk_club_accounts_usage') THEN
ALTER TABLE club_accounts
ADD CONSTRAINT chk_club_accounts_usage
CHECK (usage_type IN ('general', 'membership_fees', 'donations', 'expenses', 'reserve', 'petty_cash'));
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'chk_club_accounts_status') THEN
ALTER TABLE club_accounts
ADD CONSTRAINT chk_club_accounts_status
CHECK (status IN ('active', 'inactive', 'archived'));
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'chk_club_invoices_direction') THEN
ALTER TABLE club_invoices
ADD CONSTRAINT chk_club_invoices_direction
CHECK (invoice_direction IN ('incoming', 'outgoing'));
END IF;
END
$$;
COMMIT;

View File

@@ -4,7 +4,7 @@
<h1>
<router-link to="/" class="home-link">
<img :src="logoUrl" alt="Logo" class="home-logo" width="24" height="24" loading="lazy" />
<span>{{ $t('app.name') }}</span>
<span>{{ appBrand }}</span>
</router-link>
</h1>
<div v-if="isAuthenticated" class="user-menu">
@@ -26,19 +26,19 @@
<span class="dropdown-icon">📦</span>
{{ $t('navigation.orders') }}
</router-link>
<button v-if="canManagePermissions" type="button" class="dropdown-item" @click="openUserMenuDialog('PermissionsView', $t('navigation.permissions'))">
<button v-if="isFullAppProduct && canManagePermissions" type="button" class="dropdown-item" @click="openUserMenuDialog('PermissionsView', $t('navigation.permissions'))">
<span class="dropdown-icon">🔐</span>
{{ $t('navigation.permissions') }}
</button>
<button v-if="hasPermission('members', 'write')" type="button" class="dropdown-item" @click="openUserMenuDialog('MemberTransferSettingsView', $t('navigation.memberTransfer'))">
<button v-if="isFullAppProduct && hasPermission('members', 'write')" type="button" class="dropdown-item" @click="openUserMenuDialog('MemberTransferSettingsView', $t('navigation.memberTransfer'))">
<span class="dropdown-icon">📤</span>
{{ $t('navigation.memberTransfer') }}
</button>
<button v-if="isAdmin" type="button" class="dropdown-item" @click="openUserMenuDialog('LogsView', $t('navigation.logs'))">
<button v-if="isFullAppProduct && isAdmin" type="button" class="dropdown-item" @click="openUserMenuDialog('LogsView', $t('navigation.logs'))">
<span class="dropdown-icon">📋</span>
{{ $t('navigation.logs') }}
</button>
<button v-if="canManagePermissions" type="button" class="dropdown-item" @click="openUserMenuDialog('ClickTtView', $t('navigation.clickTtBrowser'))">
<button v-if="isFullAppProduct && canManagePermissions" type="button" class="dropdown-item" @click="openUserMenuDialog('ClickTtView', $t('navigation.clickTtBrowser'))">
<span class="dropdown-icon">🌐</span>
{{ $t('navigation.clickTtBrowser') }}
</button>
@@ -57,13 +57,13 @@
</header>
<div class="app-container">
<aside v-if="isAuthenticated" class="sidebar" :class="{ 'sidebar-collapsed': sidebarCollapsed }">
<aside v-if="shouldRenderSidebar" class="sidebar" :class="{ 'sidebar-collapsed': sidebarCollapsed }">
<button class="sidebar-toggle" @click="toggleSidebar">
<span v-if="sidebarCollapsed"></span>
<span v-else></span>
</button>
<div class="sidebar-content">
<div class="club-selector card">
<div v-if="shouldShowClubSelector" class="club-selector card">
<h3 class="card-title">{{ $t('club.select') }}</h3>
<div class="select-group">
<select v-model="selectedClub" class="club-select" @change="handleClubSelectionChange">
@@ -74,68 +74,18 @@
</div>
</div>
<nav v-if="selectedClub" class="nav-menu">
<div class="nav-section">
<h4 class="nav-title">{{ $t('navigation.dailyBusiness') }}</h4>
<router-link v-if="hasPermission('members', 'read')" to="/members" class="nav-link" title="Mitglieder">
<span class="nav-icon">👥</span>
{{ $t('navigation.members') }}
</router-link>
<router-link v-if="hasPermission('diary', 'read')" to="/diary" class="nav-link" title="Tagebuch">
<span class="nav-icon">📝</span>
{{ $t('navigation.diary') }}
</router-link>
<router-link v-if="hasPermission('diary', 'read') || hasPermission('schedule', 'read') || hasPermission('tournaments', 'read')" to="/calendar" class="nav-link" title="Kalender">
<span class="nav-icon">📆</span>
Kalender
</router-link>
<router-link v-if="canManageApprovals" to="/pending-approvals" class="nav-link" title="Freigaben">
<span class="nav-icon"></span>
{{ $t('navigation.approvals') }}
</router-link>
<router-link v-if="hasPermission('statistics', 'read')" to="/training-stats" class="nav-link" title="Trainings-Statistik">
<span class="nav-icon">📊</span>
{{ $t('navigation.statistics') }}
</router-link>
</div>
<div class="nav-section">
<h4 class="nav-title">{{ $t('navigation.competitions') }}</h4>
<router-link v-if="hasPermission('tournaments', 'read')" to="/tournaments" class="nav-link" :title="$t('navigation.clubTournaments')">
<span class="nav-icon">🏆</span>
{{ $t('navigation.clubTournaments') }}
</router-link>
<router-link v-if="hasPermission('tournaments', 'read')" to="/tournament-participations" class="nav-link" :title="$t('navigation.tournamentParticipations')">
<span class="nav-icon">📋</span>
{{ $t('navigation.tournamentParticipations') }}
</router-link>
<router-link v-if="hasPermission('schedule', 'read')" to="/schedule" class="nav-link" title="Spielpläne">
<span class="nav-icon">📅</span>
{{ $t('navigation.schedule') }}
</router-link>
<router-link v-if="hasPermission('schedule', 'read')" to="/friendly-matches" class="nav-link" title="Freundschaftsspiele">
<span class="nav-icon">🤝</span>
Freundschaftsspiele
</router-link>
</div>
<div class="nav-section">
<h4 class="nav-title">{{ $t('navigation.settings') }}</h4>
<router-link v-if="isAdmin" to="/club-settings" class="nav-link" title="Vereinseinstellungen">
<span class="nav-icon">🏛</span>
{{ $t('navigation.clubSettings') }}
</router-link>
<router-link v-if="hasPermission('predefined_activities', 'read')" to="/predefined-activities" class="nav-link" title="Vordefinierte Aktivitäten">
<span class="nav-icon">🎯</span>
{{ $t('navigation.predefinedActivities') }}
</router-link>
<router-link v-if="hasPermission('teams', 'read')" to="/team-management" class="nav-link" title="Team-Verwaltung">
<span class="nav-icon">🧩</span>
{{ $t('navigation.teamManagement') }}
</router-link>
<router-link v-if="hasPermission('members', 'read')" to="/billing" class="nav-link" :title="$t('navigation.billing')">
<span class="nav-icon">🧾</span>
{{ $t('navigation.billing') }}
<nav v-if="sidebarSections.length" class="nav-menu">
<div v-for="section in sidebarSections" :key="section.id" class="nav-section">
<h4 class="nav-title">{{ resolveSectionTitle(section) }}</h4>
<router-link
v-for="item in section.items"
:key="item.to"
:to="item.to"
class="nav-link"
:title="resolveNavItemLabel(item)"
>
<span class="nav-icon">{{ item.icon }}</span>
{{ resolveNavItemLabel(item) }}
</router-link>
</div>
</nav>
@@ -160,6 +110,7 @@
</div>
<BaseDialog
v-if="isFullAppProduct"
v-model="showMobileClubPicker"
:title="$t('club.select')"
:max-width="420"
@@ -237,6 +188,7 @@ import InfoDialog from './components/InfoDialog.vue';
import ConfirmDialog from './components/ConfirmDialog.vue';
import BaseDialog from './components/BaseDialog.vue';
import { buildInfoConfig, buildConfirmConfig } from './utils/dialogUtils.js';
import { FULL_APP_PRODUCTS, SIDEBAR_NAVIGATION } from './config/products.js';
const DialogManager = defineAsyncComponent(() => import('./components/DialogManager.vue'));
export default {
@@ -274,7 +226,28 @@ export default {
};
},
computed: {
...mapGetters(['isAuthenticated', 'currentClub', 'clubs', 'sidebarCollapsed', 'username', 'hasPermission', 'isClubOwner', 'userRole', 'language']),
...mapGetters([
'isAuthenticated',
'currentClub',
'clubs',
'sidebarCollapsed',
'username',
'hasPermission',
'isClubOwner',
'userRole',
'language',
'appProduct',
'appBrand',
]),
isClubProduct() {
return this.appProduct === 'club';
},
isFullAppProduct() {
return FULL_APP_PRODUCTS.includes(this.appProduct);
},
isPlayerProduct() {
return this.appProduct === 'player';
},
isMobileViewport() {
return this.viewportWidth <= 768;
},
@@ -303,10 +276,36 @@ export default {
viewReloadKey() {
return `${this.$route.fullPath}|${this.currentClub || 'no-club'}`;
},
shouldShowClubSelector() {
return this.isFullAppProduct;
},
shouldRenderSidebar() {
return this.isAuthenticated;
},
sidebarSections() {
const baseSections = SIDEBAR_NAVIGATION[this.appProduct] || [];
const currentSections = [];
for (const section of baseSections) {
const items = section.items.filter((item) => this.isNavItemVisible(item));
if (items.length > 0) {
currentSections.push({
...section,
items,
});
}
}
if (this.isFullAppProduct && !this.selectedClub) {
return [];
}
return currentSections;
},
},
watch: {
currentClub(newVal) {
if (newVal === 'new') {
if (this.isFullAppProduct && newVal === 'new') {
this.$router.push('/createclub');
}
if (newVal) {
@@ -337,6 +336,28 @@ export default {
}
},
methods: {
resolveSectionTitle(section) {
return section.titleKey ? this.$t(section.titleKey) : section.title;
},
resolveNavItemLabel(item) {
return item.labelKey ? this.$t(item.labelKey) : item.label;
},
isNavItemVisible(item) {
if (item.capability === 'approvals') {
return this.canManageApprovals;
}
if (item.capability === 'admin') {
return this.isAdmin;
}
if (item.permission) {
const [resource, action] = item.permission;
return this.hasPermission(resource, action);
}
if (Array.isArray(item.anyPermission)) {
return item.anyPermission.some(([resource, action]) => this.hasPermission(resource, action));
}
return true;
},
handleViewportResize() {
this.viewportWidth = window.innerWidth;
this.updateMobileClubPickerState();
@@ -399,6 +420,10 @@ export default {
},
async handleClubSelectionChange() {
if (!this.isFullAppProduct) {
return;
}
if (!this.selectedClub) {
await this.setCurrentClub(null);
this.updateMobileClubPickerState();
@@ -413,6 +438,10 @@ export default {
},
async selectClubFromMobilePicker(clubId) {
if (!this.isFullAppProduct) {
return;
}
this.selectedClub = clubId;
await this.handleClubSelectionChange();
},
@@ -423,6 +452,12 @@ export default {
return;
}
if (!this.isFullAppProduct) {
this.selectedClub = this.currentClub;
this.showMobileClubPicker = false;
return;
}
if (this.currentClub) {
this.selectedClub = this.currentClub;
this.showMobileClubPicker = false;
@@ -444,7 +479,7 @@ export default {
},
updateMobileClubPickerState() {
if (!this.isAuthenticated || !this.isMobileViewport || this.currentClub || this.$route.path === '/createclub') {
if (!this.isFullAppProduct || !this.isAuthenticated || !this.isMobileViewport || this.currentClub || this.$route.path === '/createclub') {
this.showMobileClubPicker = false;
return;
}

View File

@@ -0,0 +1,348 @@
export const CLUB_DATA_MODELS = {
club: {
table: 'clubs',
purpose: 'Vereinsstammdaten und organisatorische Grundeinstellungen für TT-Verein.',
fields: [
'id',
'name',
'short_name',
'legal_name',
'club_number',
'email',
'phone',
'website',
'street',
'postal_code',
'city',
'country_code',
'chairperson_name',
'treasurer_name',
'youth_manager_name',
'creditor_identifier',
'billing_email',
'iban',
'bic',
'is_archived',
'archived_at',
'created_at',
'updated_at',
],
},
member: {
table: 'clubmembers',
purpose: 'Mitglied als zentrale Person im Verein mit Stammdaten, Status und Zahlungsbezug.',
fields: [
'id',
'club_id',
'user_id',
'member_number',
'membership_status',
'membership_type',
'joined_on',
'left_on',
'birthdate',
'email',
'phone',
'street',
'postal_code',
'city',
'country_code',
'contribution_group_code',
'needs_sepa_mandate',
'sepa_mandate_reference',
'is_archived',
'archived_at',
'archived_reason',
'created_at',
'updated_at',
],
},
request: {
table: 'club_requests',
purpose: 'Zentraler Eingang für Kontakt-, Probetraining-, Mitgliedschafts- und Sponsoringanfragen.',
fields: [
'id',
'club_id',
'request_type',
'status',
'workflow_stage',
'priority',
'source_system',
'source_reference',
'subject',
'first_name',
'last_name',
'email',
'phone',
'birthdate',
'message',
'assigned_user_id',
'assigned_member_id',
'converted_member_id',
'received_at',
'closed_at',
'created_at',
'updated_at',
],
},
communicationThread: {
table: 'club_communication_threads',
purpose: 'Gesprächsstrang für Einzelnachrichten, Rundschreiben und spätere interne Kommunikation.',
fields: [
'id',
'club_id',
'thread_type',
'subject',
'status',
'created_by_user_id',
'scheduled_at',
'sent_at',
'created_at',
'updated_at',
],
},
distributionGroup: {
table: 'club_distribution_groups',
purpose: 'Wiederverwendbare Verteilergruppen für Kommunikation.',
fields: [
'id',
'club_id',
'name',
'description',
'group_type',
'is_system_group',
'created_at',
'updated_at',
],
},
event: {
table: 'club_events',
purpose: 'Vereinstermine für Training, Spiele und Vereinsveranstaltungen.',
fields: [
'id',
'club_id',
'event_type',
'status',
'title',
'description',
'location',
'starts_at',
'ends_at',
'registration_deadline',
'organizer_user_id',
'created_at',
'updated_at',
'archived_at',
],
},
document: {
table: 'club_documents',
purpose: 'Dokumentenstamm für Satzung, Protokolle, Formulare und Vereinsdokumente.',
fields: [
'id',
'club_id',
'document_type',
'title',
'description',
'status',
'current_version_no',
'owner_user_id',
'visibility_scope',
'archived_at',
'created_at',
'updated_at',
],
},
task: {
table: 'club_tasks',
purpose: 'Aufgaben, Wiedervorlagen und Fristen für den Vereinsbetrieb.',
fields: [
'id',
'club_id',
'title',
'task_type',
'description',
'status',
'priority',
'due_at',
'remind_at',
'created_by_user_id',
'assigned_user_id',
'automation_source',
'automation_key',
'related_entity_type',
'related_entity_id',
'source_snapshot',
'created_at',
'updated_at',
'completed_at',
'archived_at',
],
},
sponsor: {
table: 'club_sponsors',
purpose: 'Sponsorenbeziehung mit Ansprechpartnern, Verträgen und Zahlungsbezug.',
fields: [
'id',
'club_id',
'name',
'status',
'website',
'email',
'phone',
'street',
'postal_code',
'city',
'notes',
'created_at',
'updated_at',
'archived_at',
],
},
feeRule: {
table: 'club_fee_rules',
purpose: 'Beitragssätze inklusive Familienbeiträgen und Ermäßigungen.',
fields: [
'id',
'club_id',
'code',
'name',
'category',
'billing_cycle',
'base_amount_cents',
'currency_code',
'age_from',
'age_to',
'is_family_rule',
'is_reduction_rule',
'valid_from',
'valid_to',
'created_at',
'updated_at',
],
},
sepaMandate: {
table: 'club_sepa_mandates',
purpose: 'SEPA-Mandate für Mitglieder oder Beitragszahler.',
fields: [
'id',
'club_id',
'member_id',
'debtor_name',
'iban',
'bic',
'mandate_reference',
'signed_on',
'valid_from',
'revoked_at',
'status',
'history_note',
'created_at',
'updated_at',
],
},
account: {
table: 'club_accounts',
purpose: 'Vereinskonten als Grundlage für Zahlungswege, SEPA-Einzüge und spätere Finanzprozesse.',
fields: [
'id',
'club_id',
'name',
'account_holder',
'bank_name',
'iban',
'bic',
'account_type',
'usage_type',
'currency_code',
'allow_sepa_collections',
'allow_outgoing_payments',
'is_default',
'status',
'notes',
'sort_order',
'archived_at',
'created_at',
'updated_at',
],
},
paymentClaim: {
table: 'club_payment_claims',
purpose: 'Offene Beitragsforderungen und sonstige Zahlungsansprüche.',
fields: [
'id',
'club_id',
'member_id',
'fee_rule_id',
'claim_type',
'status',
'due_on',
'amount_cents',
'currency_code',
'reminder_level',
'last_reminder_at',
'created_at',
'updated_at',
'settled_at',
'archived_at',
],
},
invoice: {
table: 'club_invoices',
purpose: 'Ein- und Ausgangsrechnungen mit Parteien- und Dokumentenbezug.',
fields: [
'id',
'club_id',
'invoice_direction',
'invoice_type',
'status',
'invoice_number',
'external_reference',
'party_id',
'issued_on',
'due_on',
'paid_on',
'net_amount_cents',
'tax_amount_cents',
'gross_amount_cents',
'currency_code',
'description',
'created_at',
'updated_at',
'archived_at',
],
},
historyEntry: {
table: 'club_history_entries',
purpose: 'Änderungs- und Aktivitätsprotokoll für jedes Vereinsmodul.',
fields: [
'id',
'club_id',
'entity_type',
'entity_id',
'action_type',
'actor_user_id',
'actor_member_id',
'old_value_json',
'new_value_json',
'summary',
'created_at',
],
},
roleAssignment: {
table: 'club_user_roles',
purpose: 'Rollenbasierte Berechtigungszuordnung für Verwaltungsnutzer.',
fields: [
'id',
'club_id',
'user_id',
'member_id',
'role_code',
'valid_from',
'valid_to',
'is_primary',
'created_at',
'updated_at',
],
},
};

View File

@@ -0,0 +1,307 @@
export const CLUB_DASHBOARD_SECTIONS = [
{
id: 'action-needed',
title: 'Handlungsbedarf',
cards: [
{
title: 'Neue Anfragen',
accent: 'amber',
items: ['3 neue Probetrainings', '1 Sponsoringanfrage'],
},
{
title: 'Offene Zahlungen',
accent: 'red',
items: ['7 Mitgliedsbeiträge offen', '2 Mahnungen fällig'],
},
{
title: 'Fehlende Daten',
accent: 'blue',
items: ['5 Mitglieder ohne E-Mail', '2 Mitglieder ohne Geburtsdatum', '3 Mitglieder ohne SEPA-Mandat'],
},
{
title: 'Offene Aufgaben',
accent: 'green',
items: ['Vereinsmeisterschaft planen', 'Hallendienst besetzen'],
},
],
},
{
id: 'today-and-week',
title: 'Aktuelle Termine',
cards: [
{
title: 'Heute',
accent: 'green',
items: ['Jugendtraining 17:00 Uhr', 'Vorstandssitzung 19:30 Uhr'],
},
{
title: 'Diese Woche',
accent: 'blue',
items: ['Heimspiel Herren 1', 'Vereinsmeisterschaft Meldeschluss'],
},
],
},
{
id: 'club-status',
title: 'Vereinsstatus',
cards: [
{
title: 'Mitglieder',
value: '87 aktiv',
meta: '+4 dieses Jahr',
accent: 'green',
},
{
title: 'Anfragen',
value: '12 offen',
meta: '4 in Bearbeitung',
accent: 'amber',
},
{
title: 'Finanzen',
value: '93 % bezahlt',
meta: 'Mitgliedsbeiträge',
accent: 'blue',
},
{
title: 'Dokumente',
value: '8 unbearbeitet',
meta: 'Neue oder offene Unterlagen',
accent: 'red',
},
],
},
{
id: 'recent-activity',
title: 'Letzte Aktivitäten',
cards: [
{
title: 'Zuletzt passiert',
accent: 'neutral',
items: ['Mitglied angelegt', 'Rechnung bezahlt', 'Dokument hochgeladen', 'Anfrage beantwortet'],
},
],
},
];
export const CLUB_DASHBOARD_QUICK_LINKS = [
{ to: '/club-tasks', label: 'Aufgaben steuern', icon: '✅' },
{ to: '/club-requests', label: 'Anfragen bearbeiten', icon: '📥' },
{ to: '/members', label: 'Mitglieder öffnen', icon: '👥' },
{ to: '/club-payments', label: 'Zahlungen prüfen', icon: '💶' },
{ to: '/club-documents', label: 'Dokumente verwalten', icon: '🗂️' },
];
export const CLUB_MENU_SECTIONS = [
{
id: 'main',
title: 'Hauptmenü',
items: [
{ to: '/', icon: '🏠', label: 'Dashboard' },
{ to: '/club-requests', icon: '📥', label: 'Anfragen', permission: ['approvals', 'read'] },
{ to: '/members', icon: '👥', label: 'Mitglieder', permission: ['members', 'read'] },
{ to: '/club-communication', icon: '💬', label: 'Kommunikation', permission: ['members', 'read'] },
{ to: '/calendar', icon: '📆', label: 'Termine', permission: ['schedule', 'read'] },
{ to: '/club-documents', icon: '🗂️', label: 'Dokumente', permission: ['settings', 'read'] },
],
},
{
id: 'organisation',
title: 'Organisation',
items: [
{ to: '/club-tasks', icon: '✅', label: 'Aufgaben', permission: ['approvals', 'read'] },
{ to: '/team-management', icon: '🧩', label: 'Mannschaften', permission: ['teams', 'read'] },
{ to: '/club-events', icon: '🎪', label: 'Veranstaltungen', permission: ['schedule', 'read'] },
{ to: '/club-sponsors', icon: '🤝', label: 'Sponsoren', permission: ['settings', 'read'] },
],
},
{
id: 'finance',
title: 'Finanzen',
items: [
{ to: '/club-fees', icon: '💳', label: 'Beiträge', permission: ['members', 'write'] },
{ to: '/club-payments', icon: '💶', label: 'Zahlungen', permission: ['members', 'write'] },
{ to: '/club-invoices', icon: '🧾', label: 'Rechnungen', permission: ['members', 'write'] },
{ to: '/club-accounts', icon: '🏦', label: 'Konten', permission: ['members', 'write'] },
],
},
{
id: 'administration',
title: 'Verwaltung',
items: [
{ to: '/club-users', icon: '👤', label: 'Benutzer', permission: ['permissions', 'read'] },
{ to: '/club-roles', icon: '🛡️', label: 'Rollen', permission: ['permissions', 'read'] },
{ to: '/club-history', icon: '🕘', label: 'Historie', permission: ['members', 'read'] },
{ to: '/club-settings', icon: '⚙️', label: 'Einstellungen', capability: 'admin' },
],
},
{
id: 'analysis',
title: 'Auswertung',
items: [
{ to: '/club-statistics', icon: '📊', label: 'Statistiken', permission: ['statistics', 'read'] },
{ to: '/club-reports', icon: '📑', label: 'Berichte', permission: ['statistics', 'read'] },
{ to: '/club-archive', icon: '🗄️', label: 'Archiv', permission: ['settings', 'read'] },
],
},
];
export const CLUB_CONCEPT_ROUTES = [
{
path: '/club-requests',
name: 'club-requests',
title: 'Anfragen',
phase: 'Phase 1',
summary: 'Kontaktanfragen, Probetrainings, Mitgliedschaftsanfragen und Sponsoringanfragen in einem einheitlichen Eingang.',
highlights: ['Kontaktanfragen', 'Probetraining', 'Mitgliedschaftsanfragen', 'Sponsoringanfragen'],
principles: ['Zentrale Eingangsliste statt verteilter E-Mail-Postfächer', 'Bearbeitungsstatus für jeden Vorgang', 'Überführung in Mitglieder, Aufgaben oder Kommunikation'],
},
{
path: '/club-communication',
name: 'club-communication',
title: 'Kommunikation',
phase: 'Phase 1',
summary: 'Kommunikation für Einzelpersonen, Gruppen und Rundschreiben mit klarem Vereinskontext.',
highlights: ['Einzelnachrichten', 'Rundschreiben', 'Verteilergruppen'],
principles: ['Kommunikation direkt aus dem Vereinskontext', 'Nutzbar für Vorstand, Trainer und Verwaltung', 'Später API-fähig für externe Eingaben'],
},
{
path: '/club-documents',
name: 'club-documents',
title: 'Dokumente',
phase: 'Phase 1',
summary: 'Vereinsdokumente, Formulare und Protokolle an einem zentralen Ort statt in verstreuten Ordnern.',
highlights: ['Satzung', 'Protokolle', 'Formulare', 'Vereinsdokumente'],
principles: ['Archiv statt Löschen', 'Berechtigungen pro Dokumententyp', 'Später erweiterbar Richtung DMS'],
},
{
path: '/club-tasks',
name: 'club-tasks',
title: 'Aufgaben',
phase: 'Phase 2',
summary: 'Aufgaben, Wiedervorlagen und Fristen für den Vereinsbetrieb in einem einfachen Arbeitsbereich.',
highlights: ['Aufgabenverwaltung', 'Wiedervorlagen', 'Fristen'],
principles: ['Dashboard-getrieben: Was muss heute erledigt werden?', 'Verknüpfbar mit Anfragen, Veranstaltungen und Finanzen', 'Geeignet für Vorstand und Orga-Teams'],
},
{
path: '/club-training',
name: 'club-training',
title: 'Training',
phase: 'Phase 1',
summary: 'Trainingsbezogene Vereinsorganisation als eigener Bereich innerhalb von TT-Verein.',
highlights: ['Trainingskoordination', 'Abstimmung mit Terminen', 'Verknüpfung zu Mannschaften und Mitgliedern'],
principles: ['Nicht trainerzentriert, sondern vereinsorganisatorisch', 'Saubere Schnittstelle zum Trainings-Tagebuch', 'Fokus auf Vereinsbetrieb'],
},
{
path: '/club-events',
name: 'club-events',
title: 'Veranstaltungen',
phase: 'Phase 1',
summary: 'Planung von Vereinsveranstaltungen neben Training und Spielbetrieb.',
highlights: ['Vereinsveranstaltungen', 'Fristen', 'Verantwortlichkeiten'],
principles: ['Veranstaltungen sind eigene Vereinsobjekte', 'Organisation mit Aufgaben und Dokumenten verbinden', 'Dashboard-relevante Fristen sichtbar machen'],
},
{
path: '/club-sponsors',
name: 'club-sponsors',
title: 'Sponsoren',
phase: 'Phase 2',
summary: 'Sponsorenliste, Ansprechpartner und Vertragsbezug als eigener Organisationsbereich.',
highlights: ['Sponsorenliste', 'Ansprechpartner', 'Verträge'],
principles: ['Sponsoring nicht nur als Anfrage, sondern als laufende Beziehung', 'Verknüpfbar mit Rechnungen und Dokumenten', 'Spätere Entwicklungsauswertung möglich'],
},
{
path: '/club-fees',
name: 'club-fees',
title: 'Beiträge',
phase: 'Phase 2',
summary: 'Beitragssätze, Familienbeiträge und Ermäßigungen für die Vereinsverwaltung.',
highlights: ['Beitragssätze', 'Familienbeiträge', 'Ermäßigungen'],
principles: ['Mitglieder- und Zahlungsbezug aus einer Quelle', 'Grundlage für offene Beiträge und Mahnstufen', 'Keine externe Beitragsliste mehr nötig'],
},
{
path: '/club-payments',
name: 'club-payments',
title: 'Zahlungen',
phase: 'Phase 2',
summary: 'Offene Beiträge, Zahlungseingänge und Mahnstufen für Vorstand und Kassenrolle.',
highlights: ['Offene Beiträge', 'Zahlungseingänge', 'Mahnstufen'],
principles: ['Dashboard zeigt offenen Handlungsbedarf', 'Mitgliederdaten und Beiträge greifen zusammen', 'Basis für spätere SEPA-Workflows'],
},
{
path: '/club-invoices',
name: 'club-invoices',
title: 'Rechnungen',
phase: 'Phase 2',
summary: 'Eingangs- und Ausgangsrechnungen in einem durchgängigen Vereinskontext.',
highlights: ['Hallenmiete', 'Verbandsbeiträge', 'Material', 'Sponsoren und sonstige Forderungen'],
principles: ['Trennung von Einnahmen und Ausgaben', 'Belegbezug zu Dokumenten und Sponsoren', 'Historie und Archiv standardmäßig vorgesehen'],
},
{
path: '/club-accounts',
name: 'club-accounts',
title: 'Konten',
phase: 'Phase 2',
summary: 'Finanzkonten als organisatorische Grundlage für Zahlungen, Rechnungen und spätere SEPA-Prozesse.',
highlights: ['Kontenübersicht', 'Kontobezug für Zahlungen', 'Vorbereitung für SEPA'],
principles: ['Nicht Buchhaltung im Vollsinn, sondern Vereinsorganisation', 'Nachvollziehbare Zuordnung von Zahlungswegen', 'Grundlage für Kassenprozesse'],
},
{
path: '/club-users',
name: 'club-users',
title: 'Benutzer',
phase: 'Phase 1',
summary: 'Benutzerverwaltung für die Personen, die im Verein mit der Plattform arbeiten.',
highlights: ['Vorstand', 'Kassierer', 'Trainer', 'Jugendwart', 'Schriftführer', 'Mitglied'],
principles: ['Nicht jedes Mitglied ist automatisch Verwaltungsnutzer', 'Rollenbasiert statt frei erfundener Einzelrechte', 'Sauber trennbar von Vereinsmitgliedern'],
},
{
path: '/club-roles',
name: 'club-roles',
title: 'Rollen',
phase: 'Phase 1',
summary: 'Rollenbasierte Zugriffslogik für alle Vereinsmodule.',
highlights: ['Vorstand', 'Kassierer', 'Trainer', 'Jugendwart', 'Schriftführer', 'Mitglied'],
principles: ['Jedes Modul prüft Berechtigungen', 'Rollen sind produktzentral, nicht nachträglich angeheftet', 'Grundlage für Historie und API-Fähigkeit'],
},
{
path: '/club-history',
name: 'club-history',
title: 'Historie',
phase: 'Phase 1',
summary: 'Änderungsprotokoll, Benutzerprotokoll und Aktivitäten als durchgängiges Grundprinzip.',
highlights: ['Wer?', 'Wann?', 'Was?', 'Alter Wert', 'Neuer Wert'],
principles: ['Historie überall', 'Archiv statt Löschen', 'Nachvollziehbarkeit für den Vereinsbetrieb'],
},
{
path: '/club-statistics',
name: 'club-statistics',
title: 'Statistiken',
phase: 'Phase 3',
summary: 'Spätere Auswertung zu Mitgliederentwicklung, Altersstruktur, Beitragsentwicklung und Sponsorenentwicklung.',
highlights: ['Mitgliederentwicklung', 'Altersstruktur', 'Beitragsentwicklung', 'Sponsorenentwicklung'],
principles: ['Dashboard vor Statistik', 'Statistiken folgen erst auf belastbare Prozesse', 'Auswertungen bauen auf denselben Grunddaten auf'],
},
{
path: '/club-reports',
name: 'club-reports',
title: 'Berichte',
phase: 'Phase 3',
summary: 'Berichte, Schriftverkehr und spätere PDF-Ausgabe für den Vereinsalltag.',
highlights: ['Briefeditor', 'PDF-Erzeugung', 'Vorlagenverwaltung'],
principles: ['Vorlagen für wiederkehrende Vereinsprozesse', 'Geeignet für Aufnahme, Mahnung und Einladungen', 'Sauber mit Dokumenten und Historie verzahnt'],
},
{
path: '/club-archive',
name: 'club-archive',
title: 'Archiv',
phase: 'Phase 3',
summary: 'Archivierte Mitglieder, historische Dokumente und alte Rechnungen als eigene Auswertungsebene.',
highlights: ['Ehemalige Mitglieder', 'Historische Dokumente', 'Alte Rechnungen'],
principles: ['Archiv statt Löschen ist Standard', 'Ruhige Trennung von aktivem Bestand und Historie', 'Verknüpfbar mit Dokumenten und Historie'],
},
];
export function getClubConceptRouteByPath(path) {
return CLUB_CONCEPT_ROUTES.find((route) => route.path === path) || null;
}

View File

@@ -0,0 +1,174 @@
import { CLUB_MENU_SECTIONS } from './clubWorkspace.js';
export const PRODUCT_TRAINER = 'trainer';
export const PRODUCT_CLUB = 'club';
export const PRODUCT_PLAYER = 'player';
export const FULL_APP_PRODUCTS = [PRODUCT_TRAINER, PRODUCT_CLUB];
const PRODUCT_HOSTS = {
[PRODUCT_TRAINER]: ['tt-tagebuch.de', 'www.tt-tagebuch.de', 'trainer.localhost'],
[PRODUCT_CLUB]: ['tt-verein.de', 'www.tt-verein.de', 'club.localhost'],
[PRODUCT_PLAYER]: ['mein-tt.de', 'www.mein-tt.de', 'player.localhost'],
};
const PRODUCT_CONFIGS = {
[PRODUCT_TRAINER]: {
id: PRODUCT_TRAINER,
brandName: 'Trainings-Tagebuch',
appName: 'tt-tagebuch.de',
defaultHomeRoute: '/',
canonicalUrl: import.meta.env.VITE_CANONICAL_TRAINER_URL || 'https://tt-tagebuch.de',
seo: {
siteName: 'Trainings-Tagebuch',
defaultTitle: 'Trainings-Tagebuch Tischtennis-Trainingsverwaltung',
defaultDescription:
'Trainer ist die bisherige vollständige Tischtennis-Anwendung für Training, Organisation, Turniere, Teams und Vereinsalltag.',
imagePath: '/android-chrome-512x512.png',
},
},
[PRODUCT_CLUB]: {
id: PRODUCT_CLUB,
brandName: 'TT Verein',
appName: 'tt-verein.de',
defaultHomeRoute: '/',
canonicalUrl: import.meta.env.VITE_CANONICAL_CLUB_URL || 'https://tt-verein.de',
seo: {
siteName: 'TT Verein',
defaultTitle: 'TT Verein Vereinsverwaltung für Tischtennis',
defaultDescription:
'TT Verein ist die zentrale Arbeitsplattform für kleine und mittlere Tischtennisvereine mit Fokus auf Mitglieder, Kommunikation, Dokumente, Termine und Vereinsbetrieb.',
imagePath: '/android-chrome-512x512.png',
},
},
[PRODUCT_PLAYER]: {
id: PRODUCT_PLAYER,
brandName: 'Mein TT',
appName: 'mein-tt.de',
defaultHomeRoute: '/',
canonicalUrl: import.meta.env.VITE_CANONICAL_PLAYER_URL || 'https://mein-tt.de',
seo: {
siteName: 'Mein TT',
defaultTitle: 'Mein TT Tischtennis für Spieler',
defaultDescription:
'Mein TT ist die persönliche Tischtennisoberfläche für Spieler mit Kalender, Kontoverknüpfungen, Bestellungen und persönlichen Einstellungen.',
imagePath: '/android-chrome-512x512.png',
},
},
};
export const SIDEBAR_NAVIGATION = {
[PRODUCT_TRAINER]: [
{
id: 'daily-business',
titleKey: 'navigation.dailyBusiness',
items: [
{ to: '/members', icon: '👥', labelKey: 'navigation.members', permission: ['members', 'read'] },
{ to: '/diary', icon: '📝', labelKey: 'navigation.diary', permission: ['diary', 'read'] },
{
to: '/calendar',
icon: '📆',
label: 'Kalender',
anyPermission: [
['diary', 'read'],
['schedule', 'read'],
['tournaments', 'read'],
],
},
{ to: '/pending-approvals', icon: '⏳', labelKey: 'navigation.approvals', capability: 'approvals' },
{ to: '/training-stats', icon: '📊', labelKey: 'navigation.statistics', permission: ['statistics', 'read'] },
],
},
{
id: 'competitions',
titleKey: 'navigation.competitions',
items: [
{ to: '/tournaments', icon: '🏆', labelKey: 'navigation.clubTournaments', permission: ['tournaments', 'read'] },
{ to: '/tournament-participations', icon: '📋', labelKey: 'navigation.tournamentParticipations', permission: ['tournaments', 'read'] },
{ to: '/schedule', icon: '📅', labelKey: 'navigation.schedule', permission: ['schedule', 'read'] },
{ to: '/friendly-matches', icon: '🤝', label: 'Freundschaftsspiele', permission: ['schedule', 'read'] },
],
},
{
id: 'settings',
titleKey: 'navigation.settings',
items: [
{ to: '/club-settings', icon: '🏛️', labelKey: 'navigation.clubSettings', capability: 'admin' },
{ to: '/predefined-activities', icon: '🎯', labelKey: 'navigation.predefinedActivities', permission: ['predefined_activities', 'read'] },
{ to: '/team-management', icon: '🧩', labelKey: 'navigation.teamManagement', permission: ['teams', 'read'] },
{ to: '/billing', icon: '🧾', labelKey: 'navigation.billing', permission: ['members', 'read'] },
],
},
],
[PRODUCT_CLUB]: CLUB_MENU_SECTIONS,
[PRODUCT_PLAYER]: [
{
id: 'player-area',
title: 'Mein Bereich',
items: [
{ to: '/calendar', icon: '📆', label: 'Kalender' },
{ to: '/mytischtennis-account', icon: '🔗', labelKey: 'navigation.myTischtennisAccount' },
{ to: '/clicktt-account', icon: '🏓', labelKey: 'navigation.clickTtAccount' },
{ to: '/orders', icon: '📦', labelKey: 'navigation.orders' },
{ to: '/personal-settings', icon: '⚙️', labelKey: 'navigation.personalSettings' },
],
},
],
};
export function normalizeProduct(product) {
if (product === PRODUCT_PLAYER) return PRODUCT_PLAYER;
if (product === PRODUCT_CLUB) return PRODUCT_CLUB;
return PRODUCT_TRAINER;
}
export function resolveProductFromHostname(hostname = '') {
const normalizedHostname = String(hostname || '').toLowerCase();
if (PRODUCT_HOSTS[PRODUCT_TRAINER].includes(normalizedHostname)) {
return PRODUCT_TRAINER;
}
if (PRODUCT_HOSTS[PRODUCT_PLAYER].includes(normalizedHostname)) {
return PRODUCT_PLAYER;
}
if (PRODUCT_HOSTS[PRODUCT_CLUB].includes(normalizedHostname)) {
return PRODUCT_CLUB;
}
return PRODUCT_TRAINER;
}
export function resolveCurrentProduct() {
const override = normalizeProduct(import.meta.env.VITE_APP_PRODUCT);
if (import.meta.env.VITE_APP_PRODUCT) {
return override;
}
if (typeof window === 'undefined') {
return PRODUCT_TRAINER;
}
return resolveProductFromHostname(window.location.hostname);
}
export function getProductConfig(product = resolveCurrentProduct()) {
return PRODUCT_CONFIGS[normalizeProduct(product)];
}
export function getDefaultHomeRoute(product = resolveCurrentProduct()) {
return getProductConfig(product).defaultHomeRoute;
}
export function isProductRouteAllowed(product, routeProducts = []) {
if (!Array.isArray(routeProducts) || routeProducts.length === 0) {
return true;
}
return routeProducts.includes(normalizeProduct(product));
}
export function getCurrentBrandName() {
return getProductConfig(resolveCurrentProduct()).brandName;
}

View File

@@ -339,6 +339,24 @@
"noGroupsAssigned": "Keine Gruppen zugeordnet",
"noGroupsAvailable": "Keine Gruppen verfügbar",
"addGroup": "Gruppe hinzufügen...",
"bankAccountSection": "Bankkonto / SEPA",
"bankAccountLoading": "Bankdaten werden geladen...",
"accountHolder": "Kontoinhaber",
"iban": "IBAN",
"bic": "BIC",
"mandateReference": "Mandatsreferenz",
"signedOn": "Unterschrieben am",
"validFrom": "Gültig ab",
"bankAccountStatus": "Status",
"bankAccountStatusActive": "Aktiv",
"bankAccountStatusPending": "Ausstehend",
"bankAccountStatusRevoked": "Widerrufen",
"bankAccountNote": "Hinweis",
"saveBankAccount": "Bankkonto speichern",
"bankAccountSaved": "Bankkonto erfolgreich gespeichert.",
"bankAccountLoadError": "Bankkonto konnte nicht geladen werden.",
"bankAccountSaveError": "Bankkonto konnte nicht gespeichert werden.",
"bankAccountMissingAfterSave": "Das Bankkonto wurde nach dem Speichern nicht wiedergefunden.",
"remove": "Entfernen",
"image": "Bild",
"selectFile": "Datei auswählen",

View File

@@ -333,6 +333,24 @@
"noGroupsAssigned": "No groups assigned",
"noGroupsAvailable": "No groups available",
"addGroup": "Add group...",
"bankAccountSection": "Bank account / SEPA",
"bankAccountLoading": "Loading bank details...",
"accountHolder": "Account holder",
"iban": "IBAN",
"bic": "BIC",
"mandateReference": "Mandate reference",
"signedOn": "Signed on",
"validFrom": "Valid from",
"bankAccountStatus": "Status",
"bankAccountStatusActive": "Active",
"bankAccountStatusPending": "Pending",
"bankAccountStatusRevoked": "Revoked",
"bankAccountNote": "Note",
"saveBankAccount": "Save bank account",
"bankAccountSaved": "Bank account saved successfully.",
"bankAccountLoadError": "Bank account could not be loaded.",
"bankAccountSaveError": "Bank account could not be saved.",
"bankAccountMissingAfterSave": "The bank account could not be found again after saving.",
"remove": "Remove",
"image": "Image",
"selectFile": "Select file",

View File

@@ -1,6 +1,8 @@
import { createRouter, createWebHistory } from 'vue-router';
import { applySeoForPath } from './utils/seo.js';
import { safeSessionStorage } from './utils/storage.js';
import { safeLocalStorage, safeSessionStorage } from './utils/storage.js';
import { getDefaultHomeRoute, isProductRouteAllowed, resolveCurrentProduct } from './config/products.js';
import { CLUB_CONCEPT_ROUTES } from './config/clubWorkspace.js';
const Register = () => import('./views/Register.vue');
const Login = () => import('./views/Login.vue');
@@ -34,47 +36,163 @@ const MemberTransferSettingsView = () => import('./views/MemberTransferSettingsV
const PersonalSettings = () => import('./views/PersonalSettings.vue');
const OrdersView = () => import('./views/OrdersView.vue');
const BillingView = () => import('./views/BillingView.vue');
const ClubRequestsView = () => import('./views/ClubRequestsView.vue');
const ClubTasksView = () => import('./views/ClubTasksView.vue');
const ClubHistoryView = () => import('./views/ClubHistoryView.vue');
const ClubStatisticsView = () => import('./views/ClubStatisticsView.vue');
const ClubArchiveView = () => import('./views/ClubArchiveView.vue');
const ClubAccountsView = () => import('./views/ClubAccountsView.vue');
const ClubConceptModuleView = () => import('./views/ClubConceptModuleView.vue');
const Impressum = () => import('./views/Impressum.vue');
const Datenschutz = () => import('./views/Datenschutz.vue');
const KontoLoeschen = () => import('./views/KontoLoeschen.vue');
function withMeta(meta = {}) {
return meta;
}
function getStoredCurrentClubPermissions() {
const currentClub = safeSessionStorage.getItem('currentClub');
if (!currentClub) {
return null;
}
try {
const permissionMap = JSON.parse(safeLocalStorage.getItem('clubPermissions') || '{}');
return permissionMap[currentClub] || null;
} catch (_error) {
return null;
}
}
function hasRoutePermission(resource, action) {
const permissions = getStoredCurrentClubPermissions();
if (!permissions) {
return false;
}
if (permissions.isOwner) {
return true;
}
if (resource === 'mytischtennis') {
return true;
}
return permissions.permissions?.[resource]?.[action] === true;
}
function hasRouteCapability(capability) {
const permissions = getStoredCurrentClubPermissions();
if (!permissions) {
return false;
}
if (permissions.isOwner || permissions.isAdmin || permissions.role === 'admin') {
return true;
}
if (capability === 'approvals') {
return hasRoutePermission('approvals', 'read');
}
if (capability === 'admin') {
return false;
}
return true;
}
function isRouteAuthorized(to) {
const protectedRules = to.matched
.map((record) => record.meta || {})
.filter((meta) => meta.permission || meta.capability || Array.isArray(meta.anyPermission));
if (protectedRules.length === 0) {
return true;
}
return protectedRules.every((meta) => {
if (meta.capability && !hasRouteCapability(meta.capability)) {
return false;
}
if (meta.permission) {
const [resource, action] = meta.permission;
if (!hasRoutePermission(resource, action)) {
return false;
}
}
if (Array.isArray(meta.anyPermission) && !meta.anyPermission.some(([resource, action]) => hasRoutePermission(resource, action))) {
return false;
}
return true;
});
}
const trainerOnly = ['trainer'];
const clubOnly = ['club'];
const fullAppProducts = ['trainer', 'club'];
const allProducts = ['trainer', 'club', 'player'];
const conceptRoutes = CLUB_CONCEPT_ROUTES
.filter((route) => route.path !== '/club-requests')
.filter((route) => route.path !== '/club-tasks')
.filter((route) => route.path !== '/club-users')
.filter((route) => route.path !== '/club-roles')
.filter((route) => route.path !== '/club-history')
.filter((route) => route.path !== '/club-statistics')
.filter((route) => route.path !== '/club-archive')
.filter((route) => route.path !== '/club-accounts')
.map((route) => ({
path: route.path,
name: route.name,
component: ClubConceptModuleView,
meta: withMeta({ products: clubOnly, moduleMeta: route }),
}));
const routes = [
{ path: '/register', name: 'register', component: Register, meta: { public: true } },
{ path: '/login', name: 'login', component: Login, meta: { public: true } },
{ path: '/activate/:activationCode', name: 'activate', component: Activate, meta: { public: true } },
{ path: '/forgot-password', name: 'forgot-password', component: ForgotPassword, meta: { public: true } },
{ path: '/reset-password/:token', name: 'reset-password', component: ResetPassword, meta: { public: true } },
{ path: '/', name: 'home', component: Home, meta: { public: true } },
{ path: '/vereinssoftware-tischtennis', name: 'club-software-seo', component: TableTennisClubSoftware, meta: { public: true } },
{ path: '/mitgliederverwaltung-verein', name: 'member-management-seo', component: ClubMemberManagementPage, meta: { public: true } },
{ path: '/trainingsplanung-tischtennis', name: 'training-planning-seo', component: TrainingPlanningPage, meta: { public: true } },
{ path: '/turniersoftware-tischtennis', name: 'tournament-software-seo', component: TableTennisTournamentSoftwarePage, meta: { public: true } },
{ path: '/createclub', name: 'create-club', component: CreateClub },
{ path: '/showclub/:clubId', name: 'show-club', component: ClubView },
{ path: '/members', name: 'members', component: MembersView },
{ path: '/diary', name: 'diary', component: DiaryView },
{ path: '/calendar', name: 'calendar', component: CalendarView },
{ path: '/pending-approvals', name: 'pending-approvals', component: PendingApprovalsView},
{ path: '/schedule', name: 'schedule', component: ScheduleView},
{ path: '/friendly-matches', name: 'friendly-matches', component: ScheduleView, props: { friendlyOnly: true } },
{ path: '/tournaments', name: 'tournaments', component: TournamentsView },
{ path: '/tournament-participations', name: 'tournament-participations', component: OfficialTournaments },
{ path: '/training-stats', name: 'training-stats', component: TrainingStatsView },
{ path: '/club-settings', name: 'club-settings', component: ClubSettings },
{ path: '/predefined-activities', name: 'predefined-activities', component: PredefinedActivities },
{ path: '/mytischtennis-account', name: 'mytischtennis-account', component: MyTischtennisAccount },
{ path: '/clicktt-account', name: 'clicktt-account', component: ClickTtAccount },
{ path: '/team-management', name: 'team-management', component: TeamManagementView },
{ path: '/permissions', name: 'permissions', component: PermissionsView },
{ path: '/logs', name: 'logs', component: LogsView },
{ path: '/clicktt', name: 'clicktt', component: ClickTtView },
{ path: '/member-transfer-settings', name: 'member-transfer-settings', component: MemberTransferSettingsView },
{ path: '/personal-settings', name: 'personal-settings', component: PersonalSettings },
{ path: '/orders', name: 'orders', component: OrdersView },
{ path: '/billing', name: 'billing', component: BillingView },
{ path: '/impressum', name: 'impressum', component: Impressum, meta: { public: true } },
{ path: '/datenschutz', name: 'datenschutz', component: Datenschutz, meta: { public: true } },
{ path: '/konto-loeschen', name: 'konto-loeschen', component: KontoLoeschen, meta: { public: true } },
{ path: '/register', name: 'register', component: Register, meta: withMeta({ public: true, products: allProducts }) },
{ path: '/login', name: 'login', component: Login, meta: withMeta({ public: true, products: allProducts }) },
{ path: '/activate/:activationCode', name: 'activate', component: Activate, meta: withMeta({ public: true, products: allProducts }) },
{ path: '/forgot-password', name: 'forgot-password', component: ForgotPassword, meta: withMeta({ public: true, products: allProducts }) },
{ path: '/reset-password/:token', name: 'reset-password', component: ResetPassword, meta: withMeta({ public: true, products: allProducts }) },
{ path: '/', name: 'home', component: Home, meta: withMeta({ public: true, products: allProducts }) },
{ path: '/vereinssoftware-tischtennis', name: 'club-software-seo', component: TableTennisClubSoftware, meta: withMeta({ public: true, products: fullAppProducts }) },
{ path: '/mitgliederverwaltung-verein', name: 'member-management-seo', component: ClubMemberManagementPage, meta: withMeta({ public: true, products: fullAppProducts }) },
{ path: '/trainingsplanung-tischtennis', name: 'training-planning-seo', component: TrainingPlanningPage, meta: withMeta({ public: true, products: fullAppProducts }) },
{ path: '/turniersoftware-tischtennis', name: 'tournament-software-seo', component: TableTennisTournamentSoftwarePage, meta: withMeta({ public: true, products: fullAppProducts }) },
{ path: '/createclub', name: 'create-club', component: CreateClub, meta: withMeta({ products: fullAppProducts }) },
{ path: '/showclub/:clubId', name: 'show-club', component: ClubView, meta: withMeta({ products: fullAppProducts }) },
{ path: '/members', name: 'members', component: MembersView, meta: withMeta({ products: fullAppProducts, permission: ['members', 'read'] }) },
{ path: '/diary', name: 'diary', component: DiaryView, meta: withMeta({ products: trainerOnly }) },
{ path: '/calendar', name: 'calendar', component: CalendarView, meta: withMeta({ products: allProducts, anyPermission: [['diary', 'read'], ['schedule', 'read'], ['tournaments', 'read']] }) },
{ path: '/pending-approvals', name: 'pending-approvals', component: PendingApprovalsView, meta: withMeta({ products: fullAppProducts, capability: 'approvals' }) },
{ path: '/schedule', name: 'schedule', component: ScheduleView, meta: withMeta({ products: fullAppProducts, permission: ['schedule', 'read'] }) },
{ path: '/friendly-matches', name: 'friendly-matches', component: ScheduleView, props: { friendlyOnly: true }, meta: withMeta({ products: fullAppProducts }) },
{ path: '/tournaments', name: 'tournaments', component: TournamentsView, meta: withMeta({ products: fullAppProducts, permission: ['tournaments', 'read'] }) },
{ path: '/tournament-participations', name: 'tournament-participations', component: OfficialTournaments, meta: withMeta({ products: fullAppProducts, permission: ['tournaments', 'read'] }) },
{ path: '/training-stats', name: 'training-stats', component: TrainingStatsView, meta: withMeta({ products: fullAppProducts, permission: ['statistics', 'read'] }) },
{ path: '/club-settings', name: 'club-settings', component: ClubSettings, meta: withMeta({ products: fullAppProducts, capability: 'admin' }) },
{ path: '/predefined-activities', name: 'predefined-activities', component: PredefinedActivities, meta: withMeta({ products: fullAppProducts, permission: ['predefined_activities', 'read'] }) },
{ path: '/mytischtennis-account', name: 'mytischtennis-account', component: MyTischtennisAccount, meta: withMeta({ products: allProducts }) },
{ path: '/clicktt-account', name: 'clicktt-account', component: ClickTtAccount, meta: withMeta({ products: allProducts }) },
{ path: '/team-management', name: 'team-management', component: TeamManagementView, meta: withMeta({ products: fullAppProducts, permission: ['teams', 'read'] }) },
{ path: '/permissions', name: 'permissions', component: PermissionsView, meta: withMeta({ products: fullAppProducts, permission: ['permissions', 'read'] }) },
{ path: '/club-users', name: 'club-users', component: PermissionsView, props: { viewMode: 'users' }, meta: withMeta({ products: clubOnly, permission: ['permissions', 'read'] }) },
{ path: '/club-roles', name: 'club-roles', component: PermissionsView, props: { viewMode: 'roles' }, meta: withMeta({ products: clubOnly, permission: ['permissions', 'read'] }) },
{ path: '/logs', name: 'logs', component: LogsView, meta: withMeta({ products: fullAppProducts }) },
{ path: '/clicktt', name: 'clicktt', component: ClickTtView, meta: withMeta({ products: fullAppProducts }) },
{ path: '/member-transfer-settings', name: 'member-transfer-settings', component: MemberTransferSettingsView, meta: withMeta({ products: fullAppProducts }) },
{ path: '/personal-settings', name: 'personal-settings', component: PersonalSettings, meta: withMeta({ products: allProducts }) },
{ path: '/orders', name: 'orders', component: OrdersView, meta: withMeta({ products: allProducts }) },
{ path: '/billing', name: 'billing', component: BillingView, meta: withMeta({ products: trainerOnly }) },
{ path: '/club-requests', name: 'club-requests', component: ClubRequestsView, meta: withMeta({ products: clubOnly }) },
{ path: '/club-tasks', name: 'club-tasks', component: ClubTasksView, meta: withMeta({ products: clubOnly }) },
{ path: '/club-history', name: 'club-history', component: ClubHistoryView, meta: withMeta({ products: clubOnly, permission: ['members', 'read'] }) },
{ path: '/club-statistics', name: 'club-statistics', component: ClubStatisticsView, meta: withMeta({ products: clubOnly, permission: ['statistics', 'read'] }) },
{ path: '/club-archive', name: 'club-archive', component: ClubArchiveView, meta: withMeta({ products: clubOnly, permission: ['settings', 'read'] }) },
{ path: '/club-accounts', name: 'club-accounts', component: ClubAccountsView, meta: withMeta({ products: clubOnly, permission: ['members', 'read'] }) },
...conceptRoutes,
{ path: '/impressum', name: 'impressum', component: Impressum, meta: withMeta({ public: true, products: allProducts }) },
{ path: '/datenschutz', name: 'datenschutz', component: Datenschutz, meta: withMeta({ public: true, products: allProducts }) },
{ path: '/konto-loeschen', name: 'konto-loeschen', component: KontoLoeschen, meta: withMeta({ public: true, products: allProducts }) },
];
const router = createRouter({
@@ -83,8 +201,16 @@ const router = createRouter({
});
router.beforeEach((to, from, next) => {
const currentProduct = resolveCurrentProduct();
const defaultHomeRoute = getDefaultHomeRoute(currentProduct);
const isAuthenticated = Boolean(safeSessionStorage.getItem('token'));
const isPublicRoute = to.matched.some((record) => record.meta?.public);
const routeProducts = to.matched.flatMap((record) => record.meta?.products || []);
if (!isProductRouteAllowed(currentProduct, routeProducts)) {
next(defaultHomeRoute);
return;
}
if (!isAuthenticated && !isPublicRoute) {
next({
@@ -95,7 +221,12 @@ router.beforeEach((to, from, next) => {
}
if (isAuthenticated && (to.path === '/login' || to.path === '/register')) {
next('/');
next(defaultHomeRoute);
return;
}
if (isAuthenticated && !isPublicRoute && !isRouteAuthorized(to)) {
next(defaultHomeRoute);
return;
}

View File

@@ -3,6 +3,10 @@ import router from './router.js';
import apiClient from './apiClient.js';
import { safeSessionStorage, safeLocalStorage } from './utils/storage.js';
import i18n from './i18n';
import { getProductConfig, resolveCurrentProduct } from './config/products.js';
const initialProduct = resolveCurrentProduct();
const initialProductConfig = getProductConfig(initialProduct);
const store = createStore({
state: {
@@ -43,6 +47,9 @@ const store = createStore({
// Browser-Sprache wird in i18n/index.js erkannt
return null;
})(),
appProduct: initialProduct,
appBrand: initialProductConfig.brandName,
defaultHomeRoute: initialProductConfig.defaultHomeRoute,
},
mutations: {
setToken(state, token) {
@@ -97,6 +104,12 @@ const store = createStore({
state.language = language;
safeLocalStorage.setItem('userLanguage', language);
},
setAppProduct(state, product) {
const config = getProductConfig(product);
state.appProduct = config.id;
state.appBrand = config.brandName;
state.defaultHomeRoute = config.defaultHomeRoute;
},
clearToken(state) {
state.token = null;
safeSessionStorage.removeItem('token');
@@ -200,7 +213,9 @@ const store = createStore({
const data = response.data || {};
const normalized = {
role: data.role ?? 'member',
roles: Array.isArray(data.roles) ? data.roles : [],
isOwner: data.isOwner ?? false,
isAdmin: data.isAdmin ?? (data.role === 'admin'),
permissions: data.permissions ?? {}
};
commit('setPermissions', { clubId, permissions: normalized });
@@ -211,7 +226,9 @@ const store = createStore({
clubId,
permissions: {
role: 'member',
roles: [],
isOwner: false,
isAdmin: false,
permissions: {}
}
});
@@ -256,6 +273,9 @@ const store = createStore({
clubs: state => state.clubs,
sidebarCollapsed: state => state.sidebarCollapsed,
language: state => state.language,
appProduct: state => state.appProduct,
appBrand: state => state.appBrand,
defaultHomeRoute: state => state.defaultHomeRoute,
currentClubName: state => {
const club = state.clubs.find(club => club.id === parseInt(state.currentClub));
return club ? club.name : '';
@@ -284,7 +304,18 @@ const store = createStore({
userRole: state => {
if (!state.currentClub) return null;
const perms = state.permissions[state.currentClub];
return perms?.role || null; // null wenn nicht geladen, nicht 'member'
if (perms?.isAdmin) return 'admin';
return perms?.role || null;
},
userRoles: state => {
if (!state.currentClub) return [];
const perms = state.permissions[state.currentClub];
return Array.isArray(perms?.roles) ? perms.roles : [];
},
isAdminRole: state => {
if (!state.currentClub) return false;
const perms = state.permissions[state.currentClub];
return perms?.isAdmin || false;
},
// Dialog-Getters
dialogs: state => state.dialogs,

View File

@@ -1,82 +1,57 @@
const SITE_NAME = 'Trainingstagebuch';
const SITE_URL = 'https://tt-tagebuch.de';
const DEFAULT_IMAGE = `${SITE_URL}/android-chrome-512x512.png`;
const DEFAULT_SEO = {
title: 'Trainingstagebuch Vereinsverwaltung für Tischtennis, Trainingsplanung & Turniere',
description:
'Trainingstagebuch: Vereinssoftware für Tischtennisvereine Mitgliederverwaltung und Mitgliederprofile, Trainingsplanung, Trainingstagebuch, Turniere, Mannschaften, Statistiken, MyTischtennis-Anbindung.',
robots: 'index,follow'
};
import { getProductConfig, resolveCurrentProduct } from '../config/products.js';
const ROUTE_SEO = {
'/': {
title: DEFAULT_SEO.title,
description: DEFAULT_SEO.description,
robots: 'index,follow'
club: {
'/': {
title: 'TT Verein Vereinsverwaltung für Tischtennis',
description:
'TT Verein ist die zentrale Arbeitsplattform für kleine und mittlere Tischtennisvereine mit Fokus auf Mitglieder, Anfragen, Kommunikation, Termine, Dokumente und Zahlungen.',
robots: 'index,follow'
},
'/vereinssoftware-tischtennis': {
title: 'Vereinssoftware für Tischtennisvereine | TT Verein',
description:
'Webbasierte Vereinssoftware für Tischtennisvereine: Mitgliederverwaltung, Trainingsplanung, Mannschaften, Turniere und Auswertungen.',
robots: 'index,follow'
},
'/mitgliederverwaltung-verein': {
title: 'Mitgliederverwaltung für Tischtennisvereine | TT Verein',
description:
'Mitgliederprofile, Rollen, Gruppen und Vereinsdaten zentral pflegen und mit Training sowie Mannschaften verbinden.',
robots: 'index,follow'
},
'/trainingsplanung-tischtennis': {
title: 'Trainingsplanung für Tischtennisvereine | TT Verein',
description:
'Trainingsplanung für Tischtennisvereine mit Gruppen, Anwesenheiten, Trainingstagen und digitaler Organisation.',
robots: 'index,follow'
},
'/turniersoftware-tischtennis': {
title: 'Turniersoftware für Tischtennis | TT Verein',
description:
'Turniersoftware für Tischtennisvereine mit Teilnehmerverwaltung, Gruppen, Paarungen, Ergebnissen und Übersichten.',
robots: 'index,follow'
},
},
'/vereinssoftware-tischtennis': {
title: 'Vereinssoftware für Tischtennisvereine | Trainingstagebuch',
description:
'Webbasierte Vereinssoftware für Tischtennisvereine: Mitgliederverwaltung, Mitgliederprofile, Trainingsplanung, Mannschaften, Turniere und Auswertungen in einer Anwendung.',
robots: 'index,follow'
player: {
'/': {
title: 'Mein TT Tischtennis für Spieler',
description:
'Mein TT ist die persönliche Tischtennisoberfläche für Spieler mit Kalender, Kontoverknüpfungen, Bestellungen und persönlichen Einstellungen.',
robots: 'index,follow'
},
},
'/mitgliederverwaltung-verein': {
title: 'Mitgliederprofile & Mitgliederverwaltung für Tischtennisvereine | Trainingstagebuch',
description:
'Mitgliederverwaltung für Vereine: Mitgliederprofile und Stammdaten zentral pflegen Rollen, Gruppen, Status, Kontaktdaten und Bezug zu Training & Mannschaften im Tischtennis.',
robots: 'index,follow'
},
'/trainingsplanung-tischtennis': {
title: 'Trainingsplanung für Tischtennisvereine | Trainingstagebuch',
description: 'Trainingsplanung für Tischtennisvereine mit Gruppen, Anwesenheiten, Trainingstagebuch und digitaler Organisation von Trainingstagen.',
robots: 'index,follow'
},
'/turniersoftware-tischtennis': {
title: 'Turniersoftware für Tischtennis | Trainingstagebuch',
description: 'Turniersoftware für Tischtennisvereine mit Teilnehmerverwaltung, Gruppen, Paarungen, Ergebnissen und Organisation interner oder offizieller Turniere.',
robots: 'index,follow'
},
'/login': {
title: 'Login | Trainingstagebuch',
description: 'Im Trainingstagebuch einloggen und Vereinsdaten, Trainingsplanung, Mitglieder und Turniere verwalten.',
robots: 'noindex,follow'
},
'/register': {
title: 'Registrieren | Trainingstagebuch',
description: 'Kostenlos im Trainingstagebuch registrieren und die Vereinsverwaltung für Tischtennisvereine kennenlernen.',
robots: 'noindex,follow'
},
'/activate': {
title: 'Konto aktivieren | Trainingstagebuch',
description: 'Aktivierung des Benutzerkontos im Trainingstagebuch.',
robots: 'noindex,follow'
},
'/forgot-password': {
title: 'Passwort vergessen | Trainingstagebuch',
description: 'Zugang zum Trainingstagebuch wiederherstellen.',
robots: 'noindex,follow'
},
'/reset-password': {
title: 'Passwort zurücksetzen | Trainingstagebuch',
description: 'Passwort im Trainingstagebuch sicher zurücksetzen.',
robots: 'noindex,follow'
},
'/impressum': {
title: 'Impressum | Trainingstagebuch',
description: 'Impressum von Trainingstagebuch.',
robots: 'index,follow'
},
'/datenschutz': {
title: 'Datenschutzerklärung | Trainingstagebuch',
description: 'Datenschutzerklärung von Trainingstagebuch.',
robots: 'index,follow'
},
'/konto-loeschen': {
title: 'Konto und Daten löschen | Trainingstagebuch',
description: 'Informationen zur Löschung des Benutzerkontos und personenbezogener Daten im Trainingstagebuch.',
robots: 'index,follow'
}
};
const COMMON_ROUTE_SEO = {
'/login': { title: 'Login', description: 'Im Konto anmelden.', robots: 'noindex,follow' },
'/register': { title: 'Registrieren', description: 'Neues Konto anlegen.', robots: 'noindex,follow' },
'/activate': { title: 'Konto aktivieren', description: 'Benutzerkonto aktivieren.', robots: 'noindex,follow' },
'/forgot-password': { title: 'Passwort vergessen', description: 'Zugang wiederherstellen.', robots: 'noindex,follow' },
'/reset-password': { title: 'Passwort zurücksetzen', description: 'Passwort sicher zurücksetzen.', robots: 'noindex,follow' },
'/impressum': { title: 'Impressum', description: 'Impressum.', robots: 'index,follow' },
'/datenschutz': { title: 'Datenschutz', description: 'Datenschutzerklärung.', robots: 'index,follow' },
'/konto-loeschen': { title: 'Konto löschen', description: 'Informationen zur Kontolöschung.', robots: 'index,follow' },
};
const NOINDEX_PREFIXES = [
@@ -87,6 +62,7 @@ const NOINDEX_PREFIXES = [
'/calendar',
'/pending-approvals',
'/schedule',
'/friendly-matches',
'/tournaments',
'/tournament-participations',
'/training-stats',
@@ -100,7 +76,9 @@ const NOINDEX_PREFIXES = [
'/clicktt',
'/member-transfer-settings',
'/personal-settings',
'/orders'
'/orders',
'/billing',
'/club-'
];
function normalizePath(path = '/') {
@@ -109,27 +87,32 @@ function normalizePath(path = '/') {
return path.endsWith('/') ? path.slice(0, -1) : path;
}
export function getSeoConfigForPath(path) {
const normalizedPath = normalizePath(path);
const matchedPrefix = Object.keys(ROUTE_SEO)
function prefixMatchSeo(routeSeo, normalizedPath) {
const matchedPrefix = Object.keys(routeSeo)
.filter((routePath) => routePath !== '/' && normalizedPath.startsWith(routePath))
.sort((a, b) => b.length - a.length)[0];
const routeSeo = (matchedPrefix && ROUTE_SEO[matchedPrefix]) || ROUTE_SEO[normalizedPath];
return (matchedPrefix && routeSeo[matchedPrefix]) || routeSeo[normalizedPath];
}
export function getSeoConfigForPath(path, product = resolveCurrentProduct()) {
const normalizedPath = normalizePath(path);
const productConfig = getProductConfig(product);
const productRouteSeo = ROUTE_SEO[productConfig.id] || {};
const routeSeo = prefixMatchSeo(productRouteSeo, normalizedPath) || prefixMatchSeo(COMMON_ROUTE_SEO, normalizedPath);
const canonicalPath = normalizedPath === '/' ? '' : normalizedPath;
const shouldNoindex = !routeSeo && NOINDEX_PREFIXES.some((routePath) => normalizedPath.startsWith(routePath));
const finalSeo = routeSeo || {
...DEFAULT_SEO,
robots: shouldNoindex ? 'noindex,follow' : DEFAULT_SEO.robots
};
const fallbackTitle = productConfig.seo.defaultTitle;
const fallbackDescription = productConfig.seo.defaultDescription;
return {
title: finalSeo.title || DEFAULT_SEO.title,
description: finalSeo.description || DEFAULT_SEO.description,
robots: finalSeo.robots || DEFAULT_SEO.robots,
canonical: `${SITE_URL}${canonicalPath}`,
url: `${SITE_URL}${canonicalPath}`,
image: DEFAULT_IMAGE
title: routeSeo?.title || fallbackTitle,
description: routeSeo?.description || fallbackDescription,
robots: routeSeo?.robots || (shouldNoindex ? 'noindex,follow' : 'index,follow'),
canonical: `${productConfig.canonicalUrl}${canonicalPath}`,
url: `${productConfig.canonicalUrl}${canonicalPath}`,
image: `${productConfig.canonicalUrl}${productConfig.seo.imagePath}`,
siteName: productConfig.seo.siteName,
};
}
@@ -171,7 +154,7 @@ export function applySeoForPath(path) {
upsertMeta('meta[name="description"]', { name: 'description', content: seo.description });
upsertMeta('meta[name="robots"]', { name: 'robots', content: seo.robots });
upsertMeta('meta[property="og:type"]', { property: 'og:type', content: 'website' });
upsertMeta('meta[property="og:site_name"]', { property: 'og:site_name', content: SITE_NAME });
upsertMeta('meta[property="og:site_name"]', { property: 'og:site_name', content: seo.siteName });
upsertMeta('meta[property="og:title"]', { property: 'og:title', content: seo.title });
upsertMeta('meta[property="og:description"]', { property: 'og:description', content: seo.description });
upsertMeta('meta[property="og:url"]', { property: 'og:url', content: seo.url });

View File

@@ -0,0 +1,778 @@
<template>
<div class="club-accounts-page">
<header class="page-header card">
<div>
<p class="page-eyebrow">TT-Verein</p>
<h2>Konten</h2>
<p class="page-subtitle">
Vereinskonten als Grundlage für Beiträge, Zahlungswege und spätere SEPA-Prozesse.
</p>
</div>
<button type="button" class="btn-primary" :disabled="!canEdit" @click="resetForm">Neues Konto</button>
</header>
<section v-if="!currentClub" class="card empty-state">
<h3>Kein Verein ausgewählt</h3>
<p>Bitte zuerst einen Verein auswählen, um Vereinskonten zu verwalten.</p>
</section>
<template v-else>
<section class="accounts-stats-grid">
<article class="card stat-card">
<span class="stat-label">Aktiv</span>
<strong class="stat-value">{{ accountStats.active }}</strong>
</article>
<article class="card stat-card">
<span class="stat-label">SEPA-fähig</span>
<strong class="stat-value">{{ accountStats.sepa }}</strong>
</article>
<article class="card stat-card">
<span class="stat-label">Ausgehend</span>
<strong class="stat-value">{{ accountStats.outgoing }}</strong>
</article>
<article class="card stat-card">
<span class="stat-label">Standardkonto</span>
<strong class="stat-value">{{ defaultAccount ? defaultAccount.name : '' }}</strong>
</article>
</section>
<section class="accounts-layout">
<div class="accounts-main">
<section class="card accounts-filter-card">
<div class="accounts-filter-grid">
<label>
<span>Status</span>
<select v-model="filters.status">
<option value="">Alle</option>
<option value="active">Aktiv</option>
<option value="inactive">Inaktiv</option>
<option value="archived">Archiviert</option>
</select>
</label>
<label>
<span>Typ</span>
<select v-model="filters.accountType">
<option value="">Alle</option>
<option value="bank">Bankkonto</option>
<option value="cash">Barkasse</option>
<option value="virtual">Virtuell</option>
</select>
</label>
<label class="filter-search">
<span>Suche</span>
<input v-model.trim="filters.search" type="text" placeholder="Name, Bank oder IBAN" />
</label>
</div>
</section>
<section class="card accounts-list-card">
<div class="section-header accounts-list-header">
<h3>Kontenliste</h3>
<button type="button" class="btn-secondary" @click="loadAccounts" :disabled="loading">
{{ loading ? 'Lädt…' : 'Neu laden' }}
</button>
</div>
<p v-if="loadError" class="state-banner state-banner-error">{{ loadError }}</p>
<p v-else-if="loading" class="state-banner">Konten werden geladen</p>
<p v-else-if="filteredAccounts.length === 0" class="state-banner">Keine Konten im aktuellen Filter.</p>
<div v-else class="accounts-list">
<div
v-for="account in filteredAccounts"
:key="account.id"
class="account-row"
:class="{ active: selectedAccount?.id === account.id, archived: account.status === 'archived' }"
>
<div class="account-row-main">
<div class="account-row-topline">
<strong>{{ account.name }}</strong>
<div class="account-badge-row">
<span v-if="account.isDefault" class="account-badge badge-default">Standard</span>
<span v-if="account.allowSepaCollections" class="account-badge badge-sepa">SEPA</span>
<span class="account-badge" :class="`status-${account.status}`">{{ displayStatus(account.status) }}</span>
</div>
</div>
<p class="account-row-meta">
<span>{{ displayAccountType(account.accountType) }}</span>
<span>{{ displayUsageType(account.usageType) }}</span>
<span>{{ account.bankName || 'Ohne Bankname' }}</span>
<span>{{ displayIban(account.iban) }}</span>
</p>
<p class="account-row-description">{{ account.notes || 'Kein Hinweis hinterlegt.' }}</p>
</div>
<div class="account-row-actions">
<button type="button" class="btn-secondary" @click="selectAccount(account)">Bearbeiten</button>
<button type="button" class="btn-secondary" :disabled="!canEdit || account.status === 'archived'" @click="archiveAccount(account)">Archivieren</button>
<button type="button" class="btn-danger" :disabled="!canEdit" @click="deleteAccount(account)">Löschen</button>
</div>
</div>
</div>
</section>
</div>
<aside class="accounts-side">
<section class="card account-form-card">
<div class="section-header">
<h3>{{ form.id ? 'Konto bearbeiten' : 'Neues Konto' }}</h3>
</div>
<form class="account-form" @submit.prevent="submitAccount">
<label>
<span>Kontobezeichnung</span>
<input v-model.trim="form.name" type="text" placeholder="z. B. Vereinskonto Sparkasse" :disabled="!canEdit" required />
</label>
<div class="account-form-grid">
<label>
<span>Kontotyp</span>
<select v-model="form.accountType" :disabled="!canEdit">
<option value="bank">Bankkonto</option>
<option value="cash">Barkasse</option>
<option value="virtual">Virtuell</option>
</select>
</label>
<label>
<span>Verwendungszweck</span>
<select v-model="form.usageType" :disabled="!canEdit">
<option value="general">Allgemein</option>
<option value="membership_fees">Mitgliedsbeiträge</option>
<option value="donations">Spenden</option>
<option value="expenses">Ausgaben</option>
<option value="reserve">Rücklage</option>
<option value="petty_cash">Barkasse</option>
</select>
</label>
</div>
<div class="account-form-grid">
<label>
<span>Status</span>
<select v-model="form.status" :disabled="!canEdit">
<option value="active">Aktiv</option>
<option value="inactive">Inaktiv</option>
<option value="archived">Archiviert</option>
</select>
</label>
<label>
<span>Währung</span>
<input v-model.trim="form.currencyCode" type="text" maxlength="3" :disabled="!canEdit" />
</label>
</div>
<label>
<span>Kontoinhaber</span>
<input v-model.trim="form.accountHolder" type="text" :disabled="!canEdit" />
</label>
<label>
<span>Bankname</span>
<input v-model.trim="form.bankName" type="text" :disabled="!canEdit" />
</label>
<div class="account-form-grid">
<label>
<span>IBAN</span>
<input v-model.trim="form.iban" type="text" :disabled="!canEdit" />
</label>
<label>
<span>BIC</span>
<input v-model.trim="form.bic" type="text" :disabled="!canEdit" />
</label>
</div>
<div class="account-check-grid">
<label class="checkbox-row">
<input v-model="form.allowSepaCollections" type="checkbox" :disabled="!canEdit || form.accountType !== 'bank'" />
<span>Für SEPA-Einzüge verwenden</span>
</label>
<label class="checkbox-row">
<input v-model="form.allowOutgoingPayments" type="checkbox" :disabled="!canEdit" />
<span>Für Auszahlungen verwenden</span>
</label>
<label class="checkbox-row">
<input v-model="form.isDefault" type="checkbox" :disabled="!canEdit || form.status === 'archived'" />
<span>Als Standardkonto setzen</span>
</label>
</div>
<label>
<span>Interne Notiz</span>
<textarea v-model.trim="form.notes" rows="5" :disabled="!canEdit" placeholder="z. B. nur für Beiträge oder Jugendkasse"></textarea>
</label>
<div class="account-form-actions">
<button type="submit" class="btn-primary" :disabled="saving || !canEdit">{{ saving ? 'Speichert…' : 'Speichern' }}</button>
<button type="button" class="btn-secondary" @click="resetForm">Zurücksetzen</button>
</div>
</form>
</section>
<section v-if="selectedAccount" class="card account-detail-card">
<div class="section-header">
<h3>Details</h3>
</div>
<div class="detail-stack">
<div>
<span class="detail-label">Name</span>
<p>{{ selectedAccount.name }}</p>
</div>
<div>
<span class="detail-label">Typ</span>
<p>{{ displayAccountType(selectedAccount.accountType) }}</p>
</div>
<div>
<span class="detail-label">Verwendung</span>
<p>{{ displayUsageType(selectedAccount.usageType) }}</p>
</div>
<div>
<span class="detail-label">IBAN</span>
<p>{{ displayIban(selectedAccount.iban) }}</p>
</div>
<div>
<span class="detail-label">Status</span>
<p>{{ displayStatus(selectedAccount.status) }}</p>
</div>
</div>
</section>
</aside>
</section>
</template>
<InfoDialog
v-model="infoDialog.isOpen"
:title="infoDialog.title"
:message="infoDialog.message"
:details="infoDialog.details"
:type="infoDialog.type"
/>
<ConfirmDialog
v-model="confirmDialog.isOpen"
:title="confirmDialog.title"
:message="confirmDialog.message"
:details="confirmDialog.details"
:type="confirmDialog.type"
:confirm-text="confirmDialog.confirmText"
:cancel-text="confirmDialog.cancelText"
@confirm="handleConfirmResult(true)"
@cancel="handleConfirmResult(false)"
/>
</div>
</template>
<script>
import { mapGetters } from 'vuex';
import apiClient from '../apiClient.js';
import ConfirmDialog from '../components/ConfirmDialog.vue';
import InfoDialog from '../components/InfoDialog.vue';
import { buildConfirmConfig, buildInfoConfig, safeErrorMessage } from '../utils/dialogUtils.js';
function normalizeAccount(payload = {}) {
return {
id: payload.id,
name: payload.name || '',
accountHolder: payload.accountHolder || payload.account_holder || '',
bankName: payload.bankName || payload.bank_name || '',
iban: payload.iban || '',
bic: payload.bic || '',
accountType: payload.accountType || payload.account_type || 'bank',
usageType: payload.usageType || payload.usage_type || 'general',
currencyCode: payload.currencyCode || payload.currency_code || 'EUR',
allowSepaCollections: Boolean(payload.allowSepaCollections ?? payload.allow_sepa_collections),
allowOutgoingPayments: Boolean(payload.allowOutgoingPayments ?? payload.allow_outgoing_payments ?? true),
isDefault: Boolean(payload.isDefault ?? payload.is_default),
status: payload.status || 'active',
notes: payload.notes || '',
sortOrder: Number(payload.sortOrder ?? payload.sort_order ?? 0) || 0,
archivedAt: payload.archivedAt || payload.archived_at || null,
};
}
export default {
name: 'ClubAccountsView',
components: {
ConfirmDialog,
InfoDialog,
},
data() {
return {
loading: false,
saving: false,
loadError: '',
accounts: [],
selectedAccountId: null,
filters: {
status: '',
accountType: '',
search: '',
},
form: {
id: null,
name: '',
accountHolder: '',
bankName: '',
iban: '',
bic: '',
accountType: 'bank',
usageType: 'general',
currencyCode: 'EUR',
allowSepaCollections: false,
allowOutgoingPayments: true,
isDefault: false,
status: 'active',
notes: '',
},
infoDialog: {
isOpen: false,
title: '',
message: '',
details: '',
type: 'info',
},
confirmDialog: {
isOpen: false,
title: '',
message: '',
details: '',
type: 'warning',
confirmText: '',
cancelText: '',
resolveCallback: null,
},
};
},
computed: {
...mapGetters(['currentClub', 'hasPermission']),
canEdit() {
return this.hasPermission('members', 'write');
},
filteredAccounts() {
const needle = this.filters.search.trim().toLowerCase();
return this.accounts.filter((account) => {
if (this.filters.status && account.status !== this.filters.status) return false;
if (this.filters.accountType && account.accountType !== this.filters.accountType) return false;
if (!needle) return true;
return [account.name, account.bankName, account.iban].join(' ').toLowerCase().includes(needle);
});
},
selectedAccount() {
return this.accounts.find((account) => String(account.id) === String(this.selectedAccountId)) || null;
},
defaultAccount() {
return this.accounts.find((account) => account.isDefault && account.status !== 'archived') || null;
},
accountStats() {
return {
active: this.accounts.filter((account) => account.status === 'active').length,
sepa: this.accounts.filter((account) => account.allowSepaCollections && account.status !== 'archived').length,
outgoing: this.accounts.filter((account) => account.allowOutgoingPayments && account.status !== 'archived').length,
};
},
},
watch: {
currentClub: {
immediate: true,
async handler(newClub) {
if (!newClub) {
this.accounts = [];
this.selectedAccountId = null;
return;
}
await this.loadAccounts();
},
},
'$route.query': {
immediate: true,
handler() {
this.applyRouteQuery();
},
},
selectedAccount(account) {
if (!account) return;
this.form = {
id: account.id,
name: account.name,
accountHolder: account.accountHolder,
bankName: account.bankName,
iban: account.iban,
bic: account.bic,
accountType: account.accountType,
usageType: account.usageType,
currencyCode: account.currencyCode,
allowSepaCollections: account.allowSepaCollections,
allowOutgoingPayments: account.allowOutgoingPayments,
isDefault: account.isDefault,
status: account.status,
notes: account.notes,
};
},
},
methods: {
applyRouteQuery() {
const routeAccountId = this.$route?.query?.accountId;
const routeStatus = typeof this.$route?.query?.status === 'string' ? this.$route.query.status : '';
if (['active', 'inactive', 'archived'].includes(routeStatus)) {
this.filters.status = routeStatus;
}
if (routeAccountId && this.accounts.some((account) => String(account.id) === String(routeAccountId))) {
this.selectedAccountId = String(routeAccountId);
}
},
showInfo(title, message, details = '', type = 'info') {
this.infoDialog = buildInfoConfig({ title, message, details, type });
},
async showConfirm(title, message, details = '', type = 'warning', options = {}) {
return new Promise((resolve) => {
this.confirmDialog = buildConfirmConfig({
title,
message,
details,
type,
resolveCallback: resolve,
...options,
});
});
},
handleConfirmResult(confirmed) {
if (this.confirmDialog.resolveCallback) {
this.confirmDialog.resolveCallback(confirmed);
this.confirmDialog.resolveCallback = null;
}
this.confirmDialog.isOpen = false;
},
displayStatus(status) {
return {
active: 'Aktiv',
inactive: 'Inaktiv',
archived: 'Archiviert',
}[status] || status;
},
displayAccountType(accountType) {
return {
bank: 'Bankkonto',
cash: 'Barkasse',
virtual: 'Virtuell',
}[accountType] || accountType;
},
displayUsageType(usageType) {
return {
general: 'Allgemein',
membership_fees: 'Mitgliedsbeiträge',
donations: 'Spenden',
expenses: 'Ausgaben',
reserve: 'Rücklage',
petty_cash: 'Barkasse',
}[usageType] || usageType;
},
displayIban(iban) {
if (!iban) return 'Keine IBAN';
return String(iban).replace(/(.{4})/g, '$1 ').trim();
},
selectAccount(account) {
this.selectedAccountId = account.id;
},
resetForm() {
this.selectedAccountId = null;
this.form = {
id: null,
name: '',
accountHolder: '',
bankName: '',
iban: '',
bic: '',
accountType: 'bank',
usageType: 'general',
currencyCode: 'EUR',
allowSepaCollections: false,
allowOutgoingPayments: true,
isDefault: false,
status: 'active',
notes: '',
};
},
async loadAccounts() {
if (!this.currentClub) return;
this.loading = true;
this.loadError = '';
try {
const response = await apiClient.get(`/club-accounts/${this.currentClub}`);
const entries = Array.isArray(response.data?.accounts) ? response.data.accounts : [];
this.accounts = entries.map(normalizeAccount);
this.applyRouteQuery();
if (this.selectedAccountId && !this.selectedAccount) {
this.selectedAccountId = null;
}
if (!this.selectedAccountId && this.accounts.length > 0) {
this.selectedAccountId = this.accounts[0].id;
}
} catch (error) {
this.loadError = safeErrorMessage(error, 'Konten konnten nicht geladen werden.');
} finally {
this.loading = false;
}
},
async submitAccount() {
if (!this.currentClub || !this.canEdit) return;
this.saving = true;
try {
const payload = { ...this.form };
if (this.form.id) {
await apiClient.put(`/club-accounts/${this.currentClub}/${this.form.id}`, payload);
} else {
await apiClient.post(`/club-accounts/${this.currentClub}`, payload);
}
await this.loadAccounts();
this.showInfo('Erfolg', 'Konto gespeichert.', '', 'success');
} catch (error) {
this.showInfo('Fehler', safeErrorMessage(error, 'Konto konnte nicht gespeichert werden.'), '', 'error');
} finally {
this.saving = false;
}
},
async archiveAccount(account) {
if (!account?.id || !this.canEdit) return;
const confirmed = await this.showConfirm(
'Konto archivieren',
`Soll das Konto "${account.name}" archiviert werden?`,
'Archivierte Konten bleiben zur Nachvollziehbarkeit erhalten.',
'warning',
{ confirmText: 'Archivieren', cancelText: 'Abbrechen' }
);
if (!confirmed) return;
try {
await apiClient.patch(`/club-accounts/${this.currentClub}/${account.id}/status`, { status: 'archived' });
await this.loadAccounts();
} catch (error) {
this.showInfo('Fehler', safeErrorMessage(error, 'Konto konnte nicht archiviert werden.'), '', 'error');
}
},
async deleteAccount(account) {
if (!account?.id || !this.canEdit) return;
const confirmed = await this.showConfirm(
'Konto löschen',
`Soll das Konto "${account.name}" endgültig gelöscht werden?`,
'Diese Aktion entfernt den Kontodatensatz vollständig.',
'danger',
{ confirmText: 'Löschen', cancelText: 'Abbrechen' }
);
if (!confirmed) return;
try {
await apiClient.delete(`/club-accounts/${this.currentClub}/${account.id}`);
if (this.selectedAccount?.id === account.id) {
this.resetForm();
}
await this.loadAccounts();
} catch (error) {
this.showInfo('Fehler', safeErrorMessage(error, 'Konto konnte nicht gelöscht werden.'), '', 'error');
}
},
},
};
</script>
<style scoped>
.club-accounts-page {
display: flex;
flex-direction: column;
gap: 1rem;
}
.page-header,
.empty-state,
.accounts-filter-card,
.accounts-list-card,
.account-form-card,
.account-detail-card {
padding: 1.25rem;
}
.page-header {
display: flex;
justify-content: space-between;
gap: 1rem;
align-items: flex-start;
}
.page-eyebrow {
margin: 0 0 0.35rem;
color: var(--text-light);
text-transform: uppercase;
letter-spacing: 0.08em;
font-size: 0.8rem;
}
.page-subtitle {
margin: 0.35rem 0 0;
color: var(--text-secondary);
}
.accounts-stats-grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 1rem;
}
.stat-card {
padding: 1rem 1.1rem;
}
.stat-label {
display: block;
color: var(--text-secondary);
margin-bottom: 0.35rem;
}
.stat-value {
font-size: 1.4rem;
}
.accounts-layout {
display: grid;
grid-template-columns: minmax(0, 1.5fr) minmax(320px, 0.9fr);
gap: 1rem;
}
.accounts-main,
.accounts-side,
.account-form,
.detail-stack,
.accounts-list {
display: grid;
gap: 1rem;
}
.accounts-filter-grid,
.account-form-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 1rem;
}
.filter-search {
grid-column: 1 / -1;
}
.accounts-list-header {
display: flex;
justify-content: space-between;
gap: 1rem;
align-items: center;
}
.account-row {
border: 1px solid rgba(24, 70, 54, 0.14);
border-radius: 16px;
padding: 1rem;
display: flex;
justify-content: space-between;
gap: 1rem;
}
.account-row.active {
border-color: rgba(47, 122, 95, 0.6);
box-shadow: 0 0 0 1px rgba(47, 122, 95, 0.14);
}
.account-row.archived {
opacity: 0.75;
}
.account-row-main {
display: grid;
gap: 0.55rem;
min-width: 0;
}
.account-row-topline,
.account-row-meta,
.account-badge-row,
.account-row-actions,
.account-check-grid,
.account-form-actions {
display: flex;
gap: 0.6rem;
flex-wrap: wrap;
}
.account-row-topline {
justify-content: space-between;
align-items: center;
}
.account-row-meta,
.account-row-description {
color: var(--text-secondary);
}
.account-badge {
display: inline-flex;
align-items: center;
border-radius: 999px;
padding: 0.2rem 0.55rem;
font-size: 0.78rem;
font-weight: 700;
}
.badge-default {
background: rgba(47, 122, 95, 0.12);
color: var(--primary-strong);
}
.badge-sepa {
background: rgba(61, 118, 196, 0.12);
color: #295b9c;
}
.status-active {
background: rgba(47, 122, 95, 0.12);
color: var(--primary-strong);
}
.status-inactive {
background: rgba(160, 112, 64, 0.14);
color: #8a5b1d;
}
.status-archived {
background: rgba(120, 120, 120, 0.16);
color: #5b5b5b;
}
.checkbox-row {
display: inline-flex;
align-items: center;
gap: 0.5rem;
}
.detail-label {
display: block;
font-size: 0.82rem;
color: var(--text-secondary);
margin-bottom: 0.2rem;
}
.state-banner {
margin: 0;
color: var(--text-secondary);
}
@media (max-width: 960px) {
.accounts-layout {
grid-template-columns: 1fr;
}
}
@media (max-width: 720px) {
.accounts-stats-grid,
.accounts-filter-grid,
.account-form-grid {
grid-template-columns: 1fr;
}
.account-row {
flex-direction: column;
}
}
</style>

View File

@@ -0,0 +1,457 @@
<template>
<div class="club-archive-page">
<header class="page-header card">
<div>
<p class="page-eyebrow">TT-Verein</p>
<h2>Archiv</h2>
<p class="page-subtitle">
Inaktive Mitglieder und archivierte Vereinsvorgänge getrennt vom aktiven Tagesgeschäft.
</p>
</div>
<button type="button" class="btn-secondary" @click="loadArchive" :disabled="loading || !currentClub">
{{ loading ? 'Lädt…' : 'Neu laden' }}
</button>
</header>
<section v-if="!currentClub" class="card empty-state">
<h3>Kein Verein ausgewählt</h3>
<p>Bitte zuerst einen Verein auswählen, um das Archiv zu laden.</p>
</section>
<template v-else>
<p v-if="loadError" class="state-banner state-banner-error">{{ loadError }}</p>
<section class="archive-stats-grid">
<article class="card stat-card">
<span class="stat-label">Ehemalige Mitglieder</span>
<strong class="stat-value">{{ archive.summary?.inactiveMembers ?? 0 }}</strong>
</article>
<article class="card stat-card">
<span class="stat-label">Archivierte Anfragen</span>
<strong class="stat-value">{{ archive.summary?.archivedRequests ?? 0 }}</strong>
</article>
<article class="card stat-card">
<span class="stat-label">Archivierte Aufgaben</span>
<strong class="stat-value">{{ archive.summary?.archivedTasks ?? 0 }}</strong>
</article>
<article class="card stat-card">
<span class="stat-label">Historische Forderungen</span>
<strong class="stat-value">{{ archive.summary?.archivedClaims ?? 0 }}</strong>
</article>
</section>
<section class="archive-notes card" v-if="archive.notes?.length">
<h3>Hinweise</h3>
<ul>
<li v-for="note in archive.notes" :key="note">{{ note }}</li>
</ul>
</section>
<section class="archive-section card">
<div class="section-header">
<div>
<h3>Ehemalige Mitglieder</h3>
<p>Mitglieder, die nicht mehr aktiv geführt werden.</p>
</div>
<button type="button" class="btn-secondary" @click="openMembersArchive">
Mitglieder öffnen
</button>
</div>
<p v-if="archive.inactiveMembers.length === 0" class="state-banner">Keine inaktiven Mitglieder im Archiv.</p>
<div v-else class="table-wrap">
<table class="archive-table">
<thead>
<tr>
<th>Name</th>
<th>E-Mail</th>
<th>Ort</th>
<th>Zuletzt geändert</th>
<th></th>
</tr>
</thead>
<tbody>
<tr v-for="member in archive.inactiveMembers" :key="`member-${member.id}`">
<td>{{ member.displayName }}</td>
<td>{{ member.email || '' }}</td>
<td>{{ member.city || '' }}</td>
<td>{{ formatDateTime(member.updatedAt || member.createdAt) }}</td>
<td class="actions-cell">
<button type="button" class="btn-secondary" @click="openMember(member)">Öffnen</button>
</td>
</tr>
</tbody>
</table>
</div>
</section>
<section class="archive-section card">
<div class="section-header">
<div>
<h3>Archivierte Anfragen</h3>
<p>Abgelegte Vorgänge aus Kontakt, Probetraining, Mitgliedschaft oder Sponsoring.</p>
</div>
<button type="button" class="btn-secondary" @click="openRequestsArchive">
Anfragen öffnen
</button>
</div>
<p v-if="archive.archivedRequests.length === 0" class="state-banner">Keine archivierten Anfragen vorhanden.</p>
<div v-else class="table-wrap">
<table class="archive-table">
<thead>
<tr>
<th>Typ</th>
<th>Betreff</th>
<th>Person</th>
<th>Zuletzt geändert</th>
<th></th>
</tr>
</thead>
<tbody>
<tr v-for="request in archive.archivedRequests" :key="`request-${request.id}`">
<td>{{ displayRequestType(request.requestType) }}</td>
<td>{{ request.subject || '' }}</td>
<td>{{ request.personName || request.email || '' }}</td>
<td>{{ formatDateTime(request.updatedAt || request.createdAt) }}</td>
<td class="actions-cell">
<button type="button" class="btn-secondary" @click="openRequest(request)">Öffnen</button>
</td>
</tr>
</tbody>
</table>
</div>
</section>
<section class="archive-section card">
<div class="section-header">
<div>
<h3>Archivierte Aufgaben</h3>
<p>Aufgaben, die bewusst aus dem aktiven Arbeitsvorrat ins Archiv verschoben wurden.</p>
</div>
<button type="button" class="btn-secondary" @click="openTasksArchive">
Aufgaben öffnen
</button>
</div>
<p v-if="archive.archivedTasks.length === 0" class="state-banner">Keine archivierten Aufgaben vorhanden.</p>
<div v-else class="table-wrap">
<table class="archive-table">
<thead>
<tr>
<th>Titel</th>
<th>Typ</th>
<th>Priorität</th>
<th>Archiviert am</th>
<th></th>
</tr>
</thead>
<tbody>
<tr v-for="task in archive.archivedTasks" :key="`task-${task.id}`">
<td>{{ task.title }}</td>
<td>{{ task.taskType || '' }}</td>
<td>{{ displayPriority(task.priority) }}</td>
<td>{{ formatDateTime(task.archivedAt || task.updatedAt) }}</td>
<td class="actions-cell">
<button type="button" class="btn-secondary" @click="openTask(task)">Öffnen</button>
</td>
</tr>
</tbody>
</table>
</div>
</section>
<section class="archive-section card">
<div class="section-header">
<div>
<h3>Historische Forderungen</h3>
<p>Stornierte, abgeschriebene oder ausdrücklich archivierte Forderungen.</p>
</div>
</div>
<p v-if="archive.archivedClaims.length === 0" class="state-banner">Keine historischen Forderungen vorhanden.</p>
<div v-else class="table-wrap">
<table class="archive-table">
<thead>
<tr>
<th>Mitglied</th>
<th>Typ</th>
<th>Status</th>
<th>Fällig</th>
<th>Betrag</th>
</tr>
</thead>
<tbody>
<tr v-for="claim in archive.archivedClaims" :key="`claim-${claim.id}`">
<td>{{ claim.memberName || '' }}</td>
<td>{{ claim.claimType || '' }}</td>
<td>{{ displayClaimStatus(claim.status) }}</td>
<td>{{ formatDate(claim.dueOn) }}</td>
<td>{{ formatCurrency(claim.amountCents, claim.currencyCode) }}</td>
</tr>
</tbody>
</table>
</div>
</section>
</template>
</div>
</template>
<script>
import { mapGetters } from 'vuex';
import apiClient from '../apiClient.js';
import { safeErrorMessage } from '../utils/dialogUtils.js';
function createEmptyArchive() {
return {
summary: {
inactiveMembers: 0,
archivedRequests: 0,
archivedTasks: 0,
archivedClaims: 0,
},
inactiveMembers: [],
archivedRequests: [],
archivedTasks: [],
archivedClaims: [],
notes: [],
};
}
export default {
name: 'ClubArchiveView',
data() {
return {
loading: false,
loadError: '',
archive: createEmptyArchive(),
};
},
computed: {
...mapGetters(['currentClub']),
},
watch: {
currentClub: {
immediate: true,
async handler(newClub) {
if (!newClub) {
this.archive = createEmptyArchive();
return;
}
await this.loadArchive();
},
},
},
methods: {
async loadArchive() {
if (!this.currentClub) return;
this.loading = true;
this.loadError = '';
try {
const response = await apiClient.get(`/club-archive/${this.currentClub}`);
if (!response || response.status < 200 || response.status >= 300) {
throw new Error(response?.data?.error || 'Archiv konnte nicht geladen werden.');
}
this.archive = response.data || createEmptyArchive();
} catch (error) {
this.archive = createEmptyArchive();
this.loadError = safeErrorMessage(error, 'Archiv konnte nicht geladen werden.');
} finally {
this.loading = false;
}
},
formatDate(value) {
if (!value) return '';
const date = new Date(value);
if (Number.isNaN(date.getTime())) return '';
return new Intl.DateTimeFormat('de-DE', { dateStyle: 'medium' }).format(date);
},
formatDateTime(value) {
if (!value) return '';
const date = new Date(value);
if (Number.isNaN(date.getTime())) return '';
return new Intl.DateTimeFormat('de-DE', {
dateStyle: 'medium',
timeStyle: 'short',
}).format(date);
},
formatCurrency(amountCents, currencyCode = 'EUR') {
return new Intl.NumberFormat('de-DE', {
style: 'currency',
currency: currencyCode || 'EUR',
}).format(Number(amountCents || 0) / 100);
},
displayRequestType(type) {
return {
contact: 'Kontakt',
trial_training: 'Probetraining',
membership: 'Mitgliedschaft',
sponsoring: 'Sponsoring',
}[type] || type || 'Anfrage';
},
displayPriority(priority) {
return {
low: 'Niedrig',
normal: 'Normal',
high: 'Hoch',
urgent: 'Dringend',
}[priority] || priority || '';
},
displayClaimStatus(status) {
return {
cancelled: 'Storniert',
written_off: 'Abgeschrieben',
paid: 'Bezahlt',
open: 'Offen',
partially_paid: 'Teilbezahlt',
}[status] || status || '';
},
openMembersArchive() {
this.$router.push({ path: '/members', query: { scope: 'inactive' } });
},
openMember(member) {
this.$router.push({ path: '/members', query: { scope: 'inactive', memberId: String(member.id) } });
},
openRequestsArchive() {
this.$router.push({ path: '/club-requests', query: { status: 'archived' } });
},
openRequest(request) {
this.$router.push({ path: '/club-requests', query: { status: 'archived', requestId: String(request.id) } });
},
openTasksArchive() {
this.$router.push({ path: '/club-tasks', query: { status: 'archived' } });
},
openTask(task) {
this.$router.push({ path: '/club-tasks', query: { status: 'archived', taskId: String(task.id) } });
},
},
};
</script>
<style scoped>
.club-archive-page {
display: flex;
flex-direction: column;
gap: 1rem;
}
.page-header,
.empty-state,
.archive-notes,
.archive-section {
padding: 1.25rem;
}
.page-header {
display: flex;
justify-content: space-between;
gap: 1rem;
align-items: flex-start;
}
.page-eyebrow {
margin: 0 0 0.35rem;
color: var(--text-light);
text-transform: uppercase;
letter-spacing: 0.08em;
font-size: 0.8rem;
font-weight: 700;
}
.page-header h2,
.archive-section h3,
.archive-notes h3 {
margin: 0;
}
.page-subtitle,
.section-header p {
margin: 0.35rem 0 0;
color: var(--text-secondary);
}
.archive-stats-grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 1rem;
}
.stat-card {
padding: 1rem;
}
.stat-label {
display: block;
color: var(--text-light);
font-size: 0.9rem;
}
.stat-value {
display: block;
margin-top: 0.35rem;
font-size: 1.6rem;
}
.archive-notes ul {
margin: 0.75rem 0 0;
padding-left: 1.25rem;
}
.archive-notes li + li {
margin-top: 0.35rem;
}
.section-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 1rem;
margin-bottom: 1rem;
}
.table-wrap {
overflow-x: auto;
}
.archive-table {
width: 100%;
border-collapse: collapse;
}
.archive-table th,
.archive-table td {
padding: 0.8rem 0.9rem;
border-bottom: 1px solid rgba(24, 70, 54, 0.08);
text-align: left;
vertical-align: top;
}
.archive-table th {
color: var(--text-primary);
font-size: 0.86rem;
font-weight: 700;
background: rgba(24, 70, 54, 0.06);
}
.actions-cell {
width: 1%;
white-space: nowrap;
}
@media (max-width: 960px) {
.archive-stats-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@media (max-width: 640px) {
.page-header,
.section-header {
flex-direction: column;
}
.archive-stats-grid {
grid-template-columns: 1fr;
}
}
</style>

View File

@@ -0,0 +1,141 @@
<template>
<div class="club-concept-page">
<header class="club-concept-hero card">
<div class="club-concept-meta">
<span class="club-concept-phase">{{ modulePhase }}</span>
<span class="club-concept-tag">TT-Verein</span>
</div>
<h2>{{ moduleTitle }}</h2>
<p class="club-concept-summary">{{ moduleSummary }}</p>
</header>
<section class="club-concept-grid">
<article class="card club-concept-card">
<h3>Enthält</h3>
<ul>
<li v-for="item in moduleHighlights" :key="item">{{ item }}</li>
</ul>
</article>
<article class="card club-concept-card">
<h3>Grundprinzipien</h3>
<ul>
<li v-for="item in modulePrinciples" :key="item">{{ item }}</li>
</ul>
</article>
</section>
<section class="card club-concept-card">
<h3>Einordnung</h3>
<p>
Diese Fläche ist als konzeptioneller Arbeitsbereich für TT-Verein angelegt. Sie markiert
das Modul in der Produktstruktur und hält die Informationsarchitektur stabil, während die
konkrete Fachlogik schrittweise umgesetzt wird.
</p>
</section>
</div>
</template>
<script>
export default {
name: 'ClubConceptModuleView',
computed: {
moduleMeta() {
return this.$route.meta?.moduleMeta || {};
},
moduleTitle() {
return this.moduleMeta.title || 'TT-Verein Modul';
},
modulePhase() {
return this.moduleMeta.phase || 'Phase 1';
},
moduleSummary() {
return this.moduleMeta.summary || '';
},
moduleHighlights() {
return Array.isArray(this.moduleMeta.highlights) ? this.moduleMeta.highlights : [];
},
modulePrinciples() {
return Array.isArray(this.moduleMeta.principles) ? this.moduleMeta.principles : [];
},
},
};
</script>
<style scoped>
.club-concept-page {
display: flex;
flex-direction: column;
gap: 1rem;
max-width: 1100px;
}
.club-concept-hero {
padding: 1.5rem;
background: linear-gradient(135deg, rgba(24, 70, 54, 0.08), rgba(47, 122, 95, 0.04));
}
.club-concept-meta {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
margin-bottom: 0.75rem;
}
.club-concept-phase,
.club-concept-tag {
display: inline-flex;
align-items: center;
border-radius: 999px;
padding: 0.25rem 0.65rem;
font-size: 0.78rem;
font-weight: 600;
}
.club-concept-phase {
background: rgba(47, 122, 95, 0.12);
color: var(--primary-strong);
}
.club-concept-tag {
background: rgba(160, 112, 64, 0.12);
color: #8a5b1d;
}
.club-concept-hero h2 {
margin: 0 0 0.5rem;
}
.club-concept-summary {
margin: 0;
color: var(--text-secondary);
max-width: 70ch;
}
.club-concept-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 1rem;
}
.club-concept-card {
padding: 1.25rem;
}
.club-concept-card h3 {
margin-top: 0;
}
.club-concept-card ul {
margin: 0;
padding-left: 1.1rem;
display: grid;
gap: 0.45rem;
}
@media (max-width: 720px) {
.club-concept-grid {
grid-template-columns: 1fr;
}
}
</style>

View File

@@ -0,0 +1,759 @@
<template>
<div class="club-history-page">
<header class="page-header card">
<div>
<p class="page-eyebrow">TT-Verein</p>
<h2>Historie</h2>
<p class="page-subtitle">
Letzte Vorgänge aus Anfragen und Aufgaben für den Vereinsalltag in einer gemeinsamen Übersicht.
</p>
</div>
<button type="button" class="btn-secondary" @click="loadHistory" :disabled="loading">
{{ loading ? 'Lädt…' : 'Neu laden' }}
</button>
</header>
<section v-if="!currentClub" class="card empty-state">
<h3>Kein Verein ausgewählt</h3>
<p>Bitte zuerst einen Verein auswählen, um die Vereinshistorie zu sehen.</p>
</section>
<template v-else>
<section class="history-stats-grid">
<article class="card stat-card">
<span class="stat-label">Einträge</span>
<strong class="stat-value">{{ historyStats.total }}</strong>
</article>
<article class="card stat-card">
<span class="stat-label">Heute</span>
<strong class="stat-value">{{ historyStats.today }}</strong>
</article>
<article class="card stat-card">
<span class="stat-label">Offene Aufgaben</span>
<strong class="stat-value">{{ historyStats.openTasks }}</strong>
</article>
<article class="card stat-card">
<span class="stat-label">Offene Anfragen</span>
<strong class="stat-value">{{ historyStats.openRequests }}</strong>
</article>
</section>
<section class="history-layout">
<aside class="card history-sidebar">
<div class="section-header">
<h3>Filter</h3>
</div>
<div class="history-filter-stack">
<label>
<span>Quelle</span>
<select v-model="filters.source">
<option value="">Alle</option>
<option value="request">Anfragen</option>
<option value="request_note">Notizen</option>
<option value="task">Aufgaben</option>
<option value="task_done">Erledigungen</option>
<option value="role">Rollen</option>
<option value="user">Benutzer</option>
<option value="role_assignment">Rollenzuweisungen</option>
</select>
</label>
<label>
<span>Suche</span>
<input v-model.trim="filters.search" type="text" placeholder="Titel, Person oder Beschreibung" />
</label>
</div>
<div class="history-note-box">
<h4>Stand heute</h4>
<p>
Diese erste Historie nutzt bereits echte Vereinsdaten. Feldgenaue Änderungen mit
altem und neuem Wert folgen im nächsten Ausbauschritt.
</p>
</div>
</aside>
<section class="card history-list-card">
<div class="section-header">
<h3>Letzte Vorgänge</h3>
<span class="history-count">{{ filteredEntries.length }} Einträge</span>
</div>
<p v-if="loadError" class="state-banner state-banner-error">{{ loadError }}</p>
<p v-else-if="loading" class="state-banner">Historie wird geladen</p>
<p v-else-if="filteredEntries.length === 0" class="state-banner">Keine Einträge im aktuellen Filter.</p>
<div v-else class="history-list">
<article
v-for="entry in filteredEntries"
:key="entry.key"
class="history-entry"
>
<div class="history-entry-main">
<div class="history-entry-topline">
<span class="history-source-badge" :class="`source-${entry.source}`">{{ entry.sourceLabel }}</span>
<strong>{{ entry.title }}</strong>
</div>
<p class="history-entry-description">{{ entry.description }}</p>
<p class="history-entry-meta">
<span>{{ formatDateTime(entry.occurredAt) }}</span>
<span v-if="entry.personLabel">{{ entry.personLabel }}</span>
<span v-if="entry.statusLabel">{{ entry.statusLabel }}</span>
</p>
</div>
<button type="button" class="btn-secondary" @click="openEntry(entry)">
Öffnen
</button>
</article>
</div>
</section>
</section>
</template>
</div>
</template>
<script>
import { mapGetters } from 'vuex';
import apiClient from '../apiClient.js';
import { safeErrorMessage } from '../utils/dialogUtils.js';
function normalizeTask(payload = {}) {
return {
id: payload.id,
title: payload.title || '',
description: payload.description || '',
taskType: payload.taskType || payload.task_type || '',
status: payload.status || 'open',
priority: payload.priority || 'normal',
dueAt: payload.dueAt || payload.due_at || null,
completedAt: payload.completedAt || payload.completed_at || null,
createdAt: payload.createdAt || payload.created_at || null,
updatedAt: payload.updatedAt || payload.updated_at || null,
};
}
function normalizeRequest(payload = {}) {
return {
id: payload.id,
requestType: payload.requestType || payload.request_type || 'contact',
status: payload.status || 'open',
workflowStage: payload.workflowStage || payload.workflow_stage || '',
subject: payload.subject || '',
firstName: payload.firstName || payload.first_name || '',
lastName: payload.lastName || payload.last_name || '',
email: payload.email || '',
message: payload.message || '',
receivedAt: payload.receivedAt || payload.received_at || payload.createdAt || payload.created_at || null,
createdAt: payload.createdAt || payload.created_at || null,
updatedAt: payload.updatedAt || payload.updated_at || null,
notes: Array.isArray(payload.notes)
? payload.notes.map((note) => ({
id: note.id,
body: note.body || '',
createdAt: note.createdAt || note.created_at || null,
}))
: [],
};
}
function normalizeRole(payload = {}) {
return {
id: payload.id,
roleKey: payload.roleKey || payload.role_key || '',
name: payload.name || '',
description: payload.description || '',
isSystemRole: payload.isSystemRole === true || payload.isSystemRole === 1 || payload.isSystemRole === '1' || payload.isSystemRole === 'true',
createdAt: payload.createdAt || payload.created_at || null,
updatedAt: payload.updatedAt || payload.updated_at || null,
};
}
function normalizeMemberRole(payload = {}) {
return {
id: payload.id || null,
roleKey: payload.roleKey || payload.role_key || '',
name: payload.name || '',
isPrimary: Boolean(payload.isPrimary),
assignedAt: payload.assignedAt || payload.assigned_at || payload.createdAt || payload.created_at || null,
assignmentUpdatedAt: payload.assignmentUpdatedAt || payload.assignment_updated_at || payload.updatedAt || payload.updated_at || null,
};
}
function normalizeClubUser(payload = {}) {
return {
userId: payload.userId,
user: payload.user || null,
approved: payload.approved !== false,
isOwner: Boolean(payload.isOwner),
createdAt: payload.createdAt || payload.created_at || null,
updatedAt: payload.updatedAt || payload.updated_at || null,
roles: Array.isArray(payload.roles) ? payload.roles.map(normalizeMemberRole) : [],
};
}
function sameMoment(a, b) {
if (!a || !b) return false;
return new Date(a).getTime() === new Date(b).getTime();
}
export default {
name: 'ClubHistoryView',
data() {
return {
loading: false,
loadError: '',
tasks: [],
requests: [],
clubRoles: [],
clubUsers: [],
filters: {
source: '',
search: '',
},
};
},
computed: {
...mapGetters(['currentClub']),
historyEntries() {
const entries = [];
this.requests.forEach((request) => {
const requestLabel = this.requestDisplayLabel(request);
entries.push({
key: `request-created-${request.id}`,
source: 'request',
sourceLabel: 'Anfrage',
occurredAt: request.receivedAt || request.createdAt,
title: request.subject || requestLabel,
description: `${requestLabel} eingegangen`,
personLabel: this.requestPersonLabel(request),
statusLabel: this.requestStatusLabel(request.status),
to: '/club-requests',
});
if (request.updatedAt && !sameMoment(request.updatedAt, request.createdAt) && !sameMoment(request.updatedAt, request.receivedAt)) {
entries.push({
key: `request-updated-${request.id}`,
source: 'request',
sourceLabel: 'Anfrage',
occurredAt: request.updatedAt,
title: request.subject || requestLabel,
description: 'Anfrage aktualisiert',
personLabel: this.requestPersonLabel(request),
statusLabel: this.requestStatusLabel(request.status),
to: '/club-requests',
});
}
request.notes.forEach((note) => {
entries.push({
key: `request-note-${request.id}-${note.id || note.createdAt}`,
source: 'request_note',
sourceLabel: 'Notiz',
occurredAt: note.createdAt,
title: request.subject || requestLabel,
description: note.body || 'Interne Notiz hinzugefügt',
personLabel: this.requestPersonLabel(request),
statusLabel: this.requestStatusLabel(request.status),
to: '/club-requests',
});
});
});
this.tasks.forEach((task) => {
const taskTypeLabel = this.taskTypeLabel(task.taskType);
entries.push({
key: `task-created-${task.id}`,
source: 'task',
sourceLabel: 'Aufgabe',
occurredAt: task.createdAt,
title: task.title || 'Aufgabe',
description: `${taskTypeLabel} angelegt`,
personLabel: '',
statusLabel: this.taskStatusLabel(task.status),
to: '/club-tasks',
});
if (task.completedAt) {
entries.push({
key: `task-done-${task.id}`,
source: 'task_done',
sourceLabel: 'Erledigung',
occurredAt: task.completedAt,
title: task.title || 'Aufgabe',
description: 'Aufgabe erledigt',
personLabel: '',
statusLabel: this.taskStatusLabel(task.status),
to: '/club-tasks',
});
} else if (task.updatedAt && !sameMoment(task.updatedAt, task.createdAt)) {
entries.push({
key: `task-updated-${task.id}`,
source: 'task',
sourceLabel: 'Aufgabe',
occurredAt: task.updatedAt,
title: task.title || 'Aufgabe',
description: 'Aufgabe aktualisiert',
personLabel: '',
statusLabel: this.taskStatusLabel(task.status),
to: '/club-tasks',
});
}
});
this.clubRoles.forEach((role) => {
entries.push({
key: `role-created-${role.id}`,
source: 'role',
sourceLabel: 'Rolle',
occurredAt: role.createdAt,
title: role.name || 'Rolle',
description: role.isSystemRole ? 'Systemrolle verfügbar' : 'Rolle angelegt',
personLabel: '',
statusLabel: role.roleKey || '',
to: '/club-roles',
});
if (role.updatedAt && !sameMoment(role.updatedAt, role.createdAt)) {
entries.push({
key: `role-updated-${role.id}`,
source: 'role',
sourceLabel: 'Rolle',
occurredAt: role.updatedAt,
title: role.name || 'Rolle',
description: 'Rolle oder Rechte aktualisiert',
personLabel: '',
statusLabel: role.roleKey || '',
to: '/club-roles',
});
}
});
this.clubUsers.forEach((member) => {
const email = member.user?.email || `Benutzer ${member.userId}`;
entries.push({
key: `user-linked-${member.userId}`,
source: 'user',
sourceLabel: 'Benutzer',
occurredAt: member.createdAt,
title: email,
description: 'Benutzer dem Verein zugeordnet',
personLabel: '',
statusLabel: member.approved ? 'Aktiv' : 'Inaktiv',
to: '/club-users',
});
if (member.updatedAt && !sameMoment(member.updatedAt, member.createdAt)) {
entries.push({
key: `user-updated-${member.userId}`,
source: 'user',
sourceLabel: 'Benutzer',
occurredAt: member.updatedAt,
title: email,
description: member.approved ? 'Benutzerzugang oder Rechte aktualisiert' : 'Benutzer deaktiviert',
personLabel: '',
statusLabel: member.approved ? 'Aktiv' : 'Inaktiv',
to: '/club-users',
});
}
member.roles.forEach((role) => {
entries.push({
key: `role-assignment-${member.userId}-${role.id || role.roleKey}-${role.assignedAt}`,
source: 'role_assignment',
sourceLabel: 'Zuweisung',
occurredAt: role.assignedAt,
title: role.name || role.roleKey || 'Rolle',
description: `Rolle ${role.isPrimary ? 'primär ' : ''}zugewiesen`,
personLabel: email,
statusLabel: role.roleKey || '',
to: '/club-users',
});
});
});
return entries
.filter((entry) => entry.occurredAt)
.sort((left, right) => new Date(right.occurredAt) - new Date(left.occurredAt));
},
filteredEntries() {
const search = this.filters.search.trim().toLowerCase();
return this.historyEntries.filter((entry) => {
if (this.filters.source && entry.source !== this.filters.source) {
return false;
}
if (!search) {
return true;
}
return [
entry.title,
entry.description,
entry.personLabel,
entry.statusLabel,
].join(' ').toLowerCase().includes(search);
});
},
historyStats() {
const today = new Date();
today.setHours(0, 0, 0, 0);
return {
total: this.historyEntries.length,
today: this.historyEntries.filter((entry) => new Date(entry.occurredAt) >= today).length,
openTasks: this.tasks.filter((task) => !['done', 'cancelled', 'archived'].includes(task.status)).length,
openRequests: this.requests.filter((request) => ['open', 'in_progress', 'waiting'].includes(request.status)).length,
};
},
},
watch: {
currentClub: {
immediate: true,
async handler(newClub) {
if (!newClub) {
this.tasks = [];
this.requests = [];
this.clubRoles = [];
this.clubUsers = [];
return;
}
await this.loadHistory();
},
},
},
methods: {
async loadHistory() {
if (!this.currentClub) {
return;
}
this.loading = true;
this.loadError = '';
try {
const results = await Promise.allSettled([
apiClient.get(`/club-tasks/${this.currentClub}`),
apiClient.get(`/club-requests/${this.currentClub}`),
apiClient.get(`/permissions/${this.currentClub}/roles`),
apiClient.get(`/permissions/${this.currentClub}/members`),
]);
const [tasksResult, requestsResult, rolesResult, membersResult] = results;
const taskEntries = tasksResult.status === 'fulfilled'
? (Array.isArray(tasksResult.value.data?.tasks)
? tasksResult.value.data.tasks
: Array.isArray(tasksResult.value.data)
? tasksResult.value.data
: [])
: [];
const requestEntries = requestsResult.status === 'fulfilled'
? (Array.isArray(requestsResult.value.data)
? requestsResult.value.data
: Array.isArray(requestsResult.value.data?.requests)
? requestsResult.value.data.requests
: [])
: [];
const roleEntries = rolesResult.status === 'fulfilled' && Array.isArray(rolesResult.value.data)
? rolesResult.value.data
: [];
const memberEntries = membersResult.status === 'fulfilled' && Array.isArray(membersResult.value.data)
? membersResult.value.data
: [];
this.tasks = taskEntries.map(normalizeTask);
this.requests = requestEntries.map(normalizeRequest);
this.clubRoles = roleEntries.map(normalizeRole);
this.clubUsers = memberEntries.map(normalizeClubUser);
const blockingErrors = results
.filter((result) => result.status === 'rejected')
.map((result) => result.reason)
.filter((error) => ![403, 404].includes(error?.response?.status));
if (blockingErrors.length > 0 && this.historyEntries.length === 0) {
this.loadError = safeErrorMessage(blockingErrors[0], 'Historie konnte nicht geladen werden.');
}
} catch (error) {
this.loadError = safeErrorMessage(error, 'Historie konnte nicht geladen werden.');
} finally {
this.loading = false;
}
},
openEntry(entry) {
if (!entry?.to) {
return;
}
this.$router.push(entry.to);
},
formatDateTime(value) {
if (!value) return '';
const date = new Date(value);
if (Number.isNaN(date.getTime())) return '';
return new Intl.DateTimeFormat('de-DE', {
dateStyle: 'medium',
timeStyle: 'short',
}).format(date);
},
requestDisplayLabel(request) {
return {
contact: 'Kontaktanfrage',
trial_training: 'Probetraining',
membership: 'Mitgliedschaftsanfrage',
sponsoring: 'Sponsoringanfrage',
}[request.requestType] || 'Anfrage';
},
requestStatusLabel(status) {
return {
open: 'Offen',
in_progress: 'In Bearbeitung',
waiting: 'Wartend',
converted: 'Überführt',
rejected: 'Abgelehnt',
archived: 'Archiviert',
}[status] || status;
},
taskStatusLabel(status) {
return {
open: 'Offen',
in_progress: 'In Bearbeitung',
waiting: 'Wartend',
done: 'Erledigt',
cancelled: 'Abgebrochen',
archived: 'Archiviert',
}[status] || status;
},
taskTypeLabel(taskType) {
return {
request_followup: 'Anfrage-Folgeaufgabe',
trial_training_preparation: 'Probetraining',
membership_review: 'Mitgliedschaft',
member_record_creation: 'Mitglied anlegen',
fee_assignment: 'Beitrag zuordnen',
sepa_collection_preparation: 'SEPA vorbereiten',
}[taskType] || 'Aufgabe';
},
requestPersonLabel(request) {
const fullName = [request.firstName, request.lastName].filter(Boolean).join(' ').trim();
return fullName || request.email || '';
},
},
};
</script>
<style scoped>
.club-history-page {
display: flex;
flex-direction: column;
gap: 1rem;
}
.page-header,
.history-sidebar,
.history-list-card,
.stat-card {
padding: 1.25rem;
}
.page-eyebrow {
margin: 0 0 0.35rem;
font-size: 0.78rem;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--primary-strong);
}
.page-header h2,
.section-header h3,
.history-note-box h4 {
margin: 0;
}
.page-subtitle {
margin: 0.5rem 0 0;
color: var(--text-secondary);
max-width: 70ch;
}
.empty-state {
padding: 1.5rem;
}
.history-stats-grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 1rem;
}
.stat-card {
display: flex;
flex-direction: column;
gap: 0.35rem;
}
.stat-label {
font-size: 0.85rem;
color: var(--text-secondary);
}
.stat-value {
font-size: 1.7rem;
line-height: 1;
}
.history-layout {
display: grid;
grid-template-columns: 300px minmax(0, 1fr);
gap: 1rem;
}
.section-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
margin-bottom: 1rem;
}
.history-filter-stack {
display: grid;
gap: 0.9rem;
}
.history-filter-stack label,
.history-filter-stack span {
display: block;
}
.history-filter-stack span {
margin-bottom: 0.35rem;
font-weight: 600;
}
.history-filter-stack input,
.history-filter-stack select {
width: 100%;
}
.history-note-box {
margin-top: 1rem;
border-radius: 12px;
padding: 1rem;
background: rgba(24, 70, 54, 0.06);
color: var(--text-secondary);
}
.history-note-box p {
margin: 0.5rem 0 0;
}
.history-count {
color: var(--text-secondary);
font-size: 0.92rem;
}
.history-list {
display: grid;
gap: 0.85rem;
}
.history-entry {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 1rem;
padding: 1rem;
border-radius: 14px;
border: 1px solid rgba(20, 46, 74, 0.08);
background: rgba(255, 255, 255, 0.72);
}
.history-entry-main {
min-width: 0;
}
.history-entry-topline {
display: flex;
align-items: center;
gap: 0.75rem;
flex-wrap: wrap;
}
.history-source-badge {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0.2rem 0.55rem;
border-radius: 999px;
font-size: 0.75rem;
font-weight: 700;
}
.source-request {
background: rgba(198, 134, 28, 0.14);
color: #9a6400;
}
.source-request_note {
background: rgba(91, 75, 170, 0.14);
color: #5540ad;
}
.source-task {
background: rgba(20, 109, 76, 0.14);
color: #146d4c;
}
.source-task_done {
background: rgba(33, 118, 201, 0.14);
color: #1f68b0;
}
.history-entry-description,
.history-entry-meta {
margin: 0.45rem 0 0;
}
.history-entry-description {
color: var(--text-primary);
}
.history-entry-meta {
display: flex;
flex-wrap: wrap;
gap: 0.75rem;
color: var(--text-secondary);
font-size: 0.9rem;
}
.state-banner {
margin: 0;
padding: 1rem;
border-radius: 12px;
background: rgba(20, 46, 74, 0.05);
color: var(--text-secondary);
}
.state-banner-error {
background: rgba(180, 44, 44, 0.12);
color: #9e2f2f;
}
@media (max-width: 960px) {
.history-layout {
grid-template-columns: 1fr;
}
.history-stats-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@media (max-width: 640px) {
.history-stats-grid {
grid-template-columns: 1fr;
}
.history-entry {
flex-direction: column;
align-items: stretch;
}
}
</style>

View File

@@ -0,0 +1,808 @@
<template>
<div class="club-requests-page">
<header class="page-header card">
<div>
<p class="page-eyebrow">TT-Verein</p>
<h2>Anfragen</h2>
<p class="page-subtitle">
Kontaktanfragen, Probetrainings, Mitgliedschaftsanfragen und Sponsoringanfragen in einer Arbeitsfläche.
</p>
</div>
<button type="button" class="btn-primary" @click="resetForm">Neue Anfrage</button>
</header>
<section v-if="!currentClub" class="card empty-state">
<h3>Kein Verein ausgewählt</h3>
<p>Bitte zuerst einen Verein auswählen, um Vereinsanfragen zu verwalten.</p>
</section>
<template v-else>
<section class="requests-stats-grid">
<article class="card stat-card">
<span class="stat-label">Offen</span>
<strong class="stat-value">{{ requestStats.open }}</strong>
</article>
<article class="card stat-card">
<span class="stat-label">In Bearbeitung</span>
<strong class="stat-value">{{ requestStats.inProgress }}</strong>
</article>
<article class="card stat-card">
<span class="stat-label">Probetraining</span>
<strong class="stat-value">{{ requestStats.trialTraining }}</strong>
</article>
<article class="card stat-card">
<span class="stat-label">Mitgliedschaft</span>
<strong class="stat-value">{{ requestStats.membership }}</strong>
</article>
</section>
<section class="requests-layout">
<div class="requests-main">
<section class="card requests-filter-card">
<div class="requests-filter-grid">
<label>
<span>Status</span>
<select v-model="filters.status">
<option value="">Alle</option>
<option value="open">Offen</option>
<option value="in_progress">In Bearbeitung</option>
<option value="waiting">Wartend</option>
<option value="converted">Überführt</option>
<option value="rejected">Abgelehnt</option>
<option value="archived">Archiviert</option>
</select>
</label>
<label>
<span>Typ</span>
<select v-model="filters.type">
<option value="">Alle</option>
<option value="contact">Kontakt</option>
<option value="trial_training">Probetraining</option>
<option value="membership">Mitgliedschaft</option>
<option value="sponsoring">Sponsoring</option>
</select>
</label>
<label class="filter-search">
<span>Suche</span>
<input v-model.trim="filters.search" type="text" placeholder="Name, E-Mail oder Betreff" />
</label>
</div>
</section>
<section class="card requests-list-card">
<div class="section-header requests-list-header">
<h3>Eingang</h3>
<button type="button" class="btn-secondary" @click="loadRequests" :disabled="loading">
{{ loading ? 'Lädt…' : 'Neu laden' }}
</button>
</div>
<p v-if="loadError" class="state-banner state-banner-error">{{ loadError }}</p>
<p v-else-if="loading" class="state-banner">Anfragen werden geladen</p>
<p v-else-if="filteredRequests.length === 0" class="state-banner">Keine Anfragen im aktuellen Filter.</p>
<div v-else class="requests-list">
<button
v-for="request in filteredRequests"
:key="request.id"
type="button"
class="request-row"
:class="{ active: selectedRequest?.id === request.id }"
@click="selectRequest(request)"
>
<div class="request-row-main">
<div class="request-row-topline">
<strong>{{ request.subject || requestDisplayType(request.requestType) }}</strong>
<span class="request-status-badge" :class="`status-${request.status}`">{{ requestDisplayStatus(request.status) }}</span>
</div>
<p class="request-row-person">{{ requestDisplayName(request) }}</p>
<p class="request-row-meta">
<span>{{ requestDisplayType(request.requestType) }}</span>
<span v-if="request.workflowStage">{{ requestDisplayWorkflowStage(request.workflowStage) }}</span>
<span>{{ request.email || 'Keine E-Mail' }}</span>
<span>{{ formatDateTime(request.receivedAt || request.createdAt) }}</span>
</p>
</div>
</button>
</div>
</section>
</div>
<aside class="requests-side">
<section class="card request-form-card">
<div class="section-header">
<h3>{{ form.id ? 'Anfrage bearbeiten' : 'Neue Anfrage' }}</h3>
</div>
<form class="request-form" @submit.prevent="submitRequest">
<label>
<span>Typ</span>
<select v-model="form.requestType" required>
<option value="contact">Kontakt</option>
<option value="trial_training">Probetraining</option>
<option value="membership">Mitgliedschaft</option>
<option value="sponsoring">Sponsoring</option>
</select>
</label>
<label>
<span>Betreff</span>
<input v-model.trim="form.subject" type="text" placeholder="Betreff" required />
</label>
<div class="request-form-grid">
<label>
<span>Vorname</span>
<input v-model.trim="form.firstName" type="text" />
</label>
<label>
<span>Nachname</span>
<input v-model.trim="form.lastName" type="text" />
</label>
</div>
<div class="request-form-grid">
<label>
<span>E-Mail</span>
<input v-model.trim="form.email" type="email" />
</label>
<label>
<span>Telefon</span>
<input v-model.trim="form.phone" type="text" />
</label>
</div>
<label>
<span>Nachricht</span>
<textarea v-model.trim="form.message" rows="5" placeholder="Anfrageinhalt"></textarea>
</label>
<div class="request-form-actions">
<button type="submit" class="btn-primary" :disabled="saving">{{ saving ? 'Speichert…' : 'Speichern' }}</button>
<button type="button" class="btn-secondary" @click="resetForm">Zurücksetzen</button>
</div>
</form>
</section>
<section v-if="selectedRequest" class="card request-detail-card">
<div class="section-header">
<h3>Details</h3>
</div>
<div class="detail-stack">
<div>
<span class="detail-label">Status</span>
<select v-model="selectedRequest.status" @change="updateRequestStatus(selectedRequest)">
<option value="open">Offen</option>
<option value="in_progress">In Bearbeitung</option>
<option value="waiting">Wartend</option>
<option value="converted">Überführt</option>
<option value="rejected">Abgelehnt</option>
<option value="archived">Archiviert</option>
</select>
</div>
<div>
<span class="detail-label">Betreff</span>
<p>{{ selectedRequest.subject || 'Kein Betreff' }}</p>
</div>
<div v-if="selectedRequest.workflowStage">
<span class="detail-label">Workflow</span>
<p>{{ requestDisplayWorkflowStage(selectedRequest.workflowStage) }}</p>
</div>
<div>
<span class="detail-label">Person</span>
<p>{{ requestDisplayName(selectedRequest) }}</p>
</div>
<div>
<span class="detail-label">Nachricht</span>
<p class="detail-message">{{ selectedRequest.message || 'Keine Nachricht hinterlegt.' }}</p>
</div>
</div>
<div class="notes-block">
<div class="section-header notes-header">
<h4>Notizen</h4>
</div>
<p v-if="!selectedRequest.notes?.length" class="notes-empty">Noch keine Notizen vorhanden.</p>
<div v-else class="notes-list">
<article v-for="note in selectedRequest.notes" :key="note.id || note.createdAt" class="note-item">
<p>{{ note.body }}</p>
<small>{{ formatDateTime(note.createdAt) }}</small>
</article>
</div>
<form class="note-form" @submit.prevent="addNote">
<textarea v-model.trim="newNote" rows="3" placeholder="Interne Notiz hinzufügen"></textarea>
<button type="submit" class="btn-secondary" :disabled="noteSaving || !newNote">Notiz speichern</button>
</form>
</div>
</section>
</aside>
</section>
</template>
<InfoDialog
v-model="infoDialog.isOpen"
:title="infoDialog.title"
:message="infoDialog.message"
:details="infoDialog.details"
:type="infoDialog.type"
/>
<ConfirmDialog
v-model="confirmDialog.isOpen"
:title="confirmDialog.title"
:message="confirmDialog.message"
:details="confirmDialog.details"
:type="confirmDialog.type"
@confirm="handleConfirmResult(true)"
@cancel="handleConfirmResult(false)"
/>
</div>
</template>
<script>
import { mapGetters } from 'vuex';
import apiClient from '../apiClient.js';
import InfoDialog from '../components/InfoDialog.vue';
import ConfirmDialog from '../components/ConfirmDialog.vue';
import { buildConfirmConfig, buildInfoConfig, safeErrorMessage } from '../utils/dialogUtils.js';
function normalizeRequest(payload = {}) {
return {
id: payload.id,
requestType: payload.requestType || payload.request_type || 'contact',
status: payload.status || 'open',
workflowStage: payload.workflowStage || payload.workflow_stage || '',
priority: payload.priority || 'normal',
subject: payload.subject || '',
firstName: payload.firstName || payload.first_name || '',
lastName: payload.lastName || payload.last_name || '',
email: payload.email || '',
phone: payload.phone || '',
message: payload.message || '',
receivedAt: payload.receivedAt || payload.received_at || payload.createdAt || payload.created_at || null,
createdAt: payload.createdAt || payload.created_at || null,
updatedAt: payload.updatedAt || payload.updated_at || null,
notes: Array.isArray(payload.notes)
? payload.notes.map((note) => ({
id: note.id,
body: note.body || note.note || '',
createdAt: note.createdAt || note.created_at || null,
}))
: [],
};
}
export default {
name: 'ClubRequestsView',
components: {
InfoDialog,
ConfirmDialog,
},
data() {
return {
loading: false,
saving: false,
noteSaving: false,
loadError: '',
requests: [],
selectedRequestId: null,
newNote: '',
filters: {
status: '',
type: '',
search: '',
},
form: {
id: null,
requestType: 'contact',
subject: '',
firstName: '',
lastName: '',
email: '',
phone: '',
message: '',
},
infoDialog: {
isOpen: false,
title: '',
message: '',
details: '',
type: 'info',
},
confirmDialog: {
isOpen: false,
title: '',
message: '',
details: '',
type: 'info',
resolveCallback: null,
},
};
},
computed: {
...mapGetters(['currentClub']),
filteredRequests() {
const search = this.filters.search.trim().toLowerCase();
return this.requests.filter((request) => {
if (this.filters.status && request.status !== this.filters.status) return false;
if (this.filters.type && request.requestType !== this.filters.type) return false;
if (!search) return true;
const haystack = [
request.subject,
request.firstName,
request.lastName,
request.email,
request.message,
].join(' ').toLowerCase();
return haystack.includes(search);
});
},
selectedRequest() {
return this.requests.find((request) => String(request.id) === String(this.selectedRequestId)) || null;
},
requestStats() {
return {
open: this.requests.filter((request) => request.status === 'open').length,
inProgress: this.requests.filter((request) => request.status === 'in_progress').length,
trialTraining: this.requests.filter((request) => request.requestType === 'trial_training').length,
membership: this.requests.filter((request) => request.requestType === 'membership').length,
};
},
},
watch: {
currentClub: {
immediate: true,
async handler(newClub) {
if (!newClub) {
this.requests = [];
this.selectedRequestId = null;
return;
}
await this.loadRequests();
},
},
'$route.query': {
immediate: true,
handler() {
this.applyRouteQuery();
},
},
selectedRequest(request) {
if (request) {
this.form = {
id: request.id,
requestType: request.requestType,
subject: request.subject,
firstName: request.firstName,
lastName: request.lastName,
email: request.email,
phone: request.phone,
message: request.message,
};
}
},
},
methods: {
applyRouteQuery() {
const routeStatus = typeof this.$route?.query?.status === 'string' ? this.$route.query.status : '';
const routeRequestId = this.$route?.query?.requestId;
if (['open', 'in_progress', 'waiting', 'converted', 'rejected', 'archived'].includes(routeStatus)) {
this.filters.status = routeStatus;
}
if (routeRequestId && this.requests.some((request) => String(request.id) === String(routeRequestId))) {
this.selectedRequestId = String(routeRequestId);
}
},
async showInfo(title, message, details = '', type = 'info') {
this.infoDialog = buildInfoConfig({ title, message, details, type });
},
async showConfirm(title, message, details = '', type = 'info', options = {}) {
return new Promise((resolve) => {
this.confirmDialog = buildConfirmConfig({
title,
message,
details,
type,
resolveCallback: resolve,
...options,
});
});
},
handleConfirmResult(confirmed) {
if (this.confirmDialog.resolveCallback) {
this.confirmDialog.resolveCallback(confirmed);
this.confirmDialog.resolveCallback = null;
}
this.confirmDialog.isOpen = false;
},
requestDisplayType(type) {
return {
contact: 'Kontakt',
trial_training: 'Probetraining',
membership: 'Mitgliedschaft',
sponsoring: 'Sponsoring',
}[type] || 'Anfrage';
},
requestDisplayStatus(status) {
return {
open: 'Offen',
in_progress: 'In Bearbeitung',
waiting: 'Wartend',
converted: 'Überführt',
rejected: 'Abgelehnt',
archived: 'Archiviert',
}[status] || status;
},
requestDisplayWorkflowStage(stage) {
return {
contact_replied: 'Kontakt beantwortet',
trial_training_scheduled: 'Probetraining terminiert',
trial_training_feedback_recorded: 'Probetraining nachbereitet',
membership_reviewed: 'Mitgliedsanfrage geprüft',
admission_prepared: 'Aufnahme vorbereitet',
member_record_created: 'Mitglied angelegt',
sepa_pending: 'SEPA ausstehend',
onboarding_completed: 'Onboarding abgeschlossen',
sponsoring_contacted: 'Sponsoring kontaktiert',
}[stage] || stage;
},
requestDisplayName(request) {
const parts = [request.firstName, request.lastName].filter(Boolean).join(' ').trim();
return parts || request.email || 'Unbekannte Person';
},
formatDateTime(value) {
if (!value) return '';
const date = new Date(value);
if (Number.isNaN(date.getTime())) return '';
return new Intl.DateTimeFormat('de-DE', {
dateStyle: 'medium',
timeStyle: 'short',
}).format(date);
},
selectRequest(request) {
this.selectedRequestId = request.id;
this.newNote = '';
},
resetForm() {
this.selectedRequestId = null;
this.newNote = '';
this.form = {
id: null,
requestType: 'contact',
subject: '',
firstName: '',
lastName: '',
email: '',
phone: '',
message: '',
};
},
async loadRequests() {
if (!this.currentClub) return;
this.loading = true;
this.loadError = '';
try {
const response = await apiClient.get(`/club-requests/${this.currentClub}`);
const entries = Array.isArray(response.data?.requests)
? response.data.requests
: Array.isArray(response.data)
? response.data
: [];
this.requests = entries.map(normalizeRequest);
this.applyRouteQuery();
if (this.selectedRequestId && !this.selectedRequest) {
this.selectedRequestId = null;
}
if (!this.selectedRequestId && this.requests.length > 0) {
this.selectedRequestId = this.requests[0].id;
}
} catch (error) {
this.loadError = safeErrorMessage(error, 'Anfragen konnten nicht geladen werden.');
} finally {
this.loading = false;
}
},
async submitRequest() {
if (!this.currentClub) return;
this.saving = true;
const payload = {
requestType: this.form.requestType,
subject: this.form.subject,
firstName: this.form.firstName,
lastName: this.form.lastName,
email: this.form.email,
phone: this.form.phone,
message: this.form.message,
};
try {
if (this.form.id) {
await apiClient.put(`/club-requests/${this.currentClub}/${this.form.id}`, payload);
} else {
await apiClient.post(`/club-requests/${this.currentClub}`, payload);
}
await this.loadRequests();
await this.showInfo('Erfolg', 'Anfrage gespeichert.', '', 'success');
this.resetForm();
} catch (error) {
await this.showInfo('Fehler', safeErrorMessage(error, 'Anfrage konnte nicht gespeichert werden.'), '', 'error');
} finally {
this.saving = false;
}
},
async updateRequestStatus(request) {
if (!this.currentClub || !request?.id) return;
try {
await apiClient.patch(`/club-requests/${this.currentClub}/${request.id}/status`, {
status: request.status,
});
await this.loadRequests();
} catch (error) {
await this.showInfo('Fehler', safeErrorMessage(error, 'Status konnte nicht aktualisiert werden.'), '', 'error');
}
},
async addNote() {
if (!this.currentClub || !this.selectedRequest?.id || !this.newNote) return;
this.noteSaving = true;
try {
await apiClient.post(`/club-requests/${this.currentClub}/${this.selectedRequest.id}/notes`, {
body: this.newNote,
});
this.newNote = '';
await this.loadRequests();
} catch (error) {
await this.showInfo('Fehler', safeErrorMessage(error, 'Notiz konnte nicht gespeichert werden.'), '', 'error');
} finally {
this.noteSaving = false;
}
},
},
};
</script>
<style scoped>
.club-requests-page {
display: flex;
flex-direction: column;
gap: 1rem;
}
.page-header {
display: flex;
justify-content: space-between;
gap: 1rem;
align-items: flex-start;
padding: 1.25rem;
}
.page-eyebrow {
margin: 0 0 0.35rem;
color: var(--text-light);
text-transform: uppercase;
letter-spacing: 0.08em;
font-size: 0.8rem;
font-weight: 700;
}
.page-header h2,
.section-header h3,
.notes-header h4 {
margin: 0;
}
.page-subtitle {
margin: 0.35rem 0 0;
color: var(--text-secondary);
}
.empty-state {
padding: 1.25rem;
}
.requests-stats-grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 1rem;
}
.stat-card {
padding: 1rem;
}
.stat-label {
display: block;
color: var(--text-light);
font-size: 0.9rem;
}
.stat-value {
display: block;
margin-top: 0.35rem;
font-size: 1.55rem;
}
.requests-layout {
display: grid;
grid-template-columns: minmax(0, 1.7fr) minmax(320px, 0.95fr);
gap: 1rem;
}
.requests-main,
.requests-side {
display: flex;
flex-direction: column;
gap: 1rem;
}
.requests-filter-card,
.requests-list-card,
.request-form-card,
.request-detail-card {
padding: 1rem;
}
.requests-filter-grid,
.request-form-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0.85rem;
}
.filter-search {
grid-column: 1 / -1;
}
.requests-list-header,
.section-header {
display: flex;
justify-content: space-between;
align-items: center;
gap: 0.75rem;
margin-bottom: 0.85rem;
}
.state-banner {
margin: 0;
padding: 0.85rem 1rem;
border-radius: 12px;
background: rgba(24, 70, 54, 0.06);
color: var(--text-secondary);
}
.state-banner-error {
background: rgba(207, 84, 84, 0.12);
color: #922f2f;
}
.requests-list {
display: flex;
flex-direction: column;
gap: 0.65rem;
}
.request-row {
width: 100%;
text-align: left;
border: 1px solid rgba(24, 70, 54, 0.08);
background: rgba(255, 255, 255, 0.9);
border-radius: 14px;
padding: 0.95rem 1rem;
cursor: pointer;
}
.request-row.active {
border-color: rgba(47, 122, 95, 0.45);
box-shadow: inset 0 0 0 1px rgba(47, 122, 95, 0.25);
}
.request-row-topline,
.request-row-meta {
display: flex;
gap: 0.55rem;
flex-wrap: wrap;
align-items: center;
}
.request-row-person {
margin: 0.35rem 0;
color: var(--text-secondary);
}
.request-row-meta {
color: var(--text-light);
font-size: 0.88rem;
}
.request-status-badge {
display: inline-flex;
align-items: center;
border-radius: 999px;
padding: 0.2rem 0.55rem;
font-size: 0.78rem;
font-weight: 700;
background: rgba(24, 70, 54, 0.08);
}
.status-open { background: rgba(214, 150, 28, 0.14); color: #8a5b08; }
.status-in_progress { background: rgba(61, 118, 196, 0.14); color: #234f90; }
.status-waiting { background: rgba(160, 112, 64, 0.14); color: #8a5b1d; }
.status-converted { background: rgba(47, 122, 95, 0.14); color: #1d5f48; }
.status-rejected,
.status-archived { background: rgba(207, 84, 84, 0.14); color: #922f2f; }
.request-form,
.detail-stack,
.notes-block,
.note-form {
display: flex;
flex-direction: column;
gap: 0.85rem;
}
.request-form-actions {
display: flex;
gap: 0.75rem;
flex-wrap: wrap;
}
.detail-label {
display: block;
font-size: 0.82rem;
font-weight: 700;
color: var(--text-light);
text-transform: uppercase;
letter-spacing: 0.04em;
margin-bottom: 0.2rem;
}
.detail-message {
white-space: pre-wrap;
}
.notes-empty {
margin: 0;
color: var(--text-light);
}
.notes-list {
display: flex;
flex-direction: column;
gap: 0.65rem;
}
.note-item {
padding: 0.75rem 0.85rem;
border-radius: 12px;
background: rgba(24, 70, 54, 0.05);
}
.note-item p,
.note-item small {
margin: 0;
}
.note-item small {
display: block;
margin-top: 0.35rem;
color: var(--text-light);
}
@media (max-width: 980px) {
.requests-layout,
.requests-stats-grid {
grid-template-columns: 1fr 1fr;
}
.requests-layout {
grid-template-columns: 1fr;
}
}
@media (max-width: 640px) {
.page-header,
.requests-filter-grid,
.request-form-grid,
.requests-stats-grid {
grid-template-columns: 1fr;
}
.page-header {
flex-direction: column;
}
}
</style>

View File

@@ -6,25 +6,25 @@
<div class="tab-navigation">
<button
:class="['tab-button', { active: activeTab === 'settings' }]"
@click="activeTab = 'settings'"
@click="selectTab('settings')"
>
{{ $t('clubSettings.settings') }}
</button>
<button
:class="['tab-button', { active: activeTab === 'training-groups' }]"
@click="activeTab = 'training-groups'"
@click="selectTab('training-groups')"
>
👨👩👧👦 {{ $t('clubSettings.trainingGroups') }}
</button>
<button
:class="['tab-button', { active: activeTab === 'training-times' }]"
@click="activeTab = 'training-times'"
@click="selectTab('training-times')"
>
🕐 {{ $t('clubSettings.trainingTimes') }}
</button>
<button
:class="['tab-button', { active: activeTab === 'venues' }]"
@click="activeTab = 'venues'"
@click="selectTab('venues')"
>
🏟 Spiellokale
</button>
@@ -231,6 +231,8 @@ const GERMAN_STATES = [
{ code: 'DE-TH', name: 'Thüringen' },
];
const CLUB_SETTINGS_TABS = new Set(['settings', 'training-groups', 'training-times', 'venues']);
export default {
name: 'ClubSettings',
components: {
@@ -263,6 +265,12 @@ export default {
},
},
watch: {
'$route.query.tab': {
immediate: true,
handler(tab) {
this.syncTabFromRoute(tab);
},
},
currentClub: {
handler(clubId) {
if (clubId) {
@@ -277,6 +285,27 @@ export default {
},
},
methods: {
syncTabFromRoute(tab) {
const nextTab = CLUB_SETTINGS_TABS.has(tab) ? tab : 'settings';
if (this.activeTab !== nextTab) {
this.activeTab = nextTab;
}
},
selectTab(tab) {
const nextTab = CLUB_SETTINGS_TABS.has(tab) ? tab : 'settings';
if (this.activeTab !== nextTab) {
this.activeTab = nextTab;
}
if (this.$route.query.tab !== nextTab) {
this.$router.replace({
query: {
...this.$route.query,
tab: nextTab,
},
});
}
},
async loadClubSettings() {
if (!this.currentClub) {
this.greeting = '';

View File

@@ -0,0 +1,494 @@
<template>
<div class="club-statistics-page">
<header class="statistics-hero card">
<div class="statistics-hero-copy">
<p class="statistics-eyebrow">TT-Verein</p>
<h2>Statistiken</h2>
<p class="statistics-summary">
Auswertungen zu Mitgliederentwicklung, Altersstruktur, Beitragsentwicklung und Sponsorenentwicklung.
</p>
</div>
<button type="button" class="btn-secondary" @click="loadStatistics" :disabled="loading || !currentClub">
{{ loading ? 'Lädt…' : 'Neu laden' }}
</button>
</header>
<section v-if="!currentClub" class="card empty-state">
<h3>Kein Verein ausgewählt</h3>
<p>Bitte zuerst einen Verein auswählen, um Statistiken zu laden.</p>
</section>
<template v-else>
<p v-if="loadError" class="state-banner state-banner-error">{{ loadError }}</p>
<div class="statistics-accordion">
<details class="card statistics-section-card">
<summary class="statistics-section-trigger">
<div>
<h3>Mitgliederentwicklung</h3>
<p>Mitgliederbestand, Neuzugänge und laufende Entwicklung über die letzten Monate.</p>
</div>
<span class="statistics-section-indicator" aria-hidden="true"></span>
</summary>
<div class="statistics-section-content">
<div class="stats-overview-grid">
<article class="stat-card">
<span class="stat-label">Aktive Mitglieder</span>
<strong class="stat-value">{{ statistics.overview?.activeMembers ?? 0 }}</strong>
</article>
<article class="stat-card">
<span class="stat-label">Inaktive Mitglieder</span>
<strong class="stat-value">{{ statistics.overview?.inactiveMembers ?? 0 }}</strong>
</article>
<article class="stat-card">
<span class="stat-label">Testmitglieder</span>
<strong class="stat-value">{{ statistics.overview?.testMembers ?? 0 }}</strong>
</article>
<article class="stat-card">
<span class="stat-label">Neu in diesem Jahr</span>
<strong class="stat-value">{{ statistics.overview?.createdThisYear ?? 0 }}</strong>
</article>
</div>
<div class="stats-table-wrap">
<table class="stats-table">
<thead>
<tr>
<th>Monat</th>
<th>Neue Mitglieder</th>
<th>Bestand zum Monatsende</th>
</tr>
</thead>
<tbody>
<tr v-for="entry in statistics.memberDevelopment?.monthly || []" :key="entry.key">
<td>{{ entry.label }}</td>
<td>{{ entry.newMembers }}</td>
<td>{{ entry.memberCountSnapshot }}</td>
</tr>
</tbody>
</table>
</div>
</div>
</details>
<details class="card statistics-section-card">
<summary class="statistics-section-trigger">
<div>
<h3>Altersstruktur</h3>
<p>Verteilung der aktiven Mitglieder nach Altersgruppen und Datenabdeckung beim Geburtsdatum.</p>
</div>
<span class="statistics-section-indicator" aria-hidden="true"></span>
</summary>
<div class="statistics-section-content">
<div class="stats-overview-grid stats-overview-grid-compact">
<article class="stat-card">
<span class="stat-label">Mit Geburtsdatum</span>
<strong class="stat-value">{{ statistics.ageStructure?.knownBirthdates ?? 0 }}</strong>
</article>
<article class="stat-card">
<span class="stat-label">Ohne Geburtsdatum</span>
<strong class="stat-value">{{ statistics.ageStructure?.missingBirthdates ?? 0 }}</strong>
</article>
</div>
<div class="bar-list">
<article v-for="bucket in statistics.ageStructure?.buckets || []" :key="bucket.key" class="bar-row">
<div class="bar-row-head">
<strong>{{ bucket.label }}</strong>
<span>{{ bucket.count }}</span>
</div>
<div class="bar-track">
<div class="bar-fill" :style="{ width: ageBucketWidth(bucket.count) }"></div>
</div>
</article>
</div>
</div>
</details>
<details class="card statistics-section-card">
<summary class="statistics-section-trigger">
<div>
<h3>Beitragsentwicklung</h3>
<p>Offene, bezahlte und überfällige Forderungen im Zeitverlauf.</p>
</div>
<span class="statistics-section-indicator" aria-hidden="true"></span>
</summary>
<div class="statistics-section-content">
<div class="stats-overview-grid stats-overview-grid-compact stats-overview-grid-three">
<article class="stat-card">
<span class="stat-label">Offene Forderungen</span>
<strong class="stat-value">{{ statistics.contributionDevelopment?.totals?.openCount ?? 0 }}</strong>
<small>{{ formatEuro(statistics.contributionDevelopment?.totals?.openAmountCents) }}</small>
</article>
<article class="stat-card">
<span class="stat-label">Bezahlt</span>
<strong class="stat-value">{{ statistics.contributionDevelopment?.totals?.paidCount ?? 0 }}</strong>
<small>{{ formatEuro(statistics.contributionDevelopment?.totals?.paidAmountCents) }}</small>
</article>
<article class="stat-card">
<span class="stat-label">Überfällig</span>
<strong class="stat-value">{{ statistics.contributionDevelopment?.totals?.overdueCount ?? 0 }}</strong>
<small>{{ formatEuro(statistics.contributionDevelopment?.totals?.overdueAmountCents) }}</small>
</article>
</div>
<div class="stats-table-wrap">
<table class="stats-table">
<thead>
<tr>
<th>Monat</th>
<th>Offen</th>
<th>Bezahlt</th>
</tr>
</thead>
<tbody>
<tr v-for="entry in statistics.contributionDevelopment?.monthly || []" :key="entry.key">
<td>{{ entry.label }}</td>
<td>{{ entry.openCount }} / {{ formatEuro(entry.openAmountCents) }}</td>
<td>{{ entry.paidCount }} / {{ formatEuro(entry.paidAmountCents) }}</td>
</tr>
</tbody>
</table>
</div>
</div>
</details>
<details class="card statistics-section-card">
<summary class="statistics-section-trigger">
<div>
<h3>Sponsorenentwicklung</h3>
<p>Auswertung der Sponsoringanfragen als aktuelle verfügbare Datengrundlage.</p>
</div>
<span class="statistics-section-indicator" aria-hidden="true"></span>
</summary>
<div class="statistics-section-content">
<div class="stats-overview-grid">
<article class="stat-card">
<span class="stat-label">Sponsoringanfragen gesamt</span>
<strong class="stat-value">{{ statistics.sponsorDevelopment?.totals?.totalRequests ?? 0 }}</strong>
</article>
<article class="stat-card">
<span class="stat-label">Offen oder in Bearbeitung</span>
<strong class="stat-value">{{ statistics.sponsorDevelopment?.totals?.openRequests ?? 0 }}</strong>
</article>
<article class="stat-card">
<span class="stat-label">Überführt</span>
<strong class="stat-value">{{ statistics.sponsorDevelopment?.totals?.convertedRequests ?? 0 }}</strong>
</article>
<article class="stat-card">
<span class="stat-label">Archiviert oder abgelehnt</span>
<strong class="stat-value">{{ statistics.sponsorDevelopment?.totals?.archivedRequests ?? 0 }}</strong>
</article>
</div>
<div class="stats-table-wrap">
<table class="stats-table">
<thead>
<tr>
<th>Monat</th>
<th>Anfragen</th>
<th>Offen</th>
<th>Überführt</th>
</tr>
</thead>
<tbody>
<tr v-for="entry in statistics.sponsorDevelopment?.monthly || []" :key="entry.key">
<td>{{ entry.label }}</td>
<td>{{ entry.totalRequests }}</td>
<td>{{ entry.openRequests }}</td>
<td>{{ entry.convertedRequests }}</td>
</tr>
</tbody>
</table>
</div>
</div>
</details>
</div>
</template>
</div>
</template>
<script>
import { mapGetters } from 'vuex';
import apiClient from '../apiClient.js';
import { safeErrorMessage } from '../utils/dialogUtils.js';
function createEmptyStatistics() {
return {
overview: {},
memberDevelopment: { monthly: [] },
ageStructure: { buckets: [] },
contributionDevelopment: { totals: {}, monthly: [] },
sponsorDevelopment: { totals: {}, monthly: [] },
};
}
export default {
name: 'ClubStatisticsView',
data() {
return {
loading: false,
loadError: '',
statistics: createEmptyStatistics(),
};
},
computed: {
...mapGetters(['currentClub']),
maxAgeBucketCount() {
const buckets = this.statistics.ageStructure?.buckets || [];
return buckets.reduce((max, bucket) => Math.max(max, Number(bucket.count || 0)), 0);
},
},
watch: {
currentClub: {
immediate: true,
async handler(newClub) {
if (!newClub) {
this.statistics = createEmptyStatistics();
return;
}
await this.loadStatistics();
}
}
},
methods: {
async loadStatistics() {
if (!this.currentClub) return;
this.loading = true;
this.loadError = '';
try {
const response = await apiClient.get(`/club-statistics/${this.currentClub}`);
if (!response || response.status < 200 || response.status >= 300) {
throw new Error(response?.data?.error || 'Statistiken konnten nicht geladen werden.');
}
const payload = response.data;
if (!payload || typeof payload !== 'object' || Array.isArray(payload) || !payload.overview) {
throw new Error('Ungültige Antwort für Vereinsstatistiken.');
}
this.statistics = payload;
} catch (error) {
this.statistics = createEmptyStatistics();
this.loadError = safeErrorMessage(error, 'Statistiken konnten nicht geladen werden.');
} finally {
this.loading = false;
}
},
formatEuro(amountCents) {
const amount = Number(amountCents || 0) / 100;
return new Intl.NumberFormat('de-DE', {
style: 'currency',
currency: 'EUR',
}).format(amount);
},
ageBucketWidth(count) {
const max = this.maxAgeBucketCount || 1;
return `${Math.max(8, (Number(count || 0) / max) * 100)}%`;
},
},
};
</script>
<style scoped>
.club-statistics-page {
display: flex;
flex-direction: column;
gap: 1rem;
max-width: 1180px;
}
.statistics-hero,
.empty-state,
.statistics-section-card {
padding: 1.25rem;
}
.statistics-hero {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 1rem;
}
.statistics-eyebrow {
margin: 0 0 0.35rem;
color: var(--text-light);
text-transform: uppercase;
letter-spacing: 0.08em;
font-size: 0.8rem;
}
.statistics-summary {
margin: 0.35rem 0 0;
color: var(--text-secondary);
max-width: 72ch;
}
.statistics-accordion {
display: grid;
gap: 1rem;
}
.statistics-section-trigger {
width: 100%;
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
text-align: left;
background: transparent;
border: none;
padding: 0;
cursor: pointer;
list-style: none;
}
.statistics-section-trigger::-webkit-details-marker {
display: none;
}
.statistics-section-trigger h3 {
margin: 0;
}
.statistics-section-trigger p {
margin: 0.35rem 0 0;
color: var(--text-secondary);
}
.statistics-section-indicator {
display: inline-flex;
align-items: center;
justify-content: center;
width: 2rem;
height: 2rem;
border-radius: 999px;
background: rgba(24, 70, 54, 0.08);
color: var(--primary-strong);
font-size: 1.25rem;
font-weight: 700;
flex-shrink: 0;
}
.statistics-section-indicator::before {
content: '+';
}
.statistics-section-card[open] .statistics-section-indicator::before {
content: '';
}
.statistics-section-content {
margin-top: 1rem;
display: grid;
gap: 1rem;
}
.stats-overview-grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 1rem;
}
.stats-overview-grid-compact {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.stats-overview-grid-three {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.stat-card {
padding: 1rem;
border: 1px solid rgba(24, 70, 54, 0.12);
border-radius: 16px;
background: rgba(255, 255, 255, 0.88);
display: flex;
flex-direction: column;
gap: 0.35rem;
}
.stat-label {
color: var(--text-secondary);
font-size: 0.9rem;
}
.stat-value {
font-size: 1.8rem;
line-height: 1;
}
.stats-table-wrap {
overflow-x: auto;
}
.stats-table {
width: 100%;
border-collapse: collapse;
}
.stats-table th,
.stats-table td {
padding: 0.8rem 0.9rem;
border-bottom: 1px solid rgba(24, 70, 54, 0.08);
text-align: left;
vertical-align: top;
}
.stats-table th {
color: var(--text-on-primary);
font-size: 0.86rem;
font-weight: 700;
}
.bar-list {
display: grid;
gap: 0.9rem;
}
.bar-row {
display: grid;
gap: 0.45rem;
}
.bar-row-head {
display: flex;
justify-content: space-between;
gap: 1rem;
}
.bar-track {
width: 100%;
height: 0.8rem;
border-radius: 999px;
background: rgba(24, 70, 54, 0.08);
overflow: hidden;
}
.bar-fill {
height: 100%;
border-radius: 999px;
background: linear-gradient(135deg, var(--primary-color), var(--primary-hover));
}
@media (max-width: 960px) {
.stats-overview-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@media (max-width: 640px) {
.statistics-hero {
flex-direction: column;
}
.stats-overview-grid {
grid-template-columns: 1fr;
}
}
</style>

File diff suppressed because it is too large Load Diff

View File

@@ -1,5 +1,86 @@
<template>
<div class="home-container">
<template v-if="isAuthenticated && isTtVereinProduct">
<section class="club-dashboard-hero card">
<div>
<span class="club-dashboard-kicker">TT-Verein</span>
<h2 class="club-dashboard-title">Dashboard</h2>
<p class="club-dashboard-subtitle">
Die zentrale Arbeitsplattform für den täglichen Vereinsbetrieb:
Mitglieder, Anfragen, Kommunikation, Termine, Dokumente und Zahlungen.
</p>
</div>
<div class="club-dashboard-quick-links">
<router-link
v-for="link in clubQuickLinks"
:key="link.to"
:to="link.to"
class="club-dashboard-quick-link"
>
<span class="quick-link-icon">{{ link.icon }}</span>
<span>{{ link.label }}</span>
</router-link>
</div>
</section>
<section
v-for="section in clubDashboardSections"
:key="section.id"
class="club-dashboard-section"
>
<div class="section-header">
<h3 class="section-title">{{ section.title }}</h3>
</div>
<div class="club-dashboard-grid">
<article
v-for="card in section.cards"
:key="card.title"
class="card club-dashboard-card"
:class="`accent-${card.accent || 'neutral'}`"
>
<h4>
<router-link v-if="card.to" :to="card.to" class="club-dashboard-card-link">
{{ card.title }}
</router-link>
<template v-else>{{ card.title }}</template>
</h4>
<p v-if="card.value" class="club-dashboard-value">
<router-link v-if="card.to" :to="card.to" class="club-dashboard-card-link club-dashboard-value-link">
{{ card.value }}
</router-link>
<template v-else>{{ card.value }}</template>
</p>
<p v-if="card.meta" class="club-dashboard-meta">{{ card.meta }}</p>
<ul v-if="card.items" class="club-dashboard-list">
<li
v-for="item in card.items"
:key="typeof item === 'string' ? item : `${item?.to || 'no-link'}-${item?.label || 'empty'}`"
:class="{ 'club-dashboard-item-personal': typeof item !== 'string' && item?.isAssignedToCurrentUser }"
>
<router-link
v-if="typeof item !== 'string' && item?.to"
:to="item.to"
class="club-dashboard-item-link"
>
{{ typeof item === 'string' ? item : item?.label || '' }}
</router-link>
<template v-else>{{ typeof item === 'string' ? item : item?.label || '' }}</template>
</li>
</ul>
</article>
</div>
</section>
<section v-if="!dashboardLoading && clubDashboardSections.length === 0" class="card club-dashboard-empty">
<h3>Noch keine Dashboard-Daten</h3>
<p v-if="!currentClub">
Wähle zuerst einen Verein aus, damit Aufgaben, Mitglieder, Trainings und Termine geladen werden.
</p>
<p v-else>
Für diesen Verein sind noch keine auswertbaren Dashboard-Daten vorhanden.
</p>
</section>
</template>
<template v-else>
<div class="welcome-section">
<div class="welcome-card card">
<div class="card-header">
@@ -17,6 +98,174 @@
{{ $t('navigation.login') }}
</router-link>
</div>
<template v-if="isPlayerProduct">
<section class="hero">
<h1 class="hero-title">Tischtennis für Spieler, nicht für Verwaltung</h1>
<p class="hero-subtitle">
Mein TT bündelt deine persönlichen Tischtennisbereiche: Kalender,
Kontoverknüpfungen, Bestellungen und Einstellungen in einer ruhigen, reduzierten Oberfläche.
</p>
<div class="auth-actions">
<router-link to="/register" class="btn-primary">
<span class="btn-icon">🚀</span>
{{ $t('home.startFree') }}
</router-link>
<router-link to="/login" class="btn-secondary">
<span class="btn-icon">🔐</span>
{{ $t('navigation.login') }}
</router-link>
</div>
<ul class="hero-bullets">
<li> Persönlicher Kalender und Termine im Blick</li>
<li> myTischtennis- und click-TT-Konten zentral verknüpfen</li>
<li> Eigene Bestellungen und persönliche Einstellungen verwalten</li>
<li> Kein Vereinsverwaltungsmenü, keine überflüssigen Admin-Bereiche</li>
</ul>
</section>
<section class="seo-intro card">
<div class="seo-intro-copy">
<h2>Die persönliche Tischtennisoberfläche</h2>
<p>
Mein TT ist für einzelne Spieler gedacht, die keinen kompletten Vereinsarbeitsplatz
brauchen. Die Oberfläche konzentriert sich auf persönliche Themen statt auf
Mitgliederverwaltung, Budget oder Vereinsorganisation.
</p>
<p>
So bleibt der Einstieg schlank. Neue Spielerfunktionen wie individuelle Programme
und Zielsetzungen können später auf dieser getrennten Produktfläche sauber wachsen.
</p>
</div>
<div class="seo-intro-points">
<div class="seo-point">
<strong>Persönlich</strong>
<span>Fokus auf den einzelnen Spieler statt auf Vereinsrollen.</span>
</div>
<div class="seo-point">
<strong>Reduziert</strong>
<span>Nur Funktionen, die für den Spieler heute relevant sind.</span>
</div>
<div class="seo-point">
<strong>Ausbaufähig</strong>
<span>Saubere Grundlage für spätere Trainings- und Zielmodule.</span>
</div>
</div>
</section>
<section class="features-section">
<h3 class="section-title">Was du hier bereits nutzen kannst</h3>
<div class="features-grid">
<div class="feature-card card">
<div class="feature-icon">📆</div>
<h4 class="feature-title">Kalender</h4>
<p class="feature-description">Termine, Trainingsbezug und persönliche Orientierung an einem Ort.</p>
</div>
<div class="feature-card card">
<div class="feature-icon">🔗</div>
<h4 class="feature-title">Kontoverknüpfungen</h4>
<p class="feature-description">myTischtennis und click-TT können direkt aus der persönlichen Oberfläche gepflegt werden.</p>
</div>
<div class="feature-card card">
<div class="feature-icon">📦</div>
<h4 class="feature-title">Bestellungen</h4>
<p class="feature-description">Persönliche Bestellungen bleiben sichtbar, ohne durch Vereinsverwaltung überlagert zu werden.</p>
</div>
<div class="feature-card card">
<div class="feature-icon"></div>
<h4 class="feature-title">Einstellungen</h4>
<p class="feature-description">Persönliche Einstellungen und Kontodaten sind direkt erreichbar.</p>
</div>
</div>
</section>
</template>
<template v-else-if="isClubProduct">
<section class="hero">
<h1 class="hero-title">TT-Verein für den täglichen Vereinsbetrieb</h1>
<p class="hero-subtitle">
Mitglieder verwalten, Anfragen bearbeiten, Kommunikation organisieren,
Termine planen, Dokumente verwalten und Zahlungen im Blick behalten.
</p>
<div class="auth-actions">
<router-link to="/register" class="btn-primary">
<span class="btn-icon">🚀</span>
{{ $t('home.startFree') }}
</router-link>
<router-link to="/login" class="btn-secondary">
<span class="btn-icon">🔐</span>
{{ $t('navigation.login') }}
</router-link>
</div>
<ul class="hero-bullets">
<li> Dashboard statt Statistikflut</li>
<li> Anfragen, Mitglieder und Kommunikation zentral steuern</li>
<li> Dokumente, Veranstaltungen und Zahlungen im Vereinskontext</li>
<li> Rollenbasiert, historisiert und für APIs vorbereitet</li>
</ul>
</section>
<section class="seo-intro card">
<div class="seo-intro-copy">
<h2>Die Arbeitsplattform für kleine und mittlere TT-Vereine</h2>
<p>
TT-Verein richtet sich an Vereine mit etwa 30 bis 300 Mitgliedern und fokussiert
sich auf die täglichen Aufgaben der Vereinsarbeit statt auf Spezialwerkzeuge für
Statistik oder Vollbuchhaltung.
</p>
<p>
Im Mittelpunkt steht nicht die Frage, wie viele Kennzahlen es gibt, sondern was
heute erledigt werden muss: Anfragen, fehlende Daten, offene Zahlungen,
Vereinskommunikation und anstehende Termine.
</p>
</div>
<div class="seo-intro-points">
<div class="seo-point">
<strong>Mitglieder</strong>
<span>Stammdaten, Vereinsdaten, Mannschaften, Funktionen und Dokumente.</span>
</div>
<div class="seo-point">
<strong>Organisation</strong>
<span>Anfragen, Kommunikation, Termine, Aufgaben und Veranstaltungen.</span>
</div>
<div class="seo-point">
<strong>Verantwortung</strong>
<span>Rollen, Historie, Archiv und spätere Finanzprozesse aus einem System.</span>
</div>
</div>
</section>
<section class="search-topic-grid">
<article class="search-topic card">
<h2>Dashboard mit Handlungsbedarf</h2>
<p>
Offene Aufgaben, neue Anfragen, fehlende Mitgliedsdaten, offene Zahlungen und die
nächsten Termine stehen auf der Startseite im Vordergrund.
</p>
</article>
<article class="search-topic card">
<h2>Vereinsbetrieb statt Werkzeugmix</h2>
<p>
TT-Verein verbindet Mitglieder, Kommunikation, Dokumente und Termine in einem
gemeinsamen Arbeitskontext, damit Vorstand und Funktionsträger nicht zwischen
mehreren Systemen springen müssen.
</p>
</article>
<article class="search-topic card">
<h2>Historie überall</h2>
<p>
Jede Änderung soll nachvollziehbar bleiben: wer, wann, was, alter Wert und neuer
Wert. Archivierung ersetzt später konsequent das Löschen.
</p>
</article>
<article class="search-topic card">
<h2>API-fähig gedacht</h2>
<p>
Externe Vereinsseiten und Formulare können künftig Anfragen, Mitgliedsanträge,
Veranstaltungen und Nachrichten direkt an TT-Verein übergeben.
</p>
</article>
</section>
</template>
<template v-else>
<section class="hero">
<h1 class="hero-title">{{ $t('home.heroTitle') }}</h1>
<p class="hero-subtitle">
@@ -333,6 +582,7 @@
{{ $t('home.haveAccount') }}
</router-link>
</div>
</template>
</div>
<div v-else class="user-welcome">
@@ -355,7 +605,7 @@
<div v-if="isAuthenticated" class="features-section">
<h3 class="section-title">{{ $t('home.whatCanYouDo') }}</h3>
<div class="features-grid">
<div v-if="isTrainerProduct" class="features-grid">
<div class="feature-card card">
<div class="feature-icon">👥</div>
<h4 class="feature-title">{{ $t('home.authenticatedFeatures.manageMembers') }}</h4>
@@ -420,20 +670,94 @@
</p>
</div>
</div>
<div v-else class="features-grid">
<div class="feature-card card">
<div class="feature-icon">📆</div>
<h4 class="feature-title">Kalender</h4>
<p class="feature-description">Deine verfügbaren Tischtennistermine und Vereinsbezüge bleiben schnell auffindbar.</p>
</div>
<div class="feature-card card">
<div class="feature-icon">🔗</div>
<h4 class="feature-title">Konten</h4>
<p class="feature-description">myTischtennis- und click-TT-Verknüpfungen sind direkt aus deinem Bereich erreichbar.</p>
</div>
<div class="feature-card card">
<div class="feature-icon">📦</div>
<h4 class="feature-title">Bestellungen</h4>
<p class="feature-description">Persönliche Bestellungen lassen sich ohne Vereinsmenü öffnen und verfolgen.</p>
</div>
<div class="feature-card card">
<div class="feature-icon"></div>
<h4 class="feature-title">Persönliche Einstellungen</h4>
<p class="feature-description">Dein Konto bleibt die Startbasis für spätere individuelle Trainings- und Zielmodule.</p>
</div>
</div>
</div>
</template>
</div>
</template>
<script>
import { mapGetters, mapActions } from 'vuex';
import apiClient from '../apiClient.js';
import { CLUB_DASHBOARD_QUICK_LINKS } from '../config/clubWorkspace.js';
export default {
name: 'Home',
data() {
return {
dashboardSectionsOverride: null,
dashboardLoading: false,
};
},
computed: {
...mapGetters(['isAuthenticated']),
...mapGetters(['isAuthenticated', 'appProduct', 'currentClub']),
isPlayerProduct() {
return this.appProduct === 'player';
},
isClubProduct() {
return this.appProduct === 'club';
},
isTrainerProduct() {
return this.appProduct === 'trainer';
},
isTtVereinProduct() {
return this.isClubProduct;
},
clubDashboardSections() {
return this.dashboardSectionsOverride || [];
},
clubQuickLinks() {
return CLUB_DASHBOARD_QUICK_LINKS;
},
},
watch: {
currentClub: {
immediate: true,
async handler() {
await this.loadClubDashboard();
},
},
},
methods: {
...mapActions(['logout']),
async loadClubDashboard() {
if (!this.isTtVereinProduct || !this.isAuthenticated || !this.currentClub) {
this.dashboardSectionsOverride = null;
return;
}
this.dashboardLoading = true;
try {
const response = await apiClient.get(`/club-dashboard/${this.currentClub}`);
const sections = Array.isArray(response.data?.sections) ? response.data.sections : null;
this.dashboardSectionsOverride = sections && sections.length > 0 ? sections : null;
} catch (_error) {
this.dashboardSectionsOverride = null;
} finally {
this.dashboardLoading = false;
}
},
},
};
</script>
@@ -444,6 +768,151 @@ export default {
margin: 0 auto;
}
.club-dashboard-hero {
display: grid;
grid-template-columns: minmax(0, 1.4fr) minmax(280px, 0.9fr);
gap: 1rem;
padding: 1.5rem;
margin-bottom: 1rem;
background:
linear-gradient(140deg, rgba(24, 70, 54, 0.08), rgba(47, 122, 95, 0.04)),
radial-gradient(circle at top right, rgba(160, 112, 64, 0.12), transparent 42%);
}
.club-dashboard-kicker {
display: inline-flex;
align-items: center;
padding: 0.28rem 0.7rem;
border-radius: 999px;
background: rgba(24, 70, 54, 0.1);
color: var(--primary-strong);
font-size: 0.78rem;
font-weight: 700;
letter-spacing: 0.02em;
text-transform: uppercase;
}
.club-dashboard-card-link,
.club-dashboard-item-link {
color: inherit;
text-decoration: none;
}
.club-dashboard-card-link:hover,
.club-dashboard-item-link:hover {
text-decoration: underline;
}
.club-dashboard-value-link {
display: inline-block;
}
.club-dashboard-empty {
padding: 1.25rem 1.5rem;
}
.club-dashboard-title {
margin: 0.75rem 0 0.35rem;
font-size: 2rem;
}
.club-dashboard-subtitle {
margin: 0;
max-width: 64ch;
color: var(--text-secondary);
}
.club-dashboard-quick-links {
display: grid;
gap: 0.75rem;
align-content: start;
}
.club-dashboard-quick-link {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.9rem 1rem;
background: rgba(255, 255, 255, 0.88);
border: 1px solid rgba(24, 70, 54, 0.08);
border-radius: 14px;
text-decoration: none;
color: var(--text-primary);
font-weight: 600;
}
.quick-link-icon {
font-size: 1.2rem;
}
.club-dashboard-section {
margin-bottom: 1.25rem;
}
.club-dashboard-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
gap: 1rem;
}
.club-dashboard-card {
padding: 1.1rem;
border-top: 4px solid transparent;
}
.club-dashboard-card h4 {
margin-top: 0;
margin-bottom: 0.65rem;
}
.club-dashboard-value {
margin: 0;
font-size: 1.5rem;
font-weight: 700;
color: var(--text-primary);
}
.club-dashboard-meta {
margin: 0.35rem 0 0;
color: var(--text-secondary);
}
.club-dashboard-list {
margin: 0;
padding-left: 1rem;
display: grid;
gap: 0.45rem;
color: var(--text-secondary);
}
.club-dashboard-item-personal {
background: rgba(214, 110, 160, 0.14);
border: 1px solid rgba(214, 110, 160, 0.24);
border-radius: 10px;
padding: 0.35rem 0.55rem;
color: var(--text-primary);
}
.accent-amber {
border-top-color: #d6961c;
}
.accent-red {
border-top-color: #cf5454;
}
.accent-blue {
border-top-color: #3d76c4;
}
.accent-green {
border-top-color: #2f7a5f;
}
.accent-neutral {
border-top-color: rgba(24, 70, 54, 0.28);
}
.welcome-section {
margin-bottom: 2rem;
}
@@ -765,6 +1234,10 @@ export default {
.home-container {
padding: 0 0.75rem;
}
.club-dashboard-hero {
grid-template-columns: 1fr;
}
.welcome-card {
margin: 0;

View File

@@ -38,7 +38,7 @@
</template>
<script>
import { mapActions } from 'vuex';
import { mapActions, mapGetters } from 'vuex';
import apiClient from '../apiClient.js';
import InfoDialog from '../components/InfoDialog.vue';
import ConfirmDialog from '../components/ConfirmDialog.vue';
@@ -71,6 +71,9 @@ export default {
password: '',
};
},
computed: {
...mapGetters(['defaultHomeRoute']),
},
methods: {
// Dialog Helper Methods
async showInfo(title, message, details = '', type = 'info') {
@@ -105,7 +108,7 @@ export default {
timeout: 5000,
});
await this.login({ token: response.data.token, username: this.email });
const redirectTarget = typeof this.$route.query.redirect === 'string' ? this.$route.query.redirect : '/';
const redirectTarget = typeof this.$route.query.redirect === 'string' ? this.$route.query.redirect : this.defaultHomeRoute;
this.$router.push(redirectTarget);
} catch (error) {
const message = safeErrorMessage(error, this.$t('auth.loginFailed'));

View File

@@ -267,7 +267,7 @@
<label class="checkbox-item"><span>{{ $t('members.memberFormHandedOver') }}:</span> <input type="checkbox" v-model="newMemberFormHandedOver"></label>
<label class="checkbox-item"><span>{{ $t('members.adultReleaseApproved') }}:</span> <input type="checkbox" v-model="newAdultReleaseApproved"></label>
<label class="checkbox-item"><span>{{ $t('members.adultReserveApproved') }}:</span> <input type="checkbox" v-model="newAdultReserveApproved"></label>
<!-- Trainingsgruppen -->
<div class="contact-section" :class="{ 'member-field-warning-box': editorHasIssue('training-group') }" v-if="memberToEdit">
<label><span>{{ $t('members.trainingGroups') }}:</span></label>
@@ -308,6 +308,57 @@
</option>
</select>
</div>
<div v-if="memberToEdit && canEditMemberBankAccount" class="contact-section member-sepa-section">
<label><span>{{ $t('members.bankAccountSection') }}:</span></label>
<div v-if="memberSepaMandateLoading" class="no-groups-hint">{{ $t('members.bankAccountLoading') }}</div>
<div v-else class="member-sepa-grid">
<label>
<span>{{ $t('members.accountHolder') }}:</span>
<input v-model="memberSepaMandateForm.debtorName" type="text" autocomplete="off">
</label>
<label>
<span>{{ $t('members.iban') }}:</span>
<input v-model="memberSepaMandateForm.iban" type="text" maxlength="34" autocomplete="off">
</label>
<label>
<span>{{ $t('members.bic') }}:</span>
<input v-model="memberSepaMandateForm.bic" type="text" maxlength="11" autocomplete="off">
</label>
<label>
<span>{{ $t('members.mandateReference') }}:</span>
<input v-model="memberSepaMandateForm.mandateReference" type="text" maxlength="80" autocomplete="off">
</label>
<label>
<span>{{ $t('members.signedOn') }}:</span>
<input v-model="memberSepaMandateForm.signedOn" type="date">
</label>
<label>
<span>{{ $t('members.validFrom') }}:</span>
<input v-model="memberSepaMandateForm.validFrom" type="date">
</label>
<label>
<span>{{ $t('members.bankAccountStatus') }}:</span>
<select v-model="memberSepaMandateForm.status">
<option value="active">{{ $t('members.bankAccountStatusActive') }}</option>
<option value="pending">{{ $t('members.bankAccountStatusPending') }}</option>
<option value="revoked">{{ $t('members.bankAccountStatusRevoked') }}</option>
</select>
</label>
<label class="member-sepa-grid-full">
<span>{{ $t('members.bankAccountNote') }}:</span>
<textarea v-model="memberSepaMandateForm.historyNote" rows="3"></textarea>
</label>
</div>
<div v-if="memberSepaMandateError" class="members-state-banner members-state-banner-error">
{{ memberSepaMandateError }}
</div>
<div class="member-sepa-actions">
<button type="button" @click="saveMemberSepaMandate" :disabled="memberSepaMandateLoading">
{{ $t('members.saveBankAccount') }}
</button>
</div>
</div>
<label><span>{{ $t('members.image') }}:</span>
<div style="display: flex; gap: 10px; align-items: center;">
@@ -646,7 +697,7 @@ export default {
GroupPhotoCropDialog
},
computed: {
...mapGetters(['isAuthenticated', 'currentClub', 'token']),
...mapGetters(['isAuthenticated', 'currentClub', 'token', 'hasPermission']),
activeMembersCount() {
return this.members.filter(member => member.active && !member.testMembership).length;
@@ -660,6 +711,10 @@ export default {
return this.members.filter(member => !member.active).length;
},
canEditMemberBankAccount() {
return this.hasPermission('members', 'write');
},
trainingGroupFilterOptions() {
const groups = new Map();
this.members.forEach((member) => {
@@ -983,6 +1038,14 @@ export default {
return this.trainingGroups.filter(g => g && g.id && !memberGroupIds.has(g.id));
}
},
watch: {
'$route.query': {
immediate: true,
handler() {
this.applyRouteQuery();
}
}
},
data() {
return {
// Dialog States
@@ -1065,7 +1128,19 @@ export default {
membersLoadError: '',
selectedMemberPreview: null,
selectedPreviewTrainingGroups: [],
memberDataQualityRequirements: defaultMemberDataQualityRequirements()
memberDataQualityRequirements: defaultMemberDataQualityRequirements(),
memberSepaMandateLoading: false,
memberSepaMandateError: '',
memberSepaMandateForm: {
debtorName: '',
iban: '',
bic: '',
mandateReference: '',
signedOn: '',
validFrom: '',
status: 'active',
historyNote: ''
}
}
},
created() {
@@ -1116,6 +1191,27 @@ export default {
async init() {
await this.loadMembers();
},
applyRouteQuery() {
const routeScope = typeof this.$route?.query?.scope === 'string' ? this.$route.query.scope : '';
const routeMemberId = this.$route?.query?.memberId;
const routeMode = typeof this.$route?.query?.mode === 'string' ? this.$route.query.mode : '';
const validScopes = new Set(['all', 'active', 'test', 'activeTest', 'notTraining', 'needsForm', 'activeDataIncomplete', 'dataIncomplete', 'inactive']);
if (validScopes.has(routeScope)) {
this.selectedMemberScope = routeScope;
}
if (routeMemberId) {
const member = this.members.find(entry => String(entry.id) === String(routeMemberId));
if (member) {
if (routeMode === 'edit') {
this.editMember(member);
} else {
this.selectMember(member);
}
}
}
},
async loadMemberDataQualityRequirements() {
if (!this.currentClub) {
this.memberDataQualityRequirements = defaultMemberDataQualityRequirements();
@@ -1194,6 +1290,7 @@ export default {
await this.loadTrainingParticipations();
await Promise.allSettled(this.members.map(member => this.prefetchMemberPrimaryImage(member)));
await Promise.allSettled(this.members.map(member => this.prefetchMemberLatestImage(member)));
this.applyRouteQuery();
if (this.selectedMemberPreview) {
this.selectedMemberPreview = this.members.find(member => member.id === this.selectedMemberPreview.id) || null;
if (!this.selectedMemberPreview) {
@@ -1570,6 +1667,111 @@ export default {
phones: [{ value: '', isParent: false, parentName: '', isPrimary: false }],
emails: [{ value: '', isParent: false, parentName: '', isPrimary: false }]
};
this.resetMemberSepaMandateForm();
},
resetMemberSepaMandateForm() {
this.memberSepaMandateLoading = false;
this.memberSepaMandateError = '';
this.memberSepaMandateForm = {
debtorName: '',
iban: '',
bic: '',
mandateReference: '',
signedOn: '',
validFrom: '',
status: 'active',
historyNote: ''
};
},
applyMemberSepaMandateForm(mandate) {
if (!mandate) {
this.resetMemberSepaMandateForm();
return;
}
this.memberSepaMandateError = '';
this.memberSepaMandateForm = {
debtorName: mandate.debtorName || '',
iban: mandate.iban || '',
bic: mandate.bic || '',
mandateReference: mandate.mandateReference || '',
signedOn: this.formatDateForInput(mandate.signedOn),
validFrom: this.formatDateForInput(mandate.validFrom),
status: mandate.status || 'active',
historyNote: mandate.historyNote || ''
};
},
getMemberSepaApiError(response, fallbackKey) {
const status = Number(response?.status || 0);
if (status >= 200 && status < 300) {
return '';
}
const responseError = getSafeMessage(
response?.data?.error || response?.data?.message || response?.data?.code,
''
);
return responseError || this.$t(fallbackKey);
},
async loadMemberSepaMandate(memberId) {
this.resetMemberSepaMandateForm();
if (!memberId || !this.canEditMemberBankAccount) {
return;
}
this.memberSepaMandateLoading = true;
try {
const response = await apiClient.get(`/clubmembers/sepa/${this.currentClub}/${memberId}`);
const responseError = this.getMemberSepaApiError(response, 'members.bankAccountLoadError');
if (responseError) {
throw new Error(responseError);
}
const mandate = response.data?.mandate;
this.applyMemberSepaMandateForm(mandate);
} catch (error) {
console.error('[loadMemberSepaMandate] error:', error);
this.memberSepaMandateError = getSafeErrorMessage(error, this.$t('members.bankAccountLoadError'));
} finally {
this.memberSepaMandateLoading = false;
}
},
async saveMemberSepaMandate() {
if (!this.memberToEdit || !this.canEditMemberBankAccount) {
return;
}
this.memberSepaMandateLoading = true;
this.memberSepaMandateError = '';
try {
const response = await apiClient.put(`/clubmembers/sepa/${this.currentClub}/${this.memberToEdit.id}`, {
...this.memberSepaMandateForm
});
const responseError = this.getMemberSepaApiError(response, 'members.bankAccountSaveError');
if (responseError) {
throw new Error(responseError);
}
if (response.data?.success !== true) {
throw new Error(getSafeMessage(response.data?.error, this.$t('members.bankAccountSaveError')));
}
const reloadResponse = await apiClient.get(`/clubmembers/sepa/${this.currentClub}/${this.memberToEdit.id}`);
const reloadError = this.getMemberSepaApiError(reloadResponse, 'members.bankAccountLoadError');
if (reloadError) {
throw new Error(reloadError);
}
const mandate = reloadResponse.data?.mandate;
if (!mandate) {
throw new Error(this.$t('members.bankAccountMissingAfterSave'));
}
this.applyMemberSepaMandateForm(mandate);
await this.showInfo(this.$t('messages.success'), this.$t('members.bankAccountSaved'), '', 'success');
} catch (error) {
console.error('[saveMemberSepaMandate] error:', error);
this.memberSepaMandateError = getSafeErrorMessage(error, this.$t('members.bankAccountSaveError'));
} finally {
this.memberSepaMandateLoading = false;
}
},
addContact(type) {
if (type === 'phone') {
@@ -1872,6 +2074,7 @@ export default {
// Load training groups for this member
await this.loadMemberTrainingGroups(member.id);
await this.loadMemberSepaMandate(member.id);
try {
const response = await apiClient.get(`/clubmembers/image/${member.id}`, {
responseType: 'blob'
@@ -3813,6 +4016,49 @@ table td {
margin-bottom: 0.5rem;
}
.member-sepa-section {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.member-sepa-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
gap: 0.75rem 1rem;
}
.member-sepa-grid > label,
.member-sepa-grid-full {
display: flex;
flex-direction: column;
gap: 0.35rem;
}
.member-sepa-grid-full {
grid-column: 1 / -1;
}
.member-sepa-grid input,
.member-sepa-grid select,
.member-sepa-grid textarea {
width: 100%;
padding: 0.5rem;
border: 1px solid #ddd;
border-radius: 4px;
font: inherit;
}
.member-sepa-grid textarea {
resize: vertical;
min-height: 5rem;
}
.member-sepa-actions {
display: flex;
justify-content: flex-end;
}
.warning-icon {
margin-right: 0.25rem;
font-size: 0.8rem;

View File

@@ -1,15 +1,14 @@
<template>
<div class="permissions-view">
<div class="header">
<h1>{{ t('permissions.title') }}</h1>
<p class="subtitle">{{ t('permissions.subtitle') }}</p>
<h1>{{ pageTitle }}</h1>
<p class="subtitle">{{ pageSubtitle }}</p>
</div>
<div v-if="loading" class="loading">{{ t('permissions.loadingMembers') }}</div>
<div v-else-if="error" class="error">{{ error }}</div>
<div v-else class="permissions-content">
<!-- Role Legend -->
<div class="role-legend">
<div v-if="showRoleLegend" class="role-legend">
<h3>{{ t('permissions.availableRoles') }}</h3>
<div class="roles-grid">
<div v-for="role in availableRoles" :key="role.value" class="role-card">
@@ -19,14 +18,85 @@
</div>
</div>
<!-- Members Table -->
<div class="members-table">
<div v-if="isRolesMode" class="roles-admin-layout">
<div class="role-catalog card">
<div class="catalog-header">
<h3>Rollen im Verein</h3>
<button v-if="!isReadOnly" class="btn-primary" @click="startCreateRole">Neue Rolle</button>
</div>
<div class="role-catalog-list">
<button
v-for="role in clubRoles"
:key="role.id"
type="button"
class="role-catalog-item"
:class="{ active: selectedRole && selectedRole.id === role.id }"
@click="selectRole(role)"
>
<span class="role-catalog-name">{{ role.name }}</span>
</button>
</div>
</div>
<div class="role-editor card">
<div class="catalog-header">
<h3>{{ roleEditorTitle }}</h3>
<button
v-if="selectedRole && !selectedRole.isSystemRole && !isReadOnly"
class="btn-danger"
@click="deleteSelectedRole"
>
Rolle löschen
</button>
</div>
<div v-if="selectedRole" class="role-editor-form">
<label class="field-block">
<span>Name</span>
<input v-model="selectedRole.name" class="role-input" :disabled="isReadOnly" />
</label>
<label class="field-block">
<span>Beschreibung</span>
<textarea v-model="selectedRole.description" class="role-textarea" rows="3" :disabled="isReadOnly"></textarea>
</label>
<div class="permissions-grid">
<div v-for="(resource, key) in permissionStructure" :key="`role-${key}`" class="permission-group">
<div class="permission-group-header">
<h4>{{ resource.label }}</h4>
<button class="btn-reset" @click="resetRoleResource(key)" :disabled="isReadOnly">Zurücksetzen</button>
</div>
<div class="permission-actions">
<div v-for="action in resource.actions" :key="action" class="permission-row">
<span class="permission-action-label">{{ getActionLabel(action) }}</span>
<button
class="perm-state"
:class="selectedRole.permissions?.[key]?.[action] ? 'state-allow' : 'state-deny'"
@click="toggleRolePermission(key, action)"
:disabled="isReadOnly"
>
{{ selectedRole.permissions?.[key]?.[action] ? 'Erlaubt' : 'Verboten' }}
</button>
</div>
</div>
</div>
</div>
<div v-if="!isReadOnly" class="dialog-footer role-editor-actions">
<button @click="resetRolePermissions" class="btn-secondary">Alles zurücksetzen</button>
<button @click="saveRole" class="btn-primary">Rolle speichern</button>
</div>
</div>
</div>
</div>
<div v-else class="members-table">
<h3>{{ t('permissions.clubMembers') }}</h3>
<table>
<thead>
<tr>
<th>{{ t('permissions.email') }}</th>
<th>{{ t('permissions.role') }}</th>
<th>Rollen</th>
<th>{{ t('permissions.status') }}</th>
<th v-if="!isReadOnly">{{ t('permissions.actions') }}</th>
</tr>
@@ -35,20 +105,26 @@
<tr v-for="member in members" :key="member.userId">
<td>{{ member.user?.email || 'N/A' }}</td>
<td>
<select
v-if="!member.isOwner && !isReadOnly"
v-model="member.role"
@change="updateMemberRole(member)"
class="role-select"
>
<option v-for="role in availableRoles" :key="role.value" :value="role.value">
{{ role.label }}
</option>
</select>
<span v-else class="role-badge" :class="`role-${member.role}`">
{{ getRoleLabel(member.role) }}
<div v-if="!member.isOwner && !isReadOnly" class="member-role-list">
<label v-for="role in clubRoles" :key="`${member.userId}-${role.id}`" class="member-role-option">
<input
type="checkbox"
:checked="memberHasRole(member, role.id)"
@change="toggleMemberRole(member, role.id, $event.target.checked)"
/>
<span>{{ role.name }}</span>
</label>
</div>
<div v-else class="member-role-badges">
<span
v-for="role in member.roles"
:key="`${member.userId}-${role.id || role.roleKey}`"
class="role-badge"
>
{{ role.name }}
</span>
<span v-if="member.isOwner" class="owner-badge">👑 {{ t('permissions.creator') }}</span>
</span>
</div>
</td>
<td>
<span
@@ -76,7 +152,7 @@
@click="openPermissionsDialog(member)"
class="btn-small"
>
{{ t('permissions.customize') }}
{{ customizeButtonLabel }}
</button>
<span v-else class="muted"></span>
</td>
@@ -96,7 +172,8 @@
<div class="dialog-body">
<p class="info-text">
{{ t('permissions.baseRole') }}: <strong>{{ getRoleLabel(selectedMember.role) }}</strong><br>
Zugewiesene Rollen:
<strong>{{ selectedMemberRoleNames }}</strong><br>
{{ t('permissions.customizeInfo') }}
</p>
@@ -163,11 +240,17 @@ import ConfirmDialog from '../components/ConfirmDialog.vue';
export default {
name: 'PermissionsView',
props: {
viewMode: {
type: String,
default: 'permissions',
},
},
components: {
InfoDialog,
ConfirmDialog
},
setup() {
setup(props) {
const store = useStore();
const t = (key, params) => i18n.global.t(key, params);
const { isOwner, isAdmin, can } = usePermissions();
@@ -228,6 +311,7 @@ export default {
const currentClub = computed(() => store.getters.currentClub);
const members = ref([]);
const availableRoles = ref([]);
const clubRoles = ref([]);
const permissionStructure = ref({});
const loading = ref(true);
const error = ref(null);
@@ -238,14 +322,84 @@ export default {
return !can('permissions', 'write');
});
const pageTitle = computed(() => {
if (props.viewMode === 'users') {
return 'Benutzer';
}
if (props.viewMode === 'roles') {
return 'Rollen und Rechte';
}
return t('permissions.title');
});
const pageSubtitle = computed(() => {
if (props.viewMode === 'users') {
return 'Benutzerzugänge, Status und individuelle Rechte für diesen Verein.';
}
if (props.viewMode === 'roles') {
return 'Rollen vergeben und effektive Berechtigungen für Vereinsbenutzer steuern.';
}
return t('permissions.subtitle');
});
const showRoleLegend = computed(() => props.viewMode !== 'users');
const customizeButtonLabel = computed(() => (
props.viewMode === 'users' ? 'Rechte' : t('permissions.customize')
));
const isRolesMode = computed(() => props.viewMode === 'roles');
const selectedRole = ref(null);
const roleEditorTitle = computed(() => selectedRole.value?.id ? 'Rolle bearbeiten' : 'Neue Rolle');
const selectedMemberRoleNames = computed(() => {
if (!selectedMember.value?.roles?.length) {
return 'Keine Rolle';
}
return selectedMember.value.roles.map((role) => role.name).join(', ');
});
const normalizeRolePermissions = (permissions) => {
if (!permissions) {
return {};
}
if (typeof permissions === 'string') {
try {
return JSON.parse(permissions);
} catch (_error) {
return {};
}
}
return JSON.parse(JSON.stringify(permissions));
};
const normalizeBooleanFlag = (value) => {
if (typeof value === 'boolean') {
return value;
}
if (typeof value === 'number') {
return value === 1;
}
if (typeof value === 'string') {
const normalized = value.trim().toLowerCase();
return normalized === '1' || normalized === 'true';
}
return false;
};
const loadData = async (force = false) => {
loading.value = true;
error.value = null;
try {
// Load available roles
const rolesResponse = await apiClient.get('/permissions/roles/available');
availableRoles.value = rolesResponse.data;
const rolesResponse = await apiClient.get(`/permissions/${currentClub.value}/roles`);
clubRoles.value = (rolesResponse.data || []).map((role) => ({
...role,
isSystemRole: normalizeBooleanFlag(role.isSystemRole),
permissions: normalizeRolePermissions(role.permissions),
}));
availableRoles.value = clubRoles.value.map((role) => ({
value: role.roleKey,
label: role.name,
description: role.description,
}));
// Load permission structure
const structureResponse = await apiClient.get('/permissions/structure/all');
@@ -255,6 +409,9 @@ export default {
const bust = force ? `?t=${Date.now()}` : '';
const membersResponse = await apiClient.get(`/permissions/${currentClub.value}/members${bust}`);
members.value = membersResponse.data;
if (!selectedRole.value && clubRoles.value.length > 0) {
selectRole(clubRoles.value[0]);
}
} catch (err) {
console.error('Error loading permissions data:', err);
@@ -272,19 +429,28 @@ export default {
}
};
const updateMemberRole = async (member) => {
const memberHasRole = (member, roleId) => {
return Array.isArray(member.roles) && member.roles.some((role) => Number(role.id) === Number(roleId));
};
const toggleMemberRole = async (member, roleId, checked) => {
try {
const nextRoleIds = new Set(Array.isArray(member.roles) ? member.roles.map((role) => Number(role.id)) : []);
if (checked) {
nextRoleIds.add(Number(roleId));
} else {
nextRoleIds.delete(Number(roleId));
}
await apiClient.put(
`/permissions/${currentClub.value}/user/${member.userId}/role`,
{ role: member.role }
`/permissions/${currentClub.value}/user/${member.userId}/roles`,
{ roleIds: [...nextRoleIds] }
);
// Reload data to get updated permissions
await loadData();
} catch (err) {
console.error('Error updating role:', err);
await showInfo(t('messages.error'), err.response?.data?.error || t('permissions.errorUpdatingRole'), '', 'error');
// Reload to revert changes
console.error('Error updating roles:', err);
await showInfo(t('messages.error'), err.response?.data?.error || 'Fehler beim Aktualisieren der Rollen', '', 'error');
await loadData();
}
};
@@ -346,6 +512,85 @@ export default {
};
const selectRole = (role) => {
selectedRole.value = {
id: role.id,
roleKey: role.roleKey,
name: role.name,
description: role.description || '',
isSystemRole: normalizeBooleanFlag(role.isSystemRole),
permissions: normalizeRolePermissions(role.permissions),
};
};
const startCreateRole = () => {
selectedRole.value = {
id: null,
roleKey: '',
name: '',
description: '',
isSystemRole: false,
permissions: {},
};
};
const toggleRolePermission = (resourceKey, action) => {
if (!selectedRole.value.permissions[resourceKey]) {
selectedRole.value.permissions[resourceKey] = {};
}
selectedRole.value.permissions[resourceKey][action] = !Boolean(selectedRole.value.permissions[resourceKey][action]);
};
const resetRoleResource = (resourceKey) => {
selectedRole.value.permissions[resourceKey] = {};
};
const resetRolePermissions = () => {
selectedRole.value.permissions = {};
};
const saveRole = async () => {
if (!selectedRole.value?.name?.trim()) {
await showInfo('Fehler', 'Bitte einen Rollennamen angeben.', '', 'error');
return;
}
try {
if (selectedRole.value.id) {
await apiClient.put(`/permissions/${currentClub.value}/roles/${selectedRole.value.id}`, {
name: selectedRole.value.name,
description: selectedRole.value.description,
permissions: selectedRole.value.permissions,
});
} else {
await apiClient.post(`/permissions/${currentClub.value}/roles`, {
name: selectedRole.value.name,
description: selectedRole.value.description,
permissions: selectedRole.value.permissions,
});
}
await loadData(true);
} catch (err) {
console.error('Error saving role:', err);
await showInfo('Fehler', err.response?.data?.error || 'Rolle konnte nicht gespeichert werden.', '', 'error');
}
};
const deleteSelectedRole = async () => {
if (!selectedRole.value?.id) return;
const confirmed = await showConfirm('Rolle löschen', `Möchten Sie die Rolle "${selectedRole.value.name}" wirklich löschen?`, '', 'warning');
if (!confirmed) return;
try {
await apiClient.delete(`/permissions/${currentClub.value}/roles/${selectedRole.value.id}`);
selectedRole.value = null;
await loadData(true);
} catch (err) {
console.error('Error deleting role:', err);
await showInfo('Fehler', err.response?.data?.error || 'Rolle konnte nicht gelöscht werden.', '', 'error');
}
};
const closePermissionsDialog = () => {
selectedMember.value = null;
customPermissions.value = {};
@@ -403,8 +648,7 @@ export default {
const togglePermission = (resourceKey, action) => {
const current = customPermissions.value[resourceKey][action];
const rolePermissions = getRolePermissions(selectedMember.value.role);
const roleValue = rolePermissions[resourceKey]?.[action];
const roleValue = selectedMember.value?.effectivePermissions?.[resourceKey]?.[action] === true;
// Toggle between: role value -> opposite of role value -> role value
if (current === undefined) {
@@ -433,8 +677,7 @@ export default {
if (val === false) return 'Verboten';
// Show role-based permission
const rolePermissions = getRolePermissions(selectedMember.value.role);
const roleValue = rolePermissions[resourceKey]?.[action];
const roleValue = selectedMember.value?.effectivePermissions?.[resourceKey]?.[action] === true;
return roleValue ? 'Erlaubt' : 'Verboten';
};
@@ -453,79 +696,6 @@ export default {
return role ? role.label : roleValue;
};
const getRolePermissions = (role) => {
// Role permissions mapping (should match backend)
const rolePermissions = {
admin: {
diary: { read: true, write: true, delete: true },
members: { read: true, write: true, delete: true },
teams: { read: true, write: true, delete: true },
schedule: { read: true, write: true, delete: true },
tournaments: { read: true, write: true, delete: true },
statistics: { read: true, write: true },
settings: { read: true, write: true },
permissions: { read: true, write: true },
approvals: { read: true, write: true },
mytischtennis_admin: { read: true, write: true },
predefined_activities: { read: true, write: true, delete: true }
},
trainer: {
diary: { read: true, write: true, delete: true },
members: { read: true, write: true, delete: false },
teams: { read: true, write: true, delete: false },
schedule: { read: true, write: false, delete: false },
tournaments: { read: true, write: true, delete: false },
statistics: { read: true, write: false },
settings: { read: false, write: false },
permissions: { read: false, write: false },
approvals: { read: false, write: false },
mytischtennis_admin: { read: false, write: false },
predefined_activities: { read: true, write: true, delete: true }
},
team_manager: {
diary: { read: false, write: false, delete: false },
members: { read: true, write: false, delete: false },
teams: { read: true, write: true, delete: false },
schedule: { read: true, write: true, delete: false },
tournaments: { read: true, write: false, delete: false },
statistics: { read: true, write: false },
settings: { read: false, write: false },
permissions: { read: false, write: false },
approvals: { read: false, write: false },
mytischtennis_admin: { read: false, write: false },
predefined_activities: { read: false, write: false, delete: false }
},
tournament_manager: {
diary: { read: false, write: false, delete: false },
members: { read: true, write: false, delete: false },
teams: { read: false, write: false, delete: false },
schedule: { read: false, write: false, delete: false },
tournaments: { read: true, write: true, delete: false },
statistics: { read: true, write: false },
settings: { read: false, write: false },
permissions: { read: false, write: false },
approvals: { read: false, write: false },
mytischtennis_admin: { read: false, write: false },
predefined_activities: { read: false, write: false, delete: false }
},
member: {
diary: { read: false, write: false, delete: false },
members: { read: false, write: false, delete: false },
teams: { read: false, write: false, delete: false },
schedule: { read: false, write: false, delete: false },
tournaments: { read: false, write: false, delete: false },
statistics: { read: true, write: false },
settings: { read: false, write: false },
permissions: { read: false, write: false },
approvals: { read: false, write: false },
mytischtennis_admin: { read: false, write: false },
predefined_activities: { read: false, write: false, delete: false }
}
};
return rolePermissions[role] || rolePermissions.member;
};
const getActionLabel = (action) => {
const labels = {
read: 'Lesen',
@@ -547,12 +717,21 @@ export default {
return {
t,
pageTitle,
pageSubtitle,
showRoleLegend,
isRolesMode,
customizeButtonLabel,
loading,
error,
members,
availableRoles,
clubRoles,
permissionStructure,
selectedMember,
selectedRole,
roleEditorTitle,
selectedMemberRoleNames,
customPermissions,
isReadOnly,
isOwner,
@@ -563,10 +742,18 @@ export default {
showInfo,
showConfirm,
handleConfirmResult,
updateMemberRole,
memberHasRole,
toggleMemberRole,
toggleMemberStatus,
openPermissionsDialog,
closePermissionsDialog,
selectRole,
startCreateRole,
toggleRolePermission,
resetRoleResource,
resetRolePermissions,
saveRole,
deleteSelectedRole,
saveCustomPermissions,
togglePermission,
resetResource,

View File

@@ -34,13 +34,14 @@ export default defineConfig({
}
},
server: {
host: true,
allowedHosts: ['trainer.localhost', 'club.localhost', 'player.localhost'],
port: 5000,
watch: {
usePolling: true,
},
hmr: {
protocol: 'ws',
host: 'localhost',
port: 5000,
}
},