Updates, overview extended, club view implemented
This commit is contained in:
@@ -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;
|
||||
|
||||
163
frontend/src/components/RichTextEditor.vue
Normal file
163
frontend/src/components/RichTextEditor.vue
Normal 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>
|
||||
@@ -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%;
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
],
|
||||
},
|
||||
|
||||
@@ -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'] },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -714,6 +714,9 @@
|
||||
"durationExampleShort": "例如:2x7",
|
||||
"showImage": "显示图片/图示",
|
||||
"participants": "参与者",
|
||||
"excusedParticipants": "已请假",
|
||||
"availableParticipants": "可到场",
|
||||
"activeMembers": "活跃成员",
|
||||
"searchParticipants": "搜索参与者",
|
||||
"filterAll": "全部",
|
||||
"filterPresent": "出席",
|
||||
|
||||
@@ -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 }) },
|
||||
|
||||
117
frontend/src/utils/reportExport.js
Normal file
117
frontend/src/utils/reportExport.js
Normal 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);
|
||||
}
|
||||
429
frontend/src/utils/richTextDocumentExport.js
Normal file
429
frontend/src/utils/richTextDocumentExport.js
Normal 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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
@@ -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: '' };
|
||||
|
||||
@@ -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;
|
||||
|
||||
1510
frontend/src/views/ClubCommunicationView.vue
Normal file
1510
frontend/src/views/ClubCommunicationView.vue
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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() {
|
||||
|
||||
2950
frontend/src/views/ClubOperationsWorkspaceView.vue
Normal file
2950
frontend/src/views/ClubOperationsWorkspaceView.vue
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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; }
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user