636 lines
18 KiB
Vue
636 lines
18 KiB
Vue
<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 RichSelectBox from "../../../../Views/Components/RichSelectBox.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])),
|
|
)
|
|
|
|
/**
|
|
* Die Auswahlliste für alle Zeilen -- einmal gebaut, nicht je Zeile.
|
|
*
|
|
* Getrennt nach an- und abgemeldet, weil die Entscheidung unterschiedlich weit trägt: Eine Buchung
|
|
* auf eine abgemeldete Anmeldung zieht eine Erstattung nach sich. Als Fließtext in einer Zeile ging
|
|
* das unter, als Gruppenüberschrift steht es da.
|
|
*/
|
|
const participantOptions = computed(() => {
|
|
const options = [{value: '', label: '— ignorieren —'}]
|
|
|
|
for (const participant of participants.value) {
|
|
const state = participant.isSettled ? 'vollständig bezahlt' : participant.amountOpen + ' offen'
|
|
|
|
options.push(participant.isSignedOff
|
|
? {
|
|
value: participant.identifier,
|
|
label: participant.name,
|
|
description: 'abgemeldet am ' + participant.signedOffAt + ' · ' + state,
|
|
group: 'Abgemeldete Teilis',
|
|
icon: 'user-slash',
|
|
muted: true,
|
|
}
|
|
: {
|
|
value: participant.identifier,
|
|
label: participant.name,
|
|
description: state,
|
|
group: 'Angemeldete Teilis',
|
|
})
|
|
}
|
|
|
|
return options
|
|
})
|
|
|
|
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 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">
|
|
<RichSelectBox v-model="assignment[row.rowNumber]"
|
|
:options="participantOptions"
|
|
placeholder="— ignorieren —"
|
|
filterable/>
|
|
<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.95rem;
|
|
}
|
|
|
|
.statement-table .right {
|
|
text-align: right;
|
|
}
|
|
|
|
.nowrap {
|
|
white-space: nowrap;
|
|
}
|
|
|
|
.amount {
|
|
white-space: nowrap;
|
|
font-weight: bold;
|
|
color: #374151;
|
|
}
|
|
|
|
.assignment-column {
|
|
width: 360px;
|
|
}
|
|
|
|
/* 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;
|
|
}
|
|
|
|
|
|
.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>
|