312 lines
9.6 KiB
Vue
312 lines
9.6 KiB
Vue
<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>
|