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

292 lines
8.8 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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>