WIP jordan
This commit is contained in:
994
app/pages/quotes/new.vue
Normal file
994
app/pages/quotes/new.vue
Normal file
@@ -0,0 +1,994 @@
|
||||
<script setup lang="ts">
|
||||
import { MOCK_CUSTOMERS, fmtMoney, customerTier, type MockCustomer, type CustomerTier } from '~/data/mock-customers'
|
||||
|
||||
usePageTitle('New Quote')
|
||||
|
||||
/* ── Pipeline bar ── */
|
||||
const { deals: allDeals, getActiveDeal, createDeal } = useSalesPipeline()
|
||||
const route = useRoute()
|
||||
const activeDealId = ref<string | null>(route.query.deal as string | null)
|
||||
|
||||
const pipelineDeal = computed(() => {
|
||||
if (activeDealId.value) return allDeals.value.find(d => d.id === activeDealId.value) ?? null
|
||||
return null
|
||||
})
|
||||
|
||||
// Active deals for the deal picker (exclude completed emissions)
|
||||
const activeDeals = computed(() => allDeals.value.filter(d => d.currentStage !== 'emission').slice(0, 10))
|
||||
|
||||
function onPipelineNavigate(stage: string) {
|
||||
// Navigation map for pipeline stages
|
||||
const stageRoutes: Record<string, string> = {
|
||||
customer: '/quotes/new',
|
||||
get_quotes: '/quotes/new',
|
||||
present_quotes: '/quotes/compare',
|
||||
solicitud: '/onboarding/solicitud',
|
||||
emission: '/onboarding/emissions',
|
||||
}
|
||||
const route = stageRoutes[stage]
|
||||
if (route) navigateTo(route)
|
||||
}
|
||||
|
||||
/* ── Steps ── */
|
||||
type QuoteStep = 'customer' | 'product'
|
||||
const currentStep = ref<QuoteStep>('customer')
|
||||
|
||||
/* ── Customer selection ── */
|
||||
const customerSearch = ref('')
|
||||
const selectedCustomer = ref<MockCustomer | null>(null)
|
||||
const manualEntry = ref(false)
|
||||
|
||||
/* ── Tier filter ── */
|
||||
type TierFilter = 'all' | CustomerTier
|
||||
const activeTierFilter = ref<TierFilter>('all')
|
||||
|
||||
const tierMeta: Record<CustomerTier, { label: string; icon: string; color: string; bg: string; desc: string }> = {
|
||||
quick_lead: { label: 'Quick Lead', icon: 'i-heroicons-bolt', color: '#c27b1a', bg: 'rgba(194,123,26,0.07)', desc: 'Name + contact only' },
|
||||
lead: { label: 'Lead', icon: 'i-heroicons-user-plus', color: '#7c3aed', bg: 'rgba(124,58,237,0.07)', desc: 'Profile info, no policies' },
|
||||
customer: { label: 'Customer', icon: 'i-heroicons-shield-check', color: '#01696f', bg: 'rgba(1,105,111,0.07)', desc: 'Active policies' },
|
||||
cancelled: { label: 'Cancelled', icon: 'i-heroicons-x-circle', color: '#c13838', bg: 'rgba(193,56,56,0.07)', desc: 'All policies cancelled/lapsed' },
|
||||
}
|
||||
|
||||
const tierFilterTabs = computed(() => {
|
||||
const all = MOCK_CUSTOMERS
|
||||
const counts: Record<TierFilter, number> = {
|
||||
all: all.length,
|
||||
quick_lead: all.filter(c => customerTier(c) === 'quick_lead').length,
|
||||
lead: all.filter(c => customerTier(c) === 'lead').length,
|
||||
customer: all.filter(c => customerTier(c) === 'customer').length,
|
||||
cancelled: all.filter(c => customerTier(c) === 'cancelled').length,
|
||||
}
|
||||
return [
|
||||
{ id: 'all' as TierFilter, label: 'All', count: counts.all },
|
||||
{ id: 'customer' as TierFilter, label: 'Customers', count: counts.customer },
|
||||
{ id: 'lead' as TierFilter, label: 'Leads', count: counts.lead },
|
||||
{ id: 'quick_lead' as TierFilter, label: 'Quick Leads', count: counts.quick_lead },
|
||||
{ id: 'cancelled' as TierFilter, label: 'Cancelled', count: counts.cancelled },
|
||||
]
|
||||
})
|
||||
|
||||
const filteredCustomers = computed(() => {
|
||||
let list = MOCK_CUSTOMERS
|
||||
|
||||
// Tier filter
|
||||
if (activeTierFilter.value !== 'all') {
|
||||
list = list.filter(c => customerTier(c) === activeTierFilter.value)
|
||||
}
|
||||
|
||||
// Search
|
||||
const q = customerSearch.value.trim().toLowerCase()
|
||||
if (q) {
|
||||
list = list.filter(c =>
|
||||
c.name.toLowerCase().includes(q) ||
|
||||
c.email.toLowerCase().includes(q) ||
|
||||
c.documentId.toLowerCase().includes(q) ||
|
||||
c.id.toLowerCase().includes(q) ||
|
||||
c.phone.includes(q) ||
|
||||
c.tags.some(t => t.toLowerCase().includes(q))
|
||||
)
|
||||
}
|
||||
|
||||
return list
|
||||
})
|
||||
|
||||
function selectCustomer(c: MockCustomer) {
|
||||
selectedCustomer.value = c
|
||||
currentStep.value = 'product'
|
||||
}
|
||||
|
||||
function skipToManual() {
|
||||
manualEntry.value = true
|
||||
selectedCustomer.value = null
|
||||
currentStep.value = 'product'
|
||||
}
|
||||
|
||||
function goBackToCustomer() {
|
||||
currentStep.value = 'customer'
|
||||
}
|
||||
|
||||
/* ── Product line cards ── */
|
||||
const lobCards: { id: string; label: string; hint: string; icon: string; to: string }[] = [
|
||||
{ id: 'auto', label: 'Auto', hint: 'Vehicles, fleet, and liability coverage.', icon: 'i-heroicons-truck', to: '/quotes/auto' },
|
||||
{ id: 'health', label: 'Health', hint: 'Individual, corporate, and group medical.', icon: 'i-heroicons-heart', to: '/quotes/health' },
|
||||
{ id: 'life', label: 'Life', hint: 'Term, whole life, key person, and group.', icon: 'i-heroicons-shield-check', to: '/quotes/life' },
|
||||
{ id: 'general_risk', label: 'General Risk', hint: 'Property, liability, marine, and specialty.', icon: 'i-heroicons-building-office-2', to: '/quotes/general-risk' },
|
||||
{ id: 'custom', label: 'Custom', hint: 'Manual or non-standard product lines.', icon: 'i-heroicons-puzzle-piece', to: '/quotes/custom' },
|
||||
]
|
||||
|
||||
/* ── Helpers ── */
|
||||
function customerPolicySummary(c: MockCustomer) {
|
||||
if (c.policies.length === 0) return 'No policies'
|
||||
const lines = [...new Set(c.policies.map(p => p.line))]
|
||||
return `${c.policies.length} ${c.policies.length === 1 ? 'policy' : 'policies'} · ${lines.join(', ')}`
|
||||
}
|
||||
|
||||
function customerPaymentClass(c: MockCustomer) {
|
||||
if (c.paymentStatus === 'Overdue') return 'nq-badge-warn'
|
||||
if (c.paymentStatus === 'Grace period') return 'nq-badge-grace'
|
||||
if (c.paymentStatus === 'N/A') return 'nq-badge-na'
|
||||
return 'nq-badge-ok'
|
||||
}
|
||||
|
||||
function tierOf(c: MockCustomer) {
|
||||
return customerTier(c)
|
||||
}
|
||||
|
||||
/** Compact info line depending on tier */
|
||||
function customerInfoLine(c: MockCustomer): string {
|
||||
const t = customerTier(c)
|
||||
if (t === 'quick_lead') {
|
||||
const parts: string[] = []
|
||||
if (c.phone) parts.push(c.phone)
|
||||
if (c.email) parts.push(c.email)
|
||||
return parts.join(' · ') || 'Minimal info captured'
|
||||
}
|
||||
if (t === 'lead') {
|
||||
return c.email + (c.address ? ` · ${c.address}` : '')
|
||||
}
|
||||
return c.email
|
||||
}
|
||||
|
||||
/** Premium total (0 for leads) */
|
||||
function customerPremium(c: MockCustomer): number {
|
||||
return c.policies.reduce((s, p) => s + p.premium, 0)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="nq-page">
|
||||
<!-- Back -->
|
||||
<NuxtLink to="/quotes" class="inline-flex">
|
||||
<UButton color="neutral" variant="ghost" size="sm" icon="i-heroicons-arrow-left">Back to overview</UButton>
|
||||
</NuxtLink>
|
||||
|
||||
<!-- Sales flow indicator -->
|
||||
<SalesFlowIndicator current-stage="get_quotes" />
|
||||
|
||||
<div class="flex flex-wrap items-start justify-between gap-4">
|
||||
<div>
|
||||
<h1 class="mt-1 text-2xl font-semibold tracking-tight text-[var(--text-primary)]">Get Quotes</h1>
|
||||
<p class="mt-1 max-w-2xl text-[13px] text-[var(--text-muted)]">
|
||||
{{ currentStep === 'customer' ? 'Select a customer to quote, or enter details manually.' : 'Choose a product line to begin the quoting wizard.' }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Pipeline bar — active deal context -->
|
||||
<div v-if="activeDeals.length > 0" class="nq-pipeline-zone">
|
||||
<div v-if="!pipelineDeal" class="nq-deal-picker">
|
||||
<div class="flex items-center gap-2 text-[12px] text-[var(--text-muted)]">
|
||||
<UIcon name="i-heroicons-arrow-path" style="width: 13px; height: 13px; opacity: 0.5;" />
|
||||
<span class="font-medium">Continue an active deal:</span>
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-2 mt-2">
|
||||
<button
|
||||
v-for="d in activeDeals" :key="d.id"
|
||||
type="button"
|
||||
class="nq-deal-chip"
|
||||
@click="activeDealId = d.id"
|
||||
>
|
||||
<span class="font-semibold">{{ d.customerName.split(' ').slice(0, 2).join(' ') }}</span>
|
||||
<span class="nq-deal-chip-line">{{ d.productLine }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<template v-else>
|
||||
<div class="flex items-center justify-between mb-1">
|
||||
<span class="text-[11px] font-semibold uppercase tracking-wider text-[#8a8a86]">Active Deal</span>
|
||||
<button type="button" class="text-[11px] text-[var(--text-muted)] hover:text-[var(--text-primary)]" @click="activeDealId = null">Switch deal</button>
|
||||
</div>
|
||||
<SalesPipelineBar :deal="pipelineDeal" @navigate="onPipelineNavigate" />
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- Step indicator -->
|
||||
<div class="nq-steps">
|
||||
<div class="nq-step" :class="currentStep === 'customer' ? 'nq-step-active' : 'nq-step-done'" @click="goBackToCustomer">
|
||||
<div class="nq-step-circle">
|
||||
<UIcon v-if="currentStep === 'product'" name="i-heroicons-check" style="width: 12px; height: 12px;" />
|
||||
<span v-else>1</span>
|
||||
</div>
|
||||
<span class="nq-step-label">Customer</span>
|
||||
</div>
|
||||
<div class="nq-step-line" :class="currentStep === 'product' ? 'nq-step-line-done' : ''" />
|
||||
<div class="nq-step" :class="currentStep === 'product' ? 'nq-step-active' : ''">
|
||||
<div class="nq-step-circle">2</div>
|
||||
<span class="nq-step-label">Product</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ═══ STEP 1: Customer selection ═══ -->
|
||||
<template v-if="currentStep === 'customer'">
|
||||
<!-- Manual entry option -->
|
||||
<div class="nq-manual-banner">
|
||||
<div class="flex items-center gap-3 min-w-0">
|
||||
<div class="nq-manual-icon">
|
||||
<UIcon name="i-heroicons-pencil-square" style="width: 18px; height: 18px;" />
|
||||
</div>
|
||||
<div class="min-w-0">
|
||||
<p class="text-[13px] font-semibold text-[var(--text-primary)]">Manual quote entry</p>
|
||||
<p class="text-[12px] text-[var(--text-muted)]">Skip customer selection and enter details directly in the quoting form.</p>
|
||||
</div>
|
||||
</div>
|
||||
<button type="button" class="nq-manual-btn" @click="skipToManual">
|
||||
Enter manually
|
||||
<UIcon name="i-heroicons-arrow-right" style="width: 12px; height: 12px;" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Tier filter tabs + search -->
|
||||
<div class="nq-filter-bar">
|
||||
<div class="nq-tier-tabs">
|
||||
<button
|
||||
v-for="tab in tierFilterTabs"
|
||||
:key="tab.id"
|
||||
type="button"
|
||||
class="nq-tier-tab"
|
||||
:class="activeTierFilter === tab.id ? 'nq-tier-tab-on' : 'nq-tier-tab-off'"
|
||||
@click="activeTierFilter = tab.id"
|
||||
>
|
||||
{{ tab.label }}
|
||||
<span class="nq-tier-count" :class="activeTierFilter === tab.id ? 'nq-tier-count-on' : ''">{{ tab.count }}</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="nq-search-wrap">
|
||||
<UIcon name="i-heroicons-magnifying-glass" style="width: 15px; height: 15px; color: #8a8a86;" />
|
||||
<input
|
||||
v-model="customerSearch"
|
||||
type="text"
|
||||
class="nq-search"
|
||||
placeholder="Search by name, email, phone, ID, or tag..."
|
||||
/>
|
||||
<kbd v-if="!customerSearch" class="nq-kbd">/</kbd>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Results count -->
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<p class="text-[11px] font-semibold uppercase tracking-[0.04em] text-[#8a8a86]">
|
||||
{{ activeTierFilter === 'all' ? 'All contacts' : tierMeta[activeTierFilter as CustomerTier]?.label }}
|
||||
</p>
|
||||
<span class="text-[11px] text-[var(--text-muted)]">{{ filteredCustomers.length }} results</span>
|
||||
</div>
|
||||
|
||||
<!-- Customer cards -->
|
||||
<div class="nq-customer-grid">
|
||||
<button
|
||||
v-for="c in filteredCustomers"
|
||||
:key="c.id"
|
||||
type="button"
|
||||
class="nq-customer-card"
|
||||
:class="tierOf(c) === 'cancelled' ? 'nq-card-cancelled' : ''"
|
||||
@click="selectCustomer(c)"
|
||||
>
|
||||
<!-- Top row: avatar + name + tier badge -->
|
||||
<div class="nq-customer-top">
|
||||
<div class="nq-customer-avatar" :style="{ background: tierMeta[tierOf(c)].bg, color: tierMeta[tierOf(c)].color }">
|
||||
{{ c.name.split(' ').map(w => w[0]).join('').slice(0, 2) }}
|
||||
</div>
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="flex items-center gap-2 flex-wrap">
|
||||
<p class="text-[14px] font-semibold text-[var(--text-primary)] truncate">{{ c.name }}</p>
|
||||
<!-- Tier badge -->
|
||||
<span
|
||||
class="nq-tier-badge"
|
||||
:style="{ background: tierMeta[tierOf(c)].bg, color: tierMeta[tierOf(c)].color }"
|
||||
>
|
||||
<UIcon :name="tierMeta[tierOf(c)].icon" style="width: 10px; height: 10px;" />
|
||||
{{ tierMeta[tierOf(c)].label }}
|
||||
</span>
|
||||
<!-- Payment status (only for customers) -->
|
||||
<span v-if="tierOf(c) === 'customer'" :class="customerPaymentClass(c)">{{ c.paymentStatus }}</span>
|
||||
</div>
|
||||
<p class="text-[11px] text-[var(--text-muted)] truncate mt-0.5">{{ customerInfoLine(c) }}</p>
|
||||
</div>
|
||||
<UIcon name="i-heroicons-chevron-right" style="width: 16px; height: 16px; color: #c0c0bc; flex-shrink: 0;" />
|
||||
</div>
|
||||
|
||||
<!-- Meta row — adapts to tier -->
|
||||
<div class="nq-customer-meta">
|
||||
<!-- Quick lead: just tags -->
|
||||
<template v-if="tierOf(c) === 'quick_lead'">
|
||||
<div class="nq-customer-stat">
|
||||
<span class="nq-stat-label">Source</span>
|
||||
<span class="nq-stat-value">{{ c.tags.filter(t => t !== 'Quick lead').join(', ') || '—' }}</span>
|
||||
</div>
|
||||
<div class="nq-customer-stat">
|
||||
<span class="nq-stat-label">Added</span>
|
||||
<span class="nq-stat-value">{{ c.since }}</span>
|
||||
</div>
|
||||
<div class="nq-customer-stat">
|
||||
<span class="nq-stat-label">Agent</span>
|
||||
<span class="nq-stat-value">{{ c.agent }}</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Lead: profile data -->
|
||||
<template v-else-if="tierOf(c) === 'lead'">
|
||||
<div class="nq-customer-stat">
|
||||
<span class="nq-stat-label">ID</span>
|
||||
<span class="nq-stat-value">{{ c.documentId }}</span>
|
||||
</div>
|
||||
<div class="nq-customer-stat">
|
||||
<span class="nq-stat-label">Type</span>
|
||||
<span class="nq-stat-value">{{ c.type }}</span>
|
||||
</div>
|
||||
<div class="nq-customer-stat">
|
||||
<span class="nq-stat-label">Agent</span>
|
||||
<span class="nq-stat-value">{{ c.agent }}</span>
|
||||
</div>
|
||||
<div class="nq-customer-stat">
|
||||
<span class="nq-stat-label">Added</span>
|
||||
<span class="nq-stat-value">{{ c.since }}</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Customer / Cancelled: full stats -->
|
||||
<template v-else>
|
||||
<div class="nq-customer-stat">
|
||||
<span class="nq-stat-label">ID</span>
|
||||
<span class="nq-stat-value">{{ c.documentId }}</span>
|
||||
</div>
|
||||
<div class="nq-customer-stat">
|
||||
<span class="nq-stat-label">Type</span>
|
||||
<span class="nq-stat-value">{{ c.type }}</span>
|
||||
</div>
|
||||
<div class="nq-customer-stat">
|
||||
<span class="nq-stat-label">Premium</span>
|
||||
<span class="nq-stat-value">{{ customerPremium(c) > 0 ? fmtMoney(customerPremium(c)) + '/yr' : '—' }}</span>
|
||||
</div>
|
||||
<div class="nq-customer-stat">
|
||||
<span class="nq-stat-label">Agent</span>
|
||||
<span class="nq-stat-value">{{ c.agent }}</span>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- Policies line (customers + cancelled) -->
|
||||
<div v-if="c.policies.length > 0" class="nq-customer-policies">
|
||||
<UIcon name="i-heroicons-document-text" style="width: 12px; height: 12px; color: #8a8a86; flex-shrink: 0;" />
|
||||
<span class="text-[11px] text-[var(--text-muted)]">{{ customerPolicySummary(c) }}</span>
|
||||
<!-- Cancelled inline warning -->
|
||||
<span v-if="tierOf(c) === 'cancelled'" class="nq-cancelled-hint">
|
||||
<UIcon name="i-heroicons-exclamation-triangle" style="width: 10px; height: 10px;" />
|
||||
All policies inactive
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Activity hint for leads -->
|
||||
<div v-if="(tierOf(c) === 'lead' || tierOf(c) === 'quick_lead') && c.activity.length > 0" class="nq-customer-policies">
|
||||
<UIcon name="i-heroicons-clock" style="width: 12px; height: 12px; color: #8a8a86; flex-shrink: 0;" />
|
||||
<span class="text-[11px] text-[var(--text-muted)]">{{ c.activity[0]?.text }}</span>
|
||||
</div>
|
||||
|
||||
<!-- Tags -->
|
||||
<div v-if="c.tags.length > 0" class="nq-tag-row">
|
||||
<span
|
||||
v-for="tag in c.tags.slice(0, 3)"
|
||||
:key="tag"
|
||||
class="nq-tag"
|
||||
>{{ tag }}</span>
|
||||
<span v-if="c.tags.length > 3" class="nq-tag nq-tag-more">+{{ c.tags.length - 3 }}</span>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-if="filteredCustomers.length === 0" class="nq-empty">
|
||||
<UIcon name="i-heroicons-user-group" style="width: 32px; height: 32px; color: #c0c0bc;" />
|
||||
<p class="text-[13px] text-[var(--text-muted)] mt-2">No contacts match your search.</p>
|
||||
<button type="button" class="nq-manual-btn mt-3" @click="skipToManual">Enter details manually</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- ═══ STEP 2: Product line selection ═══ -->
|
||||
<template v-if="currentStep === 'product'">
|
||||
<!-- Selected customer summary -->
|
||||
<div v-if="selectedCustomer" class="nq-selected-banner" :class="tierOf(selectedCustomer) === 'cancelled' ? 'nq-selected-cancelled' : ''">
|
||||
<div class="flex items-center gap-3 min-w-0 flex-1">
|
||||
<div class="nq-customer-avatar" :style="{ background: tierMeta[tierOf(selectedCustomer)].bg, color: tierMeta[tierOf(selectedCustomer)].color }">
|
||||
{{ selectedCustomer.name.split(' ').map(w => w[0]).join('').slice(0, 2) }}
|
||||
</div>
|
||||
<div class="min-w-0">
|
||||
<div class="flex items-center gap-2">
|
||||
<p class="text-[14px] font-semibold text-[var(--text-primary)]">{{ selectedCustomer.name }}</p>
|
||||
<span
|
||||
class="nq-tier-badge"
|
||||
:style="{ background: tierMeta[tierOf(selectedCustomer)].bg, color: tierMeta[tierOf(selectedCustomer)].color }"
|
||||
>
|
||||
<UIcon :name="tierMeta[tierOf(selectedCustomer)].icon" style="width: 10px; height: 10px;" />
|
||||
{{ tierMeta[tierOf(selectedCustomer)].label }}
|
||||
</span>
|
||||
</div>
|
||||
<p class="text-[12px] text-[var(--text-muted)]">
|
||||
{{ customerInfoLine(selectedCustomer) }}
|
||||
<template v-if="tierOf(selectedCustomer) === 'customer'"> · {{ customerPolicySummary(selectedCustomer) }}</template>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<button type="button" class="nq-change-btn" @click="goBackToCustomer">
|
||||
<UIcon name="i-heroicons-arrows-right-left" style="width: 12px; height: 12px;" />
|
||||
Change
|
||||
</button>
|
||||
</div>
|
||||
<div v-else class="nq-selected-banner nq-selected-manual">
|
||||
<div class="flex items-center gap-3 min-w-0 flex-1">
|
||||
<div class="nq-manual-avatar">
|
||||
<UIcon name="i-heroicons-pencil-square" style="width: 16px; height: 16px;" />
|
||||
</div>
|
||||
<div class="min-w-0">
|
||||
<p class="text-[14px] font-semibold text-[var(--text-primary)]">Manual entry</p>
|
||||
<p class="text-[12px] text-[var(--text-muted)]">Customer details will be entered in the quoting form.</p>
|
||||
</div>
|
||||
</div>
|
||||
<button type="button" class="nq-change-btn" @click="goBackToCustomer">
|
||||
<UIcon name="i-heroicons-arrows-right-left" style="width: 12px; height: 12px;" />
|
||||
Select customer instead
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Cancelled customer warning -->
|
||||
<div v-if="selectedCustomer && tierOf(selectedCustomer) === 'cancelled'" class="nq-cancelled-banner">
|
||||
<UIcon name="i-heroicons-exclamation-triangle" style="width: 16px; height: 16px; color: #c13838; flex-shrink: 0;" />
|
||||
<div class="min-w-0">
|
||||
<p class="text-[13px] font-semibold text-[#c13838]">Cancelled customer</p>
|
||||
<p class="text-[12px] text-[var(--text-muted)]">All previous policies are cancelled or lapsed. This is a win-back opportunity — proceed with a fresh quote.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Quick lead / lead info hint -->
|
||||
<div v-if="selectedCustomer && (tierOf(selectedCustomer) === 'quick_lead' || tierOf(selectedCustomer) === 'lead')" class="nq-lead-hint-banner">
|
||||
<UIcon name="i-heroicons-information-circle" style="width: 16px; height: 16px; color: #7c3aed; flex-shrink: 0;" />
|
||||
<div class="min-w-0">
|
||||
<p class="text-[13px] font-semibold" style="color: #5b21b6;">
|
||||
{{ tierOf(selectedCustomer) === 'quick_lead' ? 'Quick lead — limited info' : 'Lead — no existing policies' }}
|
||||
</p>
|
||||
<p class="text-[12px] text-[var(--text-muted)]">
|
||||
{{ tierOf(selectedCustomer) === 'quick_lead'
|
||||
? 'Only basic contact info was captured. The quoting form will need all details filled manually.'
|
||||
: 'This contact has a full profile but no policies yet. Profile data will pre-fill the quoting form.'
|
||||
}}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Existing policies hint (customers only) -->
|
||||
<div v-if="selectedCustomer && tierOf(selectedCustomer) === 'customer' && selectedCustomer.policies.length > 0" class="nq-existing">
|
||||
<p class="text-[11px] font-semibold uppercase tracking-[0.04em] text-[#8a8a86] mb-2">Existing coverage</p>
|
||||
<div class="nq-existing-pills">
|
||||
<span v-for="p in selectedCustomer.policies" :key="p.id" class="nq-existing-pill">
|
||||
<span class="nq-existing-dot" />
|
||||
{{ p.line }} · {{ p.carrier }} · {{ fmtMoney(p.premium) }}
|
||||
</span>
|
||||
</div>
|
||||
<p class="text-[11px] text-[var(--text-muted)] mt-2">Consider cross-sell opportunities in lines not currently covered.</p>
|
||||
</div>
|
||||
|
||||
<!-- Product line cards -->
|
||||
<div class="nq-section">
|
||||
<p class="text-[11px] font-semibold uppercase tracking-[0.04em] text-[#8a8a86] mb-3">Select product line</p>
|
||||
<div class="grid gap-4 sm:grid-cols-2">
|
||||
<NuxtLink
|
||||
v-for="card in lobCards"
|
||||
:key="card.id"
|
||||
:to="card.to + (selectedCustomer ? `?customer=${selectedCustomer.id}` : '')"
|
||||
class="nq-lob-card group"
|
||||
>
|
||||
<div class="nq-lob-icon">
|
||||
<UIcon :name="card.icon" class="h-5 w-5" />
|
||||
</div>
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="font-semibold text-[var(--text-primary)] group-hover:text-[#01696f]">{{ card.label }}</p>
|
||||
<p class="mt-1 text-sm text-[var(--text-muted)]">{{ card.hint }}</p>
|
||||
</div>
|
||||
<UIcon
|
||||
name="i-heroicons-chevron-right"
|
||||
class="ml-auto mt-0.5 h-4 w-4 shrink-0 text-[var(--text-muted)] opacity-0 transition group-hover:opacity-100"
|
||||
/>
|
||||
</NuxtLink>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<UAlert
|
||||
color="neutral"
|
||||
variant="soft"
|
||||
icon="i-heroicons-information-circle"
|
||||
title="What is a quote?"
|
||||
description="A quote collects the risk details, selects carriers to solicit, and tracks the response — single or comparative. Customer data will be pre-filled from the selected profile."
|
||||
/>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* ── Pipeline zone ── */
|
||||
.nq-pipeline-zone {
|
||||
padding: 0;
|
||||
}
|
||||
.nq-deal-picker {
|
||||
padding: 12px 16px;
|
||||
border-radius: 12px;
|
||||
border: 1px solid rgba(0,0,0,0.06);
|
||||
background: #fff;
|
||||
box-shadow: 0 1px 3px rgba(0,0,0,0.03);
|
||||
}
|
||||
.nq-deal-chip {
|
||||
display: inline-flex; align-items: center; gap: 6px;
|
||||
padding: 6px 12px; border-radius: 8px;
|
||||
border: 1px solid rgba(0,0,0,0.06); background: #fff;
|
||||
font-size: 12px; color: var(--text-primary);
|
||||
cursor: pointer; transition: all 150ms ease;
|
||||
}
|
||||
.nq-deal-chip:hover { border-color: rgba(1,105,111,0.2); box-shadow: 0 1px 4px rgba(0,0,0,0.04); }
|
||||
.nq-deal-chip-line {
|
||||
font-size: 10px; font-weight: 600; padding: 0 5px;
|
||||
border-radius: 9999px; background: rgba(1,105,111,0.07); color: #01696f;
|
||||
}
|
||||
|
||||
.nq-page {
|
||||
max-width: 64rem;
|
||||
margin: 0 auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 24px;
|
||||
padding-bottom: 3rem;
|
||||
}
|
||||
|
||||
/* ── Steps ── */
|
||||
.nq-steps {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0;
|
||||
padding: 0 4px;
|
||||
}
|
||||
.nq-step {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
cursor: pointer;
|
||||
transition: opacity 150ms ease;
|
||||
}
|
||||
.nq-step:hover { opacity: 0.8; }
|
||||
.nq-step-circle {
|
||||
width: 28px; height: 28px;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
border: 2px solid rgba(0,0,0,0.1);
|
||||
color: #8a8a86;
|
||||
background: #fff;
|
||||
transition: all 200ms ease;
|
||||
}
|
||||
.nq-step-active .nq-step-circle {
|
||||
background: #01696f;
|
||||
border-color: #01696f;
|
||||
color: #fff;
|
||||
}
|
||||
.nq-step-done .nq-step-circle {
|
||||
background: rgba(1,105,111,0.08);
|
||||
border-color: #01696f;
|
||||
color: #01696f;
|
||||
}
|
||||
.nq-step-label {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: #8a8a86;
|
||||
}
|
||||
.nq-step-active .nq-step-label { color: var(--text-primary); font-weight: 600; }
|
||||
.nq-step-done .nq-step-label { color: #01696f; }
|
||||
|
||||
.nq-step-line {
|
||||
flex: 1;
|
||||
height: 2px;
|
||||
margin: 0 12px;
|
||||
background: rgba(0,0,0,0.08);
|
||||
border-radius: 1px;
|
||||
transition: background 300ms ease;
|
||||
}
|
||||
.nq-step-line-done { background: #01696f; }
|
||||
|
||||
/* ── Manual entry banner ── */
|
||||
.nq-manual-banner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 14px 18px;
|
||||
border-radius: 10px;
|
||||
border: 1px dashed rgba(0,0,0,0.1);
|
||||
background: rgba(0,0,0,0.015);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.nq-manual-icon {
|
||||
width: 36px; height: 36px;
|
||||
border-radius: 10px;
|
||||
background: rgba(0,0,0,0.04);
|
||||
color: #8a8a86;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.nq-manual-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
padding: 6px 12px;
|
||||
border-radius: 7px;
|
||||
border: 1px solid rgba(0,0,0,0.08);
|
||||
background: #fff;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
color: var(--text-muted);
|
||||
cursor: pointer;
|
||||
transition: all 150ms ease;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.nq-manual-btn:hover { border-color: rgba(0,0,0,0.15); color: var(--text-primary); }
|
||||
|
||||
/* ── Filter bar ── */
|
||||
.nq-filter-bar {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
.nq-tier-tabs {
|
||||
display: inline-flex;
|
||||
gap: 2px;
|
||||
padding: 3px;
|
||||
border-radius: 10px;
|
||||
background: rgba(0,0,0,0.04);
|
||||
overflow-x: auto;
|
||||
}
|
||||
.nq-tier-tab {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
padding: 6px 12px;
|
||||
border-radius: 8px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
transition: all 150ms ease;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.nq-tier-tab-on {
|
||||
background: #fff;
|
||||
color: var(--text-primary);
|
||||
box-shadow: 0 1px 3px rgba(0,0,0,0.08);
|
||||
}
|
||||
.nq-tier-tab-off {
|
||||
background: transparent;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.nq-tier-tab-off:hover { color: var(--text-primary); }
|
||||
.nq-tier-count {
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
padding: 1px 5px;
|
||||
border-radius: 9999px;
|
||||
background: rgba(0,0,0,0.06);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.nq-tier-count-on {
|
||||
background: rgba(1,105,111,0.1);
|
||||
color: #01696f;
|
||||
}
|
||||
|
||||
/* ── Search ── */
|
||||
.nq-section {}
|
||||
.nq-search-wrap {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 12px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid rgba(0,0,0,0.08);
|
||||
background: #fff;
|
||||
transition: border-color 150ms ease;
|
||||
}
|
||||
.nq-search-wrap:focus-within { border-color: #01696f; }
|
||||
.nq-search {
|
||||
flex: 1;
|
||||
border: none;
|
||||
outline: none;
|
||||
font-size: 13px;
|
||||
color: var(--text-primary);
|
||||
background: transparent;
|
||||
}
|
||||
.nq-search::placeholder { color: #8a8a86; }
|
||||
.nq-kbd {
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
padding: 1px 5px;
|
||||
border-radius: 4px;
|
||||
background: rgba(0,0,0,0.04);
|
||||
border: 1px solid rgba(0,0,0,0.08);
|
||||
color: #8a8a86;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
/* ── Customer cards ── */
|
||||
.nq-customer-grid {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
.nq-customer-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
padding: 14px 16px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid rgba(0,0,0,0.06);
|
||||
background: #fff;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
transition: all 150ms ease;
|
||||
}
|
||||
.nq-customer-card:hover {
|
||||
border-color: rgba(1,105,111,0.2);
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.04);
|
||||
}
|
||||
.nq-card-cancelled {
|
||||
border-color: rgba(193,56,56,0.08);
|
||||
background: rgba(193,56,56,0.01);
|
||||
}
|
||||
.nq-card-cancelled:hover {
|
||||
border-color: rgba(193,56,56,0.2);
|
||||
}
|
||||
.nq-customer-top {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
.nq-customer-avatar {
|
||||
width: 36px; height: 36px;
|
||||
border-radius: 10px;
|
||||
background: rgba(1,105,111,0.08);
|
||||
color: #01696f;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.nq-customer-meta {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
padding-left: 46px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.nq-customer-stat { display: flex; flex-direction: column; gap: 1px; }
|
||||
.nq-stat-label { font-size: 10px; font-weight: 500; color: #8a8a86; text-transform: uppercase; letter-spacing: 0.03em; }
|
||||
.nq-stat-value { font-size: 12px; font-weight: 500; color: var(--text-primary); font-variant-numeric: tabular-nums; }
|
||||
|
||||
.nq-customer-policies {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
padding-left: 46px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
/* ── Tier badge ── */
|
||||
.nq-tier-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 3px;
|
||||
padding: 1px 7px;
|
||||
border-radius: 9999px;
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
letter-spacing: 0.01em;
|
||||
}
|
||||
|
||||
/* ── Tags ── */
|
||||
.nq-tag-row {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
padding-left: 46px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.nq-tag {
|
||||
font-size: 10px;
|
||||
font-weight: 500;
|
||||
padding: 1px 7px;
|
||||
border-radius: 4px;
|
||||
background: rgba(0,0,0,0.04);
|
||||
color: #8a8a86;
|
||||
}
|
||||
.nq-tag-more {
|
||||
background: rgba(0,0,0,0.06);
|
||||
color: #6b6b68;
|
||||
}
|
||||
|
||||
/* ── Cancelled hint ── */
|
||||
.nq-cancelled-hint {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 3px;
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
color: #c13838;
|
||||
margin-left: 4px;
|
||||
}
|
||||
|
||||
/* ── Badges ── */
|
||||
.nq-badge-ok {
|
||||
font-size: 10px; font-weight: 600; padding: 1px 6px; border-radius: 9999px;
|
||||
background: rgba(15,123,95,0.06); color: #0f7b5f; white-space: nowrap;
|
||||
}
|
||||
.nq-badge-warn {
|
||||
font-size: 10px; font-weight: 600; padding: 1px 6px; border-radius: 9999px;
|
||||
background: rgba(193,56,56,0.06); color: #c13838; white-space: nowrap;
|
||||
}
|
||||
.nq-badge-grace {
|
||||
font-size: 10px; font-weight: 600; padding: 1px 6px; border-radius: 9999px;
|
||||
background: rgba(194,123,26,0.06); color: #c27b1a; white-space: nowrap;
|
||||
}
|
||||
.nq-badge-na {
|
||||
font-size: 10px; font-weight: 600; padding: 1px 6px; border-radius: 9999px;
|
||||
background: rgba(0,0,0,0.04); color: #8a8a86; white-space: nowrap;
|
||||
}
|
||||
|
||||
/* ── Empty ── */
|
||||
.nq-empty {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: 32px 16px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* ── Selected customer banner ── */
|
||||
.nq-selected-banner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 12px 16px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid rgba(1,105,111,0.15);
|
||||
background: rgba(1,105,111,0.02);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.nq-selected-cancelled {
|
||||
border-color: rgba(193,56,56,0.15);
|
||||
background: rgba(193,56,56,0.02);
|
||||
}
|
||||
.nq-selected-manual {
|
||||
border-color: rgba(0,0,0,0.08);
|
||||
background: rgba(0,0,0,0.015);
|
||||
border-style: dashed;
|
||||
}
|
||||
.nq-change-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 5px 10px;
|
||||
border-radius: 6px;
|
||||
border: 1px solid rgba(0,0,0,0.08);
|
||||
background: #fff;
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
color: var(--text-muted);
|
||||
cursor: pointer;
|
||||
transition: all 150ms ease;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.nq-change-btn:hover { border-color: rgba(0,0,0,0.15); color: var(--text-primary); }
|
||||
.nq-manual-avatar {
|
||||
width: 36px; height: 36px;
|
||||
border-radius: 10px;
|
||||
background: rgba(0,0,0,0.04);
|
||||
color: #8a8a86;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* ── Contextual banners ── */
|
||||
.nq-cancelled-banner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 12px 16px;
|
||||
border-radius: 10px;
|
||||
background: rgba(193,56,56,0.03);
|
||||
border: 1px solid rgba(193,56,56,0.1);
|
||||
}
|
||||
.nq-lead-hint-banner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 12px 16px;
|
||||
border-radius: 10px;
|
||||
background: rgba(124,58,237,0.03);
|
||||
border: 1px solid rgba(124,58,237,0.1);
|
||||
}
|
||||
|
||||
/* ── Existing coverage ── */
|
||||
.nq-existing {
|
||||
padding: 14px 16px;
|
||||
border-radius: 10px;
|
||||
background: rgba(124,58,237,0.02);
|
||||
border: 1px solid rgba(124,58,237,0.08);
|
||||
}
|
||||
.nq-existing-pills {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
}
|
||||
.nq-existing-pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
padding: 3px 10px;
|
||||
border-radius: 6px;
|
||||
background: rgba(124,58,237,0.05);
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
color: #5b21b6;
|
||||
}
|
||||
.nq-existing-dot {
|
||||
width: 5px; height: 5px;
|
||||
border-radius: 50%;
|
||||
background: #7c3aed;
|
||||
}
|
||||
|
||||
/* ── LOB cards ── */
|
||||
.nq-lob-card {
|
||||
display: flex;
|
||||
align-items: start;
|
||||
gap: 12px;
|
||||
padding: 18px;
|
||||
border-radius: 12px;
|
||||
border: 1px solid rgba(0,0,0,0.06);
|
||||
background: #fff;
|
||||
box-shadow: 0 1px 3px rgba(0,0,0,0.03);
|
||||
transition: all 200ms ease;
|
||||
text-decoration: none;
|
||||
}
|
||||
.nq-lob-card:hover {
|
||||
border-color: rgba(1,105,111,0.2);
|
||||
box-shadow: 0 4px 12px rgba(0,0,0,0.06);
|
||||
}
|
||||
.nq-lob-icon {
|
||||
width: 44px; height: 44px;
|
||||
border-radius: 12px;
|
||||
background: rgba(1,105,111,0.06);
|
||||
color: #01696f;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
transition: background 200ms ease;
|
||||
}
|
||||
.nq-lob-card:hover .nq-lob-icon { background: rgba(1,105,111,0.1); }
|
||||
</style>
|
||||
Reference in New Issue
Block a user