mirror of
https://github.com/InvoiceShelf/InvoiceShelf.git
synced 2026-04-18 18:54:07 +00:00
customers, items, invoices, estimates, shared document form 77 files, 14451 lines. Typed layouts (CompanyLayout, AuthLayout, header, sidebar, company switcher), auth views (login, register, forgot/reset password), admin feature (dashboard, companies, users, settings with typed store), company features (dashboard with chart/ stats, customers CRUD, items CRUD, invoices CRUD with full store, estimates CRUD with full store), and shared document form components (items table, item row, totals, notes, tax popup, template select, exchange rate converter, calculation composable). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
180 lines
5.5 KiB
Vue
180 lines
5.5 KiB
Vue
<template>
|
|
<div ref="searchBar" class="hidden rounded md:block relative">
|
|
<div>
|
|
<BaseInput
|
|
v-model="searchQuery"
|
|
:placeholder="$t('global_search.search')"
|
|
container-class="!rounded-lg !shadow-none"
|
|
class="h-8 md:h-9 !rounded-lg !bg-white/20 !border-white/10 !text-white !placeholder-white/60"
|
|
@input="onSearchInput"
|
|
>
|
|
<template #left>
|
|
<BaseIcon name="MagnifyingGlassIcon" class="!text-white/70" />
|
|
</template>
|
|
<template #right>
|
|
<span v-if="isSearching" class="h-5 w-5 animate-spin text-primary-500" />
|
|
</template>
|
|
</BaseInput>
|
|
</div>
|
|
|
|
<transition
|
|
enter-active-class="transition duration-200 ease-out"
|
|
enter-from-class="translate-y-1 opacity-0"
|
|
enter-to-class="translate-y-0 opacity-100"
|
|
leave-active-class="transition duration-150 ease-in"
|
|
leave-from-class="translate-y-0 opacity-100"
|
|
leave-to-class="translate-y-1 opacity-0"
|
|
>
|
|
<div
|
|
v-if="isShow"
|
|
class="
|
|
scrollbar-thin scrollbar-thumb-rounded-full scrollbar-thumb-surface-muted
|
|
scrollbar-track-surface-secondary overflow-y-auto bg-surface rounded-md
|
|
mt-2 shadow-lg p-3 absolute w-[300px] h-[200px] right-0
|
|
"
|
|
>
|
|
<div
|
|
v-if="customerList.length < 1 && userList.length < 1"
|
|
class="flex items-center justify-center text-subtle text-base flex-col mt-4"
|
|
>
|
|
<BaseIcon name="ExclamationCircleIcon" class="text-subtle" />
|
|
{{ $t('global_search.no_results_found') }}
|
|
</div>
|
|
|
|
<div v-else>
|
|
<div v-if="customerList.length > 0">
|
|
<label class="text-sm text-subtle mb-0.5 block px-2 uppercase">
|
|
{{ $t('global_search.customers') }}
|
|
</label>
|
|
<div
|
|
v-for="(customer, index) in customerList"
|
|
:key="index"
|
|
class="p-2 hover:bg-hover-strong cursor-pointer rounded-md"
|
|
>
|
|
<router-link
|
|
:to="{ path: `/admin/customers/${customer.id}/view` }"
|
|
class="flex items-center"
|
|
>
|
|
<span
|
|
class="
|
|
flex items-center justify-center w-9 h-9 mr-3 text-base font-semibold
|
|
bg-surface-muted rounded-full text-primary-500
|
|
"
|
|
>
|
|
{{ initGenerator(customer.name) }}
|
|
</span>
|
|
<div class="flex flex-col">
|
|
<span class="text-sm">{{ customer.name }}</span>
|
|
<span v-if="customer.contact_name" class="text-xs text-subtle">
|
|
{{ customer.contact_name }}
|
|
</span>
|
|
<span v-else class="text-xs text-subtle">{{ customer.email }}</span>
|
|
</div>
|
|
</router-link>
|
|
</div>
|
|
</div>
|
|
|
|
<div v-if="userList.length > 0" class="mt-2">
|
|
<label class="text-sm text-subtle mb-0.5 block px-2 uppercase">
|
|
{{ $t('global_search.users') }}
|
|
</label>
|
|
<div
|
|
v-for="(user, index) in userList"
|
|
:key="index"
|
|
class="p-2 hover:bg-hover-strong cursor-pointer rounded-md"
|
|
>
|
|
<router-link
|
|
:to="{ path: `/admin/members/${user.id}/edit` }"
|
|
class="flex items-center"
|
|
>
|
|
<span
|
|
class="
|
|
flex items-center justify-center w-9 h-9 mr-3 text-base font-semibold
|
|
bg-surface-muted rounded-full text-primary-500
|
|
"
|
|
>
|
|
{{ initGenerator(user.name) }}
|
|
</span>
|
|
<div class="flex flex-col">
|
|
<span class="text-sm">{{ user.name }}</span>
|
|
<span class="text-xs text-subtle">{{ user.email }}</span>
|
|
</div>
|
|
</router-link>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</transition>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import { ref, watch } from 'vue'
|
|
import { onClickOutside, useDebounceFn } from '@vueuse/core'
|
|
import { useRoute } from 'vue-router'
|
|
import { client } from '../../../api/client'
|
|
import { API } from '../../../api/endpoints'
|
|
|
|
interface SearchResult {
|
|
id: number
|
|
name: string
|
|
email?: string
|
|
contact_name?: string
|
|
}
|
|
|
|
const isShow = ref<boolean>(false)
|
|
const searchQuery = ref<string>('')
|
|
const searchBar = ref<HTMLElement | null>(null)
|
|
const isSearching = ref<boolean>(false)
|
|
const customerList = ref<SearchResult[]>([])
|
|
const userList = ref<SearchResult[]>([])
|
|
const route = useRoute()
|
|
|
|
watch(route, () => {
|
|
isShow.value = false
|
|
searchQuery.value = ''
|
|
})
|
|
|
|
onClickOutside(searchBar, () => {
|
|
isShow.value = false
|
|
searchQuery.value = ''
|
|
})
|
|
|
|
const debouncedSearch = useDebounceFn(async () => {
|
|
if (!searchQuery.value) {
|
|
isShow.value = false
|
|
return
|
|
}
|
|
|
|
isSearching.value = true
|
|
|
|
try {
|
|
const { data } = await client.get(API.SEARCH, {
|
|
params: { search: searchQuery.value },
|
|
})
|
|
|
|
customerList.value = data.customers ?? []
|
|
userList.value = data.users ?? []
|
|
isShow.value = true
|
|
} finally {
|
|
isSearching.value = false
|
|
}
|
|
}, 500)
|
|
|
|
function onSearchInput(): void {
|
|
if (searchQuery.value === '') {
|
|
isShow.value = false
|
|
return
|
|
}
|
|
debouncedSearch()
|
|
}
|
|
|
|
function initGenerator(name: string): string {
|
|
if (name) {
|
|
const nameSplit = name.split(' ')
|
|
return nameSplit[0].charAt(0).toUpperCase()
|
|
}
|
|
return ''
|
|
}
|
|
</script>
|