WIP jordan

This commit is contained in:
Jordan Weingarten
2026-04-16 11:11:44 -05:00
parent ff2d7b18b5
commit 67482f6629
163 changed files with 50627 additions and 728 deletions

View File

@@ -0,0 +1,291 @@
<script setup lang="ts">
import { emptyAutoQuoteDraft } from '~/composables/useAutoQuoteDraft'
import type { AutoQuoteIntakePayload, AutoQuoteMode, AutoQuoteSegment } from '~/types/auto-quote-intake'
/** Client-only: many Nuxt UI fields on this screen can stall hydration / main thread if SSR + client fight */
definePageMeta({ ssr: false })
usePageTitle('Quotes · Auto')
const STEP_ORDER = ['setup', 'solicit', 'acceptance'] as const
type StepId = (typeof STEP_ORDER)[number]
const STEP_LABELS: Record<StepId, string> = {
setup: 'Quote setup',
solicit: 'Quotes to solicit',
acceptance: 'Acceptance'
}
const step = ref<StepId>('setup')
/** Highest step index the user has reached (for stepper — no reactive watch loops) */
const maxStepIndex = ref(0)
const intakeBusy = ref(false)
const draft = reactive(emptyAutoQuoteDraft())
const toast = useToast()
const { quoteRequestEmailEnabled } = useQuoteRequestEmailEnabled()
const modeCards: { id: AutoQuoteMode; title: string; hint: string; icon: string }[] = [
{
id: 'single',
title: 'Single quote',
hint: 'One package — well email carriers quoting inboxes on file.',
icon: 'i-heroicons-document-text'
},
{
id: 'comparative_pdf',
title: 'Comparative quote',
hint: 'Same vehicle facts; prep plan comparisons and enter premiums when emails arrive.',
icon: 'i-heroicons-document-duplicate'
}
]
const segmentCards: { id: AutoQuoteSegment; title: string; hint: string; icon: string }[] = [
{
id: 'individual',
title: 'Individual',
hint: 'Personal auto.',
icon: 'i-heroicons-user'
},
{
id: 'corporate',
title: 'Corporate',
hint: 'Business or group.',
icon: 'i-heroicons-building-office-2'
},
{
id: 'fleet',
title: 'Fleet',
hint: 'Fleet program.',
icon: 'i-heroicons-truck'
}
]
function canProceedFromCustomer() {
const c = draft.client
if (!c.fullName.trim() || !c.email.trim()) {
toast.add({
title: 'Add legal name and email',
description: 'We need them for carrier notifications.',
color: 'warning'
})
return false
}
return true
}
function canProceedFromSetup() {
if (!draft.quoteMode) {
toast.add({ title: 'Choose a quote type', description: 'Single or comparative.', color: 'warning' })
return false
}
if (!draft.segment) {
toast.add({ title: 'Choose policy type', description: 'Individual, corporate, or fleet.', color: 'warning' })
return false
}
if (!canProceedFromCustomer()) return false
if (
(draft.segment === 'corporate' || draft.segment === 'fleet') &&
!draft.client.organizationName?.trim()
) {
toast.add({
title: 'Add organization',
description: 'Required for corporate and fleet policies.',
color: 'warning'
})
return false
}
return true
}
function canProceedFromSolicit() {
if (draft.solicit.carrierIds.length === 0 || draft.solicit.planIds.length === 0) {
toast.add({
title: 'Choose carriers and plans',
description: 'Select at least one insurance company and one coverage package.',
color: 'warning'
})
return false
}
return true
}
function goToStep(target: StepId) {
const ti = STEP_ORDER.indexOf(target)
if (ti > maxStepIndex.value) return
step.value = target
}
function onStepPillClick(stepIndex: number, target: StepId) {
if (stepIndex > maxStepIndex.value) return
goToStep(target)
}
function goPrev() {
const i = STEP_ORDER.indexOf(step.value)
if (i <= 0) return
step.value = STEP_ORDER[i - 1]!
}
function goNext() {
const i = STEP_ORDER.indexOf(step.value)
if (step.value === 'setup' && !canProceedFromSetup()) return
if (step.value === 'solicit' && !canProceedFromSolicit()) return
if (i >= STEP_ORDER.length - 1) return
const next = STEP_ORDER[i + 1]!
step.value = next
maxStepIndex.value = Math.max(maxStepIndex.value, i + 1)
}
function buildPayload(): AutoQuoteIntakePayload {
return {
quoteMode: draft.quoteMode!,
segment: draft.segment!,
client: { ...draft.client },
vehicle: { ...draft.vehicle },
solicit: {
carrierIds: [...draft.solicit.carrierIds],
planIds: [...draft.solicit.planIds]
}
}
}
async function finalize() {
if (!draft.quoteMode || !draft.segment) return
if (intakeBusy.value) return
intakeBusy.value = true
try {
const payload = buildPayload()
const emailOn = quoteRequestEmailEnabled.value
if (payload.quoteMode === 'comparative_pdf') {
toast.add({
title: emailOn ? 'Quote requests queued' : 'Comparative run saved',
description: emailOn
? 'Opening the comparative sheet. Provider emails follow your Settings → Quote requests toggle.'
: 'Emails to providers are disabled — comparative layout saved for manual or table pricing.',
color: 'success'
})
await nextTick()
await navigateTo({
path: '/quotes/compare',
query: { from: 'auto', segment: payload.segment }
})
return
}
toast.add({
title: emailOn ? 'Quote requests recorded' : 'Quote run saved (no emails)',
description: emailOn
? 'Requests can be sent to carrier quoting addresses on file when your integration is on.'
: 'Outbound provider email is off in Settings — this request stays in-app for tables, APIs, or AI.',
color: 'success'
})
} finally {
intakeBusy.value = false
}
}
</script>
<template>
<div class="mx-auto max-w-4xl space-y-6 pb-12">
<NuxtLink to="/quotes" class="inline-flex">
<UButton color="neutral" variant="ghost" size="sm" icon="i-heroicons-arrow-left">Back to quotes</UButton>
</NuxtLink>
<div class="max-w-2xl">
<h1 class="mt-1 text-2xl font-semibold tracking-tight text-[var(--text-primary)]">Auto quoting</h1>
<p class="mt-1 text-[13px] text-[var(--text-muted)]">
Set up the risk, choose who to solicit, then accept three steps.
</p>
</div>
<div
class="flex flex-wrap items-center gap-x-1 gap-y-2 text-[11px] font-medium text-[var(--text-muted)] sm:text-xs"
role="navigation"
aria-label="Steps"
>
<template v-for="(s, idx) in STEP_ORDER" :key="s">
<UIcon v-if="idx > 0" name="i-heroicons-chevron-right" class="h-3 w-3 shrink-0 opacity-40" aria-hidden="true" />
<button
type="button"
class="min-w-0 rounded-full px-2 py-1 text-left transition sm:px-2.5"
:class="
step === s
? 'bg-[var(--brand-soft)] text-[var(--brand)]'
: idx <= maxStepIndex
? 'cursor-pointer bg-[var(--sidebar-border)]/60 hover:bg-[var(--brand-soft)]/80 hover:text-[var(--brand)]'
: 'cursor-default bg-[var(--sidebar-border)]/60 opacity-50'
"
:aria-current="step === s ? 'step' : undefined"
@click.prevent.stop="onStepPillClick(idx, s)"
>
<span class="hidden sm:inline">{{ idx + 1 }}. {{ STEP_LABELS[s] }}</span>
<span class="sm:hidden">{{ idx + 1 }}</span>
</button>
</template>
</div>
<UCard :ui="{ body: { padding: 'p-5 sm:p-6' } }">
<template #header>
<div>
<p class="text-xs font-semibold uppercase tracking-wide text-[var(--text-muted)]">
Step {{ STEP_ORDER.indexOf(step) + 1 }} of {{ STEP_ORDER.length }}
</p>
<h2 class="mt-0.5 text-lg font-semibold text-[var(--text-primary)]">{{ STEP_LABELS[step] }}</h2>
</div>
</template>
<QuotesAutoSetupStep
v-if="step === 'setup'"
:draft="draft"
:mode-cards="modeCards"
:segment-cards="segmentCards"
/>
<QuotesAutoSolicitQuotesStep
v-else-if="step === 'solicit' && draft.quoteMode"
:draft="draft"
:quote-mode="draft.quoteMode"
/>
<QuotesAutoAcceptanceStep
v-else-if="step === 'acceptance' && draft.quoteMode && draft.segment"
:draft="draft"
:quote-mode="draft.quoteMode"
:segment="draft.segment"
/>
</UCard>
<div class="flex flex-wrap items-center justify-between gap-3">
<UButton
v-if="step !== 'setup'"
type="button"
color="neutral"
variant="soft"
@click="goPrev"
>
Back
</UButton>
<NuxtLink v-else to="/quotes" class="inline-flex">
<UButton color="neutral" variant="ghost" size="sm">Cancel</UButton>
</NuxtLink>
<div class="flex flex-wrap gap-2">
<UButton v-if="step !== 'acceptance'" type="button" color="primary" @click="goNext">
Continue
</UButton>
<UButton
v-else
type="button"
color="primary"
:loading="intakeBusy"
:disabled="intakeBusy"
@click="finalize"
>
{{ quoteRequestEmailEnabled ? 'Send quote requests' : 'Save quote run' }}
</UButton>
</div>
</div>
</div>
</template>

