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
This commit is contained in:
2026-04-27 14:56:53 -05:00
parent 67482f6629
commit a2eb1f3789
154 changed files with 10346 additions and 51 deletions

View File

@@ -9,6 +9,7 @@ import {
AUTO_YEAR_OPTIONS
} from '~/data/auto-quote-intake'
import type { AutoQuoteDraft, AutoQuoteSegment } from '~/types/auto-quote-intake'
import { useCustomerSelection } from '~/composables/useCustomerSelection'
const props = defineProps<{
draft: AutoQuoteDraft
@@ -24,36 +25,190 @@ const showOrganization = computed(
const inputPh =
'w-full placeholder:text-[var(--text-muted)] placeholder:opacity-[0.55] text-[var(--text-primary)]'
// Customer selection
const customerSearch = ref('')
const debouncedCustomerSearch = refDebounced(customerSearch, 300)
const customerPage = ref(1)
const { data: customersData, pending: customersPending } = useCustomer('/customers', {
query: computed(() => ({
'page[number]': customerPage.value,
'page[size]': 12,
...(debouncedCustomerSearch.value && {
'filters[0][field]': 'search',
'filters[0][op]': '==',
'filters[0][value]': debouncedCustomerSearch.value
})
}))
})
watch(debouncedCustomerSearch, () => { customerPage.value = 1 })
const customerItems = computed(() => customersData.value?.data ?? [])
function selectCustomer(customer: any) {
selectedCustomer.value = customer
}
function selectBuyer(customer: any) {
selectedBuyer.value = customer
}
const customerDisplayName = (c: any) =>
c.customer_type === 'corporate'
? (c.commercial_name || c.legal_name)
: `${c.first_name} ${c.last_name}`
const customerSubtitle = (c: any) =>
c.customer_type === 'corporate' ? c.ruc : c.email
// Use customer selection composable
const {
selectedCustomer,
selectedBuyer,
useSameForBuyer,
insured,
buyer,
isInsuredValid,
isBuyerValid,
validationErrors
} = useCustomerSelection()
</script>
<template>
<div class="space-y-8">
<!-- Insured Section -->
<div>
<h2 class="text-lg font-semibold text-[var(--text-primary)]">Client</h2>
<p class="mt-1 text-sm text-[var(--text-muted)]">Contact on file for this quote well use it for status and carrier emails.</p>
<div class="mt-5 grid grid-cols-1 gap-4 md:grid-cols-2">
<UFormField label="Legal name" required>
<UInput v-model="draft.client.fullName" :class="inputPh" placeholder="As on government ID" />
</UFormField>
<UFormField label="Email" required>
<UInput
v-model="draft.client.email"
type="email"
autocomplete="email"
:class="inputPh"
placeholder="name@company.com"
/>
</UFormField>
<UFormField label="Phone">
<UInput v-model="draft.client.phone" type="tel" :class="inputPh" placeholder="+593 …" />
</UFormField>
<UFormField label="Government ID">
<UInput v-model="draft.client.documentId" :class="inputPh" placeholder="Cédula, passport, or RUC" />
</UFormField>
<UFormField v-if="showOrganization" label="Organization" class="md:col-span-2">
<UInput v-model="draft.client.organizationName" :class="inputPh" placeholder="Company or fleet name" />
</UFormField>
<h2 class="text-lg font-semibold text-[var(--text-primary)]">Insured</h2>
<p class="mt-1 text-sm text-[var(--text-muted)]">Person or entity being insured we'll use this for carrier notifications.</p>
<div v-if="!selectedCustomer" class="mt-5">
<UInput
v-model="customerSearch"
icon="i-heroicons-magnifying-glass"
placeholder="Search by name, email, RUC..."
class="w-full max-w-sm mb-4"
/>
<div v-if="customersPending" class="grid grid-cols-1 md:grid-cols-3 gap-3">
<div v-for="n in 3" :key="n" class="h-16 animate-pulse bg-gray-100 rounded-lg" />
</div>
<div v-else class="space-y-3 max-h-72 overflow-y-auto">
<div
v-for="c in customerItems"
:key="c.id"
class="flex items-center gap-3 p-3 border-2 rounded-lg cursor-pointer transition-all"
:class="selectedCustomer?.id === c.id
? 'border-primary-500 bg-primary-50'
: 'border-gray-200 hover:border-gray-300 bg-white'"
@click="selectCustomer(c)"
>
<UAvatar :alt="customerDisplayName(c)" size="sm" />
<div class="min-w-0 flex-1">
<div class="flex items-center gap-1.5">
<p class="font-medium text-sm text-slate-800 truncate">{{ customerDisplayName(c) }}</p>
<UBadge
:color="c.customer_type === 'corporate' ? 'purple' : 'blue'"
variant="soft" size="xs" class="flex-shrink-0"
>
{{ c.customer_type === 'corporate' ? 'Corp' : 'Ind' }}
</UBadge>
</div>
<p class="text-xs text-gray-400 truncate">{{ customerSubtitle(c) }}</p>
</div>
<UIcon
v-if="selectedCustomer?.id === c.id"
name="i-heroicons-check-circle"
class="w-5 h-5 text-primary-500 flex-shrink-0"
/>
</div>
<div v-if="customerItems.length === 0" class="text-center py-6 text-gray-400 text-sm">
No customers found.
</div>
</div>
</div>
<div v-else class="mt-5 p-4 bg-primary-50 border border-primary-200 rounded-lg">
<div class="flex items-center justify-between">
<div class="flex items-center gap-3">
<UAvatar :alt="customerDisplayName(selectedCustomer)" size="sm" />
<div>
<p class="font-medium text-primary-800">{{ customerDisplayName(selectedCustomer) }}</p>
<p class="text-xs text-primary-600">{{ customerSubtitle(selectedCustomer) }}</p>
</div>
</div>
<UButton size="sm" color="neutral" variant="ghost" @click="selectedCustomer = null">
Change
</UButton>
</div>
</div>
</div>
<!-- Buyer Section -->
<div class="border-t border-[var(--sidebar-border)] pt-8">
<div class="flex items-center gap-3 mb-4">
<h2 class="text-lg font-semibold text-[var(--text-primary)]">Buyer</h2>
<div class="flex items-center gap-2">
<UToggle v-model="useSameForBuyer" />
<span class="text-sm text-[var(--text-muted)]">Same as insured</span>
</div>
</div>
<p v-if="useSameForBuyer" class="mt-1 text-sm text-[var(--text-muted)]">
Using same person as insured
</p>
<div v-else class="mt-5">
<UInput
v-model="customerSearch"
icon="i-heroicons-magnifying-glass"
placeholder="Search by name, email, RUC..."
class="w-full max-w-sm mb-4"
/>
<div v-if="customersPending" class="grid grid-cols-1 md:grid-cols-3 gap-3">
<div v-for="n in 3" :key="n" class="h-16 animate-pulse bg-gray-100 rounded-lg" />
</div>
<div v-else class="space-y-3 max-h-72 overflow-y-auto">
<div
v-for="c in customerItems"
:key="c.id"
class="flex items-center gap-3 p-3 border-2 rounded-lg cursor-pointer transition-all"
:class="selectedBuyer?.id === c.id
? 'border-primary-500 bg-primary-50'
: 'border-gray-200 hover:border-gray-300 bg-white'"
@click="selectBuyer(c)"
>
<UAvatar :alt="customerDisplayName(c)" size="sm" />
<div class="min-w-0 flex-1">
<div class="flex items-center gap-1.5">
<p class="font-medium text-sm text-slate-800 truncate">{{ customerDisplayName(c) }}</p>
<UBadge
:color="c.customer_type === 'corporate' ? 'purple' : 'blue'"
variant="soft" size="xs" class="flex-shrink-0"
>
{{ c.customer_type === 'corporate' ? 'Corp' : 'Ind' }}
</UBadge>
</div>
<p class="text-xs text-gray-400 truncate">{{ customerSubtitle(c) }}</p>
</div>
<UIcon
v-if="selectedBuyer?.id === c.id"
name="i-heroicons-check-circle"
class="w-5 h-5 text-primary-500 flex-shrink-0"
/>
</div>
<div v-if="customerItems.length === 0" class="text-center py-6 text-gray-400 text-sm">
No customers found.
</div>
</div>
</div>
</div>
</div>
<div class="border-t border-[var(--sidebar-border)] pt-8">
@@ -145,8 +300,20 @@ const inputPh =
<UFormField label="Capacity" description="Passengers">
<UInput v-model="draft.vehicle.capacidadPasajeros" :class="inputPh" inputmode="numeric" placeholder="—" />
</UFormField>
<UFormField label="Declared value" description="USD">
<UInput v-model="draft.vehicle.valorVehiculo" :class="inputPh" inputmode="decimal" placeholder="—" />
<UFormField label="RC limits">
<UInput v-model="draft.vehicle.rc_limits" :class="inputPh" placeholder="e.g., 100,000" />
</UFormField>
<UFormField label="Market value" description="USD">
<UInput v-model="draft.vehicle.market_value" :class="inputPh" inputmode="decimal" placeholder="—" />
</UFormField>
<UFormField label="Requested value" description="USD">
<UInput v-model="draft.vehicle.requested_value" :class="inputPh" inputmode="decimal" placeholder="—" />
</UFormField>
<UFormField label="Chassis number" description="Optional">
<UInput v-model="draft.vehicle.chassis_number" :class="inputPh" placeholder="—" />
</UFormField>
<UFormField label="Engine number" description="Optional">
<UInput v-model="draft.vehicle.engine_number" :class="inputPh" placeholder="—" />
</UFormField>
</div>
</div>

View File

@@ -4,13 +4,8 @@ export function emptyAutoQuoteDraft(): AutoQuoteDraft {
return {
quoteMode: null,
segment: null,
client: {
fullName: '',
email: '',
phone: '',
documentId: '',
organizationName: ''
},
insured: null,
buyer: null,
vehicle: {
subRamo: '',
clase: '',
@@ -20,7 +15,11 @@ export function emptyAutoQuoteDraft(): AutoQuoteDraft {
placa: '',
year: '',
capacidadPasajeros: '',
valorVehiculo: ''
rc_limits: '',
market_value: 0,
requested_value: 0,
chassis_number: '',
engine_number: ''
},
solicit: {
carrierIds: [],

View File

@@ -0,0 +1,137 @@
/**
* Composable for managing customer selection in quote flows
* Handles insured and buyer selection with validation
*/
export function useCustomerSelection() {
const selectedCustomer = ref<any>(null) // Auto-generated type from useCustomer
const useSameForBuyer = ref(true)
const selectedBuyer = ref<any>(null)
/**
* Convert customer-service customer to policy-service insured/buyer
* Maps customer fields to policy-service structure
*/
const toPolicyPerson = (customer: any) => {
if (customer.customer_type === 'corporate') {
return {
type: 'corporate',
company_name: customer.legal_name,
ruc: customer.ruc,
legal_rep_name: customer.legal_rep_name,
legal_rep_document: customer.legal_rep_document_id,
email: customer.email,
phone: customer.phone,
address: customer.address
}
}
return {
type: 'individual',
name: `${customer.first_name} ${customer.last_name}`.trim(),
date_of_birth: customer.birth_date,
document_id: customer.document_id,
email: customer.email,
phone: customer.phone,
address: customer.address
}
}
/**
* Get insured person from selected customer
*/
const insured = computed(() => {
if (!selectedCustomer.value) return null
return toPolicyPerson(selectedCustomer.value)
})
/**
* Get buyer person (either same as insured or different)
*/
const buyer = computed(() => {
if (useSameForBuyer.value) {
return insured.value
}
if (!selectedBuyer.value) return null
return toPolicyPerson(selectedBuyer.value)
})
/**
* Validate customer has required fields for policy submission
*/
const validateCustomer = (customer: any) => {
const missing: string[] = []
if (customer.customer_type === 'corporate') {
if (!customer.legal_name) missing.push('legal_name')
if (!customer.ruc) missing.push('ruc')
if (!customer.legal_rep_name) missing.push('legal_rep_name')
if (!customer.legal_rep_document_id) missing.push('legal_rep_document_id')
} else {
if (!customer.first_name) missing.push('first_name')
if (!customer.last_name) missing.push('last_name')
if (!customer.birth_date) missing.push('birth_date')
if (!customer.document_id) missing.push('document_id')
}
return { valid: missing.length === 0, missing }
}
/**
* Check if insured is valid
*/
const isInsuredValid = computed(() => {
if (!selectedCustomer.value) return false
return validateCustomer(selectedCustomer.value).valid
})
/**
* Check if buyer is valid
*/
const isBuyerValid = computed(() => {
if (useSameForBuyer.value) {
return isInsuredValid.value
}
if (!selectedBuyer.value) return false
return validateCustomer(selectedBuyer.value).valid
})
/**
* Get validation errors
*/
const validationErrors = computed(() => {
const errors: { insured: string[]; buyer: string[] } = { insured: [], buyer: [] }
if (selectedCustomer.value) {
const validation = validateCustomer(selectedCustomer.value)
errors.insured = validation.missing
}
if (!useSameForBuyer.value && selectedBuyer.value) {
const validation = validateCustomer(selectedBuyer.value)
errors.buyer = validation.missing
}
return errors
})
/**
* Reset selection
*/
function reset() {
selectedCustomer.value = null
selectedBuyer.value = null
useSameForBuyer.value = true
}
return {
selectedCustomer,
selectedBuyer,
useSameForBuyer,
insured,
buyer,
isInsuredValid,
isBuyerValid,
validationErrors,
reset
}
}

View File

@@ -0,0 +1,67 @@
/**
* Composable for policy API operations
* Handles quote submission and acceptance
*/
export function usePolicyApi() {
const { $policy } = useNuxtApp()
const toast = useToast()
const router = useRouter()
/**
* Submit a policy quote request
*/
async function submitPolicyQuote(payload: {
policy_type: 'car' | 'life' | 'fire_structure' | 'fire_contents'
insured: any
buyer: any
policy_details: any
selected_providers: Array<{ provider_id: string; email: string }>
}) {
try {
const data = await $policy('/policies', {
method: 'POST',
body: payload
}) as any
toast.add({ title: 'Quote submitted successfully', color: 'green' })
return data
} catch (e: any) {
toast.add({
title: 'Failed to submit quote',
description: e?.data?.error ?? e.message,
color: 'red'
})
throw e
}
}
/**
* Accept a quote plan and trigger solicitation
*/
async function acceptQuote(applicationId: string, acceptedPlanId: string, acceptedBy: string) {
try {
const data = await $policy(`/policies/${applicationId}/accept`, {
method: 'POST',
body: {
accepted_plan_id: acceptedPlanId,
accepted_by: acceptedBy
}
}) as any
toast.add({ title: 'Plan accepted successfully', color: 'green' })
return data
} catch (e: any) {
toast.add({
title: 'Failed to accept plan',
description: e?.data?.error ?? e.message,
color: 'red'
})
throw e
}
}
return {
submitPolicyQuote,
acceptQuote
}
}

View File

@@ -1,6 +1,8 @@
<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 })
@@ -26,6 +28,192 @@ 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',

View File

@@ -2,16 +2,6 @@ export type AutoQuoteMode = 'single' | 'comparative_pdf'
export type AutoQuoteSegment = 'individual' | 'corporate' | 'fleet'
export type AutoQuoteClient = {
fullName: string
email: string
phone: string
/** Cédula / pasaporte / ID */
documentId: string
/** Used when segment is corporate or fleet */
organizationName: string
}
export type AutoQuoteVehicle = {
subRamo: string
clase: string
@@ -21,7 +11,11 @@ export type AutoQuoteVehicle = {
placa: string
year: string | null
capacidadPasajeros: string
valorVehiculo: string
rc_limits: string
market_value: number
requested_value: number
chassis_number?: string
engine_number?: string
}
export type AutoQuoteSolicit = {
@@ -32,15 +26,16 @@ export type AutoQuoteSolicit = {
export type AutoQuoteDraft = {
quoteMode: AutoQuoteMode | null
segment: AutoQuoteSegment | null
client: AutoQuoteClient
insured: any | null
buyer: any | null
vehicle: AutoQuoteVehicle
solicit: AutoQuoteSolicit
}
export type AutoQuoteIntakePayload = {
quoteMode: AutoQuoteMode
segment: AutoQuoteSegment
client: AutoQuoteClient
vehicle: AutoQuoteVehicle
solicit: AutoQuoteSolicit
policy_type: 'car'
insured: any
buyer: any
policy_details: AutoQuoteVehicle
selected_providers: Array<{ provider_id: string; email: string }>
}