implementierung der ersten schritte eine komplett-suite

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

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