Zahlungsparser

This commit is contained in:
2026-09-09 16:55:08 +02:00
parent ae13841699
commit 651b6147bf
38 changed files with 3308 additions and 22 deletions
@@ -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>