440 lines
16 KiB
Vue
440 lines
16 KiB
Vue
<script setup lang="ts">
|
|
/**
|
|
* Global command palette — searches customers, policies, claims, and app pages.
|
|
* Keyboard: Ctrl/Cmd+K to focus, Escape to close, ↑↓ to navigate, Enter to go.
|
|
*/
|
|
import { MOCK_CUSTOMERS, fmtMoney } from '~/data/mock-customers'
|
|
|
|
const router = useRouter()
|
|
const open = ref(false)
|
|
const q = ref('')
|
|
const inputRef = ref<HTMLElement | null>(null)
|
|
const activeIndex = ref(-1)
|
|
|
|
/* ── Build searchable records from mock data ── */
|
|
|
|
type SearchHit = {
|
|
id: string
|
|
kind: 'customer' | 'policy' | 'claim' | 'page'
|
|
icon: string
|
|
title: string
|
|
meta: string
|
|
detail?: string
|
|
to: string
|
|
}
|
|
|
|
const allRecords = computed<SearchHit[]>(() => {
|
|
const hits: SearchHit[] = []
|
|
|
|
for (const c of MOCK_CUSTOMERS) {
|
|
// Customer record
|
|
hits.push({
|
|
id: `cust-${c.id}`,
|
|
kind: 'customer',
|
|
icon: 'i-heroicons-user',
|
|
title: c.name,
|
|
meta: `${c.type} · ${c.documentId}`,
|
|
detail: `${c.policies.length} policies · ${fmtMoney(c.policies.reduce((s, p) => s + p.premium, 0))}/yr · Agent: ${c.agent}`,
|
|
to: `/customers/${c.id}`
|
|
})
|
|
|
|
// Each policy
|
|
for (const p of c.policies) {
|
|
hits.push({
|
|
id: `pol-${p.id}`,
|
|
kind: 'policy',
|
|
icon: p.icon,
|
|
title: p.id,
|
|
meta: `${p.line} · ${p.carrier} · ${c.name}`,
|
|
detail: p.product,
|
|
to: `/customers/${c.id}`
|
|
})
|
|
}
|
|
|
|
// Each claim
|
|
for (const cl of c.claims) {
|
|
hits.push({
|
|
id: `claim-${cl.id}`,
|
|
kind: 'claim',
|
|
icon: 'i-heroicons-shield-exclamation',
|
|
title: cl.id,
|
|
meta: `${cl.type} · ${cl.status} · ${c.name}`,
|
|
detail: `Policy ${cl.policy} · $${cl.amount.toLocaleString()}`,
|
|
to: `/customers/${c.id}`
|
|
})
|
|
}
|
|
}
|
|
|
|
return hits
|
|
})
|
|
|
|
/* ── App pages / destinations ── */
|
|
const APP_PAGES: SearchHit[] = [
|
|
{ id: 'p-home', kind: 'page', icon: 'i-heroicons-squares-2x2', title: 'My Dashboard', meta: 'Home', to: '/' },
|
|
{ id: 'p-calendar', kind: 'page', icon: 'i-heroicons-calendar-days', title: 'Calendar', meta: 'Agenda & reminders', to: '/calendar' },
|
|
|
|
// Quotes
|
|
{ id: 'p-quotes', kind: 'page', icon: 'i-heroicons-calculator', title: 'Quotes Overview', meta: 'Quotes', to: '/quotes' },
|
|
{ id: 'p-quotes-auto', kind: 'page', icon: 'i-heroicons-truck', title: 'Auto Quotes', meta: 'Quotes · Motor & fleet', to: '/quotes/auto' },
|
|
{ id: 'p-quotes-health', kind: 'page', icon: 'i-heroicons-heart', title: 'Health Quotes', meta: 'Quotes · Collective & individual', to: '/quotes/health' },
|
|
{ id: 'p-quotes-life', kind: 'page', icon: 'i-heroicons-shield-check', title: 'Life Quotes', meta: 'Quotes · Individual & corporate', to: '/quotes/life' },
|
|
{ id: 'p-quotes-risk', kind: 'page', icon: 'i-heroicons-building-office-2', title: 'General Risk', meta: 'Quotes · Liability & specialty', to: '/quotes/general-risk' },
|
|
|
|
// Sales
|
|
{ id: 'p-ql', kind: 'page', icon: 'i-heroicons-bolt', title: 'Quick Lead', meta: 'Sales · Fast lead capture', to: '/sales/quick-lead' },
|
|
{ id: 'p-pipeline', kind: 'page', icon: 'i-heroicons-funnel', title: 'Sales Pipeline', meta: 'Sales · Kanban board', to: '/onboarding' },
|
|
{ id: 'p-solicitud', kind: 'page', icon: 'i-heroicons-document-text', title: 'New Solicitud', meta: 'Sales · Onboarding intake', to: '/onboarding/solicitud' },
|
|
{ id: 'p-emissions', kind: 'page', icon: 'i-heroicons-paper-airplane', title: 'Emissions Review', meta: 'Sales · QA & carrier submit', to: '/onboarding/emissions' },
|
|
{ id: 'p-nombra', kind: 'page', icon: 'i-heroicons-document-arrow-up', title: 'Nombramiento', meta: 'Sales · Broker-of-record transfer', to: '/onboarding/policy-upload/new' },
|
|
|
|
// Operations
|
|
{ id: 'p-customers', kind: 'page', icon: 'i-heroicons-users', title: 'Customers', meta: 'Operations · CRM', to: '/customers' },
|
|
{ id: 'p-new-customer', kind: 'page', icon: 'i-heroicons-user-plus', title: 'New Customer', meta: 'Operations · Registration', to: '/customers/new' },
|
|
{ id: 'p-policies', kind: 'page', icon: 'i-heroicons-briefcase', title: 'Policies', meta: 'Operations · Book of business', to: '/policies' },
|
|
|
|
// Workstations
|
|
{ id: 'p-collectivos', kind: 'page', icon: 'i-heroicons-user-group', title: 'Collectivos', meta: 'Workstations · Group management', to: '/workstation/collectivos' },
|
|
{ id: 'p-collections', kind: 'page', icon: 'i-heroicons-banknotes', title: 'Collections', meta: 'Workstations', to: '/workstation/collections' },
|
|
{ id: 'p-claims-ws', kind: 'page', icon: 'i-heroicons-shield-exclamation', title: 'Claims', meta: 'Workstations', to: '/workstation/claims' },
|
|
{ id: 'p-renewals', kind: 'page', icon: 'i-heroicons-arrow-path', title: 'Renewals', meta: 'Workstations', to: '/workstation/renewals' },
|
|
{ id: 'p-cs', kind: 'page', icon: 'i-heroicons-chat-bubble-left-right', title: 'Customer Service', meta: 'Workstations', to: '/workstation/customer-service' },
|
|
{ id: 'p-sales-factory', kind: 'page', icon: 'i-heroicons-rocket-launch', title: 'Sales Factory', meta: 'AI Tools · Lead gen & cross-sell', to: '/ai-tools/sales-factory' },
|
|
{ id: 'p-facturacion', kind: 'page', icon: 'i-heroicons-document-text', title: 'Facturación', meta: 'Workstations · Invoicing', to: '/workstation/facturacion' },
|
|
|
|
// AI Tools
|
|
{ id: 'p-comparator', kind: 'page', icon: 'i-heroicons-scale', title: 'Policy Comparator', meta: 'AI Tools', to: '/ai-tools/policy-comparator' },
|
|
{ id: 'p-email', kind: 'page', icon: 'i-heroicons-envelope', title: 'Email Writer', meta: 'AI Tools', to: '/ai-tools/email-writer' },
|
|
{ id: 'p-case', kind: 'page', icon: 'i-heroicons-light-bulb', title: 'Case Assistant', meta: 'AI Tools', to: '/ai-tools/case-assistant' },
|
|
|
|
// Reports
|
|
{ id: 'p-production', kind: 'page', icon: 'i-heroicons-chart-bar', title: 'Production Report', meta: 'Reports & Analysis', to: '/analysis/production' },
|
|
{ id: 'p-commissions', kind: 'page', icon: 'i-heroicons-currency-dollar', title: 'Commissions', meta: 'Reports & Analysis', to: '/analysis/commissions' },
|
|
{ id: 'p-claims-report', kind: 'page', icon: 'i-heroicons-chart-pie', title: 'Claims Report', meta: 'Reports & Analysis', to: '/analysis/claims' },
|
|
|
|
// Settings
|
|
{ id: 'p-account', kind: 'page', icon: 'i-heroicons-user-circle', title: 'My Account', meta: 'Settings · Profile & theme', to: '/account' },
|
|
{ id: 'p-settings', kind: 'page', icon: 'i-heroicons-cog-6-tooth', title: 'Software Settings', meta: 'Settings', to: '/settings' },
|
|
{ id: 'p-agents', kind: 'page', icon: 'i-heroicons-user-group', title: 'Agents & Commissions', meta: 'Settings · Producer management', to: '/settings/agents' },
|
|
{ id: 'p-org', kind: 'page', icon: 'i-heroicons-building-office', title: 'Organization', meta: 'Settings · Company & logo', to: '/settings/organization' },
|
|
{ id: 'p-forms', kind: 'page', icon: 'i-heroicons-clipboard-document-list', title: 'Forms Library', meta: 'Settings · Insurer forms', to: '/settings/forms' },
|
|
{ id: 'p-providers', kind: 'page', icon: 'i-heroicons-building-storefront', title: 'Providers', meta: 'Settings · Carrier setup', to: '/settings/providers' },
|
|
{ id: 'p-permissions', kind: 'page', icon: 'i-heroicons-lock-closed', title: 'Permissions', meta: 'Settings · Roles & access', to: '/settings/permissions' },
|
|
|
|
// Tasks
|
|
{ id: 'p-tasks', kind: 'page', icon: 'i-heroicons-clipboard-document-check', title: 'Tasks', meta: 'Work management', to: '/tasks' },
|
|
]
|
|
|
|
/* ── Search filtering ── */
|
|
const needle = computed(() => q.value.trim().toLowerCase())
|
|
|
|
const filteredRecords = computed(() => {
|
|
if (!needle.value) return []
|
|
const n = needle.value
|
|
return allRecords.value.filter(
|
|
(x) =>
|
|
x.title.toLowerCase().includes(n) ||
|
|
x.meta.toLowerCase().includes(n) ||
|
|
(x.detail?.toLowerCase().includes(n) ?? false)
|
|
).slice(0, 8)
|
|
})
|
|
|
|
const filteredPages = computed(() => {
|
|
if (!needle.value) return APP_PAGES.slice(0, 6) // show top pages when empty
|
|
const n = needle.value
|
|
return APP_PAGES.filter(
|
|
(x) =>
|
|
x.title.toLowerCase().includes(n) ||
|
|
x.meta.toLowerCase().includes(n)
|
|
).slice(0, 8)
|
|
})
|
|
|
|
const allFiltered = computed(() => [...filteredRecords.value, ...filteredPages.value])
|
|
|
|
/* ── Keyboard navigation ── */
|
|
function navigate(hit: SearchHit) {
|
|
open.value = false
|
|
q.value = ''
|
|
activeIndex.value = -1
|
|
router.push(hit.to)
|
|
}
|
|
|
|
function onKeydown(e: KeyboardEvent) {
|
|
if (!open.value) return
|
|
|
|
const total = allFiltered.value.length
|
|
if (e.key === 'ArrowDown') {
|
|
e.preventDefault()
|
|
activeIndex.value = (activeIndex.value + 1) % Math.max(total, 1)
|
|
} else if (e.key === 'ArrowUp') {
|
|
e.preventDefault()
|
|
activeIndex.value = activeIndex.value <= 0 ? total - 1 : activeIndex.value - 1
|
|
} else if (e.key === 'Enter' && activeIndex.value >= 0 && activeIndex.value < total) {
|
|
e.preventDefault()
|
|
navigate(allFiltered.value[activeIndex.value]!)
|
|
} else if (e.key === 'Escape') {
|
|
e.preventDefault()
|
|
open.value = false
|
|
activeIndex.value = -1
|
|
}
|
|
}
|
|
|
|
/* ── Global Cmd/Ctrl+K ── */
|
|
function onGlobalKey(e: KeyboardEvent) {
|
|
if ((e.metaKey || e.ctrlKey) && e.key === 'k') {
|
|
e.preventDefault()
|
|
open.value = true
|
|
nextTick(() => {
|
|
const el = inputRef.value
|
|
if (el) {
|
|
const input = el.querySelector('input') ?? (el as HTMLInputElement)
|
|
input?.focus()
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
/* ── Click outside ── */
|
|
const containerRef = ref<HTMLElement | null>(null)
|
|
function onDocClick(e: MouseEvent) {
|
|
const el = containerRef.value
|
|
if (!el || !open.value) return
|
|
if (!el.contains(e.target as Node)) {
|
|
open.value = false
|
|
activeIndex.value = -1
|
|
}
|
|
}
|
|
|
|
onMounted(() => {
|
|
document.addEventListener('click', onDocClick)
|
|
document.addEventListener('keydown', onGlobalKey)
|
|
})
|
|
onUnmounted(() => {
|
|
document.removeEventListener('click', onDocClick)
|
|
document.removeEventListener('keydown', onGlobalKey)
|
|
})
|
|
|
|
watch(q, () => {
|
|
open.value = true
|
|
activeIndex.value = -1
|
|
})
|
|
|
|
/* ── Kind colors ── */
|
|
const kindColor: Record<string, string> = {
|
|
customer: '#01696f',
|
|
policy: '#7c3aed',
|
|
claim: '#c13838',
|
|
page: '#8a8a86',
|
|
}
|
|
</script>
|
|
|
|
<template>
|
|
<div ref="containerRef" class="relative w-full max-w-xl min-w-0 flex-1">
|
|
<div ref="inputRef">
|
|
<UInput
|
|
v-model="q"
|
|
icon="i-heroicons-magnifying-glass"
|
|
placeholder="Search customers, policies, pages… (⌘K)"
|
|
class="w-full"
|
|
@focus="open = true"
|
|
@keydown="onKeydown"
|
|
/>
|
|
</div>
|
|
<Transition
|
|
enter-active-class="transition duration-150 ease-out"
|
|
enter-from-class="opacity-0 translate-y-1"
|
|
enter-to-class="opacity-100 translate-y-0"
|
|
leave-active-class="transition duration-100 ease-in"
|
|
leave-from-class="opacity-100"
|
|
leave-to-class="opacity-0"
|
|
>
|
|
<div
|
|
v-show="open"
|
|
class="cs-dropdown"
|
|
>
|
|
<div class="max-h-[min(70vh,460px)] overflow-y-auto">
|
|
<!-- Records section (customers, policies, claims) -->
|
|
<template v-if="filteredRecords.length > 0">
|
|
<div class="cs-section-head">
|
|
<span>Records</span>
|
|
<span class="cs-section-count">{{ filteredRecords.length }}</span>
|
|
</div>
|
|
<ul class="py-1">
|
|
<li v-for="(r, i) in filteredRecords" :key="r.id">
|
|
<button
|
|
type="button"
|
|
class="cs-hit"
|
|
:class="activeIndex === i ? 'cs-hit-active' : ''"
|
|
@click="navigate(r)"
|
|
@mouseenter="activeIndex = i"
|
|
>
|
|
<div class="cs-hit-icon" :style="`color: ${kindColor[r.kind] || '#8a8a86'}`">
|
|
<UIcon :name="r.icon" style="width: 16px; height: 16px;" />
|
|
</div>
|
|
<div class="min-w-0 flex-1">
|
|
<div class="flex items-center gap-2">
|
|
<p class="truncate text-[13px] font-medium text-[var(--text-primary)]">{{ r.title }}</p>
|
|
<span class="cs-kind-badge" :style="`background: ${kindColor[r.kind]}15; color: ${kindColor[r.kind]}`">
|
|
{{ r.kind }}
|
|
</span>
|
|
</div>
|
|
<p class="truncate text-[11px] text-[var(--text-muted)]">{{ r.meta }}</p>
|
|
<p v-if="r.detail" class="truncate text-[11px] text-[var(--text-muted)] opacity-70">{{ r.detail }}</p>
|
|
</div>
|
|
<UIcon name="i-heroicons-arrow-right" class="shrink-0 opacity-30" style="width: 12px; height: 12px;" />
|
|
</button>
|
|
</li>
|
|
</ul>
|
|
</template>
|
|
|
|
<!-- No record results message -->
|
|
<template v-if="needle && filteredRecords.length === 0">
|
|
<div class="cs-section-head">
|
|
<span>Records</span>
|
|
</div>
|
|
<p class="px-4 py-3 text-[12px] text-[var(--text-muted)]">
|
|
No customers, policies, or claims match "{{ q.trim() }}"
|
|
</p>
|
|
</template>
|
|
|
|
<!-- Pages / Go to section -->
|
|
<template v-if="filteredPages.length > 0">
|
|
<div class="cs-section-head" :class="filteredRecords.length > 0 ? 'cs-section-border' : ''">
|
|
<span>{{ needle ? 'Pages' : 'Quick access' }}</span>
|
|
</div>
|
|
<ul class="py-1 pb-2">
|
|
<li v-for="(a, ai) in filteredPages" :key="a.id">
|
|
<button
|
|
type="button"
|
|
class="cs-hit"
|
|
:class="activeIndex === filteredRecords.length + ai ? 'cs-hit-active' : ''"
|
|
@click="navigate(a)"
|
|
@mouseenter="activeIndex = filteredRecords.length + ai"
|
|
>
|
|
<div class="cs-hit-icon" style="color: #8a8a86;">
|
|
<UIcon :name="a.icon" style="width: 16px; height: 16px;" />
|
|
</div>
|
|
<div class="min-w-0 flex-1">
|
|
<p class="truncate text-[13px] text-[var(--text-primary)]">{{ a.title }}</p>
|
|
<p class="truncate text-[11px] text-[var(--text-muted)]">{{ a.meta }}</p>
|
|
</div>
|
|
<UIcon name="i-heroicons-arrow-top-right-on-square" class="shrink-0 opacity-30" style="width: 12px; height: 12px;" />
|
|
</button>
|
|
</li>
|
|
</ul>
|
|
</template>
|
|
|
|
<!-- Keyboard hint -->
|
|
<div class="cs-footer">
|
|
<span class="cs-kbd">↑↓</span> navigate
|
|
<span class="cs-kbd">↵</span> go
|
|
<span class="cs-kbd">esc</span> close
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</Transition>
|
|
</div>
|
|
</template>
|
|
|
|
<style scoped>
|
|
.cs-dropdown {
|
|
position: absolute;
|
|
left: 0; right: 0;
|
|
top: calc(100% + 6px);
|
|
z-index: 50;
|
|
overflow: hidden;
|
|
border-radius: 12px;
|
|
border: 1px solid rgba(0,0,0,0.08);
|
|
background: var(--surface, #ffffff);
|
|
box-shadow: 0 8px 30px rgba(0,0,0,0.12), 0 2px 8px rgba(0,0,0,0.06);
|
|
}
|
|
|
|
.cs-section-head {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 6px;
|
|
padding: 8px 16px 4px;
|
|
font-size: 10px;
|
|
font-weight: 600;
|
|
text-transform: uppercase;
|
|
letter-spacing: 0.06em;
|
|
color: #8a8a86;
|
|
}
|
|
.cs-section-border {
|
|
border-top: 1px solid rgba(0,0,0,0.06);
|
|
margin-top: 2px;
|
|
padding-top: 10px;
|
|
}
|
|
.cs-section-count {
|
|
font-size: 9px;
|
|
font-weight: 600;
|
|
padding: 0 4px;
|
|
border-radius: 4px;
|
|
background: rgba(0,0,0,0.05);
|
|
color: #8a8a86;
|
|
}
|
|
|
|
.cs-hit {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 10px;
|
|
width: 100%;
|
|
padding: 8px 16px;
|
|
border: none;
|
|
background: transparent;
|
|
cursor: pointer;
|
|
text-align: left;
|
|
transition: background 100ms ease;
|
|
}
|
|
.cs-hit:hover,
|
|
.cs-hit-active {
|
|
background: rgba(1,105,111,0.04);
|
|
}
|
|
|
|
.cs-hit-icon {
|
|
width: 32px;
|
|
height: 32px;
|
|
border-radius: 8px;
|
|
background: rgba(0,0,0,0.03);
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
flex-shrink: 0;
|
|
}
|
|
|
|
.cs-kind-badge {
|
|
font-size: 9px;
|
|
font-weight: 600;
|
|
text-transform: uppercase;
|
|
letter-spacing: 0.04em;
|
|
padding: 1px 5px;
|
|
border-radius: 4px;
|
|
flex-shrink: 0;
|
|
}
|
|
|
|
.cs-footer {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 8px;
|
|
padding: 8px 16px;
|
|
border-top: 1px solid rgba(0,0,0,0.06);
|
|
font-size: 10px;
|
|
color: #a0a09c;
|
|
}
|
|
|
|
.cs-kbd {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
min-width: 18px;
|
|
height: 16px;
|
|
padding: 0 4px;
|
|
border-radius: 3px;
|
|
border: 1px solid rgba(0,0,0,0.1);
|
|
background: rgba(0,0,0,0.03);
|
|
font-size: 9px;
|
|
font-weight: 600;
|
|
font-family: inherit;
|
|
color: #8a8a86;
|
|
}
|
|
</style>
|