Zahlungsparser
This commit is contained in:
@@ -0,0 +1,254 @@
|
||||
<script setup>
|
||||
import {computed, ref, watch} from 'vue'
|
||||
|
||||
/**
|
||||
* Formular für das CSV-Format eines Kontoauszugs (Options-Typ `bank-ruleset`).
|
||||
*
|
||||
* Zwei Zustände: „App-Standard" (v-model leer) und „eigenes Format" (v-model trägt ein JSON-Objekt).
|
||||
* Das ist die Zusage aus dem Backend -- ein Override gilt ganz oder gar nicht, es wird nicht feldweise
|
||||
* mit dem Standard gemischt. Deshalb schaltet hier ein Schalter um und nicht jedes Feld für sich.
|
||||
*
|
||||
* Der Wert geht als JSON-String durch das generische Options-Formular; das Zahlungsmodul dekodiert ihn.
|
||||
*/
|
||||
const props = defineProps({
|
||||
// JSON-String oder Objekt; leer = App-Standard.
|
||||
modelValue: {type: [String, Object], default: ''},
|
||||
// Der app-weite Standard, als Vorbelegung und Vergleich.
|
||||
defaults: {type: Object, default: () => ({})},
|
||||
charsets: {type: Array, default: () => ['auto', 'UTF-8', 'Windows-1252', 'ISO-8859-15']},
|
||||
})
|
||||
const emit = defineEmits(['update:modelValue'])
|
||||
|
||||
const FIELDS = [
|
||||
{key: 'payment_date', label: 'Zahlungsdatum', required: true, hint: 'Üblich: der Buchungstag.'},
|
||||
{key: 'purpose', label: 'Verwendungszweck', required: true},
|
||||
{key: 'amount', label: 'Betrag', required: true},
|
||||
{key: 'payer_name', label: 'Name des Zahlers', required: false},
|
||||
{key: 'payer_iban', label: 'IBAN des Zahlers', required: false, hint: 'Ohne diese Spalte bleiben die Erstattungsdaten leer.'},
|
||||
]
|
||||
|
||||
function parse(value) {
|
||||
if (!value) return null
|
||||
if (typeof value === 'object') return value
|
||||
try {
|
||||
const parsed = JSON.parse(value)
|
||||
return parsed && typeof parsed === 'object' ? parsed : null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function blank() {
|
||||
return {
|
||||
delimiter: ';',
|
||||
enclosure: '',
|
||||
charset: 'auto',
|
||||
has_header: true,
|
||||
date_format: 'd.m.Y',
|
||||
decimal_separator: ',',
|
||||
thousands_separator: '.',
|
||||
columns: {},
|
||||
}
|
||||
}
|
||||
|
||||
// Eine tiefe Kopie, damit das Bearbeiten nicht die angezeigten Standardwerte überschreibt.
|
||||
function fromDefaults() {
|
||||
return JSON.parse(JSON.stringify({...blank(), ...props.defaults}))
|
||||
}
|
||||
|
||||
const custom = ref(parse(props.modelValue) !== null)
|
||||
const form = ref(parse(props.modelValue) ?? fromDefaults())
|
||||
|
||||
watch(() => props.modelValue, (value) => {
|
||||
const parsed = parse(value)
|
||||
custom.value = parsed !== null
|
||||
form.value = parsed ?? fromDefaults()
|
||||
})
|
||||
|
||||
const shown = computed(() => (custom.value ? form.value : fromDefaults()))
|
||||
|
||||
const missingRequired = computed(
|
||||
() => FIELDS.filter(field => field.required && !(shown.value.columns ?? {})[field.key]).map(field => field.label),
|
||||
)
|
||||
|
||||
function useAppDefault() {
|
||||
custom.value = false
|
||||
emit('update:modelValue', '')
|
||||
}
|
||||
|
||||
function useOwnFormat() {
|
||||
custom.value = true
|
||||
form.value = fromDefaults()
|
||||
push()
|
||||
}
|
||||
|
||||
function push() {
|
||||
emit('update:modelValue', JSON.stringify(form.value))
|
||||
}
|
||||
|
||||
function setColumn(key, value) {
|
||||
form.value.columns = {...(form.value.columns ?? {})}
|
||||
if (value) {
|
||||
form.value.columns[key] = value
|
||||
} else {
|
||||
delete form.value.columns[key]
|
||||
}
|
||||
push()
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="ruleset">
|
||||
<label class="mode">
|
||||
<input type="radio" :checked="!custom" @change="useAppDefault"/>
|
||||
App-Standard verwenden
|
||||
</label>
|
||||
<label class="mode">
|
||||
<input type="radio" :checked="custom" @change="useOwnFormat"/>
|
||||
Eigenes Format für unsere Bank
|
||||
</label>
|
||||
|
||||
<p v-if="!custom" class="note">
|
||||
Es gilt der app-weite Standard. Bearbeitet ihr das Format, gilt ausschließlich eure
|
||||
Einstellung — der Standard wirkt dann nicht mehr nach.
|
||||
</p>
|
||||
|
||||
<table class="ruleset-table">
|
||||
<tr>
|
||||
<td>Trennzeichen</td>
|
||||
<td>
|
||||
<input type="text" class="form-input short" :disabled="!custom"
|
||||
:value="shown.delimiter" @input="form.delimiter = $event.target.value; push()"/>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Anführungszeichen</td>
|
||||
<td>
|
||||
<input type="text" class="form-input short" :disabled="!custom" placeholder="(keine)"
|
||||
:value="shown.enclosure" @input="form.enclosure = $event.target.value; push()"/>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Zeichensatz</td>
|
||||
<td>
|
||||
<select class="form-input" :disabled="!custom"
|
||||
:value="shown.charset" @change="form.charset = $event.target.value; push()">
|
||||
<option v-for="charset in charsets" :key="charset" :value="charset">
|
||||
{{ charset === 'auto' ? 'Automatisch erkennen' : charset }}
|
||||
</option>
|
||||
</select>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Datumsformat</td>
|
||||
<td>
|
||||
<input type="text" class="form-input short" :disabled="!custom"
|
||||
:value="shown.date_format" @input="form.date_format = $event.target.value; push()"/>
|
||||
<span class="option-hint">PHP-Schreibweise, z. B. d.m.Y für 09.09.2026</span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Dezimaltrenner</td>
|
||||
<td>
|
||||
<input type="text" class="form-input short" :disabled="!custom"
|
||||
:value="shown.decimal_separator" @input="form.decimal_separator = $event.target.value; push()"/>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Tausendertrenner</td>
|
||||
<td>
|
||||
<input type="text" class="form-input short" :disabled="!custom" placeholder="(keiner)"
|
||||
:value="shown.thousands_separator" @input="form.thousands_separator = $event.target.value; push()"/>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<h4>Spalten der Datei</h4>
|
||||
<p class="note">
|
||||
Die Überschrift der jeweiligen Spalte, genau so wie sie in der ersten Zeile des Exports steht.
|
||||
</p>
|
||||
|
||||
<table class="ruleset-table">
|
||||
<tr v-for="field in FIELDS" :key="field.key">
|
||||
<td>{{ field.label }}<span v-if="field.required" class="required-marker">*</span></td>
|
||||
<td>
|
||||
<input type="text" class="form-input" :disabled="!custom"
|
||||
:value="(shown.columns ?? {})[field.key] ?? ''"
|
||||
@input="setColumn(field.key, $event.target.value)"/>
|
||||
<span v-if="field.hint" class="option-hint">{{ field.hint }}</span>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<p v-if="custom && missingRequired.length" class="warning">
|
||||
Ohne {{ missingRequired.join(', ') }} lässt sich kein Kontoauszug einlesen.
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.ruleset {
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 8px;
|
||||
padding: 12px 14px;
|
||||
background-color: #fafafa;
|
||||
}
|
||||
|
||||
.mode {
|
||||
display: block;
|
||||
margin-bottom: 4px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.mode input {
|
||||
margin-right: 6px;
|
||||
}
|
||||
|
||||
.note {
|
||||
margin: 6px 0 10px 0;
|
||||
color: #6b7280;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.ruleset-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
.ruleset-table td {
|
||||
padding: 4px 6px 4px 0;
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
.ruleset-table td:first-child {
|
||||
width: 190px;
|
||||
color: #374151;
|
||||
}
|
||||
|
||||
.form-input.short {
|
||||
width: 90px;
|
||||
}
|
||||
|
||||
h4 {
|
||||
margin: 16px 0 0 0;
|
||||
color: #374151;
|
||||
}
|
||||
|
||||
.option-hint {
|
||||
display: block;
|
||||
color: #6b7280;
|
||||
font-size: 0.8rem;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.required-marker {
|
||||
color: #ef4444;
|
||||
margin-left: 2px;
|
||||
}
|
||||
|
||||
.warning {
|
||||
margin: 10px 0 0 0;
|
||||
color: #991b1b;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user