1001
app/pages/quotes/compare.vue Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,169 @@
<script setup lang="ts">
definePageMeta({ ssr: false })
usePageTitle('Quotes · Custom')
const toast = useToast()
const form = reactive({
clientName: '',
clientEmail: '',
clientPhone: '',
lineOfBusiness: '',
policyType: '',
carrier: '',
startDate: '',
endDate: '',
sumInsured: null as number | null,
premium: null as number | null,
commissionPct: null as number | null,
deductible: null as number | null,
notes: ''
})
const commissionAmount = computed(() => {
if (form.premium && form.commissionPct) return ((form.premium * form.commissionPct) / 100).toFixed(2)
return '0.00'
})
const linesOfBusiness = ['Auto', 'Health', 'Life', 'Property', 'Liability', 'Marine', 'Surety', 'Other']
const inputCls = 'w-full rounded-xl border border-[var(--sidebar-border)] bg-[var(--surface)] px-3 py-2 text-sm text-[var(--text-primary)] placeholder:text-[var(--text-muted)]'
const labelCls = 'block text-sm font-semibold text-[var(--text-muted)] mb-1.5'
async function save() {
if (!form.clientName.trim() || !form.lineOfBusiness) {
toast.add({ title: 'Missing fields', description: 'Client name and line of business are required.', color: 'warning' })
return
}
toast.add({ title: 'Quote saved', description: 'Your custom quote has been recorded.', color: 'success' })
await navigateTo('/quotes')
}
</script>
<template>
<div class="mx-auto max-w-4xl space-y-6 pb-12">
<NuxtLink to="/quotes" class="inline-flex">
<UButton color="neutral" variant="ghost" size="sm" icon="i-heroicons-arrow-left">Back to quotes</UButton>
</NuxtLink>
<div>
<h1 class="mt-1 text-2xl font-semibold tracking-tight text-[var(--text-primary)]">Custom quote entry</h1>
<p class="mt-1 text-[13px] text-[var(--text-muted)]">
Manually enter a quote for any line of business quick single-page form.
</p>
</div>
<UCard :ui="{ body: { padding: 'p-5 sm:p-6' } }">
<template #header>
<div>
<p class="text-xs font-medium text-[var(--text-muted)]">Client</p>
<h2 class="mt-0.5 text-lg font-semibold text-[var(--text-primary)]">Client information</h2>
</div>
</template>
<div class="grid gap-4 sm:grid-cols-3">
<div>
<label :class="labelCls">Client name *</label>
<input v-model="form.clientName" :class="inputCls" placeholder="Full name" />
</div>
<div>
<label :class="labelCls">Email</label>
<input v-model="form.clientEmail" type="email" :class="inputCls" placeholder="client@example.com" />
</div>
<div>
<label :class="labelCls">Phone</label>
<input v-model="form.clientPhone" type="tel" :class="inputCls" placeholder="+1 (555) 000-0000" />
</div>
</div>
</UCard>
<UCard :ui="{ body: { padding: 'p-5 sm:p-6' } }">
<template #header>
<div>
<p class="text-xs font-medium text-[var(--text-muted)]">Policy</p>
<h2 class="mt-0.5 text-lg font-semibold text-[var(--text-primary)]">Policy details</h2>
</div>
</template>
<div class="space-y-4">
<div class="grid gap-4 sm:grid-cols-2">
<div>
<label :class="labelCls">Line of business *</label>
<select v-model="form.lineOfBusiness" :class="inputCls">
<option value="" disabled>Select line</option>
<option v-for="l in linesOfBusiness" :key="l" :value="l">{{ l }}</option>
</select>
</div>
<div>
<label :class="labelCls">Policy type / description</label>
<input v-model="form.policyType" :class="inputCls" placeholder="e.g. Comprehensive auto" />
</div>
</div>
<div class="grid gap-4 sm:grid-cols-3">
<div>
<label :class="labelCls">Carrier</label>
<input v-model="form.carrier" :class="inputCls" placeholder="Carrier name" />
</div>
<div>
<label :class="labelCls">Start date</label>
<input v-model="form.startDate" type="date" :class="inputCls" />
</div>
<div>
<label :class="labelCls">End date</label>
<input v-model="form.endDate" type="date" :class="inputCls" />
</div>
</div>
<div class="grid gap-4 sm:grid-cols-2">
<div>
<label :class="labelCls">Sum insured / limit</label>
<input v-model.number="form.sumInsured" type="number" :class="inputCls" placeholder="0.00" min="0" step="0.01" />
</div>
<div>
<label :class="labelCls">Deductible</label>
<input v-model.number="form.deductible" type="number" :class="inputCls" placeholder="0.00" min="0" step="0.01" />
</div>
</div>
<div class="grid gap-4 sm:grid-cols-3">
<div>
<label :class="labelCls">Premium</label>
<input v-model.number="form.premium" type="number" :class="inputCls" placeholder="0.00" min="0" step="0.01" />
</div>
<div>
<label :class="labelCls">Commission %</label>
<input v-model.number="form.commissionPct" type="number" :class="inputCls" placeholder="0" min="0" max="100" step="0.1" />
</div>
<div>
<label :class="labelCls">Commission amount</label>
<div :class="[inputCls, 'flex items-center bg-[var(--brand-soft)]/30 font-medium']">
{{ commissionAmount }}
</div>
</div>
</div>
</div>
</UCard>
<UCard :ui="{ body: { padding: 'p-5 sm:p-6' } }">
<template #header>
<div>
<p class="text-xs font-medium text-[var(--text-muted)]">Notes</p>
<h2 class="mt-0.5 text-lg font-semibold text-[var(--text-primary)]">Special conditions</h2>
</div>
</template>
<div>
<label :class="labelCls">Special conditions / notes</label>
<textarea v-model="form.notes" :class="inputCls" rows="4" placeholder="Any additional terms, conditions, or notes..." />
</div>
</UCard>
<div class="flex items-center justify-between">
<NuxtLink to="/quotes">
<UButton color="neutral" variant="ghost" size="sm">Cancel</UButton>
</NuxtLink>
<UButton color="primary" @click="save">Save quote</UButton>
</div>
</div>
</template>

View File

@@ -0,0 +1,321 @@
<script setup lang="ts">
definePageMeta({ ssr: false })
usePageTitle('Quotes · General Risk')
const STEP_ORDER = ['setup', 'details', 'review'] as const
type StepId = (typeof STEP_ORDER)[number]
const STEP_LABELS: Record<StepId, string> = {
setup: 'Quote setup',
details: 'Risk details',
review: 'Review & submit'
}
const step = ref<StepId>('setup')
const maxStepIndex = ref(0)
const busy = ref(false)
const toast = useToast()
const form = reactive({
quoteType: '' as '' | 'single' | 'comparative',
riskCategory: '' as '' | 'property' | 'liability' | 'fire' | 'all-risk' | 'commercial' | 'other',
clientName: '',
clientEmail: '',
clientPhone: '',
clientOrganization: '',
propertyAddress: '',
propertyDescription: '',
sumInsured: '',
coverageStart: '',
coverageEnd: '',
specialConditions: '',
deductible: ''
})
const inputClass = 'w-full rounded-xl border border-[var(--sidebar-border)] bg-[var(--surface)] px-3 py-2 text-sm text-[var(--text-primary)] placeholder:text-[var(--text-muted)]'
const labelClass = 'block text-sm font-semibold text-[var(--text-muted)] mb-1.5'
function canProceedFromSetup() {
if (!form.quoteType) {
toast.add({ title: 'Choose quote type', description: 'Single or comparative.', color: 'warning' })
return false
}
if (!form.riskCategory) {
toast.add({ title: 'Choose risk category', description: 'Select the type of risk.', color: 'warning' })
return false
}
if (!form.clientName.trim() || !form.clientEmail.trim()) {
toast.add({ title: 'Client info required', description: 'Name and email are needed.', color: 'warning' })
return false
}
return true
}
function canProceedFromDetails() {
if (!form.sumInsured) {
toast.add({ title: 'Sum insured required', description: 'Enter the total sum insured.', color: 'warning' })
return false
}
if (!form.coverageStart || !form.coverageEnd) {
toast.add({ title: 'Coverage period required', description: 'Enter start and end dates.', color: 'warning' })
return false
}
return true
}
function onStepPillClick(idx: number, target: StepId) {
if (idx > maxStepIndex.value) return
step.value = target
}
function goPrev() {
const i = STEP_ORDER.indexOf(step.value)
if (i <= 0) return
step.value = STEP_ORDER[i - 1]!
}
function goNext() {
const i = STEP_ORDER.indexOf(step.value)
if (step.value === 'setup' && !canProceedFromSetup()) return
if (step.value === 'details' && !canProceedFromDetails()) return
if (i >= STEP_ORDER.length - 1) return
const next = STEP_ORDER[i + 1]!
step.value = next
maxStepIndex.value = Math.max(maxStepIndex.value, i + 1)
}
async function finalize() {
if (busy.value) return
busy.value = true
try {
toast.add({
title: 'General risk quote submitted',
description: 'Your quote request has been recorded and is ready for placement.',
color: 'success'
})
await nextTick()
await navigateTo('/quotes')
} finally {
busy.value = false
}
}
const riskCategoryLabel: Record<string, string> = {
property: 'Property', liability: 'Liability', fire: 'Fire',
'all-risk': 'All-Risk', commercial: 'Commercial', other: 'Other'
}
</script>
<template>
<div class="mx-auto max-w-4xl space-y-6 pb-12">
<NuxtLink to="/quotes" class="inline-flex">
<UButton color="neutral" variant="ghost" size="sm" icon="i-heroicons-arrow-left">Back to quotes</UButton>
</NuxtLink>
<div class="max-w-2xl">
<h1 class="mt-1 text-2xl font-semibold tracking-tight text-[var(--text-primary)]">General risk quoting</h1>
<p class="mt-1 text-[13px] text-[var(--text-muted)]">
Property, liability, fire, all-risk, and commercial lines set up, detail, review.
</p>
</div>
<div
class="flex flex-wrap items-center gap-x-1 gap-y-2 text-[11px] font-medium text-[var(--text-muted)] sm:text-xs"
role="navigation"
aria-label="Steps"
>
<template v-for="(s, idx) in STEP_ORDER" :key="s">
<UIcon v-if="idx > 0" name="i-heroicons-chevron-right" class="h-3 w-3 shrink-0 opacity-40" aria-hidden="true" />
<button
type="button"
class="min-w-0 rounded-full px-2 py-1 text-left transition sm:px-2.5"
:class="
step === s
? 'bg-[var(--brand-soft)] text-[var(--brand)]'
: idx <= maxStepIndex
? 'cursor-pointer bg-[var(--sidebar-border)]/60 hover:bg-[var(--brand-soft)]/80 hover:text-[var(--brand)]'
: 'cursor-default bg-[var(--sidebar-border)]/60 opacity-50'
"
:aria-current="step === s ? 'step' : undefined"
@click.prevent.stop="onStepPillClick(idx, s)"
>
<span class="hidden sm:inline">{{ idx + 1 }}. {{ STEP_LABELS[s] }}</span>
<span class="sm:hidden">{{ idx + 1 }}</span>
</button>
</template>
</div>
<UCard :ui="{ body: { padding: 'p-5 sm:p-6' } }">
<template #header>
<div>
<p class="text-xs font-medium text-[var(--text-muted)]">
Step {{ STEP_ORDER.indexOf(step) + 1 }} of {{ STEP_ORDER.length }}
</p>
<h2 class="mt-0.5 text-lg font-semibold text-[var(--text-primary)]">{{ STEP_LABELS[step] }}</h2>
</div>
</template>
<!-- Step 1: Quote setup -->
<div v-if="step === 'setup'" class="space-y-10">
<section class="space-y-4">
<div>
<h3 class="text-base font-semibold text-[var(--text-primary)]">Quote type</h3>
<p class="mt-1 text-sm text-[var(--text-muted)]">Single placement or comparative comparative sends the same risk to multiple carriers.</p>
</div>
<div class="grid gap-3 sm:grid-cols-2">
<button
v-for="opt in [
{ id: 'single', label: 'Single quote', hint: 'One risk, one placement.', icon: 'i-heroicons-document-text' },
{ id: 'comparative', label: 'Comparative quote', hint: 'Same risk sent to multiple carriers.', icon: 'i-heroicons-document-duplicate' }
]"
:key="opt.id" type="button"
class="group rounded-xl border p-5 text-left transition focus:outline-none focus-visible:ring-2 focus-visible:ring-[var(--brand)]"
:class="form.quoteType === opt.id ? 'border-[var(--brand)] bg-[var(--brand-soft)] ring-1 ring-[var(--brand)]/30' : 'border-[var(--sidebar-border)] bg-[var(--surface)] hover:border-[var(--brand)]/40'"
@click="form.quoteType = opt.id as 'single' | 'comparative'"
>
<div class="flex items-start gap-3">
<div class="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg bg-[var(--brand-faint)] text-[var(--brand)]">
<UIcon :name="opt.icon" class="h-5 w-5" />
</div>
<div class="min-w-0">
<p class="font-semibold text-[var(--text-primary)]">{{ opt.label }}</p>
<p class="mt-1 text-sm text-[var(--text-muted)]">{{ opt.hint }}</p>
</div>
</div>
</button>
</div>
</section>
<section class="space-y-4 border-t border-[var(--sidebar-border)] pt-10">
<div>
<h3 class="text-base font-semibold text-[var(--text-primary)]">Risk category</h3>
<p class="mt-1 text-sm text-[var(--text-muted)]">What type of risk are you quoting?</p>
</div>
<div class="grid gap-3 sm:grid-cols-3">
<button
v-for="cat in [
{ id: 'property', icon: 'i-heroicons-building-office-2' },
{ id: 'liability', icon: 'i-heroicons-shield-exclamation' },
{ id: 'fire', icon: 'i-heroicons-fire' },
{ id: 'all-risk', icon: 'i-heroicons-shield-check' },
{ id: 'commercial', icon: 'i-heroicons-building-storefront' },
{ id: 'other', icon: 'i-heroicons-ellipsis-horizontal-circle' }
]"
:key="cat.id" type="button"
class="rounded-xl border p-4 text-left transition focus:outline-none focus-visible:ring-2 focus-visible:ring-[var(--brand)]"
:class="form.riskCategory === cat.id ? 'border-[var(--brand)] bg-[var(--brand-soft)] ring-1 ring-[var(--brand)]/30' : 'border-[var(--sidebar-border)] bg-[var(--surface)] hover:border-[var(--brand)]/40'"
@click="form.riskCategory = cat.id as typeof form.riskCategory"
>
<UIcon :name="cat.icon" class="h-7 w-7 text-[var(--brand)]" />
<p class="mt-2 text-sm font-semibold text-[var(--text-primary)]">{{ riskCategoryLabel[cat.id] }}</p>
</button>
</div>
</section>
<section class="space-y-4 border-t border-[var(--sidebar-border)] pt-10">
<h3 class="text-base font-semibold text-[var(--text-primary)]">Client information</h3>
<div class="grid gap-4 sm:grid-cols-2">
<div>
<label :class="labelClass">Full name</label>
<input v-model="form.clientName" type="text" :class="inputClass" placeholder="Jane Doe" />
</div>
<div>
<label :class="labelClass">Email</label>
<input v-model="form.clientEmail" type="email" :class="inputClass" placeholder="jane@example.com" />
</div>
<div>
<label :class="labelClass">Phone</label>
<input v-model="form.clientPhone" type="tel" :class="inputClass" placeholder="+27 82 000 0000" />
</div>
<div>
<label :class="labelClass">Organization</label>
<input v-model="form.clientOrganization" type="text" :class="inputClass" placeholder="Acme Corp" />
</div>
</div>
</section>
</div>
<!-- Step 2: Risk details -->
<div v-else-if="step === 'details'" class="space-y-6">
<div class="grid gap-4 sm:grid-cols-2">
<div class="sm:col-span-2">
<label :class="labelClass">Property / risk address</label>
<input v-model="form.propertyAddress" type="text" :class="inputClass" placeholder="123 Main St, Johannesburg" />
</div>
<div class="sm:col-span-2">
<label :class="labelClass">Risk description</label>
<textarea v-model="form.propertyDescription" :class="inputClass" rows="3" placeholder="Describe the property or risk in detail" />
</div>
<div>
<label :class="labelClass">Sum insured (ZAR)</label>
<input v-model="form.sumInsured" type="number" :class="inputClass" placeholder="1 000 000" />
</div>
<div>
<label :class="labelClass">Deductible (ZAR)</label>
<input v-model="form.deductible" type="number" :class="inputClass" placeholder="10 000" />
</div>
<div>
<label :class="labelClass">Coverage start</label>
<input v-model="form.coverageStart" type="date" :class="inputClass" />
</div>
<div>
<label :class="labelClass">Coverage end</label>
<input v-model="form.coverageEnd" type="date" :class="inputClass" />
</div>
<div class="sm:col-span-2">
<label :class="labelClass">Special conditions</label>
<textarea v-model="form.specialConditions" :class="inputClass" rows="3" placeholder="Any special conditions, endorsements, or exclusions" />
</div>
</div>
</div>
<!-- Step 3: Review & submit -->
<div v-else-if="step === 'review'" class="space-y-5">
<div class="rounded-xl border border-[var(--sidebar-border)] bg-[var(--surface)] p-4">
<p class="text-sm font-semibold text-[var(--text-muted)] mb-3">Quote setup</p>
<dl class="grid gap-x-6 gap-y-2 text-sm sm:grid-cols-2">
<div><dt class="text-[var(--text-muted)]">Quote type</dt><dd class="font-medium text-[var(--text-primary)] capitalize">{{ form.quoteType || '—' }}</dd></div>
<div><dt class="text-[var(--text-muted)]">Risk category</dt><dd class="font-medium text-[var(--text-primary)]">{{ riskCategoryLabel[form.riskCategory] || '—' }}</dd></div>
<div><dt class="text-[var(--text-muted)]">Client</dt><dd class="font-medium text-[var(--text-primary)]">{{ form.clientName || '—' }}</dd></div>
<div><dt class="text-[var(--text-muted)]">Email</dt><dd class="font-medium text-[var(--text-primary)]">{{ form.clientEmail || '—' }}</dd></div>
<div><dt class="text-[var(--text-muted)]">Phone</dt><dd class="font-medium text-[var(--text-primary)]">{{ form.clientPhone || '—' }}</dd></div>
<div><dt class="text-[var(--text-muted)]">Organization</dt><dd class="font-medium text-[var(--text-primary)]">{{ form.clientOrganization || '—' }}</dd></div>
</dl>
</div>
<div class="rounded-xl border border-[var(--sidebar-border)] bg-[var(--surface)] p-4">
<p class="text-sm font-semibold text-[var(--text-muted)] mb-3">Risk details</p>
<dl class="grid gap-x-6 gap-y-2 text-sm sm:grid-cols-2">
<div class="sm:col-span-2"><dt class="text-[var(--text-muted)]">Address</dt><dd class="font-medium text-[var(--text-primary)]">{{ form.propertyAddress || '—' }}</dd></div>
<div class="sm:col-span-2"><dt class="text-[var(--text-muted)]">Description</dt><dd class="font-medium text-[var(--text-primary)]">{{ form.propertyDescription || '—' }}</dd></div>
<div><dt class="text-[var(--text-muted)]">Sum insured</dt><dd class="font-medium text-[var(--text-primary)]">{{ form.sumInsured ? `R ${Number(form.sumInsured).toLocaleString()}` : '—' }}</dd></div>
<div><dt class="text-[var(--text-muted)]">Deductible</dt><dd class="font-medium text-[var(--text-primary)]">{{ form.deductible ? `R ${Number(form.deductible).toLocaleString()}` : '—' }}</dd></div>
<div><dt class="text-[var(--text-muted)]">Coverage start</dt><dd class="font-medium text-[var(--text-primary)]">{{ form.coverageStart || '—' }}</dd></div>
<div><dt class="text-[var(--text-muted)]">Coverage end</dt><dd class="font-medium text-[var(--text-primary)]">{{ form.coverageEnd || '—' }}</dd></div>
<div v-if="form.specialConditions" class="sm:col-span-2"><dt class="text-[var(--text-muted)]">Special conditions</dt><dd class="font-medium text-[var(--text-primary)]">{{ form.specialConditions }}</dd></div>
</dl>
</div>
</div>
</UCard>
<div class="flex flex-wrap items-center justify-between gap-3">
<UButton v-if="step !== 'setup'" type="button" color="neutral" variant="soft" @click="goPrev">Back</UButton>
<NuxtLink v-else to="/quotes" class="inline-flex">
<UButton color="neutral" variant="ghost" size="sm">Cancel</UButton>
</NuxtLink>
<div class="flex flex-wrap gap-2">
<UButton v-if="step !== 'review'" type="button" color="primary" @click="goNext">Continue</UButton>
<UButton
v-else
type="button"
color="primary"
:loading="busy"
:disabled="busy"
@click="finalize"
>
Finalize quote
</UButton>
</div>
</div>
</div>
</template>

View File

@@ -0,0 +1,311 @@
<script setup lang="ts">
import { emptyHealthQuoteDraft } from '~/composables/useHealthQuoteDraft'
import type { HealthQuoteIntakePayload, HealthQuoteMode, HealthQuoteSegment } from '~/types/health-quote-intake'
definePageMeta({ ssr: false })
usePageTitle('Quotes · Health')
const STEP_ORDER = ['setup', 'solicit', 'acceptance'] as const
type StepId = (typeof STEP_ORDER)[number]
const STEP_LABELS: Record<StepId, string> = {
setup: 'Quote setup',
solicit: 'Carriers to quote',
acceptance: 'Acceptance'
}
const step = ref<StepId>('setup')
const maxStepIndex = ref(0)
const intakeBusy = ref(false)
const draft = reactive(emptyHealthQuoteDraft())
const toast = useToast()
const { quoteRequestEmailEnabled } = useQuoteRequestEmailEnabled()
const modeCards: { id: HealthQuoteMode; title: string; hint: string; icon: string }[] = [
{
id: 'single',
title: 'Single quote',
hint: 'One benefit package per carrier request.',
icon: 'i-heroicons-document-text'
},
{
id: 'comparative_pdf',
title: 'Comparative quote',
hint: 'Same census and coverage intent; fill the comparative sheet when rates arrive.',
icon: 'i-heroicons-document-duplicate'
}
]
const segmentCards: { id: HealthQuoteSegment; title: string; hint: string; icon: string }[] = [
{
id: 'individual',
title: 'Individual',
hint: 'Retail medical.',
icon: 'i-heroicons-user'
},
{
id: 'corporate',
title: 'Corporate',
hint: 'Employer-sponsored.',
icon: 'i-heroicons-building-office-2'
},
{
id: 'group',
title: 'Group policy',
hint: 'Trust or association block.',
icon: 'i-heroicons-user-group'
}
]
function canProceedFromCustomer() {
const c = draft.client
if (!c.fullName.trim() || !c.email.trim()) {
toast.add({
title: 'Add legal name and email',
description: 'We need them for notifications and the quote file.',
color: 'warning'
})
return false
}
return true
}
function canProceedFromSetup() {
if (!draft.quoteMode) {
toast.add({ title: 'Choose quote type', description: 'Single or comparative.', color: 'warning' })
return false
}
if (!draft.segment) {
toast.add({ title: 'Choose policy type', description: 'Individual, corporate, or group.', color: 'warning' })
return false
}
if (!canProceedFromCustomer()) return false
if (
(draft.segment === 'corporate' || draft.segment === 'group') &&
!draft.client.organizationName?.trim()
) {
toast.add({
title: 'Add organization',
description: 'Required for corporate and group policies.',
color: 'warning'
})
return false
}
if (!draft.health.dateOfBirth) {
toast.add({
title: 'Add date of birth',
description: 'Age is required for health underwriting and rate-band lookup.',
color: 'warning'
})
return false
}
if (!draft.health.coverageArea || !draft.health.networkTier || !draft.health.deductible) {
toast.add({
title: 'Complete coverage intent',
description: 'Select coverage area, network tier, and deductible.',
color: 'warning'
})
return false
}
if (!draft.forms.medicalQuestionnaire || !draft.forms.beneficiaryDesignation) {
toast.add({
title: 'Confirm forms',
description: 'Check off medical questionnaire and beneficiary designation.',
color: 'warning'
})
return false
}
if (draft.segment === 'group' && !draft.forms.groupCensus) {
toast.add({
title: 'Group census required',
description: 'Confirm the employee roster / census for group policies.',
color: 'warning'
})
return false
}
return true
}
function canProceedFromSolicit() {
if (draft.solicit.carrierIds.length === 0 || draft.solicit.planIds.length === 0) {
toast.add({
title: 'Choose carriers and plans',
description: 'Select at least one carrier and one plan shell.',
color: 'warning'
})
return false
}
return true
}
function goToStep(target: StepId) {
const ti = STEP_ORDER.indexOf(target)
if (ti > maxStepIndex.value) return
step.value = target
}
function onStepPillClick(stepIndex: number, target: StepId) {
if (stepIndex > maxStepIndex.value) return
goToStep(target)
}
function goPrev() {
const i = STEP_ORDER.indexOf(step.value)
if (i <= 0) return
step.value = STEP_ORDER[i - 1]!
}
function goNext() {
const i = STEP_ORDER.indexOf(step.value)
if (step.value === 'setup' && !canProceedFromSetup()) return
if (step.value === 'solicit' && !canProceedFromSolicit()) return
if (i >= STEP_ORDER.length - 1) return
const next = STEP_ORDER[i + 1]!
step.value = next
maxStepIndex.value = Math.max(maxStepIndex.value, i + 1)
}
function buildPayload(): HealthQuoteIntakePayload {
return {
quoteMode: draft.quoteMode!,
segment: draft.segment!,
client: { ...draft.client },
health: { ...draft.health },
solicit: {
carrierIds: [...draft.solicit.carrierIds],
planIds: [...draft.solicit.planIds]
}
}
}
async function finalize() {
if (!draft.quoteMode || !draft.segment) return
if (intakeBusy.value) return
intakeBusy.value = true
try {
const payload = buildPayload()
const emailOn = quoteRequestEmailEnabled.value
if (payload.quoteMode === 'comparative_pdf') {
toast.add({
title: emailOn ? 'Quote requests queued' : 'Comparative run saved',
description: emailOn
? 'Opening the comparative sheet. Provider emails follow your Settings → Quote requests toggle.'
: 'Emails to providers are disabled — comparative layout saved for manual or table pricing.',
color: 'success'
})
await nextTick()
await navigateTo({
path: '/quotes/compare',
query: { from: 'health', segment: payload.segment }
})
return
}
toast.add({
title: emailOn ? 'Quote requests recorded' : 'Quote run saved (no emails)',
description: emailOn
? 'Requests can be sent to carrier quoting addresses on file when your integration is on.'
: 'Outbound provider email is off in Settings — this request stays in-app for tables, APIs, or AI.',
color: 'success'
})
} finally {
intakeBusy.value = false
}
}
</script>
<template>
<div class="mx-auto max-w-4xl space-y-6 pb-12">
<NuxtLink to="/quotes" class="inline-flex">
<UButton color="neutral" variant="ghost" size="sm" icon="i-heroicons-arrow-left">Back to quotes</UButton>
</NuxtLink>
<div class="max-w-2xl">
<h1 class="mt-1 text-2xl font-semibold tracking-tight text-[var(--text-primary)]">Health quoting</h1>
<p class="mt-1 text-[13px] text-[var(--text-muted)]">
Set up the member, complete forms, pick carriers, then accept three steps.
</p>
</div>
<div
class="flex flex-wrap items-center gap-x-1 gap-y-2 text-[11px] font-medium text-[var(--text-muted)] sm:text-xs"
role="navigation"
aria-label="Steps"
>
<template v-for="(s, idx) in STEP_ORDER" :key="s">
<UIcon v-if="idx > 0" name="i-heroicons-chevron-right" class="h-3 w-3 shrink-0 opacity-40" aria-hidden="true" />
<button
type="button"
class="min-w-0 rounded-full px-2 py-1 text-left transition sm:px-2.5"
:class="
step === s
? 'bg-[var(--brand-soft)] text-[var(--brand)]'
: idx <= maxStepIndex
? 'cursor-pointer bg-[var(--sidebar-border)]/60 hover:bg-[var(--brand-soft)]/80 hover:text-[var(--brand)]'
: 'cursor-default bg-[var(--sidebar-border)]/60 opacity-50'
"
:aria-current="step === s ? 'step' : undefined"
@click.prevent.stop="onStepPillClick(idx, s)"
>
<span class="hidden sm:inline">{{ idx + 1 }}. {{ STEP_LABELS[s] }}</span>
<span class="sm:hidden">{{ idx + 1 }}</span>
</button>
</template>
</div>
<UCard :ui="{ body: { padding: 'p-5 sm:p-6' } }">
<template #header>
<div>
<p class="text-xs font-semibold uppercase tracking-wide text-[var(--text-muted)]">
Step {{ STEP_ORDER.indexOf(step) + 1 }} of {{ STEP_ORDER.length }}
</p>
<h2 class="mt-0.5 text-lg font-semibold text-[var(--text-primary)]">{{ STEP_LABELS[step] }}</h2>
</div>
</template>
<QuotesHealthSetupStep
v-if="step === 'setup'"
:draft="draft"
:mode-cards="modeCards"
:segment-cards="segmentCards"
/>
<QuotesHealthSolicitQuotesStep
v-else-if="step === 'solicit' && draft.quoteMode"
:draft="draft"
:quote-mode="draft.quoteMode"
/>
<QuotesHealthAcceptanceStep
v-else-if="step === 'acceptance' && draft.quoteMode && draft.segment"
:draft="draft"
:quote-mode="draft.quoteMode"
:segment="draft.segment"
/>
</UCard>
<div class="flex flex-wrap items-center justify-between gap-3">
<UButton v-if="step !== 'setup'" type="button" color="neutral" variant="soft" @click="goPrev"> Back </UButton>
<NuxtLink v-else to="/quotes" class="inline-flex">
<UButton color="neutral" variant="ghost" size="sm">Cancel</UButton>
</NuxtLink>
<div class="flex flex-wrap gap-2">
<UButton v-if="step !== 'acceptance'" type="button" color="primary" @click="goNext"> Continue </UButton>
<UButton
v-else
type="button"
color="primary"
:loading="intakeBusy"
:disabled="intakeBusy"
@click="finalize"
>
{{ quoteRequestEmailEnabled ? 'Send quote requests' : 'Save quote run' }}
</UButton>
</div>
</div>
</div>
</template>

