User-Comfort im Zahlungsparser

This commit is contained in:
2026-09-09 17:32:50 +02:00
parent 651b6147bf
commit 025035190d
2 changed files with 352 additions and 51 deletions
@@ -4,6 +4,7 @@ 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";
/**
@@ -41,6 +42,39 @@ 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] ?? '') !== ''
}
@@ -140,16 +174,6 @@ function reset() {
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
}
@@ -257,13 +281,10 @@ function signedOffOf(row) {
<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>
<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>
@@ -458,7 +479,7 @@ function signedOffOf(row) {
padding: 10px;
border-bottom: 1px solid #e5e7eb;
vertical-align: top;
font-size: 0.9rem;
font-size: 0.95rem;
}
.statement-table .right {
@@ -476,7 +497,7 @@ function signedOffOf(row) {
}
.assignment-column {
width: 320px;
width: 360px;
}
/* Zeilenfarbe sagt auf einen Blick, was passiert: grün wird gebucht, gelb will geprüft werden,
@@ -520,14 +541,6 @@ function signedOffOf(row) {
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;
+305 -17
View File
@@ -1,57 +1,229 @@
<script setup>
import {computed, nextTick, onBeforeUnmount, onMounted, ref} from 'vue'
import {computed, nextTick, onBeforeUnmount, onMounted, ref, watch} from 'vue'
import Icon from './Icon.vue'
// Generisches Custom-Dropdown, das je Option ein Icon + Label anzeigt (ein natives <select> kann
// keine SVG-Icons rendern). Die Options-Liste wird per <Teleport> an <body> gehängt und fixed
// positioniert, damit sie nicht von einem Eltern-Container mit overflow (z. B. .tab-content in
// TabbedPage) abgeschnitten wird. Wiederverwendbar für Icon-/Rich-Selects.
// TabbedPage oder der FullScreenModal) abgeschnitten wird. Wiederverwendbar für Icon-/Rich-Selects.
//
// Optional: Gruppen (option.group), eine zweite Textzeile je Eintrag (option.description) und ein
// Tipp-Filter (filterable). Alles drei ist opt-in -- Bestandsaufrufe bleiben unverändert.
const props = defineProps({
// Ausgewählter Wert (value einer Option).
modelValue: {type: String, default: ''},
// [{ value, label, icon? }]
/*
* [{ value, label, description?, icon?, group?, muted? }]
*
* group -- Überschrift, unter der der Eintrag einsortiert wird. Einträge ohne group stehen
* ungruppiert oben (z. B. ein "— keine Auswahl —"-Eintrag).
* muted -- gedämpft dargestellt, für Einträge mit Sonderbedeutung.
*/
options: {type: Array, default: () => []},
placeholder: {type: String, default: 'Auswählen…'},
// Trigger wird zum Texteingabefeld: Tippen filtert die Liste. Übernommen wird trotzdem immer
// eine Option, nie der getippte Text.
filterable: {type: Boolean, default: false},
})
const emit = defineEmits(['update:modelValue'])
const open = ref(false)
const root = ref(null)
const listRef = ref(null)
const inputRef = ref(null)
const pos = ref({top: 0, left: 0, width: 0})
// Was im Eingabefeld steht, und getrennt davon, wonach gefiltert wird: Beim Öffnen zeigt das Feld
// das Label der Auswahl, gefiltert wird aber erst, wenn wirklich getippt wurde.
//
// Der Feldtext MUSS über einen eigenen Ref laufen und darf nicht direkt an `selected.label` hängen:
// Vue patcht `value` bei jedem Re-Render, auch wenn der gebundene Wert gleich geblieben ist -- die
// Eingabe würde sonst bei jedem Tastendruck wieder auf das alte Label zurückspringen.
const inputText = ref('')
const query = ref('')
const highlighted = ref(-1)
const selected = computed(() => props.options.find(o => o.value === props.modelValue) ?? null)
/**
* Kleingeschrieben und ohne Sonderzeichen, in zwei Formen: einmal mit ausgeschriebenem Umlaut
* (ü → ue), einmal mit bloßem Grundbuchstaben (ü → u).
*
* Beide, weil beide Schreibweisen vorkommen: Wer „Müller" sucht, tippt mal `mueller`, mal `muller`,
* mal mit Umlaut. Ein Treffer in einer der beiden Formen genügt.
*/
function normalizeForms(value) {
const base = String(value ?? '').toLowerCase()
return [
clean(base.replace(/ä/g, 'ae').replace(/ö/g, 'oe').replace(/ü/g, 'ue').replace(/ß/g, 'ss')),
clean(base.replace(/ß/g, 'ss')),
]
}
/** Akzente auf den Grundbuchstaben zurückführen, alles außer [a-z0-9] verwerfen. */
function clean(value) {
return String(value ?? '')
// Restliche Akzente (é, ñ, …) auf den Grundbuchstaben zurückführen.
.normalize('NFD').replace(/[\u0300-\u036f]/g, '')
.replace(/[^a-z0-9]/g, '')
}
const matches = computed(() => {
const [needleLong, needleShort] = normalizeForms(query.value)
if (!props.filterable || needleLong === '') {
return props.options
}
// Gesucht wird nur im Label, nicht in der Beschreibung: Die trägt den Zustand ("abgemeldet am
// …", "120,00 € offen") und liest sich bei allen ähnlich -- ein "me" träfe sonst jedes
// "abgemeldet" und die Liste stünde voller Zufallstreffer.
return props.options.filter(option => {
const [long, short] = normalizeForms(option.label)
return long.includes(needleLong) || short.includes(needleShort)
})
})
/**
* Die gefilterten Optionen nach Gruppen, in Reihenfolge des ersten Auftretens -- die Sortierung
* liegt damit beim Aufrufer. Einträge ohne `group` bilden den ersten, überschriftenlosen Block.
*/
const groups = computed(() => {
const result = []
const byLabel = new Map()
for (const option of matches.value) {
const label = option.group ?? null
if (!byLabel.has(label)) {
const group = {label, options: []}
byLabel.set(label, group)
result.push(group)
}
byLabel.get(label).options.push(option)
}
return result
})
/** Flache Reihenfolge über alle Gruppen -- die Grundlage für die Pfeiltasten. */
const flatOptions = computed(() => groups.value.flatMap(group => group.options))
function updatePosition() {
if (!root.value) return
const rect = root.value.getBoundingClientRect()
pos.value = {top: rect.bottom + 4, left: rect.left, width: rect.width}
}
async function toggle() {
open.value = !open.value
if (open.value) {
async function openList() {
if (open.value) return
open.value = true
query.value = ''
inputText.value = selected.value?.label ?? ''
highlighted.value = flatOptions.value.findIndex(o => o.value === props.modelValue)
await nextTick()
updatePosition()
}
/**
* Schließt die Liste und stellt den Feldtext auf die bestehende Auswahl zurück.
*
* Damit wird getippter Freitext nie zum Wert: Wer die Liste ohne Auswahl verlässt (wegklicken,
* Escape), sieht wieder das, was tatsächlich ausgewählt ist.
*/
function closeList() {
open.value = false
query.value = ''
highlighted.value = -1
inputText.value = selected.value?.label ?? ''
}
async function toggle() {
if (open.value) {
closeList()
return
}
await openList()
}
/**
* Klick ins Eingabefeld öffnet und markiert den Text, damit Tippen ihn ersetzt.
*
* Nur beim Öffnen: Ein zweiter Klick ins bereits offene Feld soll den Cursor setzen dürfen, statt
* die Markierung wiederherzustellen.
*/
async function onInputFocus() {
if (open.value) return
await openList()
inputRef.value?.select()
}
function onInput(event) {
inputText.value = event.target.value
query.value = event.target.value
// Nach dem Tippen steht die Markierung auf dem ersten Treffer -- Enter wählt damit das
// Naheliegende, ohne dass jemand erst die Pfeiltaste suchen muss.
highlighted.value = flatOptions.value.length > 0 ? 0 : -1
}
function select(option) {
emit('update:modelValue', option.value)
open.value = false
query.value = ''
highlighted.value = -1
// Nicht über closeList(): `selected` liest noch den alten Wert, der Emit oben wirkt erst mit
// dem nächsten Render zurück.
inputText.value = option.label
inputRef.value?.blur()
}
function onClickOutside(event) {
const inRoot = root.value && root.value.contains(event.target)
const inList = listRef.value && listRef.value.contains(event.target)
if (!inRoot && !inList) {
open.value = false
closeList()
}
}
function moveHighlight(step) {
const count = flatOptions.value.length
if (count === 0) return
const next = highlighted.value + step
highlighted.value = next < 0 ? count - 1 : next >= count ? 0 : next
nextTick(() => {
listRef.value
?.querySelector('.rich-select__option--highlighted')
?.scrollIntoView({block: 'nearest'})
})
}
function onKeydown(event) {
if (event.key === 'Escape' || event.key === 'Esc') {
open.value = false
if (!open.value) return
// Ohne das schließt derselbe Escape auch die umgebende FullScreenModal (die lauscht am
// window, wir am document -- und document kommt zuerst). Im Zahlungsimport wären damit
// sämtliche Zuordnungen weg.
event.stopPropagation()
closeList()
return
}
if (!open.value) return
if (event.key === 'ArrowDown') {
event.preventDefault()
moveHighlight(1)
} else if (event.key === 'ArrowUp') {
event.preventDefault()
moveHighlight(-1)
} else if (event.key === 'Enter') {
const option = flatOptions.value[highlighted.value]
if (option) {
event.preventDefault()
select(option)
}
}
}
@@ -59,6 +231,14 @@ function onReposition() {
if (open.value) updatePosition()
}
// Setzt der Aufrufer den Wert von außen (oder werden die Optionen nachgeladen), zieht der Feldtext
// nach -- solange gerade niemand tippt.
watch([() => props.modelValue, () => props.options], () => {
if (!open.value) {
inputText.value = selected.value?.label ?? ''
}
}, {immediate: true})
onMounted(() => {
document.addEventListener('click', onClickOutside)
document.addEventListener('keydown', onKeydown)
@@ -76,7 +256,26 @@ onBeforeUnmount(() => {
<template>
<div ref="root" class="rich-select">
<div v-if="filterable" class="rich-select__trigger rich-select__trigger--input">
<Icon v-if="selected?.icon && !open" :key="selected.value" :name="selected.icon"/>
<input
ref="inputRef"
type="text"
class="rich-select__input"
role="combobox"
autocomplete="off"
:aria-expanded="open"
:value="inputText"
:placeholder="placeholder"
@focus="onInputFocus"
@click="onInputFocus"
@input="onInput"
/>
<span class="rich-select__caret" aria-hidden="true"></span>
</div>
<button
v-else
type="button"
class="rich-select__trigger"
:aria-expanded="open"
@@ -100,18 +299,38 @@ onBeforeUnmount(() => {
role="listbox"
:style="{ top: pos.top + 'px', left: pos.left + 'px', width: pos.width + 'px' }"
>
<li v-if="flatOptions.length === 0" class="rich-select__empty">
Kein Treffer für {{ query }}"
</li>
<template v-for="group in groups" :key="group.label ?? '_'">
<li v-if="group.label" class="rich-select__group" role="presentation">
{{ group.label }} <span class="rich-select__group-count">({{ group.options.length }})</span>
</li>
<li
v-for="option in options"
v-for="option in group.options"
:key="option.value"
class="rich-select__option"
:class="{ 'rich-select__option--active': option.value === modelValue }"
:class="{
'rich-select__option--active': option.value === modelValue,
'rich-select__option--highlighted': option === flatOptions[highlighted],
'rich-select__option--muted': option.muted,
}"
role="option"
:aria-selected="option.value === modelValue"
@click="select(option)"
@mouseenter="highlighted = flatOptions.indexOf(option)"
>
<Icon v-if="option.icon" :name="option.icon"/>
<span>{{ option.label }}</span>
<Icon v-if="option.icon" :name="option.icon" class="rich-select__option-icon"/>
<span class="rich-select__option-text">
<span class="rich-select__option-label">{{ option.label }}</span>
<span v-if="option.description" class="rich-select__option-description">
{{ option.description }}
</span>
</span>
</li>
</template>
</ul>
</Teleport>
</div>
@@ -129,12 +348,12 @@ onBeforeUnmount(() => {
justify-content: space-between;
width: 100%;
gap: 10px;
padding: 6px 10px;
padding: 8px 12px;
border: 1px solid #d1d5db;
border-radius: 6px;
background: #fff;
cursor: pointer;
font-size: 0.95rem;
font-size: 1rem;
box-sizing: border-box;
}
@@ -142,6 +361,22 @@ onBeforeUnmount(() => {
border-color: #2563eb;
}
.rich-select__trigger--input:focus-within {
border-color: #2563eb;
}
.rich-select__input {
flex: 1;
min-width: 0;
border: none;
outline: none;
padding: 0;
font-size: 1rem;
font-family: inherit;
color: inherit;
background: transparent;
}
.rich-select__value {
display: flex;
align-items: center;
@@ -168,26 +403,79 @@ onBeforeUnmount(() => {
border: 1px solid #d1d5db;
border-radius: 8px;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12);
max-height: 260px;
max-height: 320px;
overflow: auto;
box-sizing: border-box;
}
.rich-select__group {
padding: 10px 10px 4px 10px;
color: #6b7280;
font-size: 0.72rem;
font-weight: bold;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.rich-select__group-count {
font-weight: normal;
letter-spacing: 0;
}
.rich-select__empty {
padding: 14px 10px;
color: #9ca3af;
text-align: center;
}
.rich-select__option {
display: flex;
align-items: center;
gap: 10px;
padding: 8px 10px;
padding: 10px 12px;
border-radius: 6px;
cursor: pointer;
}
.rich-select__option:hover {
.rich-select__option-icon {
color: #6b7280;
flex-shrink: 0;
}
.rich-select__option-text {
display: flex;
flex-direction: column;
min-width: 0;
}
.rich-select__option-label {
font-size: 1rem;
line-height: 1.3;
}
.rich-select__option-description {
color: #6b7280;
font-size: 0.8rem;
line-height: 1.3;
margin-top: 1px;
}
.rich-select__option--muted .rich-select__option-label {
color: #6b7280;
}
/* Tastatur und Maus zeigen dieselbe Markierung -- sonst „wandert“ beim Wechsel der Eingabeart ein
zweiter Hinweis durch die Liste. */
.rich-select__option:hover,
.rich-select__option--highlighted {
background: #eff6ff;
}
.rich-select__option--active {
background: #dbeafe;
}
.rich-select__option--active .rich-select__option-label {
font-weight: 600;
}
</style>