Files
policy-ui/app/pages/quotes/auto/index.vue
HaimKortovich a2eb1f3789 Add nuxt-skills and update auto quotes to use new policy API structure
- 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
2026-04-27 14:56:53 -05:00

480 lines
14 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'
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 — 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>