1267
app/pages/quotes/index.vue Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,311 @@
<script setup lang="ts">
import { emptyLifeQuoteDraft } from '~/composables/useLifeQuoteDraft'
import type { LifeQuoteIntakePayload, LifeQuoteMode, LifeQuoteSegment } from '~/types/life-quote-intake'
definePageMeta({ ssr: false })
usePageTitle('Quotes · Life')
const STEP_ORDER = ['setup', 'solicit', 'acceptance'] as const
type StepId = (typeof STEP_ORDER)[number]
const STEP_LABELS: Record<StepId, string> = {
setup: 'Quote setup',
solicit: 'Carriers to quote',
acceptance: 'Acceptance'
}
const step = ref<StepId>('setup')
const maxStepIndex = ref(0)
const intakeBusy = ref(false)
const draft = reactive(emptyLifeQuoteDraft())
const toast = useToast()
const { quoteRequestEmailEnabled } = useQuoteRequestEmailEnabled()
const modeCards: { id: LifeQuoteMode; title: string; hint: string; icon: string }[] = [
{
id: 'single',
title: 'Single quote',
hint: 'One benefit package per carrier request.',
icon: 'i-heroicons-document-text'
},
{
id: 'comparative_pdf',
title: 'Comparative quote',
hint: 'Same insured and coverage intent; fill the comparative sheet when rates arrive.',
icon: 'i-heroicons-document-duplicate'
}
]
const segmentCards: { id: LifeQuoteSegment; title: string; hint: string; icon: string }[] = [
{
id: 'individual',
title: 'Individual',
hint: 'Retail life insurance.',
icon: 'i-heroicons-user'
},
{
id: 'corporate_keyman',
title: 'Corporate / Key person',
hint: 'Key-man or employer-sponsored.',
icon: 'i-heroicons-building-office-2'
},
{
id: 'group',
title: 'Group policy',
hint: 'Trust or association block.',
icon: 'i-heroicons-user-group'
}
]
function canProceedFromCustomer() {
const c = draft.client
if (!c.fullName.trim() || !c.email.trim()) {
toast.add({
title: 'Add legal name and email',
description: 'We need them for notifications and the quote file.',
color: 'warning'
})
return false
}
return true
}
function canProceedFromSetup() {
if (!draft.quoteMode) {
toast.add({ title: 'Choose quote type', description: 'Single or comparative.', color: 'warning' })
return false
}
if (!draft.segment) {
toast.add({ title: 'Choose policy type', description: 'Individual, corporate / key person, or group.', color: 'warning' })
return false
}
if (!canProceedFromCustomer()) return false
if (
(draft.segment === 'corporate_keyman' || draft.segment === 'group') &&
!draft.client.organizationName?.trim()
) {
toast.add({
title: 'Add organization',
description: 'Required for corporate and group policies.',
color: 'warning'
})
return false
}
if (!draft.life.dateOfBirth || !draft.life.gender) {
toast.add({
title: 'Complete age & health screening',
description: 'Date of birth and gender are required for life underwriting.',
color: 'warning'
})
return false
}
if (!draft.life.coverageAmount || !draft.life.coverageTerm) {
toast.add({
title: 'Complete coverage intent',
description: 'Select coverage amount and term.',
color: 'warning'
})
return false
}
if (!draft.forms.medicalQuestionnaire || !draft.forms.beneficiaryDesignation) {
toast.add({
title: 'Confirm forms',
description: 'Check off medical questionnaire and beneficiary designation.',
color: 'warning'
})
return false
}
if (draft.segment === 'group' && !draft.forms.groupCensus) {
toast.add({
title: 'Group census required',
description: 'Confirm the employee roster / census for group policies.',
color: 'warning'
})
return false
}
return true
}
function canProceedFromSolicit() {
if (draft.solicit.carrierIds.length === 0 || draft.solicit.planIds.length === 0) {
toast.add({
title: 'Choose carriers and plans',
description: 'Select at least one carrier and one plan shell.',
color: 'warning'
})
return false
}
return true
}
function goToStep(target: StepId) {
const ti = STEP_ORDER.indexOf(target)
if (ti > maxStepIndex.value) return
step.value = target
}
function onStepPillClick(stepIndex: number, target: StepId) {
if (stepIndex > maxStepIndex.value) return
goToStep(target)
}
function goPrev() {
const i = STEP_ORDER.indexOf(step.value)
if (i <= 0) return
step.value = STEP_ORDER[i - 1]!
}
function goNext() {
const i = STEP_ORDER.indexOf(step.value)
if (step.value === 'setup' && !canProceedFromSetup()) return
if (step.value === 'solicit' && !canProceedFromSolicit()) return
if (i >= STEP_ORDER.length - 1) return
const next = STEP_ORDER[i + 1]!
step.value = next
maxStepIndex.value = Math.max(maxStepIndex.value, i + 1)
}
function buildPayload(): LifeQuoteIntakePayload {
return {
quoteMode: draft.quoteMode!,
segment: draft.segment!,
client: { ...draft.client },
life: { ...draft.life },
solicit: {
carrierIds: [...draft.solicit.carrierIds],
planIds: [...draft.solicit.planIds]
}
}
}
async function finalize() {
if (!draft.quoteMode || !draft.segment) return
if (intakeBusy.value) return
intakeBusy.value = true
try {
const payload = buildPayload()
const emailOn = quoteRequestEmailEnabled.value
if (payload.quoteMode === 'comparative_pdf') {
toast.add({
title: emailOn ? 'Quote requests queued' : 'Comparative run saved',
description: emailOn
? 'Opening the comparative sheet. Provider emails follow your Settings -> Quote requests toggle.'
: 'Emails to providers are disabled — comparative layout saved for manual or table pricing.',
color: 'success'
})
await nextTick()
await navigateTo({
path: '/quotes/compare',
query: { from: 'life', segment: payload.segment }
})
return
}
toast.add({
title: emailOn ? 'Quote requests recorded' : 'Quote run saved (no emails)',
description: emailOn
? 'Requests can be sent to carrier quoting addresses on file when your integration is on.'
: 'Outbound provider email is off in Settings — this request stays in-app for tables, APIs, or AI.',
color: 'success'
})
} finally {
intakeBusy.value = false
}
}
</script>
<template>
<div class="mx-auto max-w-4xl space-y-6 pb-12">
<NuxtLink to="/quotes" class="inline-flex">
<UButton color="neutral" variant="ghost" size="sm" icon="i-heroicons-arrow-left">Back to quotes</UButton>
</NuxtLink>
<div class="max-w-2xl">
<h1 class="mt-1 text-2xl font-semibold tracking-tight text-[var(--text-primary)]">Life quoting</h1>
<p class="mt-1 text-[13px] text-[var(--text-muted)]">
Set up the insured, complete screening, pick carriers, then accept three steps.
</p>
</div>
<div
class="flex flex-wrap items-center gap-x-1 gap-y-2 text-[11px] font-medium text-[var(--text-muted)] sm:text-xs"
role="navigation"
aria-label="Steps"
>
<template v-for="(s, idx) in STEP_ORDER" :key="s">
<UIcon v-if="idx > 0" name="i-heroicons-chevron-right" class="h-3 w-3 shrink-0 opacity-40" aria-hidden="true" />
<button
type="button"
class="min-w-0 rounded-full px-2 py-1 text-left transition sm:px-2.5"
:class="
step === s
? 'bg-[var(--brand-soft)] text-[var(--brand)]'
: idx <= maxStepIndex
? 'cursor-pointer bg-[var(--sidebar-border)]/60 hover:bg-[var(--brand-soft)]/80 hover:text-[var(--brand)]'
: 'cursor-default bg-[var(--sidebar-border)]/60 opacity-50'
"
:aria-current="step === s ? 'step' : undefined"
@click.prevent.stop="onStepPillClick(idx, s)"
>
<span class="hidden sm:inline">{{ idx + 1 }}. {{ STEP_LABELS[s] }}</span>
<span class="sm:hidden">{{ idx + 1 }}</span>
</button>
</template>
</div>
<UCard :ui="{ body: { padding: 'p-5 sm:p-6' } }">
<template #header>
<div>
<p class="text-xs font-semibold uppercase tracking-wide text-[var(--text-muted)]">
Step {{ STEP_ORDER.indexOf(step) + 1 }} of {{ STEP_ORDER.length }}
</p>
<h2 class="mt-0.5 text-lg font-semibold text-[var(--text-primary)]">{{ STEP_LABELS[step] }}</h2>
</div>
</template>
<QuotesLifeSetupStep
v-if="step === 'setup'"
:draft="draft"
:mode-cards="modeCards"
:segment-cards="segmentCards"
/>
<QuotesLifeSolicitQuotesStep
v-else-if="step === 'solicit' && draft.quoteMode"
:draft="draft"
:quote-mode="draft.quoteMode"
/>
<QuotesLifeAcceptanceStep
v-else-if="step === 'acceptance' && draft.quoteMode && draft.segment"
:draft="draft"
:quote-mode="draft.quoteMode"
:segment="draft.segment"
/>
</UCard>
<div class="flex flex-wrap items-center justify-between gap-3">
<UButton v-if="step !== 'setup'" type="button" color="neutral" variant="soft" @click="goPrev"> Back </UButton>
<NuxtLink v-else to="/quotes" class="inline-flex">
<UButton color="neutral" variant="ghost" size="sm">Cancel</UButton>
</NuxtLink>
<div class="flex flex-wrap gap-2">
<UButton v-if="step !== 'acceptance'" type="button" color="primary" @click="goNext"> Continue </UButton>
<UButton
v-else
type="button"
color="primary"
:loading="intakeBusy"
:disabled="intakeBusy"
@click="finalize"
>
{{ quoteRequestEmailEnabled ? 'Send quote requests' : 'Save quote run' }}
</UButton>
</div>
</div>
</div>
</template>

