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:
parent
31e48c8f1d
commit
b7fd8c99e5
1 changed files with 43 additions and 3 deletions
|
|
@ -11,16 +11,19 @@ import {
|
||||||
ScanLine,
|
ScanLine,
|
||||||
RefreshCw,
|
RefreshCw,
|
||||||
UserCheck,
|
UserCheck,
|
||||||
|
Search,
|
||||||
} from 'lucide-vue-next'
|
} from 'lucide-vue-next'
|
||||||
import { format } from 'date-fns'
|
import { format } from 'date-fns'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { Badge } from '@/components/ui/badge'
|
import { Badge } from '@/components/ui/badge'
|
||||||
|
import { Input } from '@/components/ui/input'
|
||||||
import { ScrollArea } from '@/components/ui/scroll-area'
|
import { ScrollArea } from '@/components/ui/scroll-area'
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
||||||
import QRScanner from '@/components/ui/qr-scanner.vue'
|
import QRScanner from '@/components/ui/qr-scanner.vue'
|
||||||
import { useTicketScanner } from '../composables/useTicketScanner'
|
import { useTicketScanner } from '../composables/useTicketScanner'
|
||||||
import type { EventTicket } from '../composables/useTicketScanner'
|
import type { EventTicket } from '../composables/useTicketScanner'
|
||||||
import { useEventDetail } from '../composables/useEventDetail'
|
import { useEventDetail } from '../composables/useEventDetail'
|
||||||
|
import { useFuzzySearch } from '@/composables/useFuzzySearch'
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
|
@ -93,6 +96,24 @@ const unregisteredCount = computed(
|
||||||
() => allTickets.value.filter(t => !t.registered).length,
|
() => 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) {
|
async function handleManualRegister(ticket: EventTicket) {
|
||||||
pendingRegister.value.add(ticket.id)
|
pendingRegister.value.add(ticket.id)
|
||||||
const res = await registerManually(ticket.id)
|
const res = await registerManually(ticket.id)
|
||||||
|
|
@ -239,14 +260,27 @@ function fmtTime(iso: string) {
|
||||||
</span>
|
</span>
|
||||||
</h2>
|
</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
|
<!-- Unregistered rows lead the list so the operator can act
|
||||||
on the actionable ones first; tap "Register" to mark an
|
on the actionable ones first; tap "Register" to mark an
|
||||||
attendee present without a QR (e.g. lost phone, known
|
attendee present without a QR (e.g. lost phone, known
|
||||||
in person). Failures surface as a toast; the row reverts. -->
|
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">
|
<ul class="space-y-1.5 pr-3">
|
||||||
<li
|
<li
|
||||||
v-for="ticket in allTickets"
|
v-for="ticket in searchedTickets"
|
||||||
:key="ticket.id"
|
:key="ticket.id"
|
||||||
class="flex items-center justify-between gap-2 px-3 py-2 rounded-md bg-muted/40 text-sm"
|
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>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</ScrollArea>
|
</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.
|
No tickets sold yet.
|
||||||
</p>
|
</p>
|
||||||
|
<p v-else class="text-sm text-muted-foreground text-center py-12">
|
||||||
|
No tickets match “{{ searchQuery }}”.
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue