WIP jordan
This commit is contained in:
439
app/components/layout/AppCommandSearch.vue
Normal file
439
app/components/layout/AppCommandSearch.vue
Normal file
@@ -0,0 +1,439 @@
|
||||
<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>
|
||||
339
app/components/layout/AppTopBar.vue
Normal file
339
app/components/layout/AppTopBar.vue
Normal file
@@ -0,0 +1,339 @@
|
||||
<script setup lang="ts">
|
||||
import type { AppThemeId } from '~/types/app-theme'
|
||||
import { APP_THEME_OPTIONS } from '~/types/app-theme'
|
||||
|
||||
defineProps<{
|
||||
sidebarCollapsed: boolean
|
||||
brandTitle?: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
toggleSidebar: []
|
||||
refresh: []
|
||||
}>()
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const isHome = computed(() => route.path === '/')
|
||||
const { themeId, applyTheme } = useAppTheme()
|
||||
|
||||
const themeIcons: Record<string, string> = {
|
||||
light: 'i-heroicons-sun',
|
||||
purple: 'i-heroicons-sparkles',
|
||||
dark: 'i-heroicons-moon',
|
||||
'dark-purple': 'i-heroicons-star',
|
||||
}
|
||||
|
||||
const userMenuOpen = ref(false)
|
||||
const userMenuRoot = ref<HTMLElement | null>(null)
|
||||
const themeMenuOpen = ref(false)
|
||||
const themeMenuRoot = ref<HTMLElement | null>(null)
|
||||
|
||||
function closeUserMenu() {
|
||||
userMenuOpen.value = false
|
||||
}
|
||||
|
||||
function onDocClick(e: MouseEvent) {
|
||||
const userEl = userMenuRoot.value
|
||||
if (userEl && userMenuOpen.value && !userEl.contains(e.target as Node)) {
|
||||
userMenuOpen.value = false
|
||||
}
|
||||
const themeEl = themeMenuRoot.value
|
||||
if (themeEl && themeMenuOpen.value && !themeEl.contains(e.target as Node)) {
|
||||
themeMenuOpen.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => document.addEventListener('click', onDocClick))
|
||||
onUnmounted(() => document.removeEventListener('click', onDocClick))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<header class="app-topbar">
|
||||
<!-- Left: sidebar toggle + brand -->
|
||||
<div class="flex shrink-0 items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
class="app-topbar-icon-btn"
|
||||
title="Show / hide sidebar"
|
||||
aria-label="Toggle sidebar"
|
||||
@click="emit('toggleSidebar')"
|
||||
>
|
||||
<UIcon :name="sidebarCollapsed ? 'i-heroicons-bars-3' : 'i-heroicons-chevron-double-left'" style="width: 16px; height: 16px;" />
|
||||
</button>
|
||||
<NuxtLink to="/" class="flex items-center gap-1.5 rounded-lg px-1.5 py-1 transition hover:opacity-80">
|
||||
<span class="text-[12px] font-medium tracking-tight text-[#a0a09c]">{{ brandTitle || 'Segur-OS' }}</span>
|
||||
<span class="rounded-sm px-0.5 py-px text-[8px] font-medium uppercase tracking-wider text-[#c0c0bc]">Beta</span>
|
||||
</NuxtLink>
|
||||
</div>
|
||||
|
||||
<!-- Center: search (absolutely centered) -->
|
||||
<div class="app-topbar-search-wrap">
|
||||
<LayoutAppCommandSearch />
|
||||
</div>
|
||||
|
||||
<!-- Center-right: contextual actions (home page) -->
|
||||
<div v-if="isHome" class="ml-auto mr-1 hidden items-center gap-1.5 sm:flex">
|
||||
<NuxtLink to="/onboarding">
|
||||
<button type="button" class="app-topbar-action-btn">
|
||||
<UIcon name="i-heroicons-arrow-trending-up" style="width: 13px; height: 13px;" />
|
||||
Pipeline
|
||||
</button>
|
||||
</NuxtLink>
|
||||
<NuxtLink to="/sales/quick-lead">
|
||||
<button type="button" class="app-topbar-action-btn">
|
||||
<UIcon name="i-heroicons-bolt" style="width: 13px; height: 13px;" />
|
||||
Quick Lead
|
||||
</button>
|
||||
</NuxtLink>
|
||||
<NuxtLink to="/quotes">
|
||||
<button type="button" class="app-topbar-action-btn app-topbar-action-primary">
|
||||
<UIcon name="i-heroicons-document-text" style="width: 13px; height: 13px;" />
|
||||
New quote
|
||||
</button>
|
||||
</NuxtLink>
|
||||
<span class="mx-0.5 h-3 w-px" style="background: rgba(0,0,0,0.06);" />
|
||||
</div>
|
||||
|
||||
<!-- Right: actions -->
|
||||
<div class="flex shrink-0 items-center gap-1" :class="isHome ? '' : 'ml-auto'">
|
||||
<button
|
||||
type="button"
|
||||
class="app-topbar-icon-btn hidden sm:inline-flex"
|
||||
title="Refresh data"
|
||||
aria-label="Refresh"
|
||||
@click="emit('refresh')"
|
||||
>
|
||||
<UIcon name="i-heroicons-arrow-path" style="width: 16px; height: 16px;" />
|
||||
</button>
|
||||
|
||||
<!-- Quick theme switcher -->
|
||||
<div ref="themeMenuRoot" class="relative">
|
||||
<button
|
||||
type="button"
|
||||
class="app-topbar-icon-btn"
|
||||
title="Switch theme"
|
||||
aria-label="Theme"
|
||||
@click.stop="themeMenuOpen = !themeMenuOpen"
|
||||
>
|
||||
<UIcon :name="themeIcons[themeId] ?? 'i-heroicons-swatch'" style="width: 16px; height: 16px;" />
|
||||
</button>
|
||||
<Transition
|
||||
enter-active-class="transition duration-150 ease-out"
|
||||
enter-from-class="opacity-0 scale-95 translate-y-1"
|
||||
enter-to-class="opacity-100 scale-100 translate-y-0"
|
||||
leave-active-class="transition duration-100 ease-in"
|
||||
leave-from-class="opacity-100"
|
||||
leave-to-class="opacity-0 scale-95"
|
||||
>
|
||||
<div
|
||||
v-show="themeMenuOpen"
|
||||
class="absolute right-0 top-[calc(100%+8px)] z-50 w-52 overflow-hidden rounded-xl border border-[var(--sidebar-border)] bg-[var(--surface)] py-1.5 shadow-xl ring-1 ring-black/5"
|
||||
>
|
||||
<p class="px-3 py-1 text-[10px] font-semibold uppercase tracking-wider text-[var(--text-muted)]">Theme</p>
|
||||
<button
|
||||
v-for="opt in APP_THEME_OPTIONS"
|
||||
:key="opt.id"
|
||||
type="button"
|
||||
class="flex w-full items-center gap-2.5 px-3 py-2 text-left text-sm transition hover:bg-[var(--brand-faint)]"
|
||||
:class="themeId === opt.id ? 'text-[var(--brand)] font-medium' : 'text-[var(--text-primary)]'"
|
||||
@click="applyTheme(opt.id as AppThemeId); themeMenuOpen = false"
|
||||
>
|
||||
<UIcon :name="themeIcons[opt.id]" class="h-4 w-4 shrink-0" :class="themeId === opt.id ? 'text-[var(--brand)]' : 'opacity-60'" />
|
||||
<span class="flex-1">{{ opt.label }}</span>
|
||||
<UIcon
|
||||
v-if="themeId === opt.id"
|
||||
name="i-heroicons-check"
|
||||
class="h-3.5 w-3.5 text-[var(--brand)]"
|
||||
/>
|
||||
</button>
|
||||
<div class="mx-3 my-1.5 border-t border-[var(--sidebar-border)]" />
|
||||
<NuxtLink
|
||||
to="/account"
|
||||
class="flex items-center gap-2.5 px-3 py-1.5 text-xs text-[var(--text-muted)] transition hover:text-[var(--brand)]"
|
||||
@click="themeMenuOpen = false"
|
||||
>
|
||||
<UIcon name="i-heroicons-cog-6-tooth" class="h-3.5 w-3.5" />
|
||||
All appearance settings
|
||||
</NuxtLink>
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
|
||||
<NuxtLink to="/settings" class="inline-flex" title="Software settings">
|
||||
<span class="app-topbar-icon-btn">
|
||||
<UIcon name="i-heroicons-cog-6-tooth" style="width: 16px; height: 16px;" />
|
||||
</span>
|
||||
</NuxtLink>
|
||||
|
||||
<span class="mx-0.5 h-3 w-px" style="background: rgba(0,0,0,0.06);" />
|
||||
|
||||
<!-- User / Account -->
|
||||
<div ref="userMenuRoot" class="relative">
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex w-auto items-center gap-1 rounded-md px-1.5 py-0.5 text-[#a0a09c] transition hover:bg-[rgba(0,0,0,0.04)] hover:text-[#6b6b68]"
|
||||
aria-label="Account menu"
|
||||
:aria-expanded="userMenuOpen"
|
||||
@click.stop="userMenuOpen = !userMenuOpen"
|
||||
>
|
||||
<UIcon name="i-heroicons-user-circle" style="width: 15px; height: 15px;" />
|
||||
<span class="hidden text-[11px] font-medium lg:inline">Account</span>
|
||||
<UIcon name="i-heroicons-chevron-down" style="width: 8px; height: 8px; opacity: 0.35;" />
|
||||
</button>
|
||||
<Transition
|
||||
enter-active-class="transition duration-150 ease-out"
|
||||
enter-from-class="opacity-0 scale-95"
|
||||
enter-to-class="opacity-100 scale-100"
|
||||
leave-active-class="transition duration-100 ease-in"
|
||||
leave-from-class="opacity-100"
|
||||
leave-to-class="opacity-0"
|
||||
>
|
||||
<div
|
||||
v-show="userMenuOpen"
|
||||
class="absolute right-0 top-[calc(100%+8px)] z-50 w-56 overflow-hidden rounded-xl border border-[var(--sidebar-border)] bg-[var(--surface)] py-1 shadow-xl ring-1 ring-black/5"
|
||||
>
|
||||
<NuxtLink
|
||||
to="/account"
|
||||
class="flex items-center gap-2 px-3 py-2.5 text-sm text-[var(--text-primary)] transition hover:bg-[var(--brand-faint)]"
|
||||
@click="closeUserMenu"
|
||||
>
|
||||
<UIcon name="i-heroicons-user-circle" class="h-4 w-4 opacity-70" />
|
||||
My account
|
||||
</NuxtLink>
|
||||
<NuxtLink
|
||||
to="/settings"
|
||||
class="flex items-center gap-2 px-3 py-2.5 text-sm text-[var(--text-primary)] transition hover:bg-[var(--brand-faint)]"
|
||||
@click="closeUserMenu"
|
||||
>
|
||||
<UIcon name="i-heroicons-cog-6-tooth" class="h-4 w-4 opacity-70" />
|
||||
Software settings
|
||||
</NuxtLink>
|
||||
<div class="my-1 border-t border-[var(--sidebar-border)]" />
|
||||
<div class="px-3 py-1.5">
|
||||
<p class="text-[12px] font-medium text-[var(--text-primary)]">Session (mock)</p>
|
||||
<p class="text-[11px] text-[var(--text-muted)]">broker@demo.com</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="flex w-full items-center gap-2 px-3 py-2 text-left text-sm text-[var(--text-muted)] opacity-50 cursor-not-allowed"
|
||||
disabled
|
||||
>
|
||||
<UIcon name="i-heroicons-arrow-right-on-rectangle" class="h-4 w-4" />
|
||||
Sign out (soon)
|
||||
</button>
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.app-topbar {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 40;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
height: 2.5rem;
|
||||
padding: 0 0.75rem;
|
||||
border-bottom: none;
|
||||
box-shadow: 0 1px 0 rgba(0, 0, 0, 0.03);
|
||||
background: var(--topbar-bg);
|
||||
backdrop-filter: blur(12px);
|
||||
-webkit-backdrop-filter: blur(12px);
|
||||
}
|
||||
@media (min-width: 768px) {
|
||||
.app-topbar { padding: 0 1rem; }
|
||||
}
|
||||
|
||||
.app-topbar-icon-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 1.625rem;
|
||||
height: 1.625rem;
|
||||
border-radius: 6px;
|
||||
color: #a0a09c;
|
||||
transition: color 150ms ease, background 150ms ease;
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
background: none;
|
||||
}
|
||||
.app-topbar-icon-btn:hover {
|
||||
color: #6b6b68;
|
||||
background: rgba(0, 0, 0, 0.04);
|
||||
}
|
||||
|
||||
/* ---- Contextual action buttons (home) ---- */
|
||||
.app-topbar-action-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 3px 10px;
|
||||
border-radius: 6px;
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
color: #8a8a86;
|
||||
background: transparent;
|
||||
border: 1px solid rgba(0,0,0,0.06);
|
||||
cursor: pointer;
|
||||
transition: all 150ms ease;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.app-topbar-action-btn:hover {
|
||||
color: var(--text-primary);
|
||||
background: rgba(0,0,0,0.03);
|
||||
border-color: rgba(0,0,0,0.1);
|
||||
}
|
||||
.app-topbar-action-primary {
|
||||
color: #ffffff;
|
||||
background: #01696f;
|
||||
border-color: #01696f;
|
||||
}
|
||||
.app-topbar-action-primary:hover {
|
||||
color: #ffffff;
|
||||
background: #015b60;
|
||||
border-color: #015b60;
|
||||
}
|
||||
|
||||
/* ---- Centered search bar ---- */
|
||||
.app-topbar-search-wrap {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
width: 100%;
|
||||
max-width: 380px;
|
||||
display: none;
|
||||
}
|
||||
@media (min-width: 768px) {
|
||||
.app-topbar-search-wrap { display: block; }
|
||||
}
|
||||
|
||||
/* Search input — quiet at rest, reveals on focus */
|
||||
.app-topbar-search-wrap :deep(input) {
|
||||
border-radius: 8px !important;
|
||||
padding-left: 2rem !important;
|
||||
padding-right: 0.75rem !important;
|
||||
height: 1.75rem;
|
||||
font-size: 0.6875rem;
|
||||
background: transparent !important;
|
||||
border: 1px solid rgba(0, 0, 0, 0.05) !important;
|
||||
color: var(--text-secondary) !important;
|
||||
transition: background 150ms ease, border-color 150ms ease, box-shadow 150ms ease;
|
||||
}
|
||||
.app-topbar-search-wrap :deep(input):focus {
|
||||
background: var(--surface) !important;
|
||||
border-color: rgba(0, 0, 0, 0.1) !important;
|
||||
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.06) !important;
|
||||
}
|
||||
/* Round the input wrapper/container */
|
||||
.app-topbar-search-wrap :deep(.relative) {
|
||||
border-radius: 8px;
|
||||
}
|
||||
.app-topbar-search-wrap :deep(> div) {
|
||||
border-radius: 8px;
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user