User-Comfort im Zahlungsparser
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user