Zahlungsparser
This commit is contained in:
@@ -0,0 +1,622 @@
|
||||
<script setup>
|
||||
import {computed, ref} from 'vue'
|
||||
import {toast} from 'vue3-toastify'
|
||||
import FullScreenModal from "../../../../Views/Components/FullScreenModal.vue";
|
||||
import LoadingModal from "../../../../Views/Components/LoadingModal.vue";
|
||||
import Icon from "../../../../Views/Components/Icon.vue";
|
||||
import {useAjax} from "../../../../../resources/js/components/ajaxHandler.js";
|
||||
|
||||
/**
|
||||
* Zahlungseingänge aus dem CSV-Export der Bank einbuchen.
|
||||
*
|
||||
* Zwei Schritte: Datei einlesen (der Server ordnet zu und schlägt vor), dann die Prüfansicht
|
||||
* bestätigen. Gebucht wird erst im zweiten Schritt und für alle Zeilen in einem Aufruf.
|
||||
*/
|
||||
const props = defineProps({
|
||||
event: {type: Object, required: true},
|
||||
})
|
||||
const emit = defineEmits(['close', 'booked'])
|
||||
|
||||
const {request} = useAjax()
|
||||
|
||||
const fileInput = ref(null)
|
||||
const fileName = ref('')
|
||||
const parsing = ref(false)
|
||||
const booking = ref(false)
|
||||
|
||||
const rows = ref([])
|
||||
const participants = ref([])
|
||||
// rowNumber -> identifier ('' = ignorieren)
|
||||
const assignment = ref({})
|
||||
const parsed = ref(false)
|
||||
const skipped = ref(0)
|
||||
|
||||
const assignedCount = computed(
|
||||
() => rows.value.filter(row => isAssigned(row)).length,
|
||||
)
|
||||
|
||||
const openCount = computed(() => rows.value.length - assignedCount.value)
|
||||
|
||||
const participantsByIdentifier = computed(
|
||||
() => Object.fromEntries(participants.value.map(participant => [participant.identifier, participant])),
|
||||
)
|
||||
|
||||
function isAssigned(row) {
|
||||
return (assignment.value[row.rowNumber] ?? '') !== ''
|
||||
}
|
||||
|
||||
/** Der sichtbare Button klickt das versteckte Datei-Element -- wie beim Beleg-Upload der Abrechnung. */
|
||||
function chooseFile() {
|
||||
fileInput.value?.click()
|
||||
}
|
||||
|
||||
/** Auswählen und Einlesen sind ein Schritt: Ein zweiter Klick brächte nichts zu entscheiden. */
|
||||
async function onFileChosen(event) {
|
||||
const file = event.target.files?.[0] ?? null
|
||||
if (file === null) {
|
||||
return
|
||||
}
|
||||
|
||||
fileName.value = file.name
|
||||
await readStatement(file)
|
||||
}
|
||||
|
||||
async function readStatement(file) {
|
||||
parsing.value = true
|
||||
try {
|
||||
const form = new FormData()
|
||||
form.append('statement', file)
|
||||
|
||||
const response = await request(
|
||||
'/api/v1/event/details/' + props.event.id + '/bank-statement/parse',
|
||||
{method: 'POST', body: form},
|
||||
)
|
||||
|
||||
if (response?.status !== 'success') {
|
||||
toast.error(response?.message ?? 'Die Datei konnte nicht gelesen werden.')
|
||||
reset()
|
||||
return
|
||||
}
|
||||
|
||||
rows.value = response.rows ?? []
|
||||
participants.value = response.participants ?? []
|
||||
skipped.value = response.skipped ?? 0
|
||||
// Vorschläge sind vorbelegt, aber nichts ist entschieden -- gebucht wird nur, was hier
|
||||
// stehen bleibt.
|
||||
assignment.value = Object.fromEntries(
|
||||
rows.value.map(row => [row.rowNumber, row.suggestedIdentifier ?? '']),
|
||||
)
|
||||
parsed.value = true
|
||||
} finally {
|
||||
parsing.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function bookPayments() {
|
||||
const bookings = rows.value
|
||||
.filter(row => isAssigned(row))
|
||||
.map(row => ({
|
||||
participantIdentifier: assignment.value[row.rowNumber],
|
||||
rowNumber: row.rowNumber,
|
||||
paymentDate: row.paymentDate,
|
||||
amount: row.amount,
|
||||
payerName: row.payerName,
|
||||
payerIban: row.payerIban,
|
||||
purpose: row.purpose,
|
||||
}))
|
||||
|
||||
if (bookings.length === 0) {
|
||||
toast.error('Es ist keine Zahlung zugeordnet.')
|
||||
return
|
||||
}
|
||||
|
||||
booking.value = true
|
||||
try {
|
||||
const response = await request(
|
||||
'/api/v1/event/details/' + props.event.id + '/bank-statement/book',
|
||||
{method: 'POST', body: {bookings}},
|
||||
)
|
||||
|
||||
if (response?.status !== 'success') {
|
||||
toast.error(response?.message ?? 'Die Zahlungen konnten nicht gebucht werden.')
|
||||
return
|
||||
}
|
||||
|
||||
toast.success(response.message)
|
||||
emit('booked')
|
||||
emit('close')
|
||||
} finally {
|
||||
booking.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function reset() {
|
||||
rows.value = []
|
||||
participants.value = []
|
||||
assignment.value = {}
|
||||
parsed.value = false
|
||||
skipped.value = 0
|
||||
fileName.value = ''
|
||||
if (fileInput.value) fileInput.value.value = ''
|
||||
}
|
||||
|
||||
function optionLabel(participant) {
|
||||
const state = participant.isSettled
|
||||
? 'vollständig bezahlt'
|
||||
: participant.amountOpen + ' offen'
|
||||
|
||||
return participant.isSignedOff
|
||||
? participant.name + ' — abgemeldet, ' + state
|
||||
: participant.name + ' — ' + state
|
||||
}
|
||||
|
||||
function assignedParticipant(row) {
|
||||
return participantsByIdentifier.value[assignment.value[row.rowNumber]] ?? null
|
||||
}
|
||||
|
||||
/** Der Zustand einer Zeile: was gebucht wird, was noch offen ist, was geprüft gehört. */
|
||||
function rowState(row) {
|
||||
if (!isAssigned(row)) {
|
||||
return row.suggestedIdentifier === null
|
||||
? {key: 'unmatched', label: 'Keine Zuordnung', icon: 'circle-question'}
|
||||
: {key: 'ignored', label: 'Ignoriert', icon: 'ban'}
|
||||
}
|
||||
|
||||
if (assignment.value[row.rowNumber] !== row.suggestedIdentifier) {
|
||||
return {key: 'manual', label: 'Von Hand', icon: 'user-pen'}
|
||||
}
|
||||
|
||||
return row.confidence === 'unsicher'
|
||||
? {key: 'uncertain', label: 'Bitte prüfen', icon: 'triangle-exclamation'}
|
||||
: {key: 'certain', label: 'Vorschlag', icon: 'check'}
|
||||
}
|
||||
|
||||
function lastPaymentOf(row) {
|
||||
return assignedParticipant(row)?.lastPaymentDate ?? null
|
||||
}
|
||||
|
||||
/** Eine Zahlung auf eine abgemeldete Anmeldung zieht eine Erstattung nach sich. */
|
||||
function signedOffOf(row) {
|
||||
const participant = assignedParticipant(row)
|
||||
|
||||
return participant?.isSignedOff === true ? participant : null
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<FullScreenModal :show="true" @close="emit('close')">
|
||||
<div class="import">
|
||||
<header class="import-header">
|
||||
<h2>Zahlungseingänge einlesen</h2>
|
||||
<p class="subtitle">{{ event.name }}</p>
|
||||
</header>
|
||||
|
||||
<!-- Schritt 1: Datei wählen. Das Datei-Element bleibt versteckt, geklickt wird der Button. -->
|
||||
<div v-if="!parsed" class="dropzone">
|
||||
<Icon name="file-csv" class="dropzone-icon"/>
|
||||
<p class="dropzone-title">Kontoauszug als CSV hochladen</p>
|
||||
<p class="dropzone-note">
|
||||
Der Export wird nur gelesen und nicht gespeichert. Gebucht wird anschließend
|
||||
ausschließlich das, was ihr in der Prüfansicht bestätigt.
|
||||
</p>
|
||||
<input type="button" value="Kontoauszug auswählen" @click="chooseFile"/>
|
||||
</div>
|
||||
|
||||
<input ref="fileInput" type="file" accept=".csv,text/csv,text/plain"
|
||||
style="display: none" @change="onFileChosen"/>
|
||||
|
||||
<template v-if="parsed">
|
||||
<div class="filebar">
|
||||
<span class="filename"><Icon name="file-csv"/> {{ fileName }}</span>
|
||||
<label class="link" @click="reset">Andere Datei wählen</label>
|
||||
</div>
|
||||
|
||||
<div v-if="rows.length > 0" class="stats">
|
||||
<div class="stat stat-assigned">
|
||||
<span class="stat-value">{{ assignedCount }}</span>
|
||||
<span class="stat-label">wird gebucht</span>
|
||||
</div>
|
||||
<div class="stat" :class="{'stat-open': openCount > 0}">
|
||||
<span class="stat-value">{{ openCount }}</span>
|
||||
<span class="stat-label">nicht zugeordnet</span>
|
||||
</div>
|
||||
<div v-if="skipped > 0" class="stat">
|
||||
<span class="stat-value">{{ skipped }}</span>
|
||||
<span class="stat-label">bereits erfasst</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="rows.length === 0" class="empty">
|
||||
<Icon name="circle-check" class="empty-icon"/>
|
||||
<p>
|
||||
In dieser Datei sind keine offenen Zahlungseingänge zu dieser Aktion enthalten.
|
||||
<template v-if="skipped > 0">
|
||||
{{ skipped }} Zahlungen wurden bereits früher eingebucht.
|
||||
</template>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<table v-else class="statement-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Buchung</th>
|
||||
<th class="right">Betrag</th>
|
||||
<th>Zahler*in</th>
|
||||
<th>Verwendungszweck</th>
|
||||
<th class="assignment-column">Zuordnung</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="row in rows" :key="row.rowNumber" :class="'state-' + rowState(row).key">
|
||||
<td class="nowrap">{{ row.paymentDateFormatted }}</td>
|
||||
<td class="right amount">{{ row.amountFormatted }}</td>
|
||||
<td>
|
||||
<span class="payer">{{ row.payerName || '—' }}</span>
|
||||
<span v-if="row.payerIban" class="iban">{{ row.payerIban }}</span>
|
||||
</td>
|
||||
<td class="purpose">{{ row.purpose }}</td>
|
||||
<td>
|
||||
<div class="assignment">
|
||||
<select v-model="assignment[row.rowNumber]" class="assignment-select">
|
||||
<option value="">— ignorieren —</option>
|
||||
<option v-for="participant in participants" :key="participant.identifier"
|
||||
:value="participant.identifier">
|
||||
{{ optionLabel(participant) }}
|
||||
</option>
|
||||
</select>
|
||||
<span class="pill" :class="'pill-' + rowState(row).key">
|
||||
<Icon :key="rowState(row).icon" :name="rowState(row).icon"/> {{ rowState(row).label }}
|
||||
</span>
|
||||
<span v-if="signedOffOf(row)" class="pill pill-signedoff">
|
||||
<Icon name="user-slash"/> Abgemeldet
|
||||
</span>
|
||||
</div>
|
||||
<span v-if="signedOffOf(row)" class="hint hint-warning">
|
||||
Am {{ signedOffOf(row).signedOffAt }} abgemeldet — die Zahlung wird
|
||||
erfasst, danach steht eine Erstattung an.
|
||||
</span>
|
||||
<span v-if="lastPaymentOf(row)" class="hint">
|
||||
Zuletzt erfasst: {{ lastPaymentOf(row) }}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<footer v-if="rows.length > 0" class="actions">
|
||||
<span class="actions-note">
|
||||
Die Teilnehmenden erhalten je Buchung die gewohnte Zahlungsmail.
|
||||
</span>
|
||||
<input type="button" class="accept-button"
|
||||
:value="assignedCount === 1 ? '1 Zahlung einbuchen' : assignedCount + ' Zahlungen einbuchen'"
|
||||
:disabled="assignedCount === 0 || booking"
|
||||
@click="bookPayments"/>
|
||||
</footer>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- Beim Buchen geht je Zahlung eine Mail an die Teilnehmenden raus, und zwar synchron --
|
||||
bei achtzig Buchungen dauert der Aufruf entsprechend. -->
|
||||
<LoadingModal v-if="parsing || booking" :show="true"
|
||||
:message="booking
|
||||
? 'Die Zahlungen werden gebucht und die Teilnehmenden benachrichtigt. Das kann einen Moment dauern …'
|
||||
: 'Der Kontoauszug wird gelesen …'"/>
|
||||
</FullScreenModal>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.import {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.import-header {
|
||||
border-bottom: 1px solid #e5e7eb;
|
||||
padding-bottom: 12px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.import-header h2 {
|
||||
margin: 0;
|
||||
color: #1d4899;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
margin: 2px 0 0 0;
|
||||
color: #6b7280;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
/* ── Schritt 1: Datei wählen ───────────────────────────────────────────── */
|
||||
|
||||
.dropzone {
|
||||
border: 2px dashed #c7d2e8;
|
||||
border-radius: 10px;
|
||||
background-color: #fafbfe;
|
||||
padding: 48px 24px;
|
||||
text-align: center;
|
||||
max-width: 620px;
|
||||
margin: 40px auto;
|
||||
}
|
||||
|
||||
.dropzone-icon {
|
||||
font-size: 2.6rem;
|
||||
color: #809dd5;
|
||||
}
|
||||
|
||||
.dropzone-title {
|
||||
margin: 14px 0 6px 0;
|
||||
font-weight: bold;
|
||||
font-size: 1.05rem;
|
||||
color: #374151;
|
||||
}
|
||||
|
||||
.dropzone-note {
|
||||
margin: 0 auto 22px auto;
|
||||
max-width: 440px;
|
||||
color: #6b7280;
|
||||
font-size: 0.85rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
/* ── Schritt 2: Prüfansicht ────────────────────────────────────────────── */
|
||||
|
||||
.filebar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
padding: 8px 12px;
|
||||
background-color: #f9fafb;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.filename {
|
||||
color: #374151;
|
||||
font-weight: bold;
|
||||
font-size: 0.9rem;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.stats {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
margin: 16px 0;
|
||||
}
|
||||
|
||||
.stat {
|
||||
flex: 1 1 130px;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 8px;
|
||||
padding: 10px 14px;
|
||||
background-color: #ffffff;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
display: block;
|
||||
font-size: 1.5rem;
|
||||
font-weight: bold;
|
||||
color: #374151;
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
display: block;
|
||||
color: #6b7280;
|
||||
font-size: 0.8rem;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.stat-assigned {
|
||||
border-color: #508c4c;
|
||||
background-color: #f3faf3;
|
||||
}
|
||||
|
||||
.stat-assigned .stat-value {
|
||||
color: #2f6b2c;
|
||||
}
|
||||
|
||||
.stat-open .stat-value {
|
||||
color: #92400e;
|
||||
}
|
||||
|
||||
.empty {
|
||||
text-align: center;
|
||||
color: #6b7280;
|
||||
padding: 40px 20px;
|
||||
}
|
||||
|
||||
.empty-icon {
|
||||
font-size: 2rem;
|
||||
color: #508c4c;
|
||||
}
|
||||
|
||||
.statement-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
margin-top: 6px;
|
||||
}
|
||||
|
||||
.statement-table th {
|
||||
text-align: left;
|
||||
padding: 8px 10px;
|
||||
background-color: #f9fafb;
|
||||
color: #374151;
|
||||
border-bottom: 2px solid #d1d5db;
|
||||
font-size: 0.82rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.03em;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.statement-table td {
|
||||
padding: 10px;
|
||||
border-bottom: 1px solid #e5e7eb;
|
||||
vertical-align: top;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.statement-table .right {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.nowrap {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.amount {
|
||||
white-space: nowrap;
|
||||
font-weight: bold;
|
||||
color: #374151;
|
||||
}
|
||||
|
||||
.assignment-column {
|
||||
width: 320px;
|
||||
}
|
||||
|
||||
/* Zeilenfarbe sagt auf einen Blick, was passiert: grün wird gebucht, gelb will geprüft werden,
|
||||
grau bleibt liegen. */
|
||||
.state-certain td,
|
||||
.state-manual td {
|
||||
background-color: #f6fdf7;
|
||||
}
|
||||
|
||||
.state-uncertain td {
|
||||
background-color: #fffbeb;
|
||||
}
|
||||
|
||||
.state-ignored td,
|
||||
.state-unmatched td {
|
||||
background-color: #fbfbfb;
|
||||
color: #9ca3af;
|
||||
}
|
||||
|
||||
.payer {
|
||||
display: block;
|
||||
color: #374151;
|
||||
}
|
||||
|
||||
.iban {
|
||||
display: block;
|
||||
color: #9ca3af;
|
||||
font-size: 0.75rem;
|
||||
margin-top: 1px;
|
||||
}
|
||||
|
||||
.purpose {
|
||||
max-width: 320px;
|
||||
word-break: break-word;
|
||||
color: #4b5563;
|
||||
}
|
||||
|
||||
.assignment {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.assignment-select {
|
||||
width: 100%;
|
||||
padding: 5px 6px;
|
||||
border: 1px solid #d1d5db;
|
||||
border-radius: 6px;
|
||||
background-color: #ffffff;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.pill {
|
||||
align-self: flex-start;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
padding: 2px 9px;
|
||||
border-radius: 11px;
|
||||
font-size: 0.72rem;
|
||||
font-weight: bold;
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
|
||||
.pill-certain {
|
||||
color: #2f6b2c;
|
||||
background-color: #dcfce7;
|
||||
border-color: #86c884;
|
||||
}
|
||||
|
||||
.pill-manual {
|
||||
color: #1d4899;
|
||||
background-color: #e4ecfb;
|
||||
border-color: #809dd5;
|
||||
}
|
||||
|
||||
.pill-uncertain {
|
||||
color: #92400e;
|
||||
background-color: #fef3c7;
|
||||
border-color: #d9b45c;
|
||||
}
|
||||
|
||||
.pill-ignored,
|
||||
.pill-unmatched {
|
||||
color: #6b7280;
|
||||
background-color: #f3f4f6;
|
||||
border-color: #d1d5db;
|
||||
}
|
||||
|
||||
.pill-signedoff {
|
||||
color: #9a3412;
|
||||
background-color: #ffedd5;
|
||||
border-color: #e0a06a;
|
||||
}
|
||||
|
||||
.hint {
|
||||
display: block;
|
||||
color: #9ca3af;
|
||||
font-size: 0.72rem;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.hint-warning {
|
||||
color: #9a3412;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 16px;
|
||||
flex-wrap: wrap;
|
||||
margin-top: 20px;
|
||||
padding-top: 16px;
|
||||
border-top: 1px solid #e5e7eb;
|
||||
}
|
||||
|
||||
.actions-note {
|
||||
color: #6b7280;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.actions input[disabled] {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* ── Schmale Bildschirme ───────────────────────────────────────────────── */
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.assignment-column {
|
||||
width: auto;
|
||||
}
|
||||
|
||||
.statement-table th {
|
||||
position: static;
|
||||
}
|
||||
|
||||
.purpose {
|
||||
max-width: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -6,6 +6,7 @@ import EventAddons from "./EventAddons.vue";
|
||||
import ParticipationSummary from "./ParticipationSummary.vue";
|
||||
import CommonSettings from "./CommonSettings.vue";
|
||||
import EventManagement from "./EventManagement.vue";
|
||||
import BankStatementImport from "./BankStatementImport.vue";
|
||||
import Modal from "../../../../Views/Components/Modal.vue";
|
||||
import MailCompose from "./MailCompose.vue";
|
||||
import FullScreenModal from "../../../../Views/Components/FullScreenModal.vue";
|
||||
@@ -21,6 +22,12 @@ const props = defineProps({
|
||||
|
||||
const displayData = ref('main');
|
||||
const showEventData = ref(false);
|
||||
const showBankStatement = ref(false);
|
||||
|
||||
// Nach dem Buchen stimmen die Beitragsstände der Übersicht nicht mehr -- neu laden.
|
||||
async function bankStatementBooked() {
|
||||
await showMain();
|
||||
}
|
||||
|
||||
|
||||
async function showMain() {
|
||||
@@ -117,9 +124,18 @@ async function showEventAddons() {
|
||||
<a :href="'/event/details/' + props.data.event.identifier + '/pdf/photo-permission-list'">
|
||||
<input type="button" value="Foto-Erlaubnis (PDF)" />
|
||||
</a><br/>
|
||||
|
||||
<input type="button" value="Zahlungseingänge einlesen" @click="showBankStatement = true" /><br/>
|
||||
</div>
|
||||
|
||||
|
||||
<!-- Liegt außerhalb der displayData-Umschaltung: Der Import ist ein Vollbild-Dialog über der
|
||||
Übersicht, keine Unter-Ansicht, und soll auch aus einer Unter-Ansicht heraus erreichbar sein. -->
|
||||
<BankStatementImport v-if="showBankStatement && dynamicProps.event"
|
||||
:event="dynamicProps.event"
|
||||
@close="showBankStatement = false"
|
||||
@booked="bankStatementBooked" />
|
||||
|
||||
<ParticipationFees v-if="displayData === 'participationFees'" :event="dynamicProps.event" @close="showMain" />
|
||||
<ParticipationOptions v-else-if="displayData === 'participationOptions'" :event="dynamicProps.event"
|
||||
@close="showMain"/>
|
||||
@@ -158,6 +174,7 @@ async function showEventAddons() {
|
||||
<a :href="'/event/details/' + props.data.event.identifier + '/pdf/photo-permission-list'">
|
||||
<input type="button" value="Foto-Erlaubnis (PDF)" />
|
||||
</a><br/>
|
||||
<input type="button" value="Zahlungseingänge einlesen" @click="showBankStatement = true" /><br/>
|
||||
<input type="button" @click="sendPaymentReminder" class="fix-button" value="Zahlungserinnerung senden" /><br/>
|
||||
<input type="button" class="deny-button" value="Letzte Mahnung senden" style="display: none" /><br/>
|
||||
<input type="button" value="Rundmail senden" @click="mailToGroup" /><br/>
|
||||
|
||||
@@ -26,6 +26,18 @@ const paymentMethodConfigurations = reactive({})
|
||||
const showOptionsModal = ref(false)
|
||||
const optionsMethod = ref(null)
|
||||
|
||||
/**
|
||||
* Die Optionen, die pro Aktion eingefroren werden.
|
||||
*
|
||||
* Optionen mit `scope: 'tenant'` bleiben draußen: Sie gelten für den ganzen Mandanten. Das
|
||||
* Kontoauszug-Format etwa beschreibt die Bank, nicht die Zusage an die Teilnehmenden — hier
|
||||
* eingefroren ließe sich nach einem Bankwechsel für laufende Aktionen nichts mehr importieren.
|
||||
* Der Server hält sich beim Speichern an dieselbe Regel.
|
||||
*/
|
||||
function eventOptions(method) {
|
||||
return (method?.optionsSchema ?? []).filter(option => option.scope !== 'tenant')
|
||||
}
|
||||
|
||||
function openOptions(method) {
|
||||
optionsMethod.value = method
|
||||
showOptionsModal.value = true
|
||||
@@ -47,7 +59,7 @@ onMounted(async () => {
|
||||
for (const method of availablePaymentMethods.value) {
|
||||
const source = eventConfigBySlug[method.slug] ?? method.configuration ?? {}
|
||||
const config = {}
|
||||
for (const option of method.optionsSchema ?? []) {
|
||||
for (const option of eventOptions(method)) {
|
||||
config[option.name] = source[option.name] ?? ''
|
||||
}
|
||||
paymentMethodConfigurations[method.slug] = config
|
||||
@@ -358,7 +370,7 @@ onMounted(async () => {
|
||||
paymentMethod.name
|
||||
}}</label>
|
||||
<label
|
||||
v-if="paymentMethods.includes(paymentMethod.slug) && (paymentMethod.optionsSchema?.length ?? 0) > 0"
|
||||
v-if="paymentMethods.includes(paymentMethod.slug) && eventOptions(paymentMethod).length > 0"
|
||||
class="link"
|
||||
style="padding-left: 15px; font-size: 10pt;"
|
||||
@click="openOptions(paymentMethod)"
|
||||
@@ -381,7 +393,7 @@ onMounted(async () => {
|
||||
width="700px"
|
||||
@close="showOptionsModal = false"
|
||||
>
|
||||
<div v-for="option in optionsMethod?.optionsSchema ?? []" :key="option.name" class="payment-method-config-row">
|
||||
<div v-for="option in eventOptions(optionsMethod)" :key="option.name" class="payment-method-config-row">
|
||||
<label>{{ option.label }}<span v-if="option.required" class="required-marker">*</span></label>
|
||||
<RichSelectBox v-if="option.type === 'icon'"
|
||||
v-model="paymentMethodConfigurations[optionsMethod.slug][option.name]"
|
||||
|
||||
Reference in New Issue
Block a user