View File

@@ -0,0 +1,662 @@
<script setup lang="ts">
import { PIPELINE_STAGES, type PipelineStage } from '~/composables/useSalesPipeline'
definePageMeta({ ssr: false })
usePageTitle('Mission Control \u00b7 Quotes')
/* ── Mock agents ── */
interface AgentStats {
name: string
activeDeals: number
quotesSent: number
conversionRate: number
avgResponseTime: string
pipelineValue: number
}
const agents: AgentStats[] = [
{ name: 'Ana Ram\u00edrez', activeDeals: 8, quotesSent: 14, conversionRate: 71, avgResponseTime: '1.2d', pipelineValue: 18_400 },
{ name: 'Marco Villanueva', activeDeals: 4, quotesSent: 6, conversionRate: 83, avgResponseTime: '0.8d', pipelineValue: 22_100 },
{ name: 'Luc\u00eda Fern\u00e1ndez', activeDeals: 6, quotesSent: 4, conversionRate: 42, avgResponseTime: '2.4d', pipelineValue: 4_700 },
]
/* ── Mock deals spread across pipeline ── */
interface MockDeal {
id: string
customerName: string
productLine: string
currentStage: PipelineStage
agent: string
daysAtStage: number
premium: number
formCompletion: number
createdDaysAgo: number
}
const mockDeals: MockDeal[] = [
{ id: 'd01', customerName: 'Mar\u00eda Elena P\u00e9rez', productLine: 'Auto', currentStage: 'customer', agent: 'Ana Ram\u00edrez', daysAtStage: 1, premium: 1200, formCompletion: 45, createdDaysAgo: 1 },
{ id: 'd02', customerName: 'Carlos Mendoza', productLine: 'Life', currentStage: 'customer', agent: 'Luc\u00eda Fern\u00e1ndez', daysAtStage: 3, premium: 3200, formCompletion: 20, createdDaysAgo: 3 },
{ id: 'd03', customerName: 'Laura Castillo', productLine: 'Health', currentStage: 'get_quotes', agent: 'Ana Ram\u00edrez', daysAtStage: 2, premium: 2800, formCompletion: 60, createdDaysAgo: 5 },
{ id: 'd04', customerName: 'Sof\u00eda Rojas Delgado', productLine: 'Auto', currentStage: 'get_quotes', agent: 'Luc\u00eda Fern\u00e1ndez', daysAtStage: 4, premium: 1500, formCompletion: 50, createdDaysAgo: 7 },
{ id: 'd05', customerName: 'Andr\u00e9s Vargas', productLine: 'General Risk', currentStage: 'get_quotes', agent: 'Marco Villanueva', daysAtStage: 1, premium: 8500, formCompletion: 75, createdDaysAgo: 4 },
{ id: 'd06', customerName: 'Patricia Herrera', productLine: 'Auto', currentStage: 'waiting_carriers', agent: 'Ana Ram\u00edrez', daysAtStage: 6, premium: 1100, formCompletion: 100, createdDaysAgo: 10 },
{ id: 'd07', customerName: 'Fernando L\u00f3pez', productLine: 'Life', currentStage: 'waiting_carriers', agent: 'Luc\u00eda Fern\u00e1ndez', daysAtStage: 8, premium: 4200, formCompletion: 100, createdDaysAgo: 12 },
{ id: 'd08', customerName: 'Roberto Jim\u00e9nez Mora', productLine: 'Health', currentStage: 'waiting_carriers', agent: 'Marco Villanueva', daysAtStage: 3, premium: 3100, formCompletion: 100, createdDaysAgo: 8 },
{ id: 'd09', customerName: 'Gabriela Torres', productLine: 'Auto', currentStage: 'present_quotes', agent: 'Ana Ram\u00edrez', daysAtStage: 2, premium: 1400, formCompletion: 100, createdDaysAgo: 14 },
{ id: 'd10', customerName: 'Diego Salazar', productLine: 'Life', currentStage: 'present_quotes', agent: 'Marco Villanueva', daysAtStage: 1, premium: 5600, formCompletion: 100, createdDaysAgo: 9 },
{ id: 'd11', customerName: 'Isabel Moreno', productLine: 'General Risk', currentStage: 'waiting_client', agent: 'Ana Ram\u00edrez', daysAtStage: 7, premium: 2200, formCompletion: 100, createdDaysAgo: 16 },
{ id: 'd12', customerName: 'Alejandro Rios', productLine: 'Auto', currentStage: 'waiting_client', agent: 'Luc\u00eda Fern\u00e1ndez', daysAtStage: 12, premium: 900, formCompletion: 100, createdDaysAgo: 20 },
{ id: 'd13', customerName: 'Valentina Cruz', productLine: 'Health', currentStage: 'solicitud', agent: 'Ana Ram\u00edrez', daysAtStage: 3, premium: 2700, formCompletion: 68, createdDaysAgo: 18 },
{ id: 'd14', customerName: 'Jorge Navarro', productLine: 'Life', currentStage: 'solicitud', agent: 'Marco Villanueva', daysAtStage: 2, premium: 6800, formCompletion: 85, createdDaysAgo: 15 },
{ id: 'd15', customerName: 'Camila Fuentes', productLine: 'Auto', currentStage: 'emission', agent: 'Ana Ram\u00edrez', daysAtStage: 1, premium: 1600, formCompletion: 92, createdDaysAgo: 22 },
{ id: 'd16', customerName: 'Ra\u00fal Espinoza', productLine: 'General Risk', currentStage: 'emission', agent: 'Marco Villanueva', daysAtStage: 2, premium: 7200, formCompletion: 100, createdDaysAgo: 25 },
{ id: 'd17', customerName: 'M\u00f3nica Delgado', productLine: 'Life', currentStage: 'waiting_carriers', agent: 'Ana Ram\u00edrez', daysAtStage: 11, premium: 3800, formCompletion: 100, createdDaysAgo: 15 },
{ id: 'd18', customerName: 'Enrique Paredes', productLine: 'Health', currentStage: 'customer', agent: 'Luc\u00eda Fern\u00e1ndez', daysAtStage: 6, premium: 2100, formCompletion: 30, createdDaysAgo: 6 },
]
/* ── KPI computations ── */
const totalActiveDeals = computed(() => mockDeals.length)
const totalPipelinePremium = computed(() =>
mockDeals.reduce((sum, d) => sum + d.premium, 0),
)
const stageCounts = computed(() => {
const counts: Record<PipelineStage, number> = {} as any
for (const s of PIPELINE_STAGES) counts[s.id] = 0
for (const d of mockDeals) counts[d.currentStage]++
return counts
})
const bottleneckStage = computed(() => {
let max = 0
let stage = PIPELINE_STAGES[0]
for (const s of PIPELINE_STAGES) {
if (stageCounts.value[s.id] > max) {
max = stageCounts.value[s.id]
stage = s
}
}
return stage.label
})
const maxStageCount = computed(() =>
Math.max(...PIPELINE_STAGES.map(s => stageCounts.value[s.id]), 1),
)
/* ── Funnel colors ── */
const stageColors: Record<PipelineStage, string> = {
customer: '#01696f',
get_quotes: '#0d8a8f',
waiting_carriers: '#f59e0b',
present_quotes: '#3b82f6',
waiting_client: '#f59e0b',
solicitud: '#8b5cf6',
emission: '#10b981',
}
/* ── Bottleneck deals (stuck > 5 days) ── */
const stuckDeals = computed(() =>
mockDeals
.filter(d => d.daysAtStage > 5)
.sort((a, b) => b.daysAtStage - a.daysAtStage),
)
function severityColor(days: number) {
if (days > 10) return '#ef4444'
if (days > 5) return '#f59e0b'
return '#6b7280'
}
function severityBg(days: number) {
if (days > 10) return 'rgba(239,68,68,0.08)'
if (days > 5) return 'rgba(245,158,11,0.08)'
return 'transparent'
}
/* ── Stage breakdown tab ── */
const activeStageTab = ref<PipelineStage>('customer')
const stageDeals = computed(() =>
mockDeals.filter(d => d.currentStage === activeStageTab.value),
)
function stageLabelFor(id: PipelineStage) {
return PIPELINE_STAGES.find(s => s.id === id)?.label ?? id
}
function formatCurrency(v: number) {
return '$' + v.toLocaleString('en-US')
}
const toast = useToast()
const nudgeConfirmDeal = ref<MockDeal | null>(null)
function onNudge(deal: MockDeal) {
nudgeConfirmDeal.value = deal
}
function confirmNudge() {
if (nudgeConfirmDeal.value) {
toast.add({ title: `Nudge sent to ${nudgeConfirmDeal.value.customerName}`, color: 'success' })
}
nudgeConfirmDeal.value = null
}
function cancelNudge() {
nudgeConfirmDeal.value = null
}
</script>
<template>
<div class="mc-page">
<!-- Header -->
<div class="mc-header">
<h1 class="mc-title">Mission Control</h1>
<p class="mc-subtitle">Pipeline analytics &amp; agent performance</p>
</div>
<!-- KPI Strip -->
<div class="mc-kpi-strip">
<div class="mc-card mc-kpi-card">
<span class="mc-kpi-label">Total Active Deals</span>
<span class="mc-kpi-value">{{ totalActiveDeals }}</span>
</div>
<div class="mc-card mc-kpi-card">
<span class="mc-kpi-label">Quotes This Month</span>
<span class="mc-kpi-value">24</span>
</div>
<div class="mc-card mc-kpi-card">
<span class="mc-kpi-label">Avg Days in Pipeline</span>
<span class="mc-kpi-value">8.3</span>
</div>
<div class="mc-card mc-kpi-card">
<span class="mc-kpi-label">Conversion Rate</span>
<span class="mc-kpi-value">62%</span>
</div>
<div class="mc-card mc-kpi-card">
<span class="mc-kpi-label">Pipeline Premium</span>
<span class="mc-kpi-value">{{ formatCurrency(totalPipelinePremium) }}</span>
</div>
<div class="mc-card mc-kpi-card">
<span class="mc-kpi-label">Bottleneck Stage</span>
<span class="mc-kpi-value mc-kpi-value--bottleneck">{{ bottleneckStage }}</span>
</div>
</div>
<!-- Pipeline Funnel -->
<div class="mc-card mc-section">
<h2 class="mc-section-title">Pipeline Funnel</h2>
<div class="mc-funnel">
<div
v-for="stage in PIPELINE_STAGES"
:key="stage.id"
class="mc-funnel-row"
>
<span class="mc-funnel-label">{{ stage.label }}</span>
<div class="mc-funnel-bar-track">
<div
class="mc-funnel-bar"
:style="{
width: Math.max((stageCounts[stage.id] / maxStageCount) * 100, 4) + '%',
background: stageColors[stage.id],
}"
/>
</div>
<span class="mc-funnel-count">{{ stageCounts[stage.id] }}</span>
<span class="mc-funnel-pct">
{{ totalActiveDeals ? Math.round((stageCounts[stage.id] / totalActiveDeals) * 100) : 0 }}%
</span>
</div>
</div>
</div>
<!-- Agent Performance -->
<div class="mc-card mc-section">
<h2 class="mc-section-title">Agent Performance</h2>
<div class="mc-table-wrap">
<table class="mc-table">
<thead>
<tr>
<th>Agent</th>
<th>Active Deals</th>
<th>Quotes Sent</th>
<th>Conversion</th>
<th>Avg Response</th>
<th>Pipeline Value</th>
</tr>
</thead>
<tbody>
<tr v-for="a in agents" :key="a.name">
<td class="mc-agent-name">{{ a.name }}</td>
<td>{{ a.activeDeals }}</td>
<td>{{ a.quotesSent }}</td>
<td>
<span
class="mc-badge"
:style="{ background: a.conversionRate >= 70 ? 'rgba(16,185,129,0.1)' : a.conversionRate >= 50 ? 'rgba(245,158,11,0.1)' : 'rgba(239,68,68,0.1)', color: a.conversionRate >= 70 ? '#059669' : a.conversionRate >= 50 ? '#d97706' : '#dc2626' }"
>
{{ a.conversionRate }}%
</span>
</td>
<td>{{ a.avgResponseTime }}</td>
<td>{{ formatCurrency(a.pipelineValue) }}</td>
</tr>
</tbody>
</table>
</div>
</div>
<!-- Bottleneck Analysis -->
<div class="mc-card mc-section">
<h2 class="mc-section-title">Bottleneck Analysis</h2>
<p class="mc-section-desc">Deals stuck at a stage for more than 5 days</p>
<div v-if="stuckDeals.length === 0" class="mc-empty">No bottlenecks detected.</div>
<div v-else class="mc-stuck-list">
<div
v-for="deal in stuckDeals"
:key="deal.id"
class="mc-stuck-row"
:style="{ background: severityBg(deal.daysAtStage) }"
>
<div class="mc-stuck-info">
<span class="mc-stuck-name">{{ deal.customerName }}</span>
<span class="mc-stuck-meta">
<span
class="mc-stuck-days"
:style="{ color: severityColor(deal.daysAtStage) }"
>
{{ deal.daysAtStage }}d
</span>
at {{ stageLabelFor(deal.currentStage) }}
</span>
</div>
<span class="mc-stuck-agent">{{ deal.agent }}</span>
<button class="mc-btn-nudge" @click="onNudge(deal)">Nudge</button>
</div>
</div>
</div>
<!-- Stage Breakdown -->
<div class="mc-card mc-section">
<h2 class="mc-section-title">Stage Breakdown</h2>
<div class="mc-tab-bar">
<button
v-for="stage in PIPELINE_STAGES"
:key="stage.id"
class="mc-tab"
:class="{ 'mc-tab--active': activeStageTab === stage.id }"
@click="activeStageTab = stage.id"
>
{{ stage.label }}
</button>
</div>
<div v-if="stageDeals.length === 0" class="mc-empty">
<UIcon name="i-heroicons-inbox" style="width: 20px; height: 20px; color: #c0c0bc;" />
<p>No deals at this stage</p>
</div>
<div v-else class="mc-table-wrap">
<table class="mc-table">
<thead>
<tr>
<th>Customer</th>
<th>Product Line</th>
<th>Days at Stage</th>
<th>Agent</th>
<th>Premium</th>
<th>Form Completion</th>
</tr>
</thead>
<tbody>
<tr v-for="deal in stageDeals" :key="deal.id">
<td class="mc-agent-name">{{ deal.customerName }}</td>
<td>{{ deal.productLine }}</td>
<td>
<span :style="{ color: deal.daysAtStage > 5 ? severityColor(deal.daysAtStage) : 'inherit', fontWeight: deal.daysAtStage > 5 ? 600 : 400 }">
{{ deal.daysAtStage }}d
</span>
</td>
<td>{{ deal.agent }}</td>
<td>{{ formatCurrency(deal.premium) }}</td>
<td>
<div class="mc-progress-wrap">
<div class="mc-progress-track">
<div
class="mc-progress-fill"
:style="{ width: deal.formCompletion + '%' }"
/>
</div>
<span class="mc-progress-label">{{ deal.formCompletion }}%</span>
</div>
</td>
</tr>
</tbody>
</table>
</div>
</div>
<!-- Nudge confirmation modal -->
<Teleport to="body">
<Transition
enter-active-class="transition duration-150 ease-out"
enter-from-class="opacity-0"
enter-to-class="opacity-100"
leave-active-class="transition duration-100 ease-in"
leave-from-class="opacity-100"
leave-to-class="opacity-0"
>
<div v-if="nudgeConfirmDeal" class="fixed inset-0 z-50 flex items-center justify-center bg-black/30" @click.self="cancelNudge">
<div class="w-full max-w-sm rounded-xl border border-[var(--card-border)] bg-white p-6 shadow-xl">
<div class="flex items-start gap-3">
<div class="flex h-9 w-9 shrink-0 items-center justify-center rounded-lg" style="background: rgba(1,105,111,0.08); color: #01696f;">
<UIcon name="i-heroicons-bell-alert" style="width: 18px; height: 18px;" />
</div>
<div class="min-w-0">
<p class="text-[14px] font-semibold text-[var(--text-primary)]">Send nudge?</p>
<p class="mt-1 text-[13px] text-[var(--text-muted)]">
This will send a follow-up reminder to <span class="font-semibold text-[var(--text-primary)]">{{ nudgeConfirmDeal.customerName }}</span> about their pending deal.
</p>
</div>
</div>
<div class="mt-5 flex justify-end gap-2">
<UButton color="neutral" variant="soft" size="sm" @click="cancelNudge">Cancel</UButton>
<UButton color="primary" size="sm" @click="confirmNudge">Send nudge</UButton>
</div>
</div>
</div>
</Transition>
</Teleport>
</div>
</template>
<style scoped>
/* ── Page ── */
.mc-page {
display: flex;
flex-direction: column;
gap: 20px;
max-width: 1120px;
}
/* ── Header ── */
.mc-header {
margin-bottom: 4px;
}
.mc-title {
font-size: 24px;
font-weight: 650;
letter-spacing: -0.015em;
color: var(--text-primary);
}
.mc-subtitle {
font-size: 14px;
color: #8a8a86;
margin-top: 2px;
}
/* ── Card ── */
.mc-card {
background: #fff;
border: 1px solid rgba(0, 0, 0, 0.06);
border-radius: 12px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.03);
}
/* ── KPI strip ── */
.mc-kpi-strip {
display: grid;
grid-template-columns: repeat(6, 1fr);
gap: 12px;
}
.mc-kpi-card {
display: flex;
flex-direction: column;
align-items: center;
padding: 16px 12px;
gap: 6px;
}
.mc-kpi-label {
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.04em;
color: #8a8a86;
text-align: center;
}
.mc-kpi-value {
font-size: 22px;
font-weight: 650;
color: var(--text-primary);
}
.mc-kpi-value--bottleneck {
font-size: 14px;
font-weight: 600;
color: #01696f;
}
/* ── Section ── */
.mc-section {
padding: 20px 24px;
}
.mc-section-title {
font-size: 14px;
font-weight: 700;
color: var(--text-primary);
margin-bottom: 14px;
}
.mc-section-desc {
font-size: 13px;
color: #8a8a86;
margin-top: -8px;
margin-bottom: 14px;
}
/* ── Funnel ── */
.mc-funnel {
display: flex;
flex-direction: column;
gap: 8px;
}
.mc-funnel-row {
display: grid;
grid-template-columns: 140px 1fr 36px 44px;
align-items: center;
gap: 10px;
}
.mc-funnel-label {
font-size: 13px;
color: var(--text-primary);
white-space: nowrap;
}
.mc-funnel-bar-track {
height: 22px;
background: rgba(0, 0, 0, 0.03);
border-radius: 6px;
overflow: hidden;
}
.mc-funnel-bar {
height: 100%;
border-radius: 6px;
transition: width 0.4s ease;
min-width: 4px;
}
.mc-funnel-count {
font-size: 14px;
font-weight: 600;
text-align: right;
color: var(--text-primary);
}
.mc-funnel-pct {
font-size: 12px;
color: #8a8a86;
text-align: right;
}
/* ── Table ── */
.mc-table-wrap {
overflow-x: auto;
}
.mc-table {
width: 100%;
border-collapse: collapse;
font-size: 13px;
}
.mc-table th {
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.04em;
color: #8a8a86;
font-weight: 500;
text-align: left;
padding: 0 12px 10px;
border-bottom: 1px solid rgba(0, 0, 0, 0.06);
}
.mc-table td {
padding: 10px 12px;
color: var(--text-primary);
border-bottom: 1px solid rgba(0, 0, 0, 0.04);
}
.mc-table tbody tr:last-child td {
border-bottom: none;
}
.mc-agent-name {
font-weight: 500;
}
/* ── Badge ── */
.mc-badge {
display: inline-flex;
align-items: center;
padding: 2px 8px;
border-radius: 6px;
font-size: 12px;
font-weight: 600;
}
/* ── Stuck list ── */
.mc-stuck-list {
display: flex;
flex-direction: column;
gap: 6px;
}
.mc-stuck-row {
display: flex;
align-items: center;
gap: 16px;
padding: 10px 14px;
border-radius: 8px;
border: 1px solid rgba(0, 0, 0, 0.04);
}
.mc-stuck-info {
flex: 1;
display: flex;
flex-direction: column;
gap: 2px;
}
.mc-stuck-name {
font-size: 13px;
font-weight: 500;
color: var(--text-primary);
}
.mc-stuck-meta {
font-size: 12px;
color: #8a8a86;
}
.mc-stuck-days {
font-weight: 600;
}
.mc-stuck-agent {
font-size: 12px;
color: #8a8a86;
min-width: 120px;
}
.mc-btn-nudge {
font-size: 12px;
font-weight: 500;
padding: 4px 14px;
border-radius: 6px;
border: 1px solid rgba(0, 0, 0, 0.1);
background: #fff;
color: #01696f;
cursor: pointer;
white-space: nowrap;
transition: background 0.15s;
}
.mc-btn-nudge:hover {
background: rgba(1, 105, 111, 0.06);
}
/* ── Tab bar ── */
.mc-tab-bar {
display: inline-flex;
background: rgba(0, 0, 0, 0.04);
padding: 3px;
border-radius: 10px;
gap: 2px;
margin-bottom: 16px;
flex-wrap: wrap;
}
.mc-tab {
font-size: 12px;
font-weight: 500;
padding: 5px 12px;
border-radius: 8px;
border: none;
background: transparent;
color: #6b6b68;
cursor: pointer;
white-space: nowrap;
transition: all 0.15s;
}
.mc-tab--active {
background: #fff;
color: var(--text-primary);
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.08);
}
/* ── Progress bar ── */
.mc-progress-wrap {
display: flex;
align-items: center;
gap: 8px;
}
.mc-progress-track {
flex: 1;
height: 4px;
background: rgba(0, 0, 0, 0.06);
border-radius: 2px;
overflow: hidden;
min-width: 60px;
}
.mc-progress-fill {
height: 100%;
background: #01696f;
border-radius: 2px;
transition: width 0.3s ease;
}
.mc-progress-label {
font-size: 12px;
color: #8a8a86;
min-width: 32px;
}
/* ── Empty state ── */
.mc-empty {
font-size: 13px;
color: #8a8a86;
padding: 20px 0;
text-align: center;
}
/* ── Responsive ── */
@media (max-width: 900px) {
.mc-kpi-strip {
grid-template-columns: repeat(3, 1fr);
}
}
@media (max-width: 600px) {
.mc-kpi-strip {
grid-template-columns: repeat(2, 1fr);
}
}
</style>

994
app/pages/quotes/new.vue Normal file
View 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>