Files
policy-ui/app/pages/quotes/life/index.vue
Jordan Weingarten 67482f6629 WIP jordan
2026-04-16 11:11:44 -05:00

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>