@afeefa/vue-app 0.0.342 → 0.0.344

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1 +1 @@
1
- 0.0.342
1
+ 0.0.344
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@afeefa/vue-app",
3
- "version": "0.0.342",
3
+ "version": "0.0.344",
4
4
  "description": "",
5
5
  "author": "Afeefa Kollektiv <kollektiv@afeefa.de>",
6
6
  "license": "MIT",
@@ -14,6 +14,7 @@
14
14
  <v-select
15
15
  ref="field"
16
16
  class="field"
17
+ :title="committedTooltip"
17
18
  :value="committedModels"
18
19
  :items="committedModels"
19
20
  multiple
@@ -52,19 +53,26 @@
52
53
  </template>
53
54
  </v-select>
54
55
 
55
- <!-- z-index bewusst unter Vuetify-Dialogen (~200+), damit die
56
- Verwerfen-Rückfrage über dem Popup liegt (siehe close()). -->
56
+ <!-- z-index wie ASearchSelect (Overlay 299, Popup 300): über dem
57
+ flying-context (200), damit das Popup nicht dahinter verschwindet. -->
57
58
  <v-overlay
58
59
  :value="isOpen"
59
- :z-index="190"
60
+ :z-index="299"
60
61
  :opacity="0"
61
62
  />
62
63
 
63
64
  <!-- Ein-Popup: ein Panel mit (optionalem) Tabs + Suchfeld + Trefferliste -->
64
65
  <div :class="panelCssClass">
66
+ <!-- v-menu__content--active markiert das Popup als Stack-Teilnehmer für
67
+ Vuetify: dessen getMaxZIndex() (mixins/stackable) scannt nur Elemente
68
+ mit dieser bzw. der v-dialog-Klasse und liest deren z-index. Da das
69
+ Popup ein eigenes div (kein echtes v-menu) ist, „faken" wir die Klasse
70
+ hier — sie hat in Vuetify keine weitere Wirkung außer dem Stack-Scan.
71
+ Folge: ein aus dem Popup geöffneter Dialog bekommt max(200, 300)+2 =
72
+ 302 und liegt korrekt ÜBER dem Popup (wie ein Dialog aus EditModal). -->
65
73
  <div
66
74
  v-if="isOpen"
67
- :class="['popup elevation-6', 'open-' + openDirection]"
75
+ :class="['popup elevation-6 v-menu__content--active', 'open-' + openDirection]"
68
76
  :style="cwm_popupWidthStyle"
69
77
  >
70
78
  <!-- Linearer Lade-Spinner am oberen Popup-Rand (wie Select1), während
@@ -91,7 +99,6 @@
91
99
  @input="onSearchInput"
92
100
  @focus="activeTab = 'search'"
93
101
  @keydown.native.down.prevent="focusList"
94
- @keydown.native.esc.prevent="onSearchEsc"
95
102
  @keydown.native.enter.ctrl.prevent="applyIfMultiple"
96
103
  >
97
104
  <!-- Anzahl-Chip innen rechts im Suchfeld: schaltet auf die
@@ -123,6 +130,7 @@
123
130
  :getIcon="getIcon"
124
131
  :isItemDisabled="isItemDisabled"
125
132
  :allowExclude="allowExclude"
133
+ :showCheckbox="multiple && !behavesSingle"
126
134
  :isLoading="isLoading"
127
135
  :hasMore="hasMore"
128
136
  @toggle="onRowClick"
@@ -154,6 +162,7 @@
154
162
  :getSubtitle="getSubtitle"
155
163
  :getIcon="getIcon"
156
164
  :allowExclude="allowExclude"
165
+ :showCheckbox="multiple && !behavesSingle"
157
166
  @toggle="onRowClick"
158
167
  @exclude="onExcludeClick"
159
168
  >
@@ -167,19 +176,28 @@
167
176
  </template>
168
177
  </select2-list>
169
178
 
170
- <!-- Footer (nur Mehrfachauswahl): Übernehmen / Verwerfen -->
179
+ <!-- Footer (nur echte Mehrfachauswahl): Übernehmen / Verwerfen. Entfällt
180
+ bei behavesSingle (nur 1 Eintrag) — dann committet der Klick sofort.
181
+ Erst NACH dem Laden zeigen: vorher steht nicht fest, ob es bei 1 Eintrag
182
+ bleibt (behavesSingle) — sonst flackerte der Footer beim Öffnen. -->
171
183
  <div
172
- v-if="multiple"
184
+ v-if="showFooter"
173
185
  class="footer"
174
186
  >
187
+ <!-- Kompakt (passt auch ins schmale Popup, §1): Verwerfen = X-Icon,
188
+ Übernehmen = "OK". -->
175
189
  <v-btn
176
190
  x-small
177
191
  text
192
+ icon
178
193
  class="footerBtn"
