feat(activities): fuzzy search on the Tickets roster

Adds a search box above the roster list that fuzzy-matches the
holder name and ticket id via the shared useFuzzySearch (Fuse.js)
composable. Empty query keeps the unregistered-first sort intact;
typing reorders by relevance. The empty-state message now
distinguishes "no tickets sold yet" from "no rows matched the
current query" so a busy roster + a typo doesn't look like
backend trouble.
This commit is contained in:
Padreug 2026-06-04 23:41:57 +02:00 committed by padreug
commit 89853641fa

View file

@ -11,16 +11,19 @@ import {
ScanLine,
RefreshCw,
UserCheck,
Search,
} from 'lucide-vue-next'
import { format } from 'date-fns'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { Input } from '@/components/ui/input'
import { ScrollArea } from '@/components/ui/scroll-area'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import QRScanner from '@/components/ui/qr-scanner.vue'
import { useTicketScanner } from '../composables/useTicketScanner'
import type { EventTicket } from '../composables/useTicketScanner'
import { useEventDetail } from '../composables/useEventDetail'
import { useFuzzySearch } from '@/composables/useFuzzySearch'
const route = useRoute()
const router = useRouter()
@ -93,6 +96,24 @@ const unregisteredCount = computed(
() => allTickets.value.filter(t => !t.registered).length,
)
// Fuzzy match on holder name + ticket id. When the search box is
// empty, Fuse returns the list in its incoming order so our
// unregistered-first sort is preserved.
const { searchQuery, filteredItems: searchedTickets } = useFuzzySearch(
allTickets,
{
fuseOptions: {
keys: [
{ name: 'name', weight: 0.7 },
{ name: 'id', weight: 0.3 },
],
threshold: 0.3,
ignoreLocation: true,
},
matchAllWhenSearchEmpty: true,
},
)
async function handleManualRegister(ticket: EventTicket) {
pendingRegister.value.add(ticket.id)
const res = await registerManually(ticket.id)
@ -239,14 +260,27 @@ function fmtTime(iso: string) {
</span>
</h2>
<!-- Fuzzy filter on holder name + ticket id (Fuse.js via
useFuzzySearch). Empty query all rows in their
sort order; typing reordered by relevance. -->
<div v-if="allTickets.length > 0" class="relative">
<Search class="absolute left-2.5 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
v-model="searchQuery"
type="search"
placeholder="Search by name or ticket id…"
class="pl-8 h-9"
/>
</div>
<!-- Unregistered rows lead the list so the operator can act
on the actionable ones first; tap "Register" to mark an
attendee present without a QR (e.g. lost phone, known
in person). Failures surface as a toast; the row reverts. -->
<ScrollArea v-if="allTickets.length > 0" class="h-[60vh]">
<ScrollArea v-if="searchedTickets.length > 0" class="h-[60vh]">
<ul class="space-y-1.5 pr-3">
<li
v-for="ticket in allTickets"
v-for="ticket in searchedTickets"
:key="ticket.id"
class="flex items-center justify-between gap-2 px-3 py-2 rounded-md bg-muted/40 text-sm"
>
@ -289,9 +323,15 @@ function fmtTime(iso: string) {
</li>
</ul>
</ScrollArea>
<p v-else class="text-sm text-muted-foreground text-center py-12">
<p
v-else-if="allTickets.length === 0"
class="text-sm text-muted-foreground text-center py-12"
>
No tickets sold yet.
</p>
<p v-else class="text-sm text-muted-foreground text-center py-12">
No tickets match {{ searchQuery }}.
</p>
</div>
</TabsContent>
</Tabs>