Updates, overview extended, club view implemented

This commit is contained in:
Torsten Schulz (local)
2026-06-23 16:17:07 +02:00
parent dd91c07694
commit 90e6c2f9f6
82 changed files with 11226 additions and 201 deletions

View File

@@ -1,6 +1,9 @@
<template>
<section class="diary-sidebar-section">
<h3>{{ $t('diary.participants') }} ({{ participants.length }})</h3>
<p class="participant-summary">
{{ $t('diary.excusedParticipants') }}: {{ excusedCount }} | {{ $t('diary.availableParticipants') }}: {{ availableParticipantCount }} | {{ $t('diary.activeMembers') }}: {{ activeMemberCount }}
</p>
<div class="participant-toolbar">
<div class="participant-toolbar-actions">
<button
@@ -112,6 +115,18 @@ export default {
type: Array,
required: true
},
excusedCount: {
type: Number,
default: 0
},
activeMemberCount: {
type: Number,
default: 0
},
availableParticipantCount: {
type: Number,
default: 0
},
galleryLoading: {
type: Boolean,
default: false
@@ -162,6 +177,12 @@ export default {
overflow-x: auto;
}
.participant-summary {
margin: -0.35rem 0 0.85rem;
color: var(--text-muted, #5f7b8b);
font-size: 0.92rem;
}
.participant-toolbar {
display: flex;
flex-direction: column;

View File

@@ -0,0 +1,163 @@
<template>
<div class="rich-text-editor" :class="{ disabled }">
<div class="editor-toolbar" v-if="!disabled">
<button type="button" class="toolbar-button" @mousedown.prevent @click="format('bold')"><strong>B</strong></button>
<button type="button" class="toolbar-button" @mousedown.prevent @click="format('italic')"><em>I</em></button>
<button type="button" class="toolbar-button" @mousedown.prevent @click="format('underline')"><u>U</u></button>
<button type="button" class="toolbar-button" @mousedown.prevent @click="format('insertUnorderedList')"> Liste</button>
<button type="button" class="toolbar-button" @mousedown.prevent @click="format('insertOrderedList')">1. Liste</button>
<button type="button" class="toolbar-button" @mousedown.prevent="insertLink">Link</button>
<button type="button" class="toolbar-button" @mousedown.prevent @click="clearFormatting">Format löschen</button>
</div>
<div
ref="editor"
class="editor-surface"
:class="{ placeholder: !currentHtml && placeholder }"
:contenteditable="!disabled"
:data-placeholder="placeholder"
:style="{ minHeight }"
@input="handleInput"
@blur="handleBlur"
@paste="handlePaste"
/>
</div>
</template>
<script>
import { sanitizeRichTextHtml } from '../utils/richTextDocumentExport.js';
export default {
name: 'RichTextEditor',
props: {
modelValue: {
type: String,
default: '',
},
placeholder: {
type: String,
default: '',
},
disabled: {
type: Boolean,
default: false,
},
minHeight: {
type: String,
default: '180px',
},
},
emits: ['update:modelValue'],
data() {
return {
currentHtml: '',
};
},
watch: {
modelValue: {
immediate: true,
handler(value) {
const sanitized = sanitizeRichTextHtml(value || '');
this.currentHtml = sanitized;
if (this.$refs.editor && this.$refs.editor.innerHTML !== sanitized) {
this.$refs.editor.innerHTML = sanitized;
}
},
},
},
mounted() {
this.syncEditor();
},
methods: {
syncEditor() {
if (!this.$refs.editor) return;
const sanitized = sanitizeRichTextHtml(this.modelValue || '');
this.$refs.editor.innerHTML = sanitized;
this.currentHtml = sanitized;
},
emitCurrentValue() {
const nextHtml = sanitizeRichTextHtml(this.$refs.editor?.innerHTML || '');
this.currentHtml = nextHtml;
this.$emit('update:modelValue', nextHtml);
},
handleInput() {
this.emitCurrentValue();
},
handleBlur() {
this.emitCurrentValue();
},
handlePaste(event) {
if (this.disabled) return;
event.preventDefault();
const html = event.clipboardData?.getData('text/html');
const text = event.clipboardData?.getData('text/plain') || '';
const value = sanitizeRichTextHtml(html || text.replace(/\n/g, '<br>'));
document.execCommand('insertHTML', false, value || text.replace(/\n/g, '<br>'));
this.emitCurrentValue();
},
format(command) {
if (this.disabled) return;
this.$refs.editor?.focus();
document.execCommand(command, false, null);
this.emitCurrentValue();
},
insertLink() {
if (this.disabled) return;
const url = window.prompt('Link einfügen', 'https://');
if (!url) return;
this.$refs.editor?.focus();
document.execCommand('createLink', false, url);
this.emitCurrentValue();
},
clearFormatting() {
if (this.disabled) return;
this.$refs.editor?.focus();
document.execCommand('removeFormat', false, null);
this.emitCurrentValue();
},
},
};
</script>
<style scoped>
.rich-text-editor {
display: grid;
gap: 0.5rem;
}
.editor-toolbar {
display: flex;
flex-wrap: wrap;
gap: 0.45rem;
}
.toolbar-button {
border: 1px solid rgba(24, 70, 54, 0.12);
background: rgba(255, 255, 255, 0.92);
border-radius: 10px;
padding: 0.35rem 0.6rem;
}
.editor-surface {
border: 1px solid rgba(24, 70, 54, 0.16);
border-radius: 14px;
background: rgba(255, 255, 255, 0.96);
padding: 0.9rem;
line-height: 1.55;
overflow: auto;
}
.editor-surface:focus {
outline: 2px solid rgba(47, 122, 95, 0.25);
outline-offset: 1px;
}
.editor-surface.placeholder:empty::before {
content: attr(data-placeholder);
color: var(--text-light);
}
.rich-text-editor.disabled .editor-surface {
background: rgba(245, 247, 250, 0.95);
color: var(--text-secondary);
}
</style>

View File

@@ -28,10 +28,22 @@
<span class="diary-stat-label">{{ $t('diary.participants') }}</span>
<strong class="diary-stat-value">{{ participantCount }}</strong>
</div>
<div class="diary-stat-card">
<span class="diary-stat-label">{{ $t('diary.excusedParticipants') }}</span>
<strong class="diary-stat-value">{{ excusedCount }}</strong>
</div>
<div class="diary-stat-card">
<span class="diary-stat-label">{{ $t('diary.availableParticipants') }}</span>
<strong class="diary-stat-value">{{ availableParticipantCount }}</strong>
</div>
<div class="diary-stat-card">
<span class="diary-stat-label">{{ $t('diary.trainingPlan') }}</span>
<strong class="diary-stat-value">{{ trainingPlanCount }}</strong>
</div>
<div class="diary-stat-card">
<span class="diary-stat-label">{{ $t('diary.activeMembers') }}</span>
<strong class="diary-stat-value">{{ activeMemberCount }}</strong>
</div>
<div class="diary-stat-card">
<span class="diary-stat-label">{{ $t('diary.freeActivities') }}</span>
<strong class="diary-stat-value">{{ activitiesCount }}</strong>
@@ -127,6 +139,9 @@ export default {
diaryStatusText: { type: String, default: '' },
diaryTimeRangeLabel: { type: String, default: '' },
participantCount: { type: Number, default: 0 },
excusedCount: { type: Number, default: 0 },
activeMemberCount: { type: Number, default: 0 },
availableParticipantCount: { type: Number, default: 0 },
trainingPlanCount: { type: Number, default: 0 },
activitiesCount: { type: Number, default: 0 },
trainingStart: { type: String, default: '' },
@@ -214,9 +229,9 @@ export default {
.diary-workspace-stats {
display: grid;
grid-template-columns: repeat(4, minmax(110px, 1fr));
grid-template-columns: repeat(6, minmax(110px, 1fr));
gap: 0.75rem;
width: min(560px, 100%);
width: min(840px, 100%);
}
.diary-stat-card {
@@ -308,7 +323,7 @@ export default {
}
.diary-workspace-stats {
grid-template-columns: repeat(2, minmax(92px, 1fr));
grid-template-columns: repeat(3, minmax(92px, 1fr));
gap: 0.5rem;
width: 100%;
}

View File

@@ -22,6 +22,7 @@ export const CLUB_DATA_MODELS = {
'billing_email',
'iban',
'bic',
'fee_rules',
'is_archived',
'archived_at',
'created_at',
@@ -116,8 +117,8 @@ export const CLUB_DATA_MODELS = {
],
},
event: {
table: 'club_events',
purpose: 'Vereinstermine für Training, Spiele und Vereinsveranstaltungen.',
table: 'calendar_events',
purpose: 'Vereinstermine für Training, Spiele und Vereinsveranstaltungen mit Fristen und Zuständigkeiten.',
fields: [
'id',
'club_id',
@@ -126,10 +127,11 @@ export const CLUB_DATA_MODELS = {
'title',
'description',
'location',
'starts_at',
'ends_at',
'start_date',
'end_date',
'registration_deadline',
'organizer_user_id',
'notes',
'created_at',
'updated_at',
'archived_at',
@@ -137,7 +139,7 @@ export const CLUB_DATA_MODELS = {
},
document: {
table: 'club_documents',
purpose: 'Dokumentenstamm für Satzung, Protokolle, Formulare und Vereinsdokumente.',
purpose: 'Dokumentenstamm für Satzung, Protokolle, Formulare und Vereinsdokumente mit Versionen, Sichtbarkeit und Belegbezug.',
fields: [
'id',
'club_id',
@@ -180,23 +182,30 @@ export const CLUB_DATA_MODELS = {
],
},
sponsor: {
table: 'club_sponsors',
purpose: 'Sponsorenbeziehung mit Ansprechpartnern, Verträgen und Zahlungsbezug.',
table: 'club_invoice_parties',
purpose: 'Sponsorenbeziehung innerhalb der Rechnungsparteien mit Ansprechpartnern, Verträgen und Laufzeiten.',
fields: [
'id',
'club_id',
'name',
'party_type',
'status',
'website',
'name',
'contract_reference',
'valid_from',
'valid_to',
'contact_name',
'email',
'phone',
'street',
'postal_code',
'city',
'country_code',
'iban',
'bic',
'tax_identifier',
'notes',
'created_at',
'updated_at',
'archived_at',
],
},
feeRule: {
@@ -278,12 +287,14 @@ export const CLUB_DATA_MODELS = {
'status',
'due_on',
'amount_cents',
'paid_amount_cents',
'currency_code',
'reminder_level',
'last_reminder_at',
'created_at',
'updated_at',
'settled_at',
'last_paid_at',
'archived_at',
],
},

View File

@@ -85,11 +85,11 @@ export const CLUB_DASHBOARD_SECTIONS = [
];
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: '🗂️' },
{ to: '/club-tasks', label: 'Aufgaben steuern', icon: '✅', permission: ['tasks', 'read'] },
{ to: '/club-requests', label: 'Anfragen bearbeiten', icon: '📥', permission: ['requests', 'read'] },
{ to: '/members', label: 'Mitglieder öffnen', icon: '👥', permission: ['members', 'read'] },
{ to: '/club-payments', label: 'Zahlungen prüfen', icon: '💶', permission: ['finance_accounts', 'read'] },
{ to: '/club-documents', label: 'Dokumente verwalten', icon: '🗂️', permission: ['settings', 'read'] },
];
export const CLUB_MENU_SECTIONS = [
@@ -98,9 +98,9 @@ export const CLUB_MENU_SECTIONS = [
title: 'Hauptmenü',
items: [
{ to: '/', icon: '🏠', label: 'Dashboard' },
{ to: '/club-requests', icon: '📥', label: 'Anfragen', permission: ['approvals', 'read'] },
{ to: '/club-requests', icon: '📥', label: 'Anfragen', permission: ['requests', 'read'] },
{ to: '/members', icon: '👥', label: 'Mitglieder', permission: ['members', 'read'] },
{ to: '/club-communication', icon: '💬', label: 'Kommunikation', permission: ['members', 'read'] },
{ to: '/club-communication', icon: '💬', label: 'Kommunikation', permission: ['communication', 'read'] },
{ to: '/calendar', icon: '📆', label: 'Termine', permission: ['schedule', 'read'] },
{ to: '/club-documents', icon: '🗂️', label: 'Dokumente', permission: ['settings', 'read'] },
],
@@ -109,7 +109,7 @@ export const CLUB_MENU_SECTIONS = [
id: 'organisation',
title: 'Organisation',
items: [
{ to: '/club-tasks', icon: '✅', label: 'Aufgaben', permission: ['approvals', 'read'] },
{ to: '/club-tasks', icon: '✅', label: 'Aufgaben', permission: ['tasks', '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'] },
@@ -119,10 +119,10 @@ export const CLUB_MENU_SECTIONS = [
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'] },
{ to: '/club-fees', icon: '💳', label: 'Beiträge', permission: ['finance_invoices', 'write'] },
{ to: '/club-payments', icon: '💶', label: 'Zahlungen', permission: ['finance_accounts', 'write'] },
{ to: '/club-invoices', icon: '🧾', label: 'Rechnungen', permission: ['finance_invoices', 'write'] },
{ to: '/club-accounts', icon: '🏦', label: 'Konten', permission: ['finance_accounts', 'write'] },
],
},
{
@@ -131,7 +131,7 @@ export const CLUB_MENU_SECTIONS = [
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-history', icon: '🕘', label: 'Historie', permission: ['history', 'read'] },
{ to: '/club-settings', icon: '⚙️', label: 'Einstellungen', capability: 'admin' },
],
},
@@ -141,7 +141,7 @@ export const CLUB_MENU_SECTIONS = [
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'] },
{ to: '/club-archive', icon: '🗄️', label: 'Archiv', permission: ['archive', 'read'] },
],
},
];

View File

@@ -903,6 +903,9 @@
"durationExampleShort": "z.B. 2x7",
"showImage": "Bild/Zeichnung anzeigen",
"participants": "Teilnehmer",
"excusedParticipants": "Entschuldigt",
"availableParticipants": "Anwesend möglich",
"activeMembers": "Aktive Mitglieder",
"searchParticipants": "Teilnehmer suchen",
"filterAll": "Alle",
"filterPresent": "Anwesend",

View File

@@ -652,6 +652,9 @@
"durationExampleShort": "z.B. 2x7",
"showImage": "Bild/Zeichnung anzeigen",
"participants": "Teilnehmer",
"excusedParticipants": "Entschuldigt",
"availableParticipants": "Anwesend möglich",
"activeMembers": "Aktive Mitglieder",
"searchParticipants": "Teilnehmer suchen",
"filterAll": "Alle",
"filterPresent": "Anwesend",

View File

@@ -741,6 +741,9 @@
"durationExampleShort": "z.B. 2x7",
"showImage": "Bild/Zeichnung anzeigen",
"participants": "Teilnehmer",
"excusedParticipants": "Entschuldigt",
"availableParticipants": "Anwesend möglich",
"activeMembers": "Aktive Mitglieder",
"searchParticipants": "Teilnehmer suchen",
"filterAll": "Alle",
"filterPresent": "Anwesend",

View File

@@ -714,6 +714,9 @@
"durationExampleShort": "例如2x7",
"showImage": "显示图片/图示",
"participants": "参与者",
"excusedParticipants": "已请假",
"availableParticipants": "可到场",
"activeMembers": "活跃成员",
"searchParticipants": "搜索参与者",
"filterAll": "全部",
"filterPresent": "出席",

View File

@@ -43,6 +43,8 @@ const ClubStatisticsView = () => import('./views/ClubStatisticsView.vue');
const ClubArchiveView = () => import('./views/ClubArchiveView.vue');
const ClubAccountsView = () => import('./views/ClubAccountsView.vue');
const ClubInvoicesView = () => import('./views/ClubInvoicesView.vue');
const ClubCommunicationView = () => import('./views/ClubCommunicationView.vue');
const ClubOperationsWorkspaceView = () => import('./views/ClubOperationsWorkspaceView.vue');
const ClubConceptModuleView = () => import('./views/ClubConceptModuleView.vue');
const Impressum = () => import('./views/Impressum.vue');
const Datenschutz = () => import('./views/Datenschutz.vue');
@@ -142,6 +144,13 @@ const conceptRoutes = CLUB_CONCEPT_ROUTES
.filter((route) => route.path !== '/club-archive')
.filter((route) => route.path !== '/club-accounts')
.filter((route) => route.path !== '/club-invoices')
.filter((route) => route.path !== '/club-communication')
.filter((route) => route.path !== '/club-documents')
.filter((route) => route.path !== '/club-events')
.filter((route) => route.path !== '/club-sponsors')
.filter((route) => route.path !== '/club-fees')
.filter((route) => route.path !== '/club-payments')
.filter((route) => route.path !== '/club-reports')
.map((route) => ({
path: route.path,
name: route.name,
@@ -185,13 +194,20 @@ const routes = [
{ 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-requests', name: 'club-requests', component: ClubRequestsView, meta: withMeta({ products: clubOnly, permission: ['requests', 'read'] }) },
{ path: '/club-tasks', name: 'club-tasks', component: ClubTasksView, meta: withMeta({ products: clubOnly, permission: ['tasks', 'read'] }) },
{ path: '/club-history', name: 'club-history', component: ClubHistoryView, meta: withMeta({ products: clubOnly, permission: ['history', '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'] }) },
{ path: '/club-invoices', name: 'club-invoices', component: ClubInvoicesView, meta: withMeta({ products: clubOnly, permission: ['members', 'read'] }) },
{ path: '/club-archive', name: 'club-archive', component: ClubArchiveView, meta: withMeta({ products: clubOnly, permission: ['archive', 'read'] }) },
{ path: '/club-accounts', name: 'club-accounts', component: ClubAccountsView, meta: withMeta({ products: clubOnly, permission: ['finance_accounts', 'read'] }) },
{ path: '/club-invoices', name: 'club-invoices', component: ClubInvoicesView, meta: withMeta({ products: clubOnly, permission: ['finance_invoices', 'read'] }) },
{ path: '/club-communication', name: 'club-communication', component: ClubCommunicationView, meta: withMeta({ products: clubOnly, permission: ['communication', 'read'] }) },
{ path: '/club-documents', name: 'club-documents', component: ClubOperationsWorkspaceView, props: { moduleKey: 'documents' }, meta: withMeta({ products: clubOnly, permission: ['settings', 'read'] }) },
{ path: '/club-events', name: 'club-events', component: ClubOperationsWorkspaceView, props: { moduleKey: 'events' }, meta: withMeta({ products: clubOnly, permission: ['schedule', 'read'] }) },
{ path: '/club-sponsors', name: 'club-sponsors', component: ClubOperationsWorkspaceView, props: { moduleKey: 'sponsors' }, meta: withMeta({ products: clubOnly, permission: ['settings', 'read'] }) },
{ path: '/club-fees', name: 'club-fees', component: ClubOperationsWorkspaceView, props: { moduleKey: 'fees' }, meta: withMeta({ products: clubOnly, permission: ['finance_invoices', 'write'] }) },
{ path: '/club-payments', name: 'club-payments', component: ClubOperationsWorkspaceView, props: { moduleKey: 'payments' }, meta: withMeta({ products: clubOnly, permission: ['finance_accounts', 'read'] }) },
{ path: '/club-reports', name: 'club-reports', component: ClubOperationsWorkspaceView, props: { moduleKey: 'reports' }, meta: withMeta({ products: clubOnly, permission: ['statistics', '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 }) },

View File

@@ -0,0 +1,117 @@
import jsPDF from 'jspdf';
import autoTable from 'jspdf-autotable';
function downloadBlob(blob, filename) {
const url = window.URL.createObjectURL(blob);
const anchor = document.createElement('a');
anchor.href = url;
anchor.download = filename;
anchor.style.display = 'none';
document.body.appendChild(anchor);
anchor.click();
document.body.removeChild(anchor);
window.URL.revokeObjectURL(url);
}
function escapeCsvCell(value) {
return `"${String(value ?? '').replace(/"/g, '""').replace(/\r?\n/g, ' ')}"`;
}
export function downloadCsvReport(filename, sections = []) {
const lines = [];
sections.forEach((section, sectionIndex) => {
if (sectionIndex > 0) {
lines.push('');
}
if (section.title) {
lines.push(escapeCsvCell(section.title));
}
if (Array.isArray(section.headers) && section.headers.length > 0) {
lines.push(section.headers.map((header) => escapeCsvCell(header)).join(','));
}
(Array.isArray(section.rows) ? section.rows : []).forEach((row) => {
lines.push(row.map((value) => escapeCsvCell(value)).join(','));
});
});
const blob = new Blob([`${lines.join('\n')}\n`], { type: 'text/csv;charset=utf-8;' });
downloadBlob(blob, filename);
}
export function exportReportPdf({
filename,
title,
subtitle = '',
sections = [],
}) {
const doc = new jsPDF({ unit: 'pt', format: 'a4' });
const pageWidth = doc.internal.pageSize.getWidth();
const marginX = 40;
let cursorY = 48;
doc.setFont('helvetica', 'bold');
doc.setFontSize(18);
doc.text(title, marginX, cursorY);
cursorY += 18;
if (subtitle) {
doc.setFont('helvetica', 'normal');
doc.setFontSize(10.5);
doc.setTextColor(90, 90, 90);
const wrapped = doc.splitTextToSize(subtitle, pageWidth - marginX * 2);
doc.text(wrapped, marginX, cursorY);
cursorY += wrapped.length * 12 + 4;
doc.setTextColor(0, 0, 0);
}
doc.setFontSize(9);
doc.setFont('helvetica', 'normal');
doc.text(`Erstellt am ${new Intl.DateTimeFormat('de-DE', { dateStyle: 'medium', timeStyle: 'short' }).format(new Date())}`, marginX, cursorY);
cursorY += 18;
sections.forEach((section) => {
if (cursorY > doc.internal.pageSize.getHeight() - 120) {
doc.addPage();
cursorY = 42;
}
if (section.title) {
doc.setFont('helvetica', 'bold');
doc.setFontSize(12);
doc.text(section.title, marginX, cursorY);
cursorY += 14;
}
if (Array.isArray(section.lines)) {
doc.setFont('helvetica', 'normal');
doc.setFontSize(10);
section.lines.forEach((line) => {
const wrapped = doc.splitTextToSize(`- ${line}`, pageWidth - marginX * 2);
doc.text(wrapped, marginX, cursorY);
cursorY += wrapped.length * 11;
});
cursorY += 6;
}
if (section.table && Array.isArray(section.table.rows) && section.table.rows.length > 0) {
autoTable(doc, {
startY: cursorY,
head: [section.table.headers || []],
body: section.table.rows,
margin: { left: marginX, right: marginX },
styles: { fontSize: 9, cellPadding: 4 },
headStyles: { fillColor: [24, 70, 54] },
theme: 'grid',
});
cursorY = (doc.lastAutoTable?.finalY || cursorY) + 16;
} else if (section.table) {
doc.setFont('helvetica', 'italic');
doc.setFontSize(10);
doc.text('Keine Einträge vorhanden.', marginX, cursorY);
cursorY += 14;
}
});
doc.save(filename);
}

View File

@@ -0,0 +1,429 @@
import jsPDF from 'jspdf';
import {
AlignmentType,
Document,
HeadingLevel,
Paragraph,
TextRun,
Packer,
} from 'docx';
import JSZip from 'jszip';
const BLOCK_TAGS = new Set(['P', 'DIV', 'BLOCKQUOTE', 'UL', 'OL', 'LI', 'H1', 'H2', 'H3', 'H4', 'H5', 'H6']);
const INLINE_TAGS = new Set(['STRONG', 'B', 'EM', 'I', 'U', 'A', 'SPAN']);
const ALLOWED_TAGS = new Set(['BR', ...BLOCK_TAGS, ...INLINE_TAGS]);
function getDomParser() {
return typeof DOMParser !== 'undefined' ? new DOMParser() : null;
}
export function sanitizeRichTextHtml(input = '') {
const parser = getDomParser();
if (!parser) return String(input || '');
const doc = parser.parseFromString(`<div>${String(input || '')}</div>`, 'text/html');
const root = doc.body.firstElementChild || doc.body;
const cleanseNode = (node) => {
if (node.nodeType === Node.TEXT_NODE) {
return;
}
if (node.nodeType !== Node.ELEMENT_NODE) {
node.remove();
return;
}
const tagName = node.tagName.toUpperCase();
if (!ALLOWED_TAGS.has(tagName)) {
const parent = node.parentNode;
const children = Array.from(node.childNodes);
children.forEach((child) => parent.insertBefore(child, node));
node.remove();
children.forEach(cleanseNode);
return;
}
[...node.attributes].forEach((attribute) => {
const name = attribute.name.toLowerCase();
if (tagName === 'A' && name === 'href') {
const value = String(attribute.value || '').trim();
const allowed = /^(https?:|mailto:|tel:|#|\/)/i.test(value);
if (!allowed) {
node.removeAttribute(attribute.name);
}
return;
}
node.removeAttribute(attribute.name);
});
if (tagName === 'A') {
const href = node.getAttribute('href');
if (href) {
node.setAttribute('rel', 'noreferrer noopener');
node.setAttribute('target', '_blank');
}
}
Array.from(node.childNodes).forEach(cleanseNode);
};
Array.from(root.childNodes).forEach(cleanseNode);
return root.innerHTML;
}
export function stripRichTextToText(input = '') {
const parser = getDomParser();
if (!parser) {
return String(input || '')
.replace(/<br\s*\/?>/gi, '\n')
.replace(/<\/p>\s*<p>/gi, '\n\n')
.replace(/<[^>]+>/g, '')
.trim();
}
const doc = parser.parseFromString(`<div>${sanitizeRichTextHtml(input)}</div>`, 'text/html');
const root = doc.body.firstElementChild || doc.body;
const collect = (node) => {
if (node.nodeType === Node.TEXT_NODE) {
return node.textContent || '';
}
if (node.nodeType !== Node.ELEMENT_NODE) {
return '';
}
const tagName = node.tagName.toUpperCase();
if (tagName === 'BR') return '\n';
if (tagName === 'LI') return `- ${Array.from(node.childNodes).map(collect).join('').trim()}\n`;
const text = Array.from(node.childNodes).map(collect).join('');
if (BLOCK_TAGS.has(tagName)) {
return `\n${text.trim()}\n`;
}
return text;
};
return collect(root)
.replace(/\n{3,}/g, '\n\n')
.replace(/[ \t]+\n/g, '\n')
.trim();
}
function parseInlineRuns(node, style = {}) {
if (node.nodeType === Node.TEXT_NODE) {
const text = node.textContent || '';
return text ? [{ text, ...style }] : [];
}
if (node.nodeType !== Node.ELEMENT_NODE) {
return [];
}
const tagName = node.tagName.toUpperCase();
if (tagName === 'BR') {
return [{ text: '\n', ...style }];
}
const nextStyle = { ...style };
if (tagName === 'STRONG' || tagName === 'B') nextStyle.bold = true;
if (tagName === 'EM' || tagName === 'I') nextStyle.italics = true;
if (tagName === 'U') nextStyle.underline = {};
if (tagName === 'A') {
nextStyle.color = '0563C1';
nextStyle.underline = {};
}
return Array.from(node.childNodes).flatMap((child) => parseInlineRuns(child, nextStyle));
}
function htmlToDocxParagraphs(input = '') {
const parser = getDomParser();
if (!parser) {
return [new Paragraph(String(input || '').trim())];
}
const doc = parser.parseFromString(`<div>${sanitizeRichTextHtml(input)}</div>`, 'text/html');
const root = doc.body.firstElementChild || doc.body;
const paragraphs = [];
const pushParagraph = (node, options = {}) => {
const runs = Array.from(node.childNodes).flatMap((child) => parseInlineRuns(child));
const textRuns = runs.length ? runs : [{ text: node.textContent || '' }];
paragraphs.push(new Paragraph({
children: textRuns.map((run) => new TextRun(run)),
spacing: { after: 180 },
bullet: options.bullet ? { level: 0 } : undefined,
heading: options.heading || undefined,
alignment: options.alignment || AlignmentType.LEFT,
}));
};
const walk = (node) => {
if (!node) return;
if (node.nodeType === Node.TEXT_NODE) {
const text = (node.textContent || '').trim();
if (text) {
paragraphs.push(new Paragraph({ children: [new TextRun(text)], spacing: { after: 180 } }));
}
return;
}
if (node.nodeType !== Node.ELEMENT_NODE) return;
const tagName = node.tagName.toUpperCase();
if (tagName === 'UL' || tagName === 'OL') {
Array.from(node.children).forEach((child, index) => {
if (child.tagName?.toUpperCase() !== 'LI') return;
const runs = Array.from(child.childNodes).flatMap((grandChild) => parseInlineRuns(grandChild));
const textRuns = runs.length ? runs : [{ text: child.textContent || '' }];
paragraphs.push(new Paragraph({
children: textRuns.map((run) => new TextRun(run)),
bullet: tagName === 'UL' ? { level: 0 } : undefined,
numbering: tagName === 'OL' ? { reference: 'number-list', level: 0 } : undefined,
spacing: { after: 120 },
}));
});
return;
}
if (tagName === 'H1' || tagName === 'H2' || tagName === 'H3' || tagName === 'H4' || tagName === 'H5' || tagName === 'H6') {
const headingMap = {
H1: HeadingLevel.HEADING_1,
H2: HeadingLevel.HEADING_2,
H3: HeadingLevel.HEADING_3,
H4: HeadingLevel.HEADING_4,
H5: HeadingLevel.HEADING_5,
H6: HeadingLevel.HEADING_6,
};
pushParagraph(node, { heading: headingMap[tagName] });
return;
}
if (tagName === 'BLOCKQUOTE') {
pushParagraph(node);
return;
}
if (tagName === 'LI') {
pushParagraph(node, { bullet: true });
return;
}
if (tagName === 'P' || tagName === 'DIV') {
pushParagraph(node);
return;
}
Array.from(node.children).forEach(walk);
};
Array.from(root.children).forEach(walk);
if (paragraphs.length === 0) {
paragraphs.push(new Paragraph({ children: [new TextRun(stripRichTextToText(input) || '')], spacing: { after: 180 } }));
}
return paragraphs;
}
function escapeXml(value = '') {
return String(value || '')
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&apos;');
}
function htmlToOdtParagraphs(input = '') {
const parser = getDomParser();
if (!parser) {
return [escapeXml(String(input || '').trim())];
}
const doc = parser.parseFromString(`<div>${sanitizeRichTextHtml(input)}</div>`, 'text/html');
const root = doc.body.firstElementChild || doc.body;
const paragraphs = [];
const inlineText = (node) => {
if (node.nodeType === Node.TEXT_NODE) {
return escapeXml(node.textContent || '');
}
if (node.nodeType !== Node.ELEMENT_NODE) {
return '';
}
const tagName = node.tagName.toUpperCase();
if (tagName === 'BR') return '<text:line-break/>';
const text = Array.from(node.childNodes).map(inlineText).join('');
if (tagName === 'LI') {
return `${text}`;
}
return text;
};
const walk = (node) => {
if (node.nodeType === Node.TEXT_NODE) {
const text = escapeXml((node.textContent || '').trim());
if (text) paragraphs.push(`<text:p>${text}</text:p>`);
return;
}
if (node.nodeType !== Node.ELEMENT_NODE) return;
const tagName = node.tagName.toUpperCase();
if (tagName === 'UL' || tagName === 'OL') {
Array.from(node.children).forEach((child, index) => {
if (child.tagName?.toUpperCase() !== 'LI') return;
const prefix = tagName === 'OL' ? `${index + 1}. ` : '• ';
paragraphs.push(`<text:p>${prefix}${Array.from(child.childNodes).map(inlineText).join('')}</text:p>`);
});
return;
}
if (tagName === 'H1' || tagName === 'H2' || tagName === 'H3' || tagName === 'H4' || tagName === 'H5' || tagName === 'H6') {
paragraphs.push(`<text:p>${Array.from(node.childNodes).map(inlineText).join('')}</text:p>`);
return;
}
if (tagName === 'P' || tagName === 'DIV' || tagName === 'BLOCKQUOTE' || tagName === 'LI') {
paragraphs.push(`<text:p>${Array.from(node.childNodes).map(inlineText).join('')}</text:p>`);
return;
}
Array.from(node.children).forEach(walk);
};
Array.from(root.children).forEach(walk);
if (paragraphs.length === 0) {
paragraphs.push(`<text:p>${escapeXml(stripRichTextToText(input) || '')}</text:p>`);
}
return paragraphs;
}
async function downloadBlob(blob, filename) {
const url = window.URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = filename;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
window.URL.revokeObjectURL(url);
}
export async function exportRichTextPdf({ title, subject = '', bodyHtml = '', filename = 'schreiben.pdf' }) {
const html = sanitizeRichTextHtml(bodyHtml);
const doc = new jsPDF({ unit: 'pt', format: 'a4' });
const container = document.createElement('div');
container.style.width = '540pt';
container.style.padding = '24pt';
container.innerHTML = `
<div style="font-family: Arial, sans-serif; color: #1f2937;">
<h1 style="font-size: 20pt; margin: 0 0 8pt;">${escapeXml(title || 'Schreiben')}</h1>
${subject ? `<p style="margin: 0 0 12pt; color: #4b5563;"><strong>Betreff:</strong> ${escapeXml(subject)}</p>` : ''}
<div style="font-size: 11.5pt; line-height: 1.55;">${html}</div>
</div>
`;
return new Promise((resolve, reject) => {
doc.html(container, {
callback: async (pdf) => {
try {
await downloadBlob(pdf.output('blob'), filename);
resolve();
} catch (error) {
reject(error);
}
},
x: 24,
y: 24,
width: 540,
windowWidth: 960,
});
});
}
export async function exportRichTextDocx({ title, subject = '', bodyHtml = '', filename = 'schreiben.docx' }) {
const paragraphs = [
new Paragraph({
children: [new TextRun({ text: title || 'Schreiben', bold: true, size: 28 })],
spacing: { after: 180 },
}),
];
if (subject) {
paragraphs.push(new Paragraph({
children: [new TextRun({ text: `Betreff: ${subject}`, bold: true })],
spacing: { after: 180 },
}));
}
paragraphs.push(...htmlToDocxParagraphs(bodyHtml));
const document = new Document({
numbering: {
config: [{
reference: 'number-list',
levels: [{
level: 0,
format: 'decimal',
text: '%1.',
alignment: AlignmentType.START,
}],
}],
},
sections: [{
children: paragraphs,
}],
});
const blob = await Packer.toBlob(document);
await downloadBlob(blob, filename);
}
export async function exportRichTextOdt({ title, subject = '', bodyHtml = '', filename = 'schreiben.odt' }) {
const zip = new JSZip();
zip.file('mimetype', 'application/vnd.oasis.opendocument.text', { compression: 'STORE' });
zip.file('content.xml', `<?xml version="1.0" encoding="UTF-8"?>
<office:document-content xmlns:office="urn:oasis:names:tc:opendocument:xmlns:office:1.0"
xmlns:text="urn:oasis:names:tc:opendocument:xmlns:text:1.0"
xmlns:style="urn:oasis:names:tc:opendocument:xmlns:style:1.0"
xmlns:fo="urn:oasis:names:tc:opendocument:xmlns:xsl-fo-compatible:1.0"
office:version="1.2">
<office:body>
<office:text>
<text:h text:outline-level="1">${escapeXml(title || 'Schreiben')}</text:h>
${subject ? `<text:p>Betreff: ${escapeXml(subject)}</text:p>` : ''}
${htmlToOdtParagraphs(bodyHtml).join('\n')}
</office:text>
</office:body>
</office:document-content>`);
zip.file('styles.xml', `<?xml version="1.0" encoding="UTF-8"?>
<office:document-styles xmlns:office="urn:oasis:names:tc:opendocument:xmlns:office:1.0"
xmlns:style="urn:oasis:names:tc:opendocument:xmlns:style:1.0"
office:version="1.2">
<office:styles/>
</office:document-styles>`);
zip.file('meta.xml', `<?xml version="1.0" encoding="UTF-8"?>
<office:document-meta xmlns:office="urn:oasis:names:tc:opendocument:xmlns:office:1.0"
office:version="1.2">
<office:meta/>
</office:document-meta>`);
zip.folder('META-INF').file('manifest.xml', `<?xml version="1.0" encoding="UTF-8"?>
<manifest:manifest xmlns:manifest="urn:oasis:names:tc:opendocument:xmlns:manifest:1.0" manifest:version="1.2">
<manifest:file-entry manifest:full-path="/" manifest:media-type="application/vnd.oasis.opendocument.text"/>
<manifest:file-entry manifest:full-path="content.xml" manifest:media-type="text/xml"/>
<manifest:file-entry manifest:full-path="styles.xml" manifest:media-type="text/xml"/>
<manifest:file-entry manifest:full-path="meta.xml" manifest:media-type="text/xml"/>
</manifest:manifest>`);
const blob = await zip.generateAsync({ type: 'blob', mimeType: 'application/vnd.oasis.opendocument.text' });
await downloadBlob(blob, filename);
}
export function exportRichTextPlainText({ title, subject = '', bodyHtml = '', filename = 'schreiben.txt' }) {
const lines = [
title || 'Schreiben',
subject ? `Betreff: ${subject}` : '',
'',
stripRichTextToText(bodyHtml),
].filter(Boolean);
const blob = new Blob([lines.join('\n')], { type: 'text/plain;charset=utf-8' });
return downloadBlob(blob, filename);
}

View File

@@ -712,6 +712,11 @@ export default {
return (response.data || []).map(event => {
const date = this.parseDate(event.startDate);
const endDate = this.parseDate(event.endDate || event.startDate);
const subtitleParts = [
event.category || this.$t('calendar.customEvent.subtitleFallback'),
event.status || '',
event.registrationDeadline ? `Frist ${this.formatDate(event.registrationDeadline)}` : '',
].filter(Boolean);
return {
id: `custom-event-${event.id}`,
customEventId: event.id,
@@ -721,7 +726,7 @@ export default {
startsAt: this.combineDateTime(date),
time: '',
title: event.title,
subtitle: event.category || this.$t('calendar.customEvent.subtitleFallback'),
subtitle: subtitleParts.join(' · '),
};
});
},
@@ -734,6 +739,8 @@ export default {
startDate: this.customEventForm.startDate,
endDate: this.customEventForm.endDate || this.customEventForm.startDate,
category: this.customEventForm.category || null,
eventType: 'club_event',
status: 'planning',
});
this.ensureSuccess(response, this.$t('calendar.sources.customEvents'));
this.customEventForm = { title: '', startDate: '', endDate: '', category: '' };

View File

@@ -11,6 +11,10 @@
<button type="button" class="btn-primary" :disabled="!canEdit" @click="resetForm">Neues Konto</button>
</header>
<div v-if="!canEdit && currentClub" class="state-banner">
Lesemodus aktiv. Konten und Kontobewegungen können angezeigt, aber nicht bearbeitet werden.
</div>
<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>
@@ -109,6 +113,52 @@
</div>
</div>
</section>
<section class="card accounts-list-card">
<div class="section-header accounts-list-header">
<h3>Kontobewegungen</h3>
<button type="button" class="btn-secondary" :disabled="!canEdit || !selectedAccount" @click="resetTransactionForm">
Neue Buchung
</button>
</div>
<p v-if="!selectedAccount" class="state-banner">Bitte zuerst ein Konto auswählen.</p>
<p v-else-if="selectedTransactions.length === 0" class="state-banner">Für dieses Konto gibt es noch keine Kontobewegungen.</p>
<div v-else class="accounts-list">
<div
v-for="transaction in selectedTransactions"
:key="transaction.id"
class="account-row"
:class="{ active: selectedTransaction?.id === transaction.id }"
>
<div class="account-row-main">
<div class="account-row-topline">
<strong>{{ transaction.reference || 'Manuelle Buchung' }}</strong>
<div class="account-badge-row">
<span class="account-badge" :class="`direction-${transaction.direction}`">{{ displayTransactionDirection(transaction.direction) }}</span>
<span class="account-badge" :class="`status-${transaction.status}`">{{ displayTransactionStatus(transaction.status) }}</span>
<span class="account-badge" :class="`booking-${transaction.bookingType}`">{{ displayBookingType(transaction.bookingType) }}</span>
</div>
</div>
<p class="account-row-meta">
<span>{{ transaction.bookingDate || 'Kein Buchungsdatum' }}</span>
<span>{{ formatTransactionAmount(transaction) }}</span>
<span v-if="transaction.invoiceId">Rechnung #{{ transaction.invoiceId }}</span>
<span v-if="transaction.paymentClaimId">Forderung #{{ transaction.paymentClaimId }}</span>
</p>
<p v-if="transaction.paymentClaim?.member" class="account-row-meta">
{{ displayPaymentClaimMember(transaction.paymentClaim.member) }}
</p>
<p class="account-row-description">{{ transaction.notes || 'Keine Notiz hinterlegt.' }}</p>
</div>
<div class="account-row-actions">
<button type="button" class="btn-secondary" @click="selectTransaction(transaction)">Bearbeiten</button>
<button type="button" class="btn-danger" :disabled="!canEdit || transaction.bookingType !== 'manual'" @click="deleteTransaction(transaction)">Löschen</button>
</div>
</div>
</div>
</section>
</div>
<aside class="accounts-side">
@@ -207,6 +257,85 @@
</form>
</section>
<section class="card account-form-card">
<div class="section-header">
<h3>{{ transactionForm.id ? 'Buchung bearbeiten' : 'Neue Buchung' }}</h3>
</div>
<form class="account-form" @submit.prevent="submitTransaction">
<div class="account-form-grid">
<label>
<span>Art</span>
<select v-model="transactionForm.direction" :disabled="!canEdit || transactionForm.bookingType !== 'manual'">
<option value="credit">Einnahme</option>
<option value="debit">Ausgabe</option>
</select>
</label>
<label>
<span>Status</span>
<select v-model="transactionForm.status" :disabled="!canEdit || transactionForm.bookingType !== 'manual'">
<option value="planned">Geplant</option>
<option value="booked">Gebucht</option>
<option value="cancelled">Storniert</option>
</select>
</label>
</div>
<div class="account-form-grid">
<label>
<span>Buchungsdatum</span>
<input v-model="transactionForm.bookingDate" type="date" :disabled="!canEdit || transactionForm.bookingType !== 'manual'" required />
</label>
<label>
<span>Wertstellung</span>
<input v-model="transactionForm.valueDate" type="date" :disabled="!canEdit || transactionForm.bookingType !== 'manual'" />
</label>
</div>
<div class="account-form-grid">
<label>
<span>Betrag (EUR)</span>
<input v-model.number="transactionForm.amountEuro" type="number" min="0" step="0.01" :disabled="!canEdit || transactionForm.bookingType !== 'manual'" required />
</label>
<label>
<span>Währung</span>
<input v-model.trim="transactionForm.currencyCode" type="text" maxlength="3" :disabled="!canEdit || transactionForm.bookingType !== 'manual'" />
</label>
</div>
<label>
<span>Forderung</span>
<select v-model="transactionForm.paymentClaimId" :disabled="!canEdit || transactionForm.bookingType !== 'manual'">
<option value="">Ohne Zuordnung</option>
<option v-for="claim in openPaymentClaims" :key="claim.id" :value="String(claim.id)">
{{ displayPaymentClaimOption(claim) }}
</option>
</select>
</label>
<label>
<span>Referenz</span>
<input v-model.trim="transactionForm.reference" type="text" :disabled="!canEdit || transactionForm.bookingType !== 'manual'" />
</label>
<label>
<span>Notiz</span>
<textarea v-model.trim="transactionForm.notes" rows="4" :disabled="!canEdit || transactionForm.bookingType !== 'manual'"></textarea>
</label>
<p v-if="transactionForm.bookingType !== 'manual'" class="detail-label">
Diese Buchung wurde automatisch erzeugt und kann nicht manuell geändert werden.
</p>
<div class="account-form-actions">
<button type="submit" class="btn-primary" :disabled="transactionSaving || !canEdit || !selectedAccount || transactionForm.bookingType !== 'manual'">
{{ transactionSaving ? 'Speichert…' : 'Buchung speichern' }}
</button>
<button type="button" class="btn-secondary" @click="resetTransactionForm">Zurücksetzen</button>
</div>
</form>
</section>
<section v-if="selectedAccount" class="card account-detail-card">
<div class="section-header">
<h3>Details</h3>
@@ -288,6 +417,42 @@ function normalizeAccount(payload = {}) {
};
}
function normalizeTransaction(payload = {}) {
return {
id: payload.id,
accountId: Number(payload.accountId || payload.account_id || 0) || null,
invoiceId: Number(payload.invoiceId || payload.invoice_id || 0) || null,
paymentClaimId: Number(payload.paymentClaimId || payload.payment_claim_id || 0) || null,
paymentClaim: payload.paymentClaim || payload.payment_claim || null,
direction: payload.direction || 'credit',
bookingType: payload.bookingType || payload.booking_type || 'manual',
status: payload.status || 'booked',
bookingDate: payload.bookingDate || payload.booking_date || '',
valueDate: payload.valueDate || payload.value_date || '',
amountCents: Number(payload.amountCents || payload.amount_cents || 0),
currencyCode: payload.currencyCode || payload.currency_code || 'EUR',
reference: payload.reference || '',
notes: payload.notes || '',
};
}
function createEmptyTransactionForm(accountId = '') {
return {
id: null,
accountId: accountId ? String(accountId) : '',
direction: 'credit',
bookingType: 'manual',
status: 'booked',
bookingDate: new Date().toISOString().slice(0, 10),
valueDate: '',
amountEuro: 0,
currencyCode: 'EUR',
paymentClaimId: '',
reference: '',
notes: '',
};
}
export default {
name: 'ClubAccountsView',
components: {
@@ -298,9 +463,13 @@ export default {
return {
loading: false,
saving: false,
transactionSaving: false,
loadError: '',
accounts: [],
transactions: [],
paymentClaims: [],
selectedAccountId: null,
selectedTransactionId: null,
filters: {
status: '',
accountType: '',
@@ -322,6 +491,7 @@ export default {
status: 'active',
notes: '',
},
transactionForm: createEmptyTransactionForm(),
infoDialog: {
isOpen: false,
title: '',
@@ -344,7 +514,7 @@ export default {
computed: {
...mapGetters(['currentClub', 'hasPermission']),
canEdit() {
return this.hasPermission('members', 'write');
return this.hasPermission('finance_accounts', 'write');
},
filteredAccounts() {
const needle = this.filters.search.trim().toLowerCase();
@@ -358,6 +528,15 @@ export default {
selectedAccount() {
return this.accounts.find((account) => String(account.id) === String(this.selectedAccountId)) || null;
},
selectedTransactions() {
return this.transactions.filter((transaction) => String(transaction.accountId) === String(this.selectedAccountId));
},
openPaymentClaims() {
return this.paymentClaims.filter((claim) => ['open', 'partially_paid'].includes(claim.status));
},
selectedTransaction() {
return this.selectedTransactions.find((transaction) => String(transaction.id) === String(this.selectedTransactionId)) || null;
},
defaultAccount() {
return this.accounts.find((account) => account.isDefault && account.status !== 'archived') || null;
},
@@ -375,7 +554,12 @@ export default {
async handler(newClub) {
if (!newClub) {
this.accounts = [];
this.transactions = [];
this.selectedAccountId = null;
this.selectedTransactionId = null;
this.resetForm();
this.resetTransactionForm();
this.loadError = '';
return;
}
await this.loadAccounts();
@@ -388,7 +572,10 @@ export default {
},
},
selectedAccount(account) {
if (!account) return;
if (!account) {
this.resetForm();
return;
}
this.form = {
id: account.id,
name: account.name,
@@ -405,6 +592,29 @@ export default {
status: account.status,
notes: account.notes,
};
if (!this.transactionForm.accountId || this.transactionForm.accountId !== String(account.id)) {
this.resetTransactionForm(account.id);
}
},
selectedTransaction(transaction) {
if (!transaction) {
this.resetTransactionForm();
return;
}
this.transactionForm = {
id: transaction.id,
accountId: transaction.accountId ? String(transaction.accountId) : '',
direction: transaction.direction,
bookingType: transaction.bookingType,
status: transaction.status,
bookingDate: transaction.bookingDate || '',
valueDate: transaction.valueDate || '',
amountEuro: Number(transaction.amountCents || 0) / 100,
currencyCode: transaction.currencyCode || 'EUR',
paymentClaimId: transaction.paymentClaimId ? String(transaction.paymentClaimId) : '',
reference: transaction.reference || '',
notes: transaction.notes || '',
};
},
},
methods: {
@@ -464,13 +674,45 @@ export default {
petty_cash: 'Barkasse',
}[usageType] || usageType;
},
displayTransactionDirection(direction) {
return direction === 'debit' ? 'Ausgabe' : 'Einnahme';
},
displayTransactionStatus(status) {
return {
planned: 'Geplant',
booked: 'Gebucht',
cancelled: 'Storniert',
}[status] || status;
},
displayBookingType(bookingType) {
return {
manual: 'Manuell',
invoice: 'Rechnung',
adjustment: 'Korrektur',
payment_claim: 'Forderung',
}[bookingType] || bookingType;
},
displayPaymentClaimMember(member) {
return [member?.lastName, member?.firstName].filter(Boolean).join(', ') || member?.email || 'Mitglied';
},
displayIban(iban) {
if (!iban) return 'Keine IBAN';
return String(iban).replace(/(.{4})/g, '$1 ').trim();
},
formatTransactionAmount(transaction) {
const amount = Number(transaction?.amountCents || 0) / 100;
const signedAmount = transaction?.direction === 'debit' ? amount * -1 : amount;
return new Intl.NumberFormat('de-DE', {
style: 'currency',
currency: transaction?.currencyCode || 'EUR',
}).format(signedAmount);
},
selectAccount(account) {
this.selectedAccountId = account.id;
},
selectTransaction(transaction) {
this.selectedTransactionId = transaction.id;
},
resetForm() {
this.selectedAccountId = null;
this.form = {
@@ -490,6 +732,22 @@ export default {
notes: '',
};
},
resetTransactionForm() {
this.selectedTransactionId = null;
this.transactionForm = createEmptyTransactionForm(this.selectedAccountId || '');
},
async loadPaymentClaims() {
if (!this.currentClub) {
this.paymentClaims = [];
return;
}
try {
const response = await apiClient.get(`/club-payment-claims/${this.currentClub}`);
this.paymentClaims = Array.isArray(response.data?.claims) ? response.data.claims : [];
} catch (_error) {
this.paymentClaims = [];
}
},
async loadAccounts() {
if (!this.currentClub) return;
this.loading = true;
@@ -498,10 +756,15 @@ export default {
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.transactions = Array.isArray(response.data?.transactions) ? response.data.transactions.map(normalizeTransaction) : [];
await this.loadPaymentClaims();
this.applyRouteQuery();
if (this.selectedAccountId && !this.selectedAccount) {
this.selectedAccountId = null;
}
if (this.selectedTransactionId && !this.selectedTransaction) {
this.selectedTransactionId = null;
}
if (!this.selectedAccountId && this.accounts.length > 0) {
this.selectedAccountId = this.accounts[0].id;
}
@@ -529,6 +792,36 @@ export default {
this.saving = false;
}
},
async submitTransaction() {
if (!this.currentClub || !this.canEdit || !this.selectedAccount) return;
this.transactionSaving = true;
try {
const payload = {
accountId: this.transactionForm.accountId || this.selectedAccount.id,
direction: this.transactionForm.direction,
bookingType: this.transactionForm.bookingType,
status: this.transactionForm.status,
bookingDate: this.transactionForm.bookingDate || null,
valueDate: this.transactionForm.valueDate || null,
amountCents: Math.round(Number(this.transactionForm.amountEuro || 0) * 100),
currencyCode: this.transactionForm.currencyCode || 'EUR',
paymentClaimId: this.transactionForm.paymentClaimId || null,
reference: this.transactionForm.reference,
notes: this.transactionForm.notes,
};
if (this.transactionForm.id) {
await apiClient.put(`/club-accounts/${this.currentClub}/transactions/${this.transactionForm.id}`, payload);
} else {
await apiClient.post(`/club-accounts/${this.currentClub}/transactions`, payload);
}
await this.loadAccounts();
this.resetTransactionForm();
} catch (error) {
this.showInfo('Fehler', safeErrorMessage(error, 'Kontobewegung konnte nicht gespeichert werden.'), '', 'error');
} finally {
this.transactionSaving = false;
}
},
async archiveAccount(account) {
if (!account?.id || !this.canEdit) return;
const confirmed = await this.showConfirm(
@@ -568,6 +861,33 @@ export default {
this.showInfo('Fehler', safeErrorMessage(error, 'Konto konnte nicht gelöscht werden.'), '', 'error');
}
},
async deleteTransaction(transaction) {
if (!transaction?.id || !this.canEdit || transaction.bookingType !== 'manual') return;
const confirmed = await this.showConfirm(
'Kontobewegung löschen',
`Soll die Buchung "${transaction.reference || 'Manuelle Buchung'}" gelöscht werden?`,
'',
'danger',
{ confirmText: 'Löschen', cancelText: 'Abbrechen' }
);
if (!confirmed) return;
try {
await apiClient.delete(`/club-accounts/${this.currentClub}/transactions/${transaction.id}`);
if (this.selectedTransaction?.id === transaction.id) {
this.resetTransactionForm();
}
await this.loadAccounts();
} catch (error) {
this.showInfo('Fehler', safeErrorMessage(error, 'Kontobewegung konnte nicht gelöscht werden.'), '', 'error');
}
},
displayPaymentClaimOption(claim) {
const member = claim?.member || {};
const memberName = [member?.lastName, member?.firstName].filter(Boolean).join(', ') || `Forderung #${claim?.id || '?'}`;
const remaining = Number(claim?.remainingAmountCents || claim?.amountCents || 0) / 100;
return `${memberName} · ${new Intl.NumberFormat('de-DE', { style: 'currency', currency: claim?.currencyCode || 'EUR' }).format(remaining)} · ${claim?.dueOn || 'ohne Fälligkeit'}`;
},
},
};
</script>
@@ -643,6 +963,29 @@ export default {
gap: 1rem;
}
.account-badge.direction-credit,
.account-badge.booking-manual {
background: rgba(46, 125, 50, 0.12);
color: #1f6d2b;
}
.account-badge.direction-debit,
.account-badge.booking-invoice {
background: rgba(198, 40, 40, 0.12);
color: #9a1d1d;
}
.account-badge.booking-adjustment,
.account-badge.status-planned {
background: rgba(191, 144, 0, 0.12);
color: #8b6a00;
}
.account-badge.status-cancelled {
background: rgba(97, 97, 97, 0.12);
color: #525252;
}
.accounts-filter-grid,
.account-form-grid {
display: grid;

File diff suppressed because it is too large Load Diff

View File

@@ -11,6 +11,10 @@
<button type="button" class="btn-primary" :disabled="!canEdit" @click="resetInvoiceForm">Neue Rechnung</button>
</header>
<div v-if="!canEdit && currentClub" class="state-banner">
Lesemodus aktiv. Rechnungen und Parteien können angezeigt, aber nicht bearbeitet werden.
</div>
<section v-if="!currentClub" class="card empty-state">
<h3>Kein Verein ausgewählt</h3>
<p>Bitte zuerst einen Verein auswählen, um Rechnungen zu verwalten.</p>
@@ -110,6 +114,51 @@
<input v-model.trim="partyForm.email" type="email" :disabled="!canEdit" />
</label>
</div>
<div class="invoice-form-grid">
<label>
<span>Status</span>
<select v-model="partyForm.status" :disabled="!canEdit">
<option value="active">Aktiv</option>
<option value="prospect">Interessent</option>
<option value="paused">Pausiert</option>
<option value="ended">Beendet</option>
</select>
</label>
<label>
<span>Vertragsreferenz</span>
<input v-model.trim="partyForm.contractReference" type="text" :disabled="!canEdit" />
</label>
</div>
<div class="invoice-form-grid">
<label>
<span>Vertragsbeginn</span>
<input v-model="partyForm.validFrom" type="date" :disabled="!canEdit" />
</label>
<label>
<span>Vertragsende</span>
<input v-model="partyForm.validTo" type="date" :disabled="!canEdit" />
</label>
</div>
<div class="invoice-form-grid">
<label>
<span>Straße</span>
<input v-model.trim="partyForm.street" type="text" :disabled="!canEdit" />
</label>
<label>
<span>Ort</span>
<input v-model.trim="partyForm.city" type="text" :disabled="!canEdit" />
</label>
</div>
<div class="invoice-form-grid">
<label>
<span>PLZ</span>
<input v-model.trim="partyForm.postalCode" type="text" :disabled="!canEdit" />
</label>
<label>
<span>Notizen</span>
<input v-model.trim="partyForm.notes" type="text" :disabled="!canEdit" />
</label>
</div>
<div class="invoice-form-actions">
<button type="submit" class="btn-primary" :disabled="!canEdit || partySaving">{{ partySaving ? 'Speichert…' : 'Partei speichern' }}</button>
<button type="button" class="btn-secondary" @click="resetPartyForm">Zurücksetzen</button>
@@ -336,9 +385,17 @@ function normalizeParty(payload = {}) {
id: payload.id,
name: payload.name || '',
partyType: payload.partyType || payload.party_type || 'customer',
status: payload.status || 'active',
contractReference: payload.contractReference || payload.contract_reference || '',
validFrom: payload.validFrom || payload.valid_from || '',
validTo: payload.validTo || payload.valid_to || '',
contactName: payload.contactName || payload.contact_name || '',
email: payload.email || '',
phone: payload.phone || '',
street: payload.street || '',
postalCode: payload.postalCode || payload.postal_code || '',
city: payload.city || '',
notes: payload.notes || '',
};
}
@@ -394,7 +451,8 @@ function addDaysIso(baseDate, days) {
}
function buildInvoiceNumber(prefix, nextNumber, referenceDate = new Date()) {
const year = referenceDate.getFullYear();
const parsedReferenceDate = referenceDate instanceof Date ? referenceDate : new Date(referenceDate || new Date());
const year = Number.isNaN(parsedReferenceDate.getTime()) ? new Date().getFullYear() : parsedReferenceDate.getFullYear();
const normalizedPrefix = String(prefix || '').trim().toUpperCase();
const paddedCounter = String(Math.max(1, Number.parseInt(nextNumber, 10) || 1)).padStart(4, '0');
return normalizedPrefix ? `${normalizedPrefix}-${year}-${paddedCounter}` : `${year}-${paddedCounter}`;
@@ -447,9 +505,17 @@ export default {
id: null,
name: '',
partyType: 'customer',
status: 'active',
contractReference: '',
validFrom: '',
validTo: '',
contactName: '',
email: '',
phone: '',
street: '',
postalCode: '',
city: '',
notes: '',
},
infoDialog: {
isOpen: false,
@@ -473,7 +539,7 @@ export default {
computed: {
...mapGetters(['currentClub', 'hasPermission']),
canEdit() {
return this.hasPermission('members', 'write');
return this.hasPermission('finance_invoices', 'write');
},
filteredInvoices() {
const needle = this.filters.search.trim().toLowerCase();
@@ -525,9 +591,11 @@ export default {
return this.invoiceForm.invoiceNumber || '';
}
const isIncoming = this.invoiceForm.invoiceDirection === 'incoming';
const referenceDate = this.invoiceForm.issuedOn || new Date();
return buildInvoiceNumber(
isIncoming ? this.invoiceSettings.incomingInvoicePrefix : this.invoiceSettings.outgoingInvoicePrefix,
isIncoming ? this.invoiceSettings.incomingInvoiceNextNumber : this.invoiceSettings.outgoingInvoiceNextNumber
isIncoming ? this.invoiceSettings.incomingInvoiceNextNumber : this.invoiceSettings.outgoingInvoiceNextNumber,
referenceDate
);
},
},
@@ -546,13 +614,25 @@ export default {
incomingInvoiceNextNumber: 1,
};
this.selectedInvoiceId = null;
this.resetInvoiceForm();
this.resetPartyForm();
this.loadError = '';
return;
}
await this.loadInvoices();
},
},
'$route.query': {
immediate: true,
handler() {
this.applyWorkflowQueryPrefill();
},
},
selectedInvoice(invoice) {
if (!invoice) return;
if (!invoice) {
this.resetInvoiceForm();
return;
}
this.invoiceForm = {
id: invoice.id,
invoiceDirection: invoice.invoiceDirection,
@@ -578,14 +658,25 @@ export default {
};
},
selectedParty(party) {
if (!party) return;
if (!party) {
this.resetPartyForm();
return;
}
this.partyForm = {
id: party.id,
name: party.name,
partyType: party.partyType,
status: party.status || 'active',
contractReference: party.contractReference || '',
validFrom: party.validFrom || '',
validTo: party.validTo || '',
contactName: party.contactName,
email: party.email,
phone: party.phone,
street: party.street || '',
postalCode: party.postalCode || '',
city: party.city || '',
notes: party.notes || '',
};
},
},
@@ -661,6 +752,7 @@ export default {
if (!this.selectedInvoiceId && this.invoices.length > 0) {
this.selectedInvoiceId = this.invoices[0].id;
}
this.applyWorkflowQueryPrefill();
} catch (error) {
this.loadError = safeErrorMessage(error, 'Rechnungen konnten nicht geladen werden.');
} finally {
@@ -673,6 +765,37 @@ export default {
selectParty(party) {
this.selectedPartyId = party.id;
},
applyWorkflowQueryPrefill() {
if (!this.currentClub || !Array.isArray(this.parties)) return;
const partyId = String(this.$route?.query?.partyId || this.$route?.query?.selectedPartyId || '').trim();
if (!partyId) return;
const matchingParty = this.parties.find((party) => String(party.id) === partyId);
if (!matchingParty) return;
this.selectedPartyId = matchingParty.id;
this.selectedInvoiceId = null;
this.invoiceForm = {
id: null,
invoiceDirection: this.$route?.query?.invoiceDirection || 'outgoing',
invoiceType: this.$route?.query?.invoiceType || 'sponsoring',
status: 'draft',
invoiceNumber: '',
externalReference: this.$route?.query?.externalReference || '',
partyId: String(matchingParty.id),
accountId: '',
issuedOn: new Date().toISOString().slice(0, 10),
dueOn: addDaysIso(new Date(), 14),
paidOn: '',
currencyCode: 'EUR',
description: this.$route?.query?.invoiceDescription || `Sponsoringrechnung für ${matchingParty.name}`,
items: [createEmptyInvoiceItem()],
};
if (!this.invoiceForm.items[0].description) {
this.invoiceForm.items[0].description = `Sponsoring ${matchingParty.name}`;
this.invoiceForm.items[0].unitPriceEuro = 0;
this.invoiceForm.items[0].taxRate = 0;
}
},
resetInvoiceForm() {
this.selectedInvoiceId = null;
const issuedOn = new Date().toISOString().slice(0, 10);
@@ -699,9 +822,17 @@ export default {
id: null,
name: '',
partyType: 'customer',
status: 'active',
contractReference: '',
validFrom: '',
validTo: '',
contactName: '',
email: '',
phone: '',
street: '',
postalCode: '',
city: '',
notes: '',
};
},
resetPartyForm() {

File diff suppressed because it is too large Load Diff

View File

@@ -124,6 +124,53 @@
</div>
</section>
<section v-if="currentClub && !loading" class="card">
<h2>Beitragsregeln</h2>
<p class="hint">Einfaches manuelles Regelwerk für typische Vereinsfälle. Die Regeln werden nur als Beitragsstamm gespeichert, nicht automatisch abgerechnet.</p>
<div class="fee-rule-presets">
<button type="button" class="btn btn-secondary" @click="addFeeRulePreset('jugend')">Jugend</button>
<button type="button" class="btn btn-secondary" @click="addFeeRulePreset('erwachsene')">Erwachsene</button>
<button type="button" class="btn btn-secondary" @click="addFeeRulePreset('familie')">Familie</button>
<button type="button" class="btn btn-secondary" @click="addFeeRulePreset('passiv')">Passiv</button>
</div>
<div v-if="feeRules.length === 0" class="hint">Noch keine Beitragsregeln angelegt.</div>
<div v-else class="fee-rules-list">
<article v-for="(rule, index) in feeRules" :key="rule.id || index" class="fee-rule-card">
<div class="field-grid">
<div class="field-group">
<label>Gruppe</label>
<input v-model.trim="rule.code" class="text-input" placeholder="z. B. Jugend" />
</div>
<div class="field-group">
<label>Bezeichnung</label>
<input v-model.trim="rule.label" class="text-input" placeholder="z. B. Jugendbeitrag" />
</div>
<div class="field-group">
<label>Betrag (EUR)</label>
<input v-model.number="rule.amountEuro" class="text-input" type="number" min="0" step="0.01" />
</div>
<div class="field-group">
<label>Rhythmus</label>
<select v-model="rule.cycle" class="text-input">
<option value="monthly">monatlich</option>
<option value="quarterly">vierteljährlich</option>
<option value="half_yearly">halbjährlich</option>
<option value="yearly">jährlich</option>
<option value="one_time">einmalig</option>
</select>
</div>
</div>
<label>
<span>Hinweis</span>
<textarea v-model.trim="rule.note" class="text-input" rows="2" placeholder="z. B. für Seniorenbeitrag oder Familienregel"></textarea>
</label>
<div class="actions">
<button type="button" class="btn btn-danger" @click="removeFeeRule(index)">Entfernen</button>
</div>
</article>
</div>
</section>
<section v-if="currentClub && !loading" class="card">
<h2>Rechnungsnummern</h2>
<p class="hint">Automatische Nummernvergabe für Ausgangs- und Eingangsrechnungen.</p>
@@ -273,6 +320,7 @@ export default {
myTischtennisFedNickname: '',
autoFetchRankings: false,
memberDataQualityRequirements: defaultMemberDataQualityRequirements(),
feeRules: [],
outgoingInvoicePrefix: 'RE',
outgoingInvoiceNextNumber: 1,
incomingInvoicePrefix: 'EI',
@@ -343,6 +391,7 @@ export default {
this.myTischtennisFedNickname = '';
this.autoFetchRankings = false;
this.memberDataQualityRequirements = defaultMemberDataQualityRequirements();
this.feeRules = [];
this.outgoingInvoicePrefix = 'RE';
this.outgoingInvoiceNextNumber = 1;
this.incomingInvoicePrefix = 'EI';
@@ -362,6 +411,7 @@ export default {
this.myTischtennisFedNickname = club?.myTischtennisFedNickname ?? '';
this.autoFetchRankings = !!club?.autoFetchRankings;
this.memberDataQualityRequirements = this.normalizeMemberDataQualityRequirements(club?.memberDataQualityRequirements);
this.feeRules = this.normalizeFeeRules(club?.feeRules);
this.outgoingInvoicePrefix = club?.outgoingInvoicePrefix ?? 'RE';
this.outgoingInvoiceNextNumber = Number(club?.outgoingInvoiceNextNumber ?? 1) || 1;
this.incomingInvoicePrefix = club?.incomingInvoicePrefix ?? 'EI';
@@ -375,6 +425,7 @@ export default {
this.myTischtennisFedNickname = '';
this.autoFetchRankings = false;
this.memberDataQualityRequirements = defaultMemberDataQualityRequirements();
this.feeRules = [];
this.outgoingInvoicePrefix = 'RE';
this.outgoingInvoiceNextNumber = 1;
this.incomingInvoicePrefix = 'EI';
@@ -396,6 +447,45 @@ export default {
])
);
},
normalizeFeeRules(rules) {
if (!Array.isArray(rules)) {
return [];
}
return rules
.map((rule, index) => {
if (!rule || typeof rule !== 'object' || Array.isArray(rule)) {
return null;
}
const amountCents = Number.parseInt(rule.amountCents ?? Math.round(Number(rule.amountEuro || 0) * 100), 10);
return {
id: rule.id || `${Date.now()}-${index}`,
code: String(rule.code || '').trim(),
label: String(rule.label || '').trim(),
amountEuro: Number.isFinite(amountCents) ? amountCents / 100 : 0,
cycle: ['monthly', 'quarterly', 'half_yearly', 'yearly', 'one_time'].includes(String(rule.cycle || 'monthly'))
? String(rule.cycle || 'monthly')
: 'monthly',
note: String(rule.note || '').trim(),
};
})
.filter((rule) => rule && (rule.code || rule.label || Number(rule.amountEuro || 0) > 0));
},
addFeeRulePreset(kind) {
const presets = {
jugend: { code: 'Jugend', label: 'Jugendbeitrag', amountEuro: 0, cycle: 'quarterly', note: 'Typischer Jugendbeitrag' },
erwachsene: { code: 'Erwachsene', label: 'Erwachsenenbeitrag', amountEuro: 0, cycle: 'quarterly', note: 'Typischer Erwachsenenbeitrag' },
familie: { code: 'Familie', label: 'Familienbeitrag', amountEuro: 0, cycle: 'quarterly', note: 'Familienregel' },
passiv: { code: 'Passiv', label: 'Passivbeitrag', amountEuro: 0, cycle: 'yearly', note: 'Passive Mitgliedschaft' },
};
const preset = presets[kind];
if (!preset) {
return;
}
this.feeRules = [...this.feeRules, { id: `${Date.now()}-${this.feeRules.length}`, ...preset }];
},
removeFeeRule(index) {
this.feeRules.splice(index, 1);
},
parseJsonSetting(value) {
try {
return JSON.parse(value);
@@ -504,6 +594,10 @@ export default {
myTischtennisFedNickname: this.myTischtennisFedNickname || null,
autoFetchRankings: this.autoFetchRankings,
memberDataQualityRequirements: this.normalizeMemberDataQualityRequirements(this.memberDataQualityRequirements),
feeRules: this.normalizeFeeRules(this.feeRules).map((rule) => ({
...rule,
amountCents: Math.round(Number(rule.amountEuro || 0) * 100),
})),
outgoingInvoicePrefix: this.normalizeInvoicePrefix(this.outgoingInvoicePrefix, 'RE'),
outgoingInvoiceNextNumber: this.normalizeInvoiceNextNumber(this.outgoingInvoiceNextNumber),
incomingInvoicePrefix: this.normalizeInvoicePrefix(this.incomingInvoicePrefix, 'EI'),
@@ -545,6 +639,14 @@ export default {
.text-input { width: 100%; border: 1px solid #ddd; border-radius: 6px; padding: 8px; font-size: 14px; }
.rankings-row { margin-bottom: 12px; }
.rankings-fields { margin-top: 12px; }
.fee-rule-presets { display: flex; gap: 8px; flex-wrap: wrap; margin: 10px 0 14px; }
.fee-rules-list { display: grid; gap: 12px; }
.fee-rule-card {
border: 1px solid #e5e7eb;
border-radius: 8px;
padding: 12px;
background: #fbfcfe;
}
.field-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px; }
.quality-options { display: grid; gap: 10px; margin-top: 12px; }
.field-group label { display: block; margin-bottom: 4px; font-weight: 500; color: #333; }

View File

@@ -8,9 +8,13 @@
Aufgaben, Wiedervorlagen und Fristen für den täglichen Vereinsbetrieb in einer gemeinsamen Arbeitsfläche.
</p>
</div>
<button type="button" class="btn-primary" @click="resetForm">Neue Aufgabe</button>
<button type="button" class="btn-primary" :disabled="!canEdit" @click="resetForm">Neue Aufgabe</button>
</header>
<div v-if="!canEdit && currentClub" class="state-banner">
Lesemodus aktiv. Aufgaben können angezeigt, aber nicht bearbeitet werden.
</div>
<section v-if="!currentClub" class="card empty-state">
<h3>Kein Verein ausgewählt</h3>
<p>Bitte zuerst einen Verein auswählen, um Aufgaben zu verwalten.</p>
@@ -110,13 +114,14 @@
type="button"
class="btn-secondary"
@click="archiveTask(task)"
:disabled="task.status === 'archived'"
:disabled="!canEdit || task.status === 'archived'"
>
Archivieren
</button>
<button
type="button"
class="btn-danger"
:disabled="!canEdit"
@click="deleteTask(task)"
>
Löschen
@@ -130,7 +135,7 @@
<article class="card task-suggestion-card">
<div class="section-header">
<h3>Automatische Vorschläge</h3>
<div class="section-header-actions">
<div class="section-header-actions">
<button
type="button"
class="btn-secondary"
@@ -142,7 +147,7 @@
type="button"
class="btn-secondary"
@click="materializeSuggestions(filteredSuggestions.map((suggestion) => suggestion.automationKey))"
:disabled="materializing || filteredSuggestions.length === 0"
:disabled="!canEdit || materializing || filteredSuggestions.length === 0"
>
{{ materializing ? 'Erstellt…' : 'Alle übernehmen' }}
</button>
@@ -168,7 +173,7 @@
type="button"
class="btn-secondary"
@click="materializeSuggestions([suggestion.automationKey])"
:disabled="materializing"
:disabled="!canEdit || materializing"
>
Vorschlag übernehmen
</button>
@@ -176,7 +181,7 @@
type="button"
class="btn-secondary"
@click="dismissSuggestion(suggestion)"
:disabled="materializing"
:disabled="!canEdit || materializing"
>
Vorschlag ausblenden
</button>
@@ -194,12 +199,12 @@
<form class="task-form" @submit.prevent="submitTask">
<label>
<span>Titel</span>
<input v-model.trim="form.title" type="text" placeholder="Titel" required />
<input v-model.trim="form.title" type="text" placeholder="Titel" :disabled="!canEdit" required />
</label>
<div class="task-form-grid">
<label>
<span>Status</span>
<select v-model="form.status">
<select v-model="form.status" :disabled="!canEdit">
<option value="open">Offen</option>
<option value="in_progress">In Bearbeitung</option>
<option value="waiting">Wartend</option>
@@ -210,7 +215,7 @@
</label>
<label>
<span>Priorität</span>
<select v-model="form.priority">
<select v-model="form.priority" :disabled="!canEdit">
<option value="low">Niedrig</option>
<option value="normal">Normal</option>
<option value="high">Hoch</option>
@@ -221,16 +226,16 @@
<div class="task-form-grid">
<label>
<span>Fällig am</span>
<input v-model="form.dueAt" type="datetime-local" />
<input v-model="form.dueAt" type="datetime-local" :disabled="!canEdit" />
</label>
<label>
<span>Erinnerung</span>
<input v-model="form.remindAt" type="datetime-local" />
<input v-model="form.remindAt" type="datetime-local" :disabled="!canEdit" />
</label>
</div>
<label>
<span>Zuständig</span>
<select v-model="form.assignedUserId">
<select v-model="form.assignedUserId" :disabled="!canEdit">
<option value="">Nicht zugewiesen</option>
<option v-for="user in assignableUsers" :key="user.userId" :value="String(user.userId)">
{{ user.label }}
@@ -239,15 +244,31 @@
</label>
<label>
<span>Beschreibung</span>
<textarea v-model.trim="form.description" rows="5" placeholder="Beschreibung der Aufgabe"></textarea>
<textarea v-model.trim="form.description" rows="5" placeholder="Beschreibung der Aufgabe" :disabled="!canEdit"></textarea>
</label>
<div class="task-form-actions">
<button type="submit" class="btn-primary" :disabled="saving">{{ saving ? 'Speichert…' : 'Speichern' }}</button>
<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 class="card task-detail-card">
<div class="section-header">
<h3>Aufgabenquellen</h3>
</div>
<div v-if="workflowSources.length" class="task-definition-list">
<article v-for="source in workflowSources" :key="source.key" class="task-definition-item">
<div class="task-definition-topline">
<strong>{{ source.label }}</strong>
<span class="task-definition-category">{{ source.key }}</span>
</div>
<p>{{ source.description }}</p>
</article>
</div>
<p v-else class="state-banner">Noch keine Aufgabenquellen geladen.</p>
</section>
<section v-if="selectedTask" class="card task-detail-card">
<div class="section-header">
<h3>Details</h3>
@@ -255,7 +276,7 @@
<div class="detail-stack">
<div>
<span class="detail-label">Status</span>
<select v-model="selectedTask.status" @change="updateTaskStatus(selectedTask)">
<select v-model="selectedTask.status" :disabled="!canEdit" @change="updateTaskStatus(selectedTask)">
<option value="open">Offen</option>
<option value="in_progress">In Bearbeitung</option>
<option value="waiting">Wartend</option>
@@ -294,13 +315,14 @@
type="button"
class="btn-secondary"
@click="archiveSelectedTask"
:disabled="selectedTask.status === 'archived'"
:disabled="!canEdit || selectedTask.status === 'archived'"
>
Archivieren
</button>
<button
type="button"
class="btn-danger"
:disabled="!canEdit"
@click="deleteSelectedTask"
>
Endgültig löschen
@@ -414,6 +436,7 @@ export default {
tasks: [],
taskDefinitions: [],
taskSuggestions: [],
workflowSources: [],
assignableUsers: [],
selectedTaskId: null,
filters: {
@@ -452,7 +475,10 @@ export default {
};
},
computed: {
...mapGetters(['currentClub']),
...mapGetters(['currentClub', 'hasPermission']),
canEdit() {
return this.hasPermission('tasks', 'write');
},
filteredTasks() {
const search = this.filters.search.trim().toLowerCase();
return this.tasks.filter((task) => {
@@ -483,7 +509,10 @@ export default {
async handler(newClub) {
if (!newClub) {
this.tasks = [];
this.workflowSources = [];
this.selectedTaskId = null;
this.resetForm();
this.loadError = '';
return;
}
await this.loadTasks();
@@ -496,18 +525,20 @@ export default {
},
},
selectedTask(task) {
if (task) {
this.form = {
id: task.id,
title: task.title,
description: task.description,
status: task.status,
priority: task.priority,
dueAt: toDateTimeLocal(task.dueAt),
remindAt: toDateTimeLocal(task.remindAt),
assignedUserId: task.assignedUserId ? String(task.assignedUserId) : '',
};
if (!task) {
this.resetForm();
return;
}
this.form = {
id: task.id,
title: task.title,
description: task.description,
status: task.status,
priority: task.priority,
dueAt: toDateTimeLocal(task.dueAt),
remindAt: toDateTimeLocal(task.remindAt),
assignedUserId: task.assignedUserId ? String(task.assignedUserId) : '',
};
},
},
methods: {
@@ -625,6 +656,7 @@ export default {
: [];
this.taskDefinitions = Array.isArray(response.data?.taskDefinitions) ? response.data.taskDefinitions : [];
this.taskSuggestions = Array.isArray(response.data?.taskSuggestions) ? response.data.taskSuggestions : [];
this.workflowSources = Array.isArray(response.data?.workflowSources) ? response.data.workflowSources : [];
this.assignableUsers = Array.isArray(response.data?.assignableUsers)
? response.data.assignableUsers
.map((user) => this.normalizeAssignableUser(user))

View File

@@ -85,6 +85,9 @@
:diary-status-text="diaryStatusText"
:diary-time-range-label="diaryTimeRangeLabel"
:participant-count="participants.length"
:excused-count="excusedActiveMemberCount"
:active-member-count="activeMemberCount"
:available-participant-count="availableParticipantCount"
:training-plan-count="trainingPlan.length"
:activities-count="activities.length"
:training-start="trainingStart"
@@ -711,6 +714,9 @@
v-show="!isMobileView || activeTab === 'members'"
:members="filteredDiaryMembers"
:participants="participants"
:excused-count="excusedActiveMemberCount"
:active-member-count="activeMemberCount"
:available-participant-count="availableParticipantCount"
:gallery-loading="galleryLoading"
:participant-search-query="participantSearchQuery"
:participant-filter="participantFilter"
@@ -1146,6 +1152,18 @@ export default {
const presentSet = new Set(this.participants);
return this.members.filter(m => presentSet.has(m.id));
},
activeMembers() {
return (this.members || []).filter(member => member && member.active);
},
activeMemberCount() {
return this.activeMembers.length;
},
excusedActiveMemberCount() {
return this.activeMembers.filter(member => this.getParticipantStatus(member.id) === 'excused').length;
},
availableParticipantCount() {
return Math.max(this.activeMemberCount - this.excusedActiveMemberCount, 0);
},
timeblockCount() {
return (this.trainingPlan || []).filter(item => item && item.isTimeblock).length;
},

View File

@@ -23,6 +23,41 @@
</div>
</section>
<section v-if="clubDashboardSpotlightCards.length" class="club-dashboard-spotlight card">
<div class="section-header">
<h3 class="section-title">Sofort relevant</h3>
<p class="club-dashboard-spotlight-note">
Die wichtigsten Vereinsaufgaben und Statuswerte auf einen Blick.
</p>
</div>
<div class="club-dashboard-spotlight-grid">
<article
v-for="card in clubDashboardSpotlightCards"
:key="`spotlight-${card.title}`"
class="club-dashboard-spotlight-card"
:class="`accent-${card.accent || 'neutral'}`"
>
<div class="club-dashboard-spotlight-topline">
<strong>{{ card.title }}</strong>
<span v-if="card.value" class="club-dashboard-spotlight-value">{{ card.value }}</span>
</div>
<p v-if="card.meta" class="club-dashboard-spotlight-meta">{{ card.meta }}</p>
<ul v-if="card.items?.length" class="club-dashboard-spotlight-list">
<li v-for="item in card.items.slice(0, 2)" :key="typeof item === 'string' ? item : `${item?.to || 'no-link'}-${item?.label || 'empty'}`">
<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-for="section in clubDashboardSections"
:key="section.id"
@@ -727,6 +762,26 @@ export default {
clubDashboardSections() {
return this.dashboardSectionsOverride || [];
},
clubDashboardSpotlightCards() {
const priorityTitles = [
'Offene Zahlungen',
'Offene Aufgaben',
'Kommunikation',
'Rechnungen',
'Fehlende Daten',
'Archiv',
'Neue Anfragen',
];
const allCards = this.clubDashboardSections.flatMap((section) => (
Array.isArray(section.cards)
? section.cards.map((card) => ({ ...card, sectionId: section.id }))
: []
));
return priorityTitles
.map((title) => allCards.find((card) => card.title === title))
.filter(Boolean)
.slice(0, 4);
},
clubQuickLinks() {
return CLUB_DASHBOARD_QUICK_LINKS;
},
@@ -849,6 +904,60 @@ export default {
margin-bottom: 1.25rem;
}
.club-dashboard-spotlight {
padding: 1.1rem 1.25rem;
margin-bottom: 1rem;
background:
linear-gradient(135deg, rgba(24, 70, 54, 0.05), rgba(160, 112, 64, 0.06)),
rgba(255, 255, 255, 0.96);
}
.club-dashboard-spotlight-note {
margin: 0;
color: var(--text-secondary);
}
.club-dashboard-spotlight-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
gap: 0.85rem;
}
.club-dashboard-spotlight-card {
padding: 1rem;
border-radius: 16px;
border: 1px solid rgba(24, 70, 54, 0.08);
background: rgba(255, 255, 255, 0.92);
}
.club-dashboard-spotlight-topline {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 0.75rem;
margin-bottom: 0.35rem;
}
.club-dashboard-spotlight-value {
font-size: 1.35rem;
font-weight: 700;
color: var(--primary-strong);
}
.club-dashboard-spotlight-meta {
margin: 0 0 0.65rem;
color: var(--text-secondary);
font-size: 0.92rem;
}
.club-dashboard-spotlight-list {
margin: 0;
padding-left: 1rem;
display: grid;
gap: 0.35rem;
color: var(--text-primary);
}
.club-dashboard-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));

View File

@@ -65,6 +65,33 @@
{{ membersLoadError }}
</div>
<section v-else class="card members-contribution-summary">
<div class="section-header">
<h3>Beiträge</h3>
</div>
<div class="members-summary-grid">
<article class="members-summary-card">
<span class="members-summary-label">Mit Beitragsgruppe</span>
<strong class="members-summary-value">{{ memberContributionStats.assignedGroups }}</strong>
</article>
<article class="members-summary-card">
<span class="members-summary-label">Ohne Beitragsgruppe</span>
<strong class="members-summary-value">{{ memberContributionStats.missingGroups }}</strong>
</article>
<article class="members-summary-card">
<span class="members-summary-label">Offene Forderungen</span>
<strong class="members-summary-value">{{ memberContributionStats.openClaims }}</strong>
</article>
<article class="members-summary-card">
<span class="members-summary-label">Offener Rest</span>
<strong class="members-summary-value">{{ formatCurrency(memberContributionStats.openAmountCents) }}</strong>
</article>
</div>
<p class="members-summary-note">
{{ memberContributionStats.missingGroups }} Mitglieder sind noch keiner Beitragsgruppe zugeordnet.
</p>
</section>
<BaseDialog
:model-value="!!selectedMemberPreview"
:title="$t('members.memberDetails')"
@@ -82,6 +109,7 @@
</div>
<div class="member-preview-actions">
<button type="button" class="btn-primary" @click="editMember(selectedMemberPreview)">{{ $t('members.editMember') }}</button>
<button type="button" @click="openPaymentsForMember(selectedMemberPreview)">Forderung anlegen</button>
<button
v-if="canShowClickTtRegistration(selectedMemberPreview)"
type="button"
@@ -276,6 +304,29 @@
<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>
<div class="contact-section">
<label><span>Beitragsgruppe:</span></label>
<input
type="text"
v-model.trim="newContributionGroupCode"
placeholder="z. B. Jugend, Erwachsene, Familie"
>
<div class="member-editor-field-hint">
{{ getContributionGroupHint(getContributionGroupContext()) }}
</div>
<div class="member-contribution-presets">
<button
v-for="preset in getContributionGroupPresets(getContributionGroupContext())"
:key="preset.code"
type="button"
class="btn-secondary member-contribution-preset"
@click="applyContributionGroupPreset(preset.code)"
>
{{ preset.label }}
</button>
</div>
</div>
<!-- Trainingsgruppen -->
<div class="contact-section" :class="{ 'member-field-warning-box': editorHasIssue('training-group') }" v-if="memberToEdit">
<label><span>{{ $t('members.trainingGroups') }}:</span></label>
@@ -416,6 +467,7 @@
<th>{{ $t('members.imageInternet') }}</th>
<th>{{ $t('members.name') }}</th>
<th>{{ $t('members.status') }}</th>
<th>Beiträge</th>
<th>{{ $t('members.ttrQttr') }}</th>
<th>{{ $t('members.contact') }}</th>
<th>{{ $t('members.birthdate') }}</th>
@@ -486,6 +538,19 @@
</span>
</div>
</td>
<td>
<div class="member-contribution-cell">
<div class="member-contribution-line">
<strong>{{ getMemberContributionLabel(member) }}</strong>
</div>
<div class="member-contribution-line">
{{ getMemberContributionOpenSummary(member) }}
</div>
<div class="member-contribution-line member-contribution-muted">
{{ getMemberContributionHint(member) }}
</div>
</div>
</td>
<td class="rating-cell">
<button
type="button"
@@ -740,6 +805,21 @@ export default {
return this.members.filter(member => !member.active).length;
},
memberContributionStats() {
const assignedGroups = this.members.filter((member) => String(member.contributionGroupCode || member.contribution_group_code || '').trim()).length;
const missingGroups = this.members.length - assignedGroups;
const openClaims = this.paymentClaims.filter((claim) => ['open', 'partially_paid'].includes(claim.status)).length;
const openAmountCents = this.paymentClaims
.filter((claim) => ['open', 'partially_paid'].includes(claim.status))
.reduce((sum, claim) => sum + Number(claim.remainingAmountCents || 0), 0);
return {
assignedGroups,
missingGroups,
openClaims,
openAmountCents,
};
},
canEditMemberBankAccount() {
return this.hasPermission('members', 'write');
},
@@ -1106,6 +1186,7 @@ export default {
newPostalCode: '',
newCity: '',
newBirthdate: '',
newContributionGroupCode: '',
newPhone: '',
newEmail: '',
memberContacts: {
@@ -1161,6 +1242,7 @@ export default {
membersLoadError: '',
selectedMemberPreview: null,
selectedPreviewTrainingGroups: [],
paymentClaims: [],
memberDataQualityRequirements: defaultMemberDataQualityRequirements(),
memberSepaMandateLoading: false,
memberSepaMandateError: '',
@@ -1321,6 +1403,7 @@ export default {
});
await this.loadTrainingParticipations();
await this.loadPaymentClaims();
await Promise.allSettled(this.members.map(member => this.prefetchMemberPrimaryImage(member)));
await Promise.allSettled(this.members.map(member => this.prefetchMemberLatestImage(member)));
this.applyRouteQuery();
@@ -1338,6 +1421,35 @@ export default {
this.isLoadingMembers = false;
}
},
async loadPaymentClaims() {
if (!this.currentClub) {
this.paymentClaims = [];
return;
}
try {
const response = await apiClient.get(`/club-payment-claims/${this.currentClub}`);
const claims = Array.isArray(response.data?.claims) ? response.data.claims : [];
this.paymentClaims = claims.map((claim) => this.normalizePaymentClaim(claim));
} catch (error) {
console.error('[loadPaymentClaims] error:', error);
this.paymentClaims = [];
}
},
normalizePaymentClaim(claim = {}) {
const amountCents = Number(claim.amountCents || claim.amount_cents || 0);
const paidAmountCents = Math.max(0, Number(claim.paidAmountCents || claim.paid_amount_cents || 0));
return {
...claim,
id: Number(claim.id),
memberId: Number(claim.memberId || claim.member_id || claim.member?.id || 0) || null,
amountCents,
paidAmountCents: Math.min(amountCents, paidAmountCents),
remainingAmountCents: Math.max(0, amountCents - paidAmountCents),
currencyCode: claim.currencyCode || claim.currency_code || 'EUR',
status: claim.status || 'open',
};
},
async loadTrainingParticipations() {
try {
@@ -1664,6 +1776,52 @@ export default {
this.clickTtPendingMemberIds = this.clickTtPendingMemberIds.filter(id => id !== member.id);
}
},
openPaymentsForMember(member) {
if (!member?.id) {
return;
}
this.$router.push({
path: '/club-payments',
query: { memberId: String(member.id) },
});
this.closeMemberPreviewDialog();
},
getMemberContributionClaims(member) {
const memberId = Number(member?.id);
if (!memberId) return [];
return this.paymentClaims.filter((claim) => Number(claim.memberId || claim.member?.id || 0) === memberId);
},
getMemberContributionOpenClaims(member) {
return this.getMemberContributionClaims(member).filter((claim) => ['open', 'partially_paid'].includes(claim.status));
},
getMemberContributionLabel(member) {
return String(member?.contributionGroupCode || member?.contribution_group_code || '').trim() || 'Nicht zugeordnet';
},
getMemberContributionOpenSummary(member) {
const openClaims = this.getMemberContributionOpenClaims(member);
const openAmountCents = openClaims.reduce((sum, claim) => sum + Number(claim.remainingAmountCents || 0), 0);
if (openClaims.length === 0) {
return 'Keine offenen Forderungen';
}
return `${openClaims.length} offen · ${this.formatCurrency(openAmountCents)}`;
},
getMemberContributionHint(member) {
const contributionGroup = String(member?.contributionGroupCode || member?.contribution_group_code || '').trim();
const mandateReference = String(member?.sepaMandateReference || member?.sepa_mandate_reference || '').trim();
if (!contributionGroup) {
return 'Beitragsgruppe fehlt';
}
if (!mandateReference) {
return 'SEPA-Hinweis fehlt';
}
return 'Beitragszuordnung vollständig';
},
formatCurrency(amountCents, currencyCode = 'EUR') {
return new Intl.NumberFormat('de-DE', {
style: 'currency',
currency: currencyCode || 'EUR',
}).format(Number(amountCents || 0) / 100);
},
isClickTtRegistrationPending(member) {
return !!member?.id && this.clickTtPendingMemberIds.includes(member.id);
},
@@ -1718,6 +1876,7 @@ export default {
this.newPostalCode = '';
this.newCity = '';
this.newBirthdate = '';
this.newContributionGroupCode = '';
this.newPhone = '';
this.newEmail = '';
this.newActive = true;
@@ -1839,6 +1998,64 @@ export default {
this.memberSepaMandateLoading = false;
}
},
getContributionGroupContext() {
return {
birthDate: this.memberToEdit?.birthDate || this.newBirthdate || '',
testMembership: this.memberToEdit?.testMembership ?? this.testMembership,
contributionGroupCode: this.newContributionGroupCode,
};
},
getContributionGroupPresets(member = null) {
const age = this.getAge(member?.birthDate || this.newBirthdate);
if (member?.testMembership || this.testMembership) {
return [
{ code: 'Test', label: 'Test' },
{ code: 'Jugend', label: 'Jugend' },
{ code: 'Erwachsene', label: 'Erwachsene' },
];
}
if (Number.isFinite(age) && age < 18) {
return [
{ code: 'Jugend', label: 'Jugend' },
{ code: 'Ermäßigt', label: 'Ermäßigt' },
{ code: 'Familie', label: 'Familie' },
];
}
return [
{ code: 'Erwachsene', label: 'Erwachsene' },
{ code: 'Familie', label: 'Familie' },
{ code: 'Passiv', label: 'Passiv' },
];
},
getSuggestedContributionGroupCode(member = null) {
const age = this.getAge(member?.birthDate || this.newBirthdate);
if (member?.testMembership || this.testMembership) {
return 'Test';
}
if (Number.isFinite(age) && age < 18) {
return 'Jugend';
}
if (Number.isFinite(age) && age >= 18) {
return 'Erwachsene';
}
return '';
},
getContributionGroupHint(member = null) {
const currentCode = String(member?.contributionGroupCode || member?.contribution_group_code || member?.contributionGroupCode || this.newContributionGroupCode || '').trim();
const suggestedCode = this.getSuggestedContributionGroupCode(member);
if (!currentCode && suggestedCode) {
return `Vorschlag: ${suggestedCode}`;
}
if (!currentCode) {
return 'Noch keine Beitragsgruppe gesetzt.';
}
return suggestedCode && currentCode !== suggestedCode
? `Gesetzt: ${currentCode} · Vorschlag: ${suggestedCode}`
: `Gesetzt: ${currentCode}`;
},
applyContributionGroupPreset(code) {
this.newContributionGroupCode = String(code || '').trim();
},
addContact(type) {
if (type === 'phone') {
this.memberContacts.phones.push({
@@ -2055,6 +2272,7 @@ export default {
memberFormHandedOver: this.newMemberFormHandedOver,
adultReleaseApproved: this.newAdultReleaseApproved,
adultReserveApproved: this.newAdultReserveApproved,
contributionGroupCode: this.newContributionGroupCode,
contacts: contacts
};
@@ -2100,6 +2318,7 @@ export default {
this.newGender = member.gender || 'unknown';
this.newActive = member.active;
this.newBirthdate = this.formatDateForInput(birthDate);
this.newContributionGroupCode = String(member.contributionGroupCode || member.contribution_group_code || '').trim();
this.testMembership = member.testMembership;
this.newPicsInInternetAllowed = member.picsInInternetAllowed;
this.newMemberFormHandedOver = !!member.memberFormHandedOver;
@@ -3673,6 +3892,42 @@ table td {
color: #8b1e1e;
}
.members-contribution-summary {
padding: 1rem 1.1rem;
margin-bottom: 1rem;
}
.members-summary-grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 0.75rem;
}
.members-summary-card {
border: 1px solid #dce5ee;
border-radius: 12px;
padding: 0.85rem 0.95rem;
background: linear-gradient(180deg, #ffffff, #f6f8fb);
}
.members-summary-label,
.members-summary-note,
.member-contribution-muted {
color: #66788a;
}
.members-summary-value {
display: block;
margin-top: 0.25rem;
font-size: 1.35rem;
color: #102a43;
}
.members-summary-note {
margin: 0.75rem 0 0;
font-size: 0.92rem;
}
.member-preview-panel {
border: 1px solid #d7dee6;
border-radius: 14px;
@@ -3713,6 +3968,26 @@ table td {
gap: 0.9rem;
}
.member-contribution-cell {
display: grid;
gap: 0.2rem;
}
.member-contribution-line {
line-height: 1.25;
}
.member-contribution-presets {
display: flex;
gap: 0.4rem;
flex-wrap: wrap;
margin-top: 0.45rem;
}
.member-contribution-preset {
padding: 0.45rem 0.7rem;
}
.member-preview-item {
display: flex;
flex-direction: column;

View File

@@ -623,18 +623,7 @@ export default {
const memberIndex = members.value.findIndex(m => m.userId === selectedMember.value.userId);
if (memberIndex !== -1) {
members.value[memberIndex].permissions = permissionsToSave;
// Recalculate effective permissions
const permService = { getEffectivePermissions: (uc) => {
const rolePerms = getRolePermissions(uc.role);
const customPerms = uc.permissions || {};
const merged = JSON.parse(JSON.stringify(rolePerms));
for (const resource in customPerms) {
if (!merged[resource]) merged[resource] = {};
merged[resource] = { ...merged[resource], ...customPerms[resource] };
}
return merged;
}};
members.value[memberIndex].effectivePermissions = permService.getEffectivePermissions(members.value[memberIndex]);
members.value[memberIndex].effectivePermissions = response.data?.effectivePermissions || members.value[memberIndex].effectivePermissions;
}
closePermissionsDialog();