179
194
  :tabindex="-1"
195
+ title="Verwerfen"
180
196
  @click="discardAndClose"
181
197
  >
182
- Verwerfen
198
+ <v-icon small>
199
+ $closeIcon
200
+ </v-icon>
183
201
  </v-btn>
184
202
  <v-btn
185
203
  x-small
@@ -187,13 +205,7 @@
187
205
  color="green white--text"
188
206
  @click="apply"
189
207
  >
190
- <v-icon
191
- left
192
- class="mr-1"
193
- >
194
- $checkIcon
195
- </v-icon>
196
- Übernehmen
208
+ OK
197
209
  </v-btn>
198
210
  </div>
199
211
  </div>
@@ -259,7 +271,7 @@ import Select2List from './select2/Select2List'
259
271
  // auf [popupMinWidth, popupMaxWidth] (px) begrenzt. null = keine Grenze.
260
272
  // Eigene Namen statt minWidth/maxWidth, weil ComponentWidthMixin maxWidth
261
273
  // bereits für die FELD-Breite belegt (Kollision vermeiden).
262
- // Default 200px Mindestbreite, damit schmale Filter-Felder kein Mini-Popup
274
+ // Default 150px Mindestbreite, damit schmale Filter-Felder kein Mini-Popup
263
275
  // bekommen.
264
276
  popupMinWidth: 150,
265
277
  popupMaxWidth: null
