- Add nuxt-skills (vue, nuxt, nuxt-ui) to .claude/skills/ - Create useCustomerSelection() composable for managing insured/buyer selection - Create usePolicyApi() composable for policy API operations - Update auto quote components to use insured/buyer instead of client - Update vehicle fields: remove valorVehiculo, add market_value, requested_value, rc_limits - Make chassis_number and engine_number optional - Update auto quote types and composables to match new API structure - Update auto quote page to submit to policy API with new structure
480 lines
14 KiB
Vue
480 lines
14 KiB
Vue
<script setup lang="ts">
|
||
import { emptyAutoQuoteDraft } from '~/composables/useAutoQuoteDraft'
|
||
import type { AutoQuoteIntakePayload, AutoQuoteMode, AutoQuoteSegment } from '~/types/auto-quote-intake'
|
||
import { useCustomerSelection } from '~/composables/useCustomerSelection'
|
||
import { usePolicyApi } from '~/composables/usePolicyApi'
|
||
|
||
/** 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()
|
||
|
||
// Use customer selection composable
|
||
const {
|
||
insured,
|
||
buyer,
|
||
isInsuredValid,
|
||
isBuyerValid,
|
||
validationErrors
|
||
} = useCustomerSelection()
|
||
|
||
// Use policy API composable
|
||
const { submitPolicyQuote } = usePolicyApi()
|
||
|
||
const modeCards: { id: AutoQuoteMode; title: string; hint: string; icon: string }[] = [
|
||
{
|
||
id: 'single',
|
||
title: 'Single quote',
|
||
hint: 'One package — we'll 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 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 (!isInsuredValid.value) {
|
||
toast.add({
|
||
title: 'Complete insured information',
|
||
description: `Missing: ${validationErrors.value.insured.join(', ')}`,
|
||
color: 'warning'
|
||
})
|
||
return false
|
||
}
|
||
if (!isBuyerValid.value) {
|
||
toast.add({
|
||
title: 'Complete buyer information',
|
||
description: `Missing: ${validationErrors.value.buyer.join(', ')}`,
|
||
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 {
|
||
policy_type: 'car',
|
||
insured: insured.value,
|
||
buyer: buyer.value,
|
||
policy_details: { ...draft.vehicle },
|
||
selected_providers: draft.solicit.carrierIds.map(id => ({
|
||
provider_id: id,
|
||
email: getProviderEmail(id)
|
||
}))
|
||
}
|
||
}
|
||
|
||
function getProviderEmail(providerId: string): string {
|
||
// This would come from the providers API
|
||
// For now, return a placeholder
|
||
return `quotes@${providerId}.com`
|
||
}
|
||
|
||
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
|
||
}
|
||
|
||
// Submit to policy API
|
||
const data = await submitPolicyQuote(payload)
|
||
|
||
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'
|
||
})
|
||
|
||
// Navigate to policy detail page
|
||
await navigateTo(`/policies/${data.application_id}`)
|
||
} finally {
|
||
intakeBusy.value = false
|
||
}
|
||
}
|
||
|
||
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 — we’ll 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>
|