implementierung der ersten schritte eine komplett-suite
This commit is contained in:
60
backend/controllers/clubAccountController.js
Normal file
60
backend/controllers/clubAccountController.js
Normal 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();
|
||||
19
backend/controllers/clubArchiveController.js
Normal file
19
backend/controllers/clubArchiveController.js
Normal 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();
|
||||
557
backend/controllers/clubDashboardController.js
Normal file
557
backend/controllers/clubDashboardController.js
Normal 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.'),
|
||||
});
|
||||
}
|
||||
};
|
||||
174
backend/controllers/clubRequestController.js
Normal file
174
backend/controllers/clubRequestController.js
Normal 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.'),
|
||||
});
|
||||
}
|
||||
};
|
||||
19
backend/controllers/clubStatisticsController.js
Normal file
19
backend/controllers/clubStatisticsController.js
Normal 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();
|
||||
274
backend/controllers/clubTaskController.js
Normal file
274
backend/controllers/clubTaskController.js
Normal 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.'),
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
};
|
||||
|
||||
|
||||
94
backend/models/ClubAccount.js
Normal file
94
backend/models/ClubAccount.js
Normal 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;
|
||||
78
backend/models/ClubPaymentClaim.js
Normal file
78
backend/models/ClubPaymentClaim.js
Normal 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;
|
||||
94
backend/models/ClubRequest.js
Normal file
94
backend/models/ClubRequest.js
Normal 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;
|
||||
32
backend/models/ClubRequestNote.js
Normal file
32
backend/models/ClubRequestNote.js
Normal 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;
|
||||
58
backend/models/ClubRole.js
Normal file
58
backend/models/ClubRole.js
Normal 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;
|
||||
64
backend/models/ClubSepaMandate.js
Normal file
64
backend/models/ClubSepaMandate.js
Normal 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;
|
||||
94
backend/models/ClubTask.js
Normal file
94
backend/models/ClubTask.js
Normal 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;
|
||||
31
backend/models/ClubTaskSuppression.js
Normal file
31
backend/models/ClubTaskSuppression.js
Normal 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;
|
||||
44
backend/models/ClubUserRole.js
Normal file
44
backend/models/ClubUserRole.js
Normal 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;
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
16
backend/routes/clubAccountRoutes.js
Normal file
16
backend/routes/clubAccountRoutes.js
Normal 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;
|
||||
11
backend/routes/clubArchiveRoutes.js
Normal file
11
backend/routes/clubArchiveRoutes.js
Normal 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;
|
||||
10
backend/routes/clubDashboardRoutes.js
Normal file
10
backend/routes/clubDashboardRoutes.js
Normal 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;
|
||||
20
backend/routes/clubRequestRoutes.js
Normal file
20
backend/routes/clubRequestRoutes.js
Normal 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;
|
||||
11
backend/routes/clubStatisticsRoutes.js
Normal file
11
backend/routes/clubStatisticsRoutes.js
Normal 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;
|
||||
24
backend/routes/clubTaskRoutes.js
Normal file
24
backend/routes/clubTaskRoutes.js
Normal 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;
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
228
backend/services/clubAccountService.js
Normal file
228
backend/services/clubAccountService.js
Normal 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();
|
||||
155
backend/services/clubArchiveService.js
Normal file
155
backend/services/clubArchiveService.js
Normal 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();
|
||||
285
backend/services/clubStatisticsService.js
Normal file
285
backend/services/clubStatisticsService.js
Normal 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();
|
||||
556
backend/services/clubTaskAutomationService.js
Normal file
556
backend/services/clubTaskAutomationService.js
Normal 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();
|
||||
196
backend/services/clubTaskDefinitions.js
Normal file
196
backend/services/clubTaskDefinitions.js
Normal 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;
|
||||
}, {});
|
||||
}
|
||||
65
backend/services/clubWorkflowSourceService.js
Normal file
65
backend/services/clubWorkflowSourceService.js
Normal 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();
|
||||
@@ -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 || ''))) {
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
80
frontend/MULTI_PRODUCT_PLAN.md
Normal file
80
frontend/MULTI_PRODUCT_PLAN.md
Normal 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.
|
||||
@@ -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 – DSGVO‑konform 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">
|
||||
|
||||
202
frontend/sql/tt-verein-v1-schema.mysql.sql
Normal file
202
frontend/sql/tt-verein-v1-schema.mysql.sql
Normal 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;
|
||||
650
frontend/sql/tt-verein-v1-schema.sql
Normal file
650
frontend/sql/tt-verein-v1-schema.sql
Normal 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;
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
348
frontend/src/config/clubDataModels.js
Normal file
348
frontend/src/config/clubDataModels.js
Normal 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',
|
||||
],
|
||||
},
|
||||
};
|
||||
307
frontend/src/config/clubWorkspace.js
Normal file
307
frontend/src/config/clubWorkspace.js
Normal 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;
|
||||
}
|
||||
174
frontend/src/config/products.js
Normal file
174
frontend/src/config/products.js
Normal 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;
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 });
|
||||
|
||||
778
frontend/src/views/ClubAccountsView.vue
Normal file
778
frontend/src/views/ClubAccountsView.vue
Normal 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>
|
||||
457
frontend/src/views/ClubArchiveView.vue
Normal file
457
frontend/src/views/ClubArchiveView.vue
Normal 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>
|
||||
141
frontend/src/views/ClubConceptModuleView.vue
Normal file
141
frontend/src/views/ClubConceptModuleView.vue
Normal 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>
|
||||
759
frontend/src/views/ClubHistoryView.vue
Normal file
759
frontend/src/views/ClubHistoryView.vue
Normal 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>
|
||||
808
frontend/src/views/ClubRequestsView.vue
Normal file
808
frontend/src/views/ClubRequestsView.vue
Normal 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>
|
||||
@@ -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 = '';
|
||||
|
||||
494
frontend/src/views/ClubStatisticsView.vue
Normal file
494
frontend/src/views/ClubStatisticsView.vue
Normal 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>
|
||||
1142
frontend/src/views/ClubTasksView.vue
Normal file
1142
frontend/src/views/ClubTasksView.vue
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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;
|
||||
|
||||
@@ -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'));
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user