@@ -285,13 +297,16 @@ export default class ASelect2 extends Mixins(ComponentWidthMixin, UsesPositionSe
285
297
  // Mehrfachauswahl-Tabs (Etappe 3)
286
298
  activeTab = 'search'
287
299
 
288
- // Dynamische Suche (Etappe 2)
289
- search = ''
300
+ // Dynamische Suche (Etappe 2). Leer = `null` (nicht ''), siehe onSearchInput.
301
+ search = null
290
302
  searchResults = []
291
303
  page = 1
292
304
  hasMore = false
293
305
  count = null
294
- // Wurde mindestens eine Seite geladen? Erst danach ist `hasMore` aussagekräftig
306
+ // Gesamtzahl der ungefilterten Liste (Trefferzahl ohne Suchbegriff) steuert
307
+ // showSearchField stabil, unabhängig vom aktuellen Such-`count`.
308
+ totalCount = null
309
+ // Wurde mindestens eine Seite geladen? Erst danach ist `totalCount` bekannt
295
310
  // (steuert, ob das Suchfeld bei einseitigen Listen entfällt — siehe showSearchField).
296
311
  loaded = false
297
312
  isLoading = false
@@ -351,18 +366,21 @@ export default class ASelect2 extends Mixins(ComponentWidthMixin, UsesPositionSe
351
366
  // Liste passt nicht auf eine Seite (`hasMore`). Bei einer einseitigen Liste
352
367
  // (z.B. 8 Auftragsstatus) ist Suchen sinnlos → kein Suchfeld.
353
368
  //
354
- // Zwei Sonderfälle, die das Feld trotzdem sichtbar halten müssen:
355
- // - Schon getippt (`search`): nach dem Tippen kann die Liste auf 1 Seite
356
- // schrumpfen (`hasMore` false) das Feld muss bleiben, sonst lässt sich die
357
- // Suche nicht mehr ändern/leeren.
358
- // - Noch nichts geladen (`!loaded`): `hasMore` ist erst nach der ersten Seite
359
- // bekannt. Bis dahin Feld zeigen, statt es kurz zu verstecken und nachträglich
360
- // aufpoppen zu lassen.
369
+ // Maßstab ist die GESAMTzahl der ungefilterten Liste (`totalCount`), nicht der
370
+ // aktuelle Such-`count` und nicht `hasMore`: beide fallen während einer Suche
371
+ // (0 Treffer → hasMore false) und flackern beim Reload. `totalCount` bleibt
372
+ // stabil. Sonderfälle:
373
+ // - Noch nichts geladen (`!loaded`): KEIN Feld die Items erscheinen ohnehin
374
+ // erst mit der ersten Antwort; das Feld kommt dann (falls nötig) zusammen mit
375
+ // der Liste. Vorher kurz eins zu zeigen, das bei kurzen Listen gleich wieder
376
+ // verschwindet, wäre Flackern.
377
+ // - Schon getippt (`search`): das Feld muss bleiben, sonst lässt sich die Suche
378
+ // nicht mehr ändern/leeren.
361
379
  get showSearchField () {
362
- if (!this.isDynamic || !this.hasSearch) {
380
+ if (!this.isDynamic || !this.hasSearch || !this.loaded) {
363
381
  return false
364
382
  }
365
- return this.hasMore || !!this.search || !this.loaded
383
+ return !!this.search || this.totalCount > this.pageSize
366
384
  }
367
385
 
368
386
  // Such-Label wie bei ASearchSelect: "Suche <label>" (z.B. "Suche SpraMi").
@@ -469,6 +487,13 @@ export default class ASelect2 extends Mixins(ComponentWidthMixin, UsesPositionSe
469
487
  this.searchResults = reset ? models : [...this.searchResults, ...models]
470
488
  this.count = meta.count_search != null ? meta.count_search : this.searchResults.length
471
489
  this.hasMore = this.searchResults.length < this.count
490
+ // Gesamtzahl OHNE Suchbegriff (aber mit aktiven Filtern) — steht in jeder
491
+ // Antwort als `count_filter`, auch während einer 0-Treffer-Suche. Steuert
492
+ // stabil, ob die Liste ein Suchfeld braucht (anders als `count`, das bei einer
493
+ // Suche auf die Trefferzahl fällt). Fallback auf count_all bzw. count.
494
+ this.totalCount = meta.count_filter != null
495
+ ? meta.count_filter
496
+ : (meta.count_all != null ? meta.count_all : this.count)
472
497
  this.loaded = true
473
498
 
474
499
  this.isLoading = false
@@ -502,7 +527,9 @@ export default class ASelect2 extends Mixins(ComponentWidthMixin, UsesPositionSe
502
527
  // Eintrag zeigen. Bezieht Felder/Filter/Suche/Seite ein.
503
528
  cacheKey (page) {
504
529
  return buildRequestCacheKey(this.optionsRequest(), {
505
- q: this.search,
530
+ // Leere Suche als '' (nicht null) in den Key — sonst zeigt der Lookup auf
531
+ // einen anderen Eintrag als die main.js-Vorwärmung (die mit q:'' primt).
532
+ q: this.search || '',
506
533
  page,
507
534
  pageSize: this.pageSize
508
535
  })
@@ -510,7 +537,7 @@ export default class ASelect2 extends Mixins(ComponentWidthMixin, UsesPositionSe
510
537
 
511
538
  // Suche + Seite als Filter (für Request und Cache-Key gleichermaßen).
512
539
  pageFilters (page) {
513
- return { q: this.search, page, page_size: this.pageSize }
540
+ return { q: this.search || '', page, page_size: this.pageSize }
514
541
  }
515
542
 
516
543
  // Baut die ListAction der Datenquelle und setzt Suche + Seite als Filter.
@@ -524,11 +551,28 @@ export default class ASelect2 extends Mixins(ComponentWidthMixin, UsesPositionSe
524
551
 
525
552
  // --- Auswahl-Helfer ------------------------------------------------------
526
553
 
527
- // Die im Popup aktive Auswahl: Mehrfach Draft (Arbeitskopie), Einfach
528
- // committed (Klick committet sofort, kein Draft nötig). Liste + Chips lesen
529
- // hierüber, damit beide Modi denselben Code teilen.
554
+ // Mehrfachauswahl, die sich aber wie Single bedient: wenn nur EIN Eintrag
555
+ // wählbar ist (vollständig geladen, `displayItems.length === 1`), ist eine
556
+ // Draft-/Übernehmen-Bar sinnlos ein Klick wählt und schließt sofort, kein
557
+ // Footer. Das **Wertformat** bleibt am Prop `multiple` hängen (toOuter/fromOuter
558
+ // unverändert, sonst bräche die Filter-Hülle, die ein Array erwartet) — nur das
559
+ // Bedien-VERHALTEN kippt.
560
+ get behavesSingle () {
561
+ return this.multiple && !this.hasMore && this.displayItems.length === 1
562
+ }
563
+
564
+ // Footer (X/OK) nur bei echter Mehrfachauswahl UND erst, wenn die Items geladen
565
+ // sind: bei dynamischer Quelle vor dem ersten Laden noch nicht (da ist weder die
566
+ // Trefferliste noch behavesSingle entschieden) — bei fester Liste sofort.
567
+ get showFooter () {
568
+ return this.multiple && !this.behavesSingle && (!this.isDynamic || this.loaded)
569
+ }
570
+
571
+ // Die im Popup aktive Auswahl: Mehrfach → Draft (Arbeitskopie), Einfach (auch
572
+ // behavesSingle) → committed (Klick committet sofort, kein Draft nötig). Liste +
573
+ // Chips lesen hierüber, damit beide Modi denselben Code teilen.
530
574
  get activeSelection () {
531
- return this.multiple ? this.draft : this.committed
575
+ return this.multiple && !this.behavesSingle ? this.draft : this.committed
532
576
  }
533
577
 
534
578
  get sortedSelection () {
@@ -543,6 +587,18 @@ export default class ASelect2 extends Mixins(ComponentWidthMixin, UsesPositionSe
543
587
  return this.sortedSelection.map(e => e.model)
544
588
  }
545
589
 
590
+ // Hover-Titel (natives title-Attribut) am Feld: ALLE gewählten Items, eins pro
591
+ // Zeile (das Feld selbst zeigt nur den ersten Namen + "+N"). Ausschluss mit
592
+ // "nicht " markiert, wie im Feld-Text. Leer → kein Tooltip.
593
+ get committedTooltip () {
594
+ if (!this.sortedSelection.length) {
595
+ return null
596
+ }
597
+ return this.sortedSelection
598
+ .map(e => (e.polarity === 'exclude' ? 'nicht ' : '') + this.itemTitle(e.model))
599
+ .join('\n')
600
+ }
601
+
546
602
  // Polarität aus der committed-Auswahl (Feld-Chips zeigen den bestätigten Stand,
547
603
  // nicht den Popup-Draft — anders als polarityOf, das den aktiven Draft liest).
548
604
  committedPolarityOf (model) {
@@ -576,9 +632,10 @@ export default class ASelect2 extends Mixins(ComponentWidthMixin, UsesPositionSe
576
632
 
577
633
  // --- Tabs / Auswahl-Tab --------------------------------------------------
578
634
 
579
- // Es gibt etwas Auswählbares zum Umschalten (Auswahl-Chip sichtbar).
635
+ // Es gibt etwas Auswählbares zum Umschalten (Auswahl-Chip sichtbar). Nicht bei
636
+ // behavesSingle (nur 1 Eintrag, kein Mehrfach-Bedienmodell).
580
637
  get showTabs () {
581
- return this.multiple && this.activeSelection.length > 0
638
+ return this.multiple && !this.behavesSingle && this.activeSelection.length > 0
582
639
  }
583
640
 
584
641
  // Models der aktuellen Auswahl (für die Auswahl-Ansicht).
@@ -628,15 +685,17 @@ export default class ASelect2 extends Mixins(ComponentWidthMixin, UsesPositionSe
628
685
  }
629
686
 
630
687
  // Schreibt die neue Auswahl: Mehrfach → in den Draft, Popup bleibt offen;
631
- // Einfach → sofort committen und schließen.
688
+ // Einfach (auch behavesSingle: nur 1 Eintrag) → sofort committen und schließen.
689
+ // Fokus zurück aufs Feld (wie der apply-Pfad bei Mehrfach), damit nach Klick/Enter/
690
+ // Space die Komponente fokussiert bleibt und nicht auf body fällt.
632
691
  commitSelection (next) {
633
- if (this.multiple) {
692
+ if (this.multiple && !this.behavesSingle) {
634
693
  this.draft = next
635
694
  return
636
695
  }
637
696
  this.committed = next
638
697
  this.emitChange()
639
- this.close()
698
+ this.close({ returnFocus: true })
640
699
  }
641
700
 
642
701
  withItem (selection, model, polarity) {
@@ -675,20 +734,13 @@ export default class ASelect2 extends Mixins(ComponentWidthMixin, UsesPositionSe
675
734
  // Tippen im Suchfeld wechselt zurück in die Trefferliste (falls gerade die
676
735
  // Auswahl-Ansicht aktiv ist) — sonst sucht man, sieht aber die Auswahl.
677
736
  onSearchInput (value) {
678
- this.search = value
737
+ // Leere Suche ist intern `null` (a-text-field mit `clearable` emittiert beim
738
+ // ×-Klick ohnehin `null`). Alle Lese-Stellen nutzen `!this.search` (truthy),
739
+ // sind also null-fest; `q` im Request fällt damit weg statt leerem String.
740
+ this.search = value || null
679
741
  this.activeTab = 'search'
680
742
  }
681
743
 
682
- // Esc im Suchfeld: mit Text → Suche leeren (offen bleiben); leer → schließen.
683
- onSearchEsc () {
684
- if (this.search) {
685
- this.search = ''
686
- return
687
- }
688
- // Esc = bewusst abbrechen, aber im Feld bleiben (Fokus zurück).
689
- this.close({ returnFocus: true })
690
- }
691
-
692
744
  applyIfMultiple () {
693
745
  if (this.multiple) {
694
746
  this.apply()
@@ -790,13 +842,13 @@ export default class ASelect2 extends Mixins(ComponentWidthMixin, UsesPositionSe
790
842
  // wirkt (§4). Die committed-Auswahl bleibt unberührt; nur der flüchtige Such-
791
843
  // String/die Trefferliste werden neu aufgebaut. Der cacheResults-Cache greift
792
844
  // davon unabhängig weiter (gleicher Key → aus dem Cache).
793
- // search='' kann den @Watch('search') auslösen; der würde einen zweiten,
845
+ // search=null kann den @Watch('search') auslösen; der würde einen zweiten,
794
846
  // debounced Request anstoßen. Daher den Watcher für diesen einen Reset
795
847
  // unterdrücken (nur wenn search sich wirklich ändert) und EINMAL direkt laden.
796
848
  if (this.isDynamic) {
797
849
  if (this.search) {
798
850
  this.suppressSearchWatch = true
799
- this.search = ''
851
+ this.search = null
800
852
  }
801
853
  // Fokus erst NACH dem ersten Laden setzen: vorher ist `showSearchField`
802
854
  // noch unentschieden (loaded=false ⇒ true), und bei einer einseitigen Liste
@@ -859,8 +911,10 @@ export default class ASelect2 extends Mixins(ComponentWidthMixin, UsesPositionSe
859
911
  return
860
912
  }
861
913
 
862
- // Mehrfachauswahl mit ungespeicherten Draft-Änderungen → rückfragen.
863
- if (!skipDiscardCheck && this.multiple && this.isDirty) {
914
+ // Mehrfachauswahl mit ungespeicherten Draft-Änderungen → rückfragen. Nicht bei
915
+ // behavesSingle (nur 1 Item): der Klick committet dort sofort, es gibt keinen
916
+ // Draft zu verwerfen.
917
+ if (!skipDiscardCheck && this.multiple && !this.behavesSingle && this.isDirty) {
864
918
  const result = await this.$events.dispatch(new DialogEvent(DialogEvent.SHOW, {
865
919
  title: 'Änderungen verwerfen?',
866
920
  message: 'Die Auswahl wurde geändert. Sollen die Änderungen verworfen werden?',
@@ -970,7 +1024,17 @@ export default class ASelect2 extends Mixins(ComponentWidthMixin, UsesPositionSe
970
1024
  }
971
1025
 
972
1026
  coe_cancelOnEsc () {
973
- // Esc = bewusst abbrechen, aber im Feld bleiben (Fokus zurück).
1027
+ // EINZIGER Esc-Pfad (window-keyup, kaskadierend). Mit Suchtext: nur leeren,
1028
+ // Popup bleibt offen (§14). Sonst: schließen, Fokus zurück. Früher gab es
1029
+ // zusätzlich einen @keydown.esc am Suchfeld → Esc leerte UND schloss, weil
1030
+ // keydown (Feld) und keyup (dieser Mixin) zwei getrennte Events sind.
1031
+ if (this.search) {
1032
+ // Suche leeren — der @Watch('search') lädt (debounced) neu. Das Suchfeld
1033
+ // bleibt dabei stabil sichtbar, weil showSearchField an `totalCount` hängt
1034
+ // (Gesamtzahl ohne Suchbegriff), nicht am wegfallenden Such-`count`.
1035
+ this.search = null
1036
+ return false // stop esc propagation
1037
+ }
974
1038
  this.close({ returnFocus: true })
975
1039
  return false // stop esc propagation
976
1040
  }
@@ -1053,7 +1117,15 @@ export default class ASelect2 extends Mixins(ComponentWidthMixin, UsesPositionSe
1053
1117
  [class*=" select2Panel-"] {
1054
1118
  .popup {
1055
1119
  position: absolute;
1056
- z-index: 191;
1120
+ z-index: 300;
1121
+
1122
+ // Das Popup trägt die Fake-Klasse v-menu__content--active (Template), damit
1123
+ // Vuetify es im z-index-Stack mitzählt. Diese Klasse bringt aus Vuetify
1124
+ // jedoch `pointer-events: none` mit (VMenu.sass) — hier zurücksetzen, sonst
1125
+ // wäre das Popup unklickbar. Höhere Spezifität (.select2Panel .popup)
1126
+ // schlägt Vuetifys Single-Class-Regel.
1127
+ pointer-events: auto;
1128
+
1057
1129
  background: white;
1058
1130
  max-height: 40vh;
1059
1131
  overflow: hidden;
@@ -1121,7 +1193,7 @@ export default class ASelect2 extends Mixins(ComponentWidthMixin, UsesPositionSe
1121
1193
  display: flex;
1122
1194
  justify-content: flex-end;
1123
1195
  gap: .5rem;
1124
- margin-top: 1.5rem;
1196
+ margin-top: .5rem;
1125
1197
  }
1126
1198
 
1127
1199
  // Größe zwischen x-small (20px) und small (28px): Vuetify bietet dazwischen
@@ -113,6 +113,7 @@
113
113
  <select2-chip
114
114
  v-for="filter in selectedFilters"
115
115
  :key="filter.name"
116
+ :tooltip="filter.title"
116
117
  @remove="resetFilter(filter.name)"
117
118
  >
118
119
  {{ filter.label }}: <b>{{ filter.value }}</b>
@@ -186,11 +187,11 @@ export default class ListFilterBar extends Vue {
186
187
  return Object.values(this.filters).filter(f => !f.hasDefaultValueSet()).length - minus
187
188
  }
188
189
 
189
- listFilterChanged ({ payload: { name, label, value } }) {
190
- this.setSelectedFilter(name, label, value)
190
+ listFilterChanged ({ payload: { name, label, value, title } }) {
191
+ this.setSelectedFilter(name, label, value, title)
191
192
  }
192
193
 
193
- setSelectedFilter (name, label, value) {
194
+ setSelectedFilter (name, label, value, title = null) {
194
195
  const hasValue = !this.filters[name].hasDefaultValueSet()
195
196
 
196
197
  if (!hasValue) {
@@ -199,12 +200,12 @@ export default class ListFilterBar extends Vue {
199
200
  if (this.selectedFilters.some(f => f.name === name)) {
200
201
  this.selectedFilters = this.selectedFilters.map(f => {
201
202
  if (f.name === name) {
202
- return { name, label, value }
203
+ return { name, label, value, title }
203
204
  }
204
205
  return f
205
206
  })
206
207
  } else {
207
- this.selectedFilters.push({ name, label, value })
208
+ this.selectedFilters.push({ name, label, value, title })
208
209
  }
209
210
  }
210
211
  }
@@ -6,14 +6,22 @@ import { ListFilterEvent } from '@a-vue/events'
6
6
  })
7
7
  export class ListFilterMixin extends Vue {
8
8
  displayValue = null
9
+ // Optionaler vollständiger Tooltip-Text für den Bar-Chip (Hover) — der Chip
10
+ // selbst zeigt den gekürzten `displayValue`. Filter, die das nicht setzen,
11
+ // bekommen keinen Tooltip. Mehrere Einträge bricht man mit \n um.
12
+ displayTitle = null
9
13
  name_ = null
10
14
 
15
+ // Nur auf displayValue lauschen — displayTitle wird immer GEMEINSAM mit
16
+ // displayValue gesetzt (s. ListFilterSelect2), ein eigener Watcher darauf würde
17
+ // dasselbe Event nur ein zweites Mal feuern. Der title wird hier mitgelesen.
11
18
  @Watch('displayValue')
12
19
  displayValueChanged () {
13
20
  this.$events.dispatch(new ListFilterEvent(ListFilterEvent.CHANGE, {
14
21
  name: this.name,
15
22
  label: this.label,
16
- value: this.displayValue
23
+ value: this.displayValue,
24
+ title: this.displayTitle
17
25
  }))
18
26
  }
19
27
 
@@ -6,7 +6,7 @@
6
6
  :placeholder="placeholder"
7
7
  :getIcon="getIcon"
8
8
  :specialItems="specialItems"
9
- multiple
9
+ :multiple="filter.multiple"
10
10
  :allowExclude="allowExclude"
11
11
  clearable
12
12
  v-bind="$attrs"
@@ -59,14 +59,15 @@ export default class ListFilterSelect2 extends Mixins(ListFilterMixin) {
59
59
  }
60
60
  this.selectValue = await this.loadSelectValue(this.filter.value)
61
61
  // Bar-Chip (eingeklappte Leiste) auch beim initialen Laden mit lesbaren
62
- // Namen befüllen — nicht erst nach einer Änderung.
62
+ // Namen befüllen — nicht erst nach einer Änderung. displayTitle ZUERST, damit
63
+ // es beim displayValue-Watcher (ListFilterMixin) schon aktuell ist.
64
+ this.displayTitle = this.toDisplayTitle(this.selectValue)
63
65
  this.displayValue = this.toDisplayString(this.selectValue)
64
66
  }
65
67
 
66
68
  // Token-Array (Filterwert, z.B. ['2', 'n-5']) → {model, polarity}[] für ASelect2.
67
- // Lädt die Vorauswahl-Models über die `get`-Action je ID (Muster aus
68
- // ListFilterSearchSelect.createLoadSelectedItemRequest, auf N IDs erweitert)
69
- // die List-Action der Resource hat i.d.R. keinen id-Filter, daher get statt list.
69
+ // Echte Models werden GEBÜNDELT in EINEM list-Request geladen (filter[id]=[…],
70
+ // §4) statt je ID einzeln; Sonder-Items lokal aus den Filter-Options aufgelöst.
70
71
  async loadSelectValue (value) {
71
72
  const tokens = Array.isArray(value) ? value : []
72
73
  if (!tokens.length) {
@@ -75,29 +76,39 @@ export default class ListFilterSelect2 extends Mixins(ListFilterMixin) {
75
76
 
76
77
  const specials = this.specialItems
77
78
  const entries = tokens.map(t => this.parseToken(t))
78
- const loaded = await Promise.all(
79
- entries.map(async entry => {
80
- // Sonder-Items sind Pseudo-Werte (z.B. "none") ohne echte Resource —
81
- // lokal aus den Filter-Options auflösen, nicht per get-Action laden.
79
+
80
+ // Echte IDs (keine Sonder-Items) gebündelt nachladen, dann je Token zuordnen.
81
+ const realIds = entries
82
+ .filter(e => !specials.some(s => String(s.id) === String(e.id)))
83
+ .map(e => e.id)
84
+ const modelsById = await this.loadModelsByIds(realIds)
85
+
86
+ return entries
87
+ .map(entry => {
82
88
  const special = specials.find(s => String(s.id) === String(entry.id))
83
89
  if (special) {
84
90
  return { model: special, polarity: entry.polarity }
85
91
  }
86
- const model = await this.loadModelById(entry.id)
92
+ const model = modelsById[String(entry.id)]
87
93
  return model ? { model, polarity: entry.polarity } : null
88
94
  })
89
- )
90
- return loaded.filter(Boolean)
95
+ .filter(e => e)
91
96
  }
92
97
 
93
- async loadModelById (id) {
94
- const request = this.filter.createOptionsRequest()
95
- const getAction = this.$apiResources.getAction({
96
- resourceType: request.getAction().getResource().getType(),
97
- actionName: 'get'
98
- })
99
- request.action(getAction).params({ id })
100
- return ApiAction.fromRequest(request).hideError().execute()
98
+ // Lädt mehrere Models in EINEM list-Request über den server-seitigen id-Filter
99
+ // (whereIn). Rückgabe: Map id→Model. Setzt voraus, dass die Resource den
100
+ // id-Filter kennt (z.B. AppointmentTypeResource, AssignmentContextResource).
101
+ async loadModelsByIds (ids) {
102
+ if (!ids.length) {
103
+ return {}
104
+ }
105
+ const request = this.filter.createOptionsRequest().addFilters({ id: ids })
106
+ const result = await ApiAction.fromRequest(request).hideError().execute()
107
+ const models = (result && result.models) || []
108
+ return models.reduce((map, model) => {
109
+ map[String(model.id)] = model
110
+ return map
111
+ }, {})
101
112
  }
102
113
 
103
114
  optionsRequest () {
@@ -147,13 +158,17 @@ export default class ListFilterSelect2 extends Mixins(ListFilterMixin) {
147
158
  }
148
159
  }
149
160
 
150
- // {model, polarity}[] (von ASelect2) Token-Array (Filterwert).
161
+ // {model, polarity} (Einfach) bzw. {model, polarity}[] (Mehrfach) von ASelect2
162
+ // → Token-Array (Filterwert). Einfachauswahl emittiert KEIN Array (api §1), daher
163
+ // hier auf Array normalisieren — die Hülle arbeitet intern einheitlich mit Arrays.
151
164
  onChange (value) {
152
165
  this.filterChangedFromInside = true
153
166
 
154
- const tokens = (value || []).map(e => this.toToken(e.model.id, e.polarity))
167
+ const selection = this.toSelectionArray(value)
168
+ const tokens = selection.map(e => this.toToken(e.model.id, e.polarity))
155
169
  this.filter.value = tokens.length ? tokens : null
156
- this.selectValue = value || []
170
+ this.selectValue = selection
171
+ this.displayTitle = this.toDisplayTitle(this.selectValue)
157
172
  this.displayValue = this.toDisplayString(this.selectValue)
158
173
 
159
174
  this.$nextTick(() => {
@@ -161,6 +176,15 @@ export default class ListFilterSelect2 extends Mixins(ListFilterMixin) {
161
176
  })
162
177
  }
163
178
 
179
+ // ASelect2-Außenwert → immer Array: Mehrfach ist schon ein Array, Einfach ein
180
+ // einzelnes {model, polarity} (oder null bei leerer Auswahl).
181
+ toSelectionArray (value) {
182
+ if (!value) {
183
+ return []
184
+ }
185
+ return Array.isArray(value) ? value : [value]
186
+ }
187
+
164
188
  // 'n-5' → {id: '5', polarity: 'exclude'}; '2' → {id: '2', polarity: 'include'}.
165
189
  parseToken (token) {
166
190
  const str = String(token)
@@ -187,5 +211,17 @@ export default class ListFilterSelect2 extends Mixins(ListFilterMixin) {
187
211
  const rest = selection.length - 1
188
212
  return rest > 0 ? `${truncated} +${rest}` : truncated
189
213
  }
214
+
215
+ // Vollständige Liste für den Bar-Chip-Tooltip (Hover): ALLE Items, eins pro
216
+ // Zeile, Ausschluss mit "nicht " markiert. Der Chip selbst zeigt nur die kurze
217
+ // toDisplayString-Form.
218
+ toDisplayTitle (selection) {
219
+ if (!selection || !selection.length) {
220
+ return null
221
+ }
222
+ return selection
223
+ .map(e => (e.polarity === 'exclude' ? 'nicht ' : '') + e.model.getTitle())
224
+ .join('\n')
225
+ }
190
226
  }
191
227
  </script>
@@ -5,7 +5,7 @@
5
5
  :close="closable && !disabled"
6
6
  :color="chipColor"
7
7
  :text-color="chipTextColor"
8
- :title="title"
8
+ :title="tooltip || title"
9
9
  @click:close="$emit('remove')"
10
10
  >
11
11
  <span class="label">
@@ -28,6 +28,9 @@ import { Component, Vue } from '@a-vue'
28
28
  'getTitle',
29
29
  'color',
30
30
  'textColor',
31
+ // Expliziter Hover-Tooltip (Vorrang vor dem aus `item` abgeleiteten Titel) —
32
+ // z.B. der Bar-Chip zeigt darin alle gewählten Items, eins pro Zeile.
33
+ 'tooltip',
31
34
  {
32
35
  polarity: 'include',
33
36
  disabled: false,
@@ -8,6 +8,7 @@
8
8
  @keydown.up.prevent="moveActive(-1)"
9
9
  @keydown.enter.ctrl.prevent="$emit('apply')"
10
10
  @keydown.enter.exact.prevent="activateEnter"
11
+ @keydown.space.exact.prevent="activateEnter"
11
12
  @keydown.enter.shift.prevent="activateExclude"
12
13
  @keydown.tab.shift.prevent="$emit('backtab')"
13
14
  >
@@ -40,6 +41,18 @@
40
41
  :selected="isSelected(model)"
41
42
  :polarity="polarityOf(model)"
42
43
  >
44
+ <!-- Checkbox vorn (nur Mehrfachauswahl): zeigt den gewählt-Zustand.
45
+ Reine Anzeige — der Klick auf die ganze Zeile toggelt. -->
46
+ <v-simple-checkbox
47
+ v-if="showCheckbox"
48
+ class="rowCheckbox"
49
+ :value="isSelected(model)"
50
+ :disabled="rowDisabled(model)"
51
+ dense
52
+ hide-details
53
+ @input="onRowClick(model)"
54
+ />
55
+
43
56
  <a-icon
44
57
  v-if="getIcon"
45
58
  :icon="getIcon(model)"
@@ -126,6 +139,10 @@ import { Component, Vue, Watch } from '@a-vue'
126
139
  'isItemDisabled',
127
140
  {
128
141
  allowExclude: false,
142
+ // Checkbox vorn pro Zeile: macht den Auswahl-Zustand bei Mehrfachauswahl
143
+ // klar sichtbar (besonders bei nur 1 Eintrag). Nur Anzeige des „gewählt"-
144
+ // Zustands — die Polarität (include/exclude) zeigt weiter der Nicht-Button.
145
+ showCheckbox: false,
129
146
  isLoading: false,
130
147
  hasMore: false,
131
148
  // Anzahl der oben angepinnten Sonder-Items (§5): die ersten N Zeilen
@@ -328,6 +345,21 @@ export default class Select2List extends Vue {
328
345
  visibility: visible;
329
346
  }
330
347
 
348
+ // Checkbox vorn (Mehrfachauswahl): kompakt, kein Eigen-Margin (die Zeile bringt
349
+ // den gap mit). flex-shrink 0, damit sie bei langen Titeln nicht gequetscht wird.
350
+
351
+ .rowCheckbox {
352
+ flex: 0 0 auto;
353
+ margin: 0;
354
+
355
+ // Vuetify setzt margin-right: 8px aufs Selection-Control — die Zeile bringt
356
+ // den gap schon mit, daher hier weg.
357
+
358
+ :deep(.v-input--selection-controls__input) {
359
+ margin-right: 0;
360
+ }
361
+ }
362
+
331
363
  // Oben angepinnte Sonder-Items (§5): bleiben beim Scrollen sichtbar. Weißer
332
364
  // Hintergrund, damit durchscrollende Treffer nicht durchscheinen.
333
365
 
@@ -4,7 +4,8 @@
4
4
  // vorkommen, nur einmal laden statt pro Komponente. Singleton im Stil von
5
5
  // positionService — kein Vue-Plugin, keine Vue.prototype-Erweiterung.
6
6
  //
7
- // Nur sinnvoll für Listen ohne Suchbegriff (die ASelect2 nur dann cached).
7
+ // Bei cacheResults=true läuft JEDE Anfrage über den Cache jede Seite, auch mit
8
+ // Suchbegriff (q/page sind Teil des Keys, siehe buildRequestCacheKey).
8
9
  // Zeitbasierte Invalidierung (Default 30 Min), keine aktive Invalidierung.
9
10
  // inFlight-Dedup verhindert parallele Doppel-Requests auf denselben Key.
10
11