big refactor
This commit is contained in:
53
app/components/ArrayInput.vue
Normal file
53
app/components/ArrayInput.vue
Normal file
@@ -0,0 +1,53 @@
|
||||
<script setup lang="ts">
|
||||
const props = defineProps<{
|
||||
modelValue: string[]
|
||||
placeholder?: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [string[]]
|
||||
}>()
|
||||
|
||||
const textValue = computed({
|
||||
get: () => props.modelValue.join('\n'),
|
||||
set: (val: string) => {
|
||||
const array = val.split('\n').map(s => s.trim()).filter(s => s.length > 0)
|
||||
emit('update:modelValue', array)
|
||||
}
|
||||
})
|
||||
|
||||
function removeTag(index: number) {
|
||||
const newValue = [...props.modelValue]
|
||||
newValue.splice(index, 1)
|
||||
emit('update:modelValue', newValue)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="space-y-2">
|
||||
<div v-if="modelValue.length > 0" class="flex flex-wrap gap-2">
|
||||
<UBadge
|
||||
v-for="(item, index) in modelValue"
|
||||
:key="index"
|
||||
color="neutral"
|
||||
variant="soft"
|
||||
class="flex items-center gap-1"
|
||||
>
|
||||
{{ item }}
|
||||
<UButton
|
||||
icon="i-heroicons-x-mark"
|
||||
size="xs"
|
||||
color="neutral"
|
||||
variant="ghost"
|
||||
@click="removeTag(index)"
|
||||
/>
|
||||
</UBadge>
|
||||
</div>
|
||||
<UTextarea
|
||||
:model-value="textValue"
|
||||
@update:model-value="textValue = $event"
|
||||
:placeholder="placeholder"
|
||||
:rows="4"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
189
app/components/CustomerSelector.vue
Normal file
189
app/components/CustomerSelector.vue
Normal file
@@ -0,0 +1,189 @@
|
||||
<script setup lang="ts">
|
||||
const props = defineProps<{
|
||||
modelValue: {
|
||||
selectedCustomer: any
|
||||
useSameForBuyer: boolean
|
||||
selectedBuyer: any
|
||||
}
|
||||
search: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: {
|
||||
selectedCustomer: any
|
||||
useSameForBuyer: boolean
|
||||
selectedBuyer: any
|
||||
}]
|
||||
'update:search': [value: string]
|
||||
}>()
|
||||
|
||||
const customerPage = ref(1)
|
||||
const localSearch = ref(props.search)
|
||||
const debouncedSearch = refDebounced(localSearch, 300)
|
||||
|
||||
watch(() => props.search, (newVal) => {
|
||||
localSearch.value = newVal
|
||||
})
|
||||
|
||||
const { data: customersData, pending: customersPending } = useCustomer('/customers', {
|
||||
query: computed(() => ({
|
||||
'page_size': 12,
|
||||
'page': customerPage.value,
|
||||
...(debouncedSearch.value && {
|
||||
'filters[0][field]': 'search',
|
||||
'filters[0][op]': '==',
|
||||
'filters[0][value]': debouncedSearch.value
|
||||
})
|
||||
}))
|
||||
})
|
||||
|
||||
function customerDisplayName(c: any) {
|
||||
if (!c) return ''
|
||||
return c.customer_type === 'corporate'
|
||||
? (c.commercial_name || c.legal_name)
|
||||
: `${c.first_name} ${c.last_name}`
|
||||
}
|
||||
|
||||
function customerSubtitle(c: any) {
|
||||
if (!c) return ''
|
||||
return c.customer_type === 'corporate' ? c.ruc : c.email
|
||||
}
|
||||
|
||||
function updateSelectedCustomer(customer: any) {
|
||||
emit('update:modelValue', {
|
||||
...props.modelValue,
|
||||
selectedCustomer: customer,
|
||||
selectedBuyer: props.modelValue.useSameForBuyer ? customer : props.modelValue.selectedBuyer
|
||||
})
|
||||
}
|
||||
|
||||
function updateSelectedBuyer(customer: any) {
|
||||
emit('update:modelValue', {
|
||||
...props.modelValue,
|
||||
selectedBuyer: customer
|
||||
})
|
||||
}
|
||||
|
||||
function updateUseSameForBuyer(value: boolean) {
|
||||
emit('update:modelValue', {
|
||||
...props.modelValue,
|
||||
useSameForBuyer: value,
|
||||
selectedBuyer: value ? props.modelValue.selectedCustomer : props.modelValue.selectedBuyer
|
||||
})
|
||||
}
|
||||
|
||||
function onSearchInput(value: string) {
|
||||
localSearch.value = value
|
||||
emit('update:search', value)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="space-y-6">
|
||||
<!-- Insured Section -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-[var(--text-primary)] mb-2">Insured</label>
|
||||
<p class="text-sm text-[var(--text-muted)] mb-4">Person or entity being insured</p>
|
||||
|
||||
<div v-if="!modelValue.selectedCustomer">
|
||||
<UInput
|
||||
:model-value="localSearch"
|
||||
@update:model-value="onSearchInput"
|
||||
placeholder="Search by name, email, RUC..."
|
||||
size="lg"
|
||||
icon="i-heroicons-magnifying-glass"
|
||||
/>
|
||||
|
||||
<div v-if="customersPending" class="mt-4 text-center text-[var(--text-muted)]">
|
||||
Loading customers...
|
||||
</div>
|
||||
|
||||
<div v-else-if="customersData?.data && customersData.data.length > 0" class="mt-4 space-y-2">
|
||||
<div
|
||||
v-for="c in customersData.data"
|
||||
:key="c.id"
|
||||
class="p-4 border border-[var(--card-border)] rounded-lg cursor-pointer hover:bg-[var(--card-border)] transition-colors"
|
||||
@click="updateSelectedCustomer(c)"
|
||||
>
|
||||
<div class="flex items-center gap-3">
|
||||
<UBadge :color="c.customer_type === 'corporate' ? 'purple' : 'blue'" variant="soft" size="sm">
|
||||
{{ c.customer_type === 'corporate' ? 'Corp' : 'Ind' }}
|
||||
</UBadge>
|
||||
<span class="font-medium text-[var(--text-primary)]">{{ customerDisplayName(c) }}</span>
|
||||
</div>
|
||||
<div class="text-sm text-[var(--text-muted)] mt-1">{{ customerSubtitle(c) }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else class="mt-4 text-center text-[var(--text-muted)]">
|
||||
No customers found. Try a different search term.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else class="p-4 bg-[var(--brand-soft)] border border-[var(--brand)] rounded-lg">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<div class="flex items-center gap-3">
|
||||
<UBadge :color="modelValue.selectedCustomer.customer_type === 'corporate' ? 'purple' : 'blue'" variant="soft" size="sm">
|
||||
{{ modelValue.selectedCustomer.customer_type === 'corporate' ? 'Corp' : 'Ind' }}
|
||||
</UBadge>
|
||||
<span class="font-medium text-[var(--text-primary)]">{{ customerDisplayName(modelValue.selectedCustomer) }}</span>
|
||||
</div>
|
||||
<div class="text-sm text-[var(--brand)] mt-1">{{ customerSubtitle(modelValue.selectedCustomer) }}</div>
|
||||
</div>
|
||||
<UButton size="sm" color="neutral" variant="outline" @click="updateSelectedCustomer(null)">Change</UButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Buyer Section -->
|
||||
<div>
|
||||
<div class="flex items-center gap-3 mb-2">
|
||||
<label class="block text-sm font-medium text-[var(--text-primary)]">Buyer</label>
|
||||
<div class="flex items-center gap-2">
|
||||
<UToggle :model-value="modelValue.useSameForBuyer" @update:model-value="updateUseSameForBuyer" />
|
||||
<span class="text-sm text-[var(--text-muted)]">Same as insured</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p v-if="modelValue.useSameForBuyer" class="text-sm text-[var(--text-muted)]">
|
||||
Using same person as insured
|
||||
</p>
|
||||
|
||||
<div v-else>
|
||||
<UInput
|
||||
:model-value="localSearch"
|
||||
@update:model-value="onSearchInput"
|
||||
placeholder="Search by name, email, RUC..."
|
||||
size="lg"
|
||||
icon="i-heroicons-magnifying-glass"
|
||||
/>
|
||||
|
||||
<div v-if="customersPending" class="mt-4 text-center text-[var(--text-muted)]">
|
||||
Loading customers...
|
||||
</div>
|
||||
|
||||
<div v-else-if="customersData?.data && customersData.data.length > 0" class="mt-4 space-y-2">
|
||||
<div
|
||||
v-for="c in customersData.data"
|
||||
:key="c.id"
|
||||
class="p-4 border border-[var(--card-border)] rounded-lg cursor-pointer hover:bg-[var(--card-border)] transition-colors"
|
||||
@click="updateSelectedBuyer(c)"
|
||||
>
|
||||
<div class="flex items-center gap-3">
|
||||
<UBadge :color="c.customer_type === 'corporate' ? 'purple' : 'blue'" variant="soft" size="sm">
|
||||
{{ c.customer_type === 'corporate' ? 'Corp' : 'Ind' }}
|
||||
</UBadge>
|
||||
<span class="font-medium text-[var(--text-primary)]">{{ customerDisplayName(c) }}</span>
|
||||
</div>
|
||||
<div class="text-sm text-[var(--text-muted)] mt-1">{{ customerSubtitle(c) }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else class="mt-4 text-center text-[var(--text-muted)]">
|
||||
No customers found. Try a different search term.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
81
app/components/ProviderSelector.vue
Normal file
81
app/components/ProviderSelector.vue
Normal file
@@ -0,0 +1,81 @@
|
||||
<script setup lang="ts">
|
||||
const props = defineProps<{
|
||||
selected: string[]
|
||||
search: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:selected': [value: string[]]
|
||||
'update:search': [value: string]
|
||||
}>()
|
||||
|
||||
const providerPage = ref(1)
|
||||
const localSearch = ref(props.search)
|
||||
const debouncedSearch = refDebounced(localSearch, 300)
|
||||
|
||||
watch(() => props.search, (newVal) => {
|
||||
localSearch.value = newVal
|
||||
})
|
||||
|
||||
const { data: providersData, pending: providersPending } = useProviders('/providers', {
|
||||
query: computed(() => ({
|
||||
'page_size': 12,
|
||||
'page': providerPage.value,
|
||||
...(debouncedSearch.value && {
|
||||
'filters[0][field]': 'search',
|
||||
'filters[0][op]': '==',
|
||||
'filters[0][value]': debouncedSearch.value
|
||||
})
|
||||
}))
|
||||
})
|
||||
|
||||
function toggleProvider(providerId: string) {
|
||||
const newValue = props.selected.includes(providerId)
|
||||
? props.selected.filter(id => id !== providerId)
|
||||
: [...props.selected, providerId]
|
||||
emit('update:selected', newValue)
|
||||
}
|
||||
|
||||
function onSearchInput(value: string) {
|
||||
localSearch.value = value
|
||||
emit('update:search', value)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="space-y-4">
|
||||
<label class="block text-sm font-medium text-[var(--text-primary)] mb-2">Select Providers</label>
|
||||
<p class="text-sm text-[var(--text-muted)] mb-4">Choose insurance providers to get quotes from</p>
|
||||
|
||||
<UInput
|
||||
:model-value="localSearch"
|
||||
@update:model-value="onSearchInput"
|
||||
placeholder="Search providers..."
|
||||
size="lg"
|
||||
icon="i-heroicons-magnifying-glass"
|
||||
/>
|
||||
|
||||
<div v-if="providersPending" class="text-center text-[var(--text-muted)] py-4">
|
||||
Loading providers...
|
||||
</div>
|
||||
|
||||
<div v-else-if="providersData?.data && providersData.data.length > 0" class="space-y-2">
|
||||
<div
|
||||
v-for="provider in providersData.data"
|
||||
:key="provider.provider_id"
|
||||
class="flex items-center gap-3 p-4 border border-[var(--card-border)] rounded-lg hover:bg-[var(--card-border)] transition-colors"
|
||||
>
|
||||
<UCheckbox
|
||||
:model-value="selected.includes(provider.provider_id)"
|
||||
@update:model-value="toggleProvider(provider.provider_id)"
|
||||
size="lg"
|
||||
/>
|
||||
<span class="font-medium text-[var(--text-primary)]">{{ provider.name }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else class="text-center text-[var(--text-muted)] py-4">
|
||||
No providers found. Try a different search term.
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,20 +1,27 @@
|
||||
<script setup lang="ts">
|
||||
import type { AppThemeId } from '~/types/app-theme'
|
||||
const colorMode = useColorMode()
|
||||
const isDark = computed({
|
||||
get () {
|
||||
return colorMode.value === 'dark'
|
||||
},
|
||||
set () {
|
||||
colorMode.preference = colorMode.value === 'dark' ? 'light' : 'dark'
|
||||
}
|
||||
})
|
||||
|
||||
const { themeId, themeOptions, applyTheme } = useAppTheme()
|
||||
const themeOptions = [
|
||||
{ id: 'light', label: 'Light', description: 'Clean and bright interface' },
|
||||
{ id: 'dark', label: 'Dark', description: 'Easy on the eyes in low light' },
|
||||
]
|
||||
|
||||
const themeGradients: Record<string, string> = {
|
||||
light: 'from-sky-100 via-blue-50 to-indigo-100',
|
||||
purple: 'from-violet-100 via-fuchsia-50 to-purple-100',
|
||||
dark: 'from-slate-700 via-slate-800 to-slate-900',
|
||||
'dark-purple': 'from-violet-900 via-purple-950 to-slate-900'
|
||||
}
|
||||
|
||||
const themeIcons: Record<string, string> = {
|
||||
light: 'i-heroicons-sun',
|
||||
purple: 'i-heroicons-sparkles',
|
||||
dark: 'i-heroicons-moon',
|
||||
'dark-purple': 'i-heroicons-star'
|
||||
dark: 'i-heroicons-moon'
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -34,15 +41,15 @@ const themeIcons: Record<string, string> = {
|
||||
:key="opt.id"
|
||||
type="button"
|
||||
class="app-theme-card text-left"
|
||||
:class="themeId === opt.id ? 'app-theme-card-selected' : ''"
|
||||
@click="applyTheme(opt.id as AppThemeId)"
|
||||
:class="colorMode === opt.id ? 'app-theme-card-selected' : ''"
|
||||
@click="colorMode.preference = opt.id"
|
||||
>
|
||||
<!-- Header -->
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<div class="flex items-center gap-2.5">
|
||||
<div
|
||||
class="flex h-8 w-8 items-center justify-center rounded-lg"
|
||||
:class="themeId === opt.id ? 'bg-[var(--brand-soft)] text-[var(--brand)]' : 'bg-[var(--badge-muted-bg)] text-[var(--text-muted)]'"
|
||||
:class="colorMode === opt.id ? 'bg-[var(--brand-soft)] text-[var(--brand)]' : 'bg-[var(--badge-muted-bg)] text-[var(--text-muted)]'"
|
||||
>
|
||||
<UIcon :name="themeIcons[opt.id]" class="h-4 w-4" />
|
||||
</div>
|
||||
@@ -60,7 +67,7 @@ const themeIcons: Record<string, string> = {
|
||||
leave-to-class="opacity-0 scale-75"
|
||||
>
|
||||
<UIcon
|
||||
v-if="themeId === opt.id"
|
||||
v-if="colorMode === opt.id"
|
||||
name="i-heroicons-check-circle-solid"
|
||||
class="h-6 w-6 shrink-0 text-[var(--brand)]"
|
||||
/>
|
||||
@@ -76,7 +83,7 @@ const themeIcons: Record<string, string> = {
|
||||
<!-- Component preview (scoped to this theme) -->
|
||||
<div
|
||||
class="mt-3 rounded-lg border border-[var(--sidebar-border)] bg-[var(--page-bg)] p-3"
|
||||
:data-theme="opt.id"
|
||||
:class="opt.id === 'dark' ? 'dark' : ''"
|
||||
>
|
||||
<div class="space-y-3">
|
||||
<!-- Buttons row -->
|
||||
@@ -119,7 +126,9 @@ const themeIcons: Record<string, string> = {
|
||||
<p>
|
||||
Theme applies instantly to all pages including sidebar navigation, cards, inputs, buttons, badges, and KPI panels.
|
||||
You can also quickly switch themes from the
|
||||
<UIcon name="i-heroicons-swatch" class="inline h-3.5 w-3.5" />
|
||||
<UIcon name="i-heroicons-sun" class="inline h-3.5 w-3.5" />
|
||||
/
|
||||
<UIcon name="i-heroicons-moon" class="inline h-3.5 w-3.5" />
|
||||
icon in the top bar.
|
||||
</p>
|
||||
</div>
|
||||
@@ -127,3 +136,27 @@ const themeIcons: Record<string, string> = {
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.app-theme-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
padding: 1rem;
|
||||
border-radius: 0.75rem;
|
||||
border: 1px solid var(--card-border);
|
||||
background: var(--surface);
|
||||
transition: all 0.15s ease;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.app-theme-card:hover {
|
||||
border-color: rgba(1, 105, 111, 0.2);
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04);
|
||||
}
|
||||
|
||||
.app-theme-card-selected {
|
||||
border-color: var(--brand);
|
||||
background: rgba(1, 105, 111, 0.02);
|
||||
}
|
||||
</style>
|
||||
|
||||
153
app/components/back-office/KanbanColumn.vue
Normal file
153
app/components/back-office/KanbanColumn.vue
Normal file
@@ -0,0 +1,153 @@
|
||||
<script setup lang="ts">
|
||||
import KanbanTaskCard from './KanbanTaskCard.vue'
|
||||
|
||||
const props = defineProps<{
|
||||
title: string
|
||||
status: string
|
||||
tasks: any[]
|
||||
loading: boolean
|
||||
}>()
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
function navigateToTask(task: any) {
|
||||
router.push(`/back-office/workload/${encodeURIComponent(task.id)}`)
|
||||
}
|
||||
|
||||
// Stage configuration for colors
|
||||
const stageConfig: Record<string, {
|
||||
color: string;
|
||||
dot: string;
|
||||
headerBg: string;
|
||||
}> = {
|
||||
created: {
|
||||
color: 'text-[var(--text-muted)]',
|
||||
dot: 'bg-[var(--text-muted)]',
|
||||
headerBg: 'bg-[var(--surface)] border-[var(--card-border)]'
|
||||
},
|
||||
draft: {
|
||||
color: 'text-[var(--brand)]',
|
||||
dot: 'bg-[var(--brand)]',
|
||||
headerBg: 'bg-[var(--brand-faint)] border-[var(--brand-soft)]'
|
||||
},
|
||||
approved: {
|
||||
color: 'text-emerald-700',
|
||||
dot: 'bg-emerald-500',
|
||||
headerBg: 'bg-emerald-50 border-emerald-200'
|
||||
},
|
||||
completed: {
|
||||
color: 'text-gray-500',
|
||||
dot: 'bg-gray-400',
|
||||
headerBg: 'bg-gray-50 border-gray-200'
|
||||
}
|
||||
}
|
||||
|
||||
function daysInStage(createdAt: string): string {
|
||||
const days = Math.floor((Date.now() - new Date(createdAt).getTime()) / (1000 * 60 * 60 * 24))
|
||||
return days === 0 ? 'Today' : `${days}d`
|
||||
}
|
||||
|
||||
function taskUrgent(task: any): boolean {
|
||||
// Placeholder - will be based on priority/due_date from backend
|
||||
return false
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="kanban-column">
|
||||
<div class="column-header" :class="stageConfig[status].headerBg">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="h-2 w-2 rounded-full" :class="stageConfig[status].dot" />
|
||||
<span class="text-[13px] font-semibold" :class="stageConfig[status].color">
|
||||
{{ title }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-1.5">
|
||||
<span class="flex h-5 min-w-[20px] items-center justify-center rounded-full bg-[var(--surface)]/70 px-1.5 text-[11px] font-semibold text-[var(--text-muted)] ring-1 ring-[var(--card-border)]/60">
|
||||
{{ tasks.length }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="task-list">
|
||||
<KanbanTaskCard
|
||||
v-for="task in tasks"
|
||||
:key="task.id"
|
||||
:task="task"
|
||||
:urgent="taskUrgent(task)"
|
||||
@click="navigateToTask(task)"
|
||||
/>
|
||||
|
||||
<div v-if="tasks.length === 0 && !loading" class="empty-state">
|
||||
<UIcon name="i-heroicons-inbox" class="w-8 h-8" />
|
||||
</div>
|
||||
|
||||
<div v-if="loading" class="loading-state">
|
||||
<div v-for="n in 3" :key="n" class="skeleton-card" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.kanban-column {
|
||||
flex: 0 0 240px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: var(--surface);
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--card-border);
|
||||
overflow: hidden;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.column-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 8px 12px;
|
||||
border-bottom: 1px solid var(--card-border);
|
||||
background: var(--surface);
|
||||
border-radius: 8px 8px 0 0;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.task-list {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
padding: 8px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 32px 16px;
|
||||
color: var(--text-muted);
|
||||
min-height: 100%;
|
||||
}
|
||||
|
||||
.loading-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.skeleton-card {
|
||||
height: 100px;
|
||||
background: var(--card-border);
|
||||
border-radius: 6px;
|
||||
animation: pulse 1.5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%, 100% { opacity: 0.6; }
|
||||
50% { opacity: 1; }
|
||||
}
|
||||
</style>
|
||||
110
app/components/back-office/KanbanTaskCard.vue
Normal file
110
app/components/back-office/KanbanTaskCard.vue
Normal file
@@ -0,0 +1,110 @@
|
||||
<script setup lang="ts">
|
||||
const props = defineProps<{
|
||||
task: any
|
||||
urgent?: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
click: [task: any]
|
||||
}>()
|
||||
|
||||
const policyTypeColors: Record<string, string> = {
|
||||
car: 'bg-blue-100 text-blue-700',
|
||||
life: 'bg-violet-100 text-violet-700',
|
||||
fire: 'bg-amber-100 text-amber-700'
|
||||
}
|
||||
|
||||
function onClick() {
|
||||
emit('click', props.task)
|
||||
}
|
||||
|
||||
function daysInStage(createdAt: string): string {
|
||||
const days = Math.floor((Date.now() - new Date(createdAt).getTime()) / (1000 * 60 * 60 * 24))
|
||||
return days === 0 ? 'Today' : `${days}d`
|
||||
}
|
||||
|
||||
function formatDate(date: string, format: 'short' = 'short'): string {
|
||||
if (!date) return '—'
|
||||
const d = new Date(date)
|
||||
if (format === 'short') {
|
||||
return d.toLocaleDateString('es-PA', { month: 'short', day: 'numeric' })
|
||||
}
|
||||
return d.toLocaleDateString('es-PA', {
|
||||
day: '2-digit',
|
||||
month: 'short',
|
||||
year: 'numeric'
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="kanban-task-card rounded-xl border px-3.5 py-3 shadow-sm ring-1 transition hover:shadow-md"
|
||||
:class="urgent
|
||||
? 'border-rose-300 bg-rose-50/50 ring-rose-100 hover:border-rose-400'
|
||||
: 'border-[var(--card-border)] bg-[var(--surface)] ring-[var(--surface)] hover:border-[var(--brand)]/30'"
|
||||
@click="onClick"
|
||||
>
|
||||
<!-- Header row: Application ID + Policy type badge -->
|
||||
<div class="flex items-start justify-between gap-1 mb-1">
|
||||
<p class="text-[13px] font-mono font-semibold leading-tight text-[var(--text-primary)] truncate">
|
||||
{{ task.application_id }}
|
||||
</p>
|
||||
<span
|
||||
class="ml-1 shrink-0 rounded-md px-1.5 py-0.5 text-[10px] font-semibold"
|
||||
:class="policyTypeColors[task.policy_type] || 'bg-gray-100 text-gray-700'"
|
||||
>
|
||||
{{ task.policy_type?.toUpperCase() }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Provider name -->
|
||||
<p class="text-[12px] text-[var(--text-muted)] truncate">
|
||||
{{ task.provider_name || 'Unknown Provider' }}
|
||||
</p>
|
||||
|
||||
<!-- Urgent flag -->
|
||||
<span v-if="urgent" class="mt-1.5 inline-flex items-center gap-1 rounded-full bg-rose-100 px-1.5 py-0.5 text-[9px] font-bold uppercase tracking-wide text-rose-600">
|
||||
<span class="h-1.5 w-1.5 rounded-full bg-rose-500" />
|
||||
Urgent
|
||||
</span>
|
||||
|
||||
<!-- Meta row -->
|
||||
<div class="mt-2.5 flex items-center justify-between border-t border-[var(--divider)] pt-2">
|
||||
<div class="flex items-center gap-1">
|
||||
<!-- Assignee -->
|
||||
<div v-if="task.assignee" class="flex items-center gap-1 text-[11px] text-[var(--text-muted)]">
|
||||
<UIcon name="i-heroicons-user" class="w-3 h-3" />
|
||||
<span class="truncate max-w-[60px]">{{ task.assignee.name }}</span>
|
||||
</div>
|
||||
<div v-else class="flex items-center gap-1 text-[11px] text-[var(--text-muted)] opacity-50">
|
||||
<UIcon name="i-heroicons-user-minus" class="w-3 h-3" />
|
||||
<span>Unassigned</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-1.5 text-[11px] text-[var(--text-muted)]">
|
||||
<!-- Due date if present -->
|
||||
<span v-if="task.due_date" class="truncate">
|
||||
Due: {{ formatDate(task.due_date, 'short') }}
|
||||
</span>
|
||||
<!-- Days in stage -->
|
||||
<span class="flex items-center gap-0.5">
|
||||
<UIcon name="i-heroicons-clock" class="w-3 h-3" />
|
||||
{{ daysInStage(task.created_at) }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.kanban-task-card {
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.kanban-task-card:hover {
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
</style>
|
||||
|
||||
118
app/components/back-office/NestedJsonViewer.vue
Normal file
118
app/components/back-office/NestedJsonViewer.vue
Normal file
@@ -0,0 +1,118 @@
|
||||
<script setup lang="ts">
|
||||
defineOptions({ name: 'NestedJsonViewer' })
|
||||
|
||||
interface Props {
|
||||
data: any
|
||||
depth?: number
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
|
||||
const depth = computed(() => props.depth || 0)
|
||||
|
||||
// Font size decreases with depth: 12px base, -1px per level
|
||||
const fontSize = computed(() => Math.max(10, 12 - depth.value * 1))
|
||||
|
||||
// Padding decreases with depth
|
||||
const padding = computed(() => Math.max(1, 4 - depth.value))
|
||||
|
||||
function isObject(value: any): boolean {
|
||||
return value !== null && typeof value === 'object' && !Array.isArray(value)
|
||||
}
|
||||
|
||||
function isArray(value: any): boolean {
|
||||
return Array.isArray(value)
|
||||
}
|
||||
|
||||
function isDateString(value: string): boolean {
|
||||
if (typeof value !== 'string') return false
|
||||
return /^\d{4}-\d{2}-\d{2}/.test(value) && !isNaN(Date.parse(value))
|
||||
}
|
||||
|
||||
function formatValue(value: any): string {
|
||||
if (value === null || value === undefined) return '—'
|
||||
if (typeof value === 'boolean') return value ? 'Yes' : 'No'
|
||||
if (typeof value === 'number') return value.toLocaleString()
|
||||
if (isDateString(value)) {
|
||||
try {
|
||||
return new Date(value).toLocaleDateString('es-PA', {
|
||||
day: '2-digit',
|
||||
month: 'short',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
})
|
||||
} catch {
|
||||
return value
|
||||
}
|
||||
}
|
||||
return String(value)
|
||||
}
|
||||
|
||||
function formatKey(key: string): string {
|
||||
return key.replace(/_/g, ' ').replace(/\b\w/g, l => l.toUpperCase())
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="nested-json-viewer" :style="{ fontSize: fontSize + 'px' }">
|
||||
<!-- Object: render as table -->
|
||||
<table v-if="isObject(data) && Object.keys(data).length > 0" class="json-table border rounded overflow-hidden mb-2">
|
||||
<tbody>
|
||||
<tr v-for="(value, key) in data" :key="key" class="border-t border-gray-200 hover:bg-gray-50">
|
||||
<td class="field-name px-3 py-2 font-medium text-gray-600 align-top whitespace-nowrap" :style="{ padding: padding + 'px', fontSize: (fontSize - 1) + 'px' }">
|
||||
{{ formatKey(key) }}
|
||||
</td>
|
||||
<td class="field-value px-3 py-2 text-gray-900 align-top" :style="{ padding: padding + 'px' }">
|
||||
<NestedJsonViewer :data="value" :depth="depth + 1" />
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<div v-else-if="isObject(data) && Object.keys(data).length === 0" class="text-gray-400 italic" :style="{ fontSize: fontSize + 'px' }">
|
||||
Empty object
|
||||
</div>
|
||||
|
||||
<!-- Array -->
|
||||
<div v-else-if="isArray(data)" class="array-container">
|
||||
<div v-for="(item, index) in data" :key="index" class="array-item mb-2 border rounded p-2">
|
||||
<div class="text-xs font-semibold text-gray-500 mb-1">[{{ index }}]</div>
|
||||
<NestedJsonViewer :data="item" :depth="depth + 1" />
|
||||
</div>
|
||||
<div v-if="data.length === 0" class="text-gray-400 italic" :style="{ fontSize: fontSize + 'px' }">
|
||||
Empty array
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Primitive -->
|
||||
<span v-else class="primitive-value" :class="{ 'text-gray-400': data === '—' }">
|
||||
{{ formatValue(data) }}
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.nested-json-viewer {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.json-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
background: white;
|
||||
}
|
||||
|
||||
.field-name {
|
||||
min-width: 120px;
|
||||
background: #f9fafb;
|
||||
}
|
||||
|
||||
.field-value {
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.array-item {
|
||||
background: white;
|
||||
border-color: var(--card-border, #e5e7eb);
|
||||
}
|
||||
</style>
|
||||
42
app/components/back-office/QuoteTaskInfoDisplay.vue
Normal file
42
app/components/back-office/QuoteTaskInfoDisplay.vue
Normal file
@@ -0,0 +1,42 @@
|
||||
<script setup lang="ts">
|
||||
interface Props {
|
||||
task: any // TODO: Type properly
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
|
||||
const formatDate = (d: string) => d
|
||||
? new Date(d).toLocaleDateString('es-PA', { day: '2-digit', month: 'short', year: 'numeric', hour: '2-digit', minute: '2-digit' })
|
||||
: '—'
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="space-y-4 text-sm">
|
||||
<!-- Basic Info -->
|
||||
<div class="grid grid-cols-2 gap-x-4 gap-y-2">
|
||||
<div class="text-gray-500">Task ID</div>
|
||||
<div class="font-mono text-xs text-right">{{ task.id }}</div>
|
||||
|
||||
<div class="text-gray-500">Application ID</div>
|
||||
<div class="font-mono text-xs text-right">{{ task.application_id }}</div>
|
||||
|
||||
<div class="text-gray-500">Provider ID</div>
|
||||
<div class="font-mono text-xs text-right">{{ task.task_info?.provider_id }}</div>
|
||||
|
||||
<div v-if="task.task_info?.provider_name" class="text-gray-500">Provider</div>
|
||||
<div v-if="task.task_info?.provider_name" class="text-right">{{ task.task_info.provider_name }}</div>
|
||||
|
||||
<div class="text-gray-500">Org</div>
|
||||
<div class="font-mono text-xs text-right">{{ task.org_id }}</div>
|
||||
|
||||
<div class="text-gray-500">Created</div>
|
||||
<div class="text-right">{{ formatDate(task.created_at) }}</div>
|
||||
|
||||
<div class="text-gray-500">Updated</div>
|
||||
<div class="text-right">{{ formatDate(task.updated_at) }}</div>
|
||||
|
||||
<div class="text-gray-500">Policy Type</div>
|
||||
<div class="text-right uppercase text-xs">{{ task.task_info?.policy_type || '—' }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
74
app/components/back-office/SolicitationTaskInfoDisplay.vue
Normal file
74
app/components/back-office/SolicitationTaskInfoDisplay.vue
Normal file
@@ -0,0 +1,74 @@
|
||||
<script setup lang="ts">
|
||||
interface Props {
|
||||
task: any // TODO: Type properly
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="space-y-6">
|
||||
<!-- Applicant Info -->
|
||||
<div v-if="task.task_info?.applicant_info">
|
||||
<h3 class="text-xs font-semibold text-gray-500 uppercase mb-2">Applicant Information</h3>
|
||||
<div class="border rounded-lg overflow-hidden">
|
||||
<table class="w-full text-sm">
|
||||
<thead class="bg-gray-50">
|
||||
<tr>
|
||||
<th class="px-3 py-2 text-left text-xs font-medium text-gray-500 uppercase">Field</th>
|
||||
<th class="px-3 py-2 text-left text-xs font-medium text-gray-500 uppercase">Value</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-200">
|
||||
<tr v-for="(value, key) in task.task_info.applicant_info" :key="key">
|
||||
<td class="px-3 py-2 text-gray-600 font-medium capitalize">{{ key.replace(/_/g, ' ') }}</td>
|
||||
<td class="px-3 py-2 text-gray-900">{{ value || '—' }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Vehicle Info -->
|
||||
<div v-if="task.task_info?.vehicle_info">
|
||||
<h3 class="text-xs font-semibold text-gray-500 uppercase mb-2">Vehicle Information</h3>
|
||||
<div class="border rounded-lg overflow-hidden">
|
||||
<table class="w-full text-sm">
|
||||
<thead class="bg-gray-50">
|
||||
<tr>
|
||||
<th class="px-3 py-2 text-left text-xs font-medium text-gray-500 uppercase">Field</th>
|
||||
<th class="px-3 py-2 text-left text-xs font-medium text-gray-500 uppercase">Value</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-200">
|
||||
<tr v-for="(value, key) in task.task_info.vehicle_info" :key="key">
|
||||
<td class="px-3 py-2 text-gray-600 font-medium capitalize">{{ key.replace(/_/g, ' ') }}</td>
|
||||
<td class="px-3 py-2 text-gray-900">{{ value || '—' }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Policy Details -->
|
||||
<div v-if="task.task_info?.policy_details">
|
||||
<h3 class="text-xs font-semibold text-gray-500 uppercase mb-2">Policy Details</h3>
|
||||
<div class="border rounded-lg overflow-hidden">
|
||||
<table class="w-full text-sm">
|
||||
<thead class="bg-gray-50">
|
||||
<tr>
|
||||
<th class="px-3 py-2 text-left text-xs font-medium text-gray-500 uppercase">Field</th>
|
||||
<th class="px-3 py-2 text-left text-xs font-medium text-gray-500 uppercase">Value</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-200">
|
||||
<tr v-for="(value, key) in task.task_info.policy_details" :key="key">
|
||||
<td class="px-3 py-2 text-gray-600 font-medium capitalize">{{ key.replace(/_/g, ' ') }}</td>
|
||||
<td class="px-3 py-2 text-gray-900">{{ value || '—' }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -3,15 +3,13 @@
|
||||
* Global command palette — searches customers, policies, claims, and app pages.
|
||||
* Keyboard: Ctrl/Cmd+K to focus, Escape to close, ↑↓ to navigate, Enter to go.
|
||||
*/
|
||||
import { MOCK_CUSTOMERS, fmtMoney } from '~/data/mock-customers'
|
||||
|
||||
const router = useRouter()
|
||||
const open = ref(false)
|
||||
const q = ref('')
|
||||
const inputRef = ref<HTMLElement | null>(null)
|
||||
const activeIndex = ref(-1)
|
||||
|
||||
/* ── Build searchable records from mock data ── */
|
||||
/* ── Build searchable records from API data ── */
|
||||
|
||||
type SearchHit = {
|
||||
id: string
|
||||
@@ -24,48 +22,9 @@ type SearchHit = {
|
||||
}
|
||||
|
||||
const allRecords = computed<SearchHit[]>(() => {
|
||||
const hits: SearchHit[] = []
|
||||
|
||||
for (const c of MOCK_CUSTOMERS) {
|
||||
// Customer record
|
||||
hits.push({
|
||||
id: `cust-${c.id}`,
|
||||
kind: 'customer',
|
||||
icon: 'i-heroicons-user',
|
||||
title: c.name,
|
||||
meta: `${c.type} · ${c.documentId}`,
|
||||
detail: `${c.policies.length} policies · ${fmtMoney(c.policies.reduce((s, p) => s + p.premium, 0))}/yr · Agent: ${c.agent}`,
|
||||
to: `/customers/${c.id}`
|
||||
})
|
||||
|
||||
// Each policy
|
||||
for (const p of c.policies) {
|
||||
hits.push({
|
||||
id: `pol-${p.id}`,
|
||||
kind: 'policy',
|
||||
icon: p.icon,
|
||||
title: p.id,
|
||||
meta: `${p.line} · ${p.carrier} · ${c.name}`,
|
||||
detail: p.product,
|
||||
to: `/customers/${c.id}`
|
||||
})
|
||||
}
|
||||
|
||||
// Each claim
|
||||
for (const cl of c.claims) {
|
||||
hits.push({
|
||||
id: `claim-${cl.id}`,
|
||||
kind: 'claim',
|
||||
icon: 'i-heroicons-shield-exclamation',
|
||||
title: cl.id,
|
||||
meta: `${cl.type} · ${cl.status} · ${c.name}`,
|
||||
detail: `Policy ${cl.policy} · $${cl.amount.toLocaleString()}`,
|
||||
to: `/customers/${c.id}`
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return hits
|
||||
// TODO: Replace with API calls to fetch customers, policies, and claims
|
||||
// For now, return empty array since mock data has been removed
|
||||
return []
|
||||
})
|
||||
|
||||
/* ── App pages / destinations ── */
|
||||
|
||||
@@ -1,7 +1,4 @@
|
||||
<script setup lang="ts">
|
||||
import type { AppThemeId } from '~/types/app-theme'
|
||||
import { APP_THEME_OPTIONS } from '~/types/app-theme'
|
||||
|
||||
defineProps<{
|
||||
sidebarCollapsed: boolean
|
||||
brandTitle?: string
|
||||
@@ -15,19 +12,18 @@ const emit = defineEmits<{
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const isHome = computed(() => route.path === '/')
|
||||
const { themeId, applyTheme } = useAppTheme()
|
||||
|
||||
const themeIcons: Record<string, string> = {
|
||||
light: 'i-heroicons-sun',
|
||||
purple: 'i-heroicons-sparkles',
|
||||
dark: 'i-heroicons-moon',
|
||||
'dark-purple': 'i-heroicons-star',
|
||||
}
|
||||
const colorMode = useColorMode()
|
||||
const isDark = computed({
|
||||
get () {
|
||||
return colorMode.value === 'dark'
|
||||
},
|
||||
set () {
|
||||
colorMode.preference = colorMode.value === 'dark' ? 'light' : 'dark'
|
||||
}
|
||||
})
|
||||
|
||||
const userMenuOpen = ref(false)
|
||||
const userMenuRoot = ref<HTMLElement | null>(null)
|
||||
const themeMenuOpen = ref(false)
|
||||
const themeMenuRoot = ref<HTMLElement | null>(null)
|
||||
|
||||
function closeUserMenu() {
|
||||
userMenuOpen.value = false
|
||||
@@ -38,10 +34,6 @@ function onDocClick(e: MouseEvent) {
|
||||
if (userEl && userMenuOpen.value && !userEl.contains(e.target as Node)) {
|
||||
userMenuOpen.value = false
|
||||
}
|
||||
const themeEl = themeMenuRoot.value
|
||||
if (themeEl && themeMenuOpen.value && !themeEl.contains(e.target as Node)) {
|
||||
themeMenuOpen.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => document.addEventListener('click', onDocClick))
|
||||
@@ -107,58 +99,19 @@ onUnmounted(() => document.removeEventListener('click', onDocClick))
|
||||
<UIcon name="i-heroicons-arrow-path" style="width: 16px; height: 16px;" />
|
||||
</button>
|
||||
|
||||
<!-- Quick theme switcher -->
|
||||
<div ref="themeMenuRoot" class="relative">
|
||||
<button
|
||||
type="button"
|
||||
class="app-topbar-icon-btn"
|
||||
title="Switch theme"
|
||||
<!-- Theme toggle -->
|
||||
<ClientOnly>
|
||||
<UButton
|
||||
:icon="isDark ? 'i-heroicons-moon-20-solid' : 'i-heroicons-sun-20-solid'"
|
||||
color="gray"
|
||||
variant="ghost"
|
||||
aria-label="Theme"
|
||||
@click.stop="themeMenuOpen = !themeMenuOpen"
|
||||
>
|
||||
<UIcon :name="themeIcons[themeId] ?? 'i-heroicons-swatch'" style="width: 16px; height: 16px;" />
|
||||
</button>
|
||||
<Transition
|
||||
enter-active-class="transition duration-150 ease-out"
|
||||
enter-from-class="opacity-0 scale-95 translate-y-1"
|
||||
enter-to-class="opacity-100 scale-100 translate-y-0"
|
||||
leave-active-class="transition duration-100 ease-in"
|
||||
leave-from-class="opacity-100"
|
||||
leave-to-class="opacity-0 scale-95"
|
||||
>
|
||||
<div
|
||||
v-show="themeMenuOpen"
|
||||
class="absolute right-0 top-[calc(100%+8px)] z-50 w-52 overflow-hidden rounded-xl border border-[var(--sidebar-border)] bg-[var(--surface)] py-1.5 shadow-xl ring-1 ring-black/5"
|
||||
>
|
||||
<p class="px-3 py-1 text-[10px] font-semibold uppercase tracking-wider text-[var(--text-muted)]">Theme</p>
|
||||
<button
|
||||
v-for="opt in APP_THEME_OPTIONS"
|
||||
:key="opt.id"
|
||||
type="button"
|
||||
class="flex w-full items-center gap-2.5 px-3 py-2 text-left text-sm transition hover:bg-[var(--brand-faint)]"
|
||||
:class="themeId === opt.id ? 'text-[var(--brand)] font-medium' : 'text-[var(--text-primary)]'"
|
||||
@click="applyTheme(opt.id as AppThemeId); themeMenuOpen = false"
|
||||
>
|
||||
<UIcon :name="themeIcons[opt.id]" class="h-4 w-4 shrink-0" :class="themeId === opt.id ? 'text-[var(--brand)]' : 'opacity-60'" />
|
||||
<span class="flex-1">{{ opt.label }}</span>
|
||||
<UIcon
|
||||
v-if="themeId === opt.id"
|
||||
name="i-heroicons-check"
|
||||
class="h-3.5 w-3.5 text-[var(--brand)]"
|
||||
/>
|
||||
</button>
|
||||
<div class="mx-3 my-1.5 border-t border-[var(--sidebar-border)]" />
|
||||
<NuxtLink
|
||||
to="/account"
|
||||
class="flex items-center gap-2.5 px-3 py-1.5 text-xs text-[var(--text-muted)] transition hover:text-[var(--brand)]"
|
||||
@click="themeMenuOpen = false"
|
||||
>
|
||||
<UIcon name="i-heroicons-cog-6-tooth" class="h-3.5 w-3.5" />
|
||||
All appearance settings
|
||||
</NuxtLink>
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
@click="isDark = !isDark"
|
||||
/>
|
||||
<template #fallback>
|
||||
<div class="w-8 h-8" />
|
||||
</template>
|
||||
</ClientOnly>
|
||||
|
||||
<NuxtLink to="/settings" class="inline-flex" title="Software settings">
|
||||
<span class="app-topbar-icon-btn">
|
||||
|
||||
148
app/components/policies/ConfirmAcceptModal.vue
Normal file
148
app/components/policies/ConfirmAcceptModal.vue
Normal file
@@ -0,0 +1,148 @@
|
||||
<script setup lang="ts">
|
||||
interface Plan {
|
||||
provider_id: string
|
||||
plan_id: string
|
||||
name: string
|
||||
premium: number
|
||||
}
|
||||
|
||||
interface Props {
|
||||
open: boolean
|
||||
plan: Plan | null
|
||||
loading?: boolean
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
loading: false
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:open': [value: boolean]
|
||||
accept: []
|
||||
cancel: []
|
||||
}>()
|
||||
|
||||
function close() {
|
||||
emit('update:open', false)
|
||||
emit('cancel')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<Transition
|
||||
enter-active-class="transition duration-200 ease-out"
|
||||
enter-from-class="opacity-0"
|
||||
enter-to-class="opacity-100"
|
||||
leave-active-class="transition duration-150 ease-in"
|
||||
leave-from-class="opacity-100"
|
||||
leave-to-class="opacity-0"
|
||||
>
|
||||
<div v-if="open" class="confirm-modal-overlay" @click.self="close">
|
||||
<Transition
|
||||
enter-active-class="transition duration-200 ease-out"
|
||||
enter-from-class="opacity-0 scale-95 translate-y-2"
|
||||
enter-to-class="opacity-100 scale-100 translate-y-0"
|
||||
leave-active-class="transition duration-150 ease-in"
|
||||
leave-from-class="opacity-100 scale-100 translate-y-0"
|
||||
leave-to-class="opacity-0 scale-95 translate-y-2"
|
||||
>
|
||||
<div v-if="open" class="confirm-modal">
|
||||
<div class="confirm-modal-head">
|
||||
<h3 class="text-lg font-semibold text-[var(--text-primary)]">Accept Quote</h3>
|
||||
<button type="button" class="confirm-modal-close" @click="close">
|
||||
<UIcon name="i-heroicons-x-mark" style="width: 16px; height: 16px;" />
|
||||
</button>
|
||||
</div>
|
||||
<div class="confirm-modal-body">
|
||||
<div v-if="plan" class="space-y-3">
|
||||
<div class="bg-gray-50 rounded-lg p-4">
|
||||
<div class="text-sm text-gray-500">Selected Plan</div>
|
||||
<div class="text-lg font-semibold text-[var(--text-primary)] mt-1">{{ plan.name }}</div>
|
||||
<div class="text-2xl font-bold text-green-600 mt-2">${{ plan.premium?.toLocaleString() }}</div>
|
||||
<div class="text-xs text-gray-500 mt-1">Provider: {{ plan.provider_id }}</div>
|
||||
</div>
|
||||
<p class="text-sm text-gray-600">
|
||||
Are you sure you want to accept this plan? This will trigger the solicitation process.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="confirm-modal-foot">
|
||||
<div class="flex justify-end gap-3">
|
||||
<UButton color="gray" variant="soft" @click="close">Cancel</UButton>
|
||||
<UButton color="primary" :loading="loading" @click="emit('accept')">Accept</UButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* Modal overlay */
|
||||
.confirm-modal-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 100;
|
||||
background: rgba(0, 0, 0, 0.25);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 20px;
|
||||
backdrop-filter: blur(2px);
|
||||
}
|
||||
|
||||
/* Modal container */
|
||||
.confirm-modal {
|
||||
width: 100%;
|
||||
max-width: 480px;
|
||||
border-radius: 12px;
|
||||
background: #ffffff;
|
||||
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.15), 0 4px 16px rgba(0, 0, 0, 0.08);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Modal header */
|
||||
.confirm-modal-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 18px 20px 0;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* Modal body */
|
||||
.confirm-modal-body {
|
||||
padding: 16px 20px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
/* Modal footer */
|
||||
.confirm-modal-foot {
|
||||
padding: 12px 20px 16px;
|
||||
border-top: 1px solid rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
|
||||
/* Close button */
|
||||
.confirm-modal-close {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 6px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: #8a8a86;
|
||||
cursor: pointer;
|
||||
transition: all 150ms ease;
|
||||
}
|
||||
|
||||
.confirm-modal-close:hover {
|
||||
background: rgba(0, 0, 0, 0.05);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
</style>
|
||||
99
app/components/policies/QuoteSelectionBoard.vue
Normal file
99
app/components/policies/QuoteSelectionBoard.vue
Normal file
@@ -0,0 +1,99 @@
|
||||
<script setup lang="ts">
|
||||
interface PlanItem {
|
||||
provider_id: string
|
||||
quote_id: string
|
||||
valid_until: string | null
|
||||
received_at: string
|
||||
plan_id: string
|
||||
name: string
|
||||
premium: number
|
||||
coverage_details: string
|
||||
deductible?: number
|
||||
coverage_limit?: number
|
||||
}
|
||||
|
||||
interface Props {
|
||||
items: PlanItem[]
|
||||
selectedPlanId: string | null
|
||||
policyApplicationId: string
|
||||
loading?: boolean
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
loading: false
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
select: [planId: string]
|
||||
}>()
|
||||
|
||||
function formatPremium(value: number): string {
|
||||
return '$' + value.toLocaleString()
|
||||
}
|
||||
|
||||
function formatDate(dateStr: string | null): string {
|
||||
if (!dateStr) return ''
|
||||
return new Date(dateStr).toLocaleDateString('es-PA', {
|
||||
day: '2-digit',
|
||||
month: 'short',
|
||||
year: 'numeric'
|
||||
})
|
||||
}
|
||||
|
||||
function handleSelect(planId: string) {
|
||||
if (props.loading) return
|
||||
emit('select', planId)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="quote-selection-board grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
|
||||
<div
|
||||
v-for="item in items"
|
||||
:key="item.plan_id"
|
||||
class="quote-card rounded-xl border px-3.5 py-3 shadow-sm transition cursor-pointer"
|
||||
:class="selectedPlanId === item.plan_id
|
||||
? 'border-primary ring-2 ring-primary/30 bg-primary/5'
|
||||
: 'border-[var(--card-border)] bg-[var(--surface)] hover:border-[var(--brand)]/30 hover:shadow-md'"
|
||||
@click="handleSelect(item.plan_id)"
|
||||
>
|
||||
<!-- Provider ID -->
|
||||
<p class="text-[11px] font-mono text-gray-500 truncate">{{ item.provider_id }}</p>
|
||||
|
||||
<!-- Plan name -->
|
||||
<p class="text-sm font-semibold text-[var(--text-primary)] mt-1 truncate">{{ item.name }}</p>
|
||||
|
||||
<!-- Premium -->
|
||||
<p class="text-lg font-bold text-green-600 mt-2">{{ formatPremium(item.premium) }}</p>
|
||||
|
||||
<!-- Valid until -->
|
||||
<p v-if="item.valid_until" class="text-[11px] text-gray-500 mt-1">
|
||||
Valid until {{ formatDate(item.valid_until) }}
|
||||
</p>
|
||||
|
||||
<!-- Coverage details preview -->
|
||||
<p v-if="item.coverage_details" class="text-xs text-gray-600 mt-2 line-clamp-2">
|
||||
{{ item.coverage_details }}
|
||||
</p>
|
||||
|
||||
<!-- Footer -->
|
||||
<div class="mt-3 flex justify-end">
|
||||
<UBadge v-if="selectedPlanId === item.plan_id" color="green" variant="soft" size="xs">Selected</UBadge>
|
||||
<UButton v-else-if="!loading" size="xs" color="primary" @click.stop="handleSelect(item.plan_id)">Select</UButton>
|
||||
<UBadge v-else color="gray" variant="soft" size="xs">Processing...</UBadge>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.quote-card {
|
||||
transition: all 0.15s ease;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.quote-card:hover {
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
</style>
|
||||
@@ -1,168 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import type { QuoteComparativeView } from '~/types/quote-view-model'
|
||||
|
||||
defineProps<{
|
||||
model: QuoteComparativeView
|
||||
}>()
|
||||
|
||||
function fmtUsd(n: number) {
|
||||
return new Intl.NumberFormat('en-US', {
|
||||
style: 'currency',
|
||||
currency: 'USD',
|
||||
maximumFractionDigits: 0
|
||||
}).format(n)
|
||||
}
|
||||
|
||||
function fmtDate(iso: string) {
|
||||
try {
|
||||
return new Intl.DateTimeFormat('es-PA', { dateStyle: 'long' }).format(new Date(`${iso}T12:00:00`))
|
||||
} catch {
|
||||
return iso
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="quote-comparative space-y-8 text-[var(--text-primary)]">
|
||||
<div
|
||||
class="flex flex-wrap items-start justify-between gap-4 rounded-xl border border-[var(--card-border)] bg-gradient-to-br from-[var(--surface)] to-white p-6 shadow-sm"
|
||||
>
|
||||
<div class="min-w-0">
|
||||
<p class="text-xs font-semibold uppercase tracking-wide text-[var(--brand)]">{{ model.title }}</p>
|
||||
<h2 class="mt-1 text-2xl font-bold tracking-tight text-[var(--text-primary)]">{{ model.subtitle }}</h2>
|
||||
<p class="mt-1 text-sm text-[var(--text-muted)]">{{ model.tagline }}</p>
|
||||
</div>
|
||||
<div class="rounded-lg border border-[var(--brand-soft)] bg-[var(--brand-faint)] px-4 py-2 text-right text-sm">
|
||||
<p class="text-[var(--text-muted)]">Cotización</p>
|
||||
<p class="font-medium text-[var(--text-primary)]">{{ fmtDate(model.quoteDateIso) }}</p>
|
||||
<UBadge color="primary" variant="soft" class="mt-1">Válida {{ model.validDays }} días</UBadge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="overflow-hidden rounded-xl border border-[var(--card-border)] bg-[var(--surface)] shadow-sm">
|
||||
<div
|
||||
class="border-b border-[var(--brand-soft)] bg-gradient-to-r from-[var(--brand)] to-[var(--brand)] px-5 py-2 text-sm font-semibold text-white"
|
||||
>
|
||||
1 · Cliente y cotización solicitada
|
||||
</div>
|
||||
<div class="grid gap-6 p-5 md:grid-cols-2">
|
||||
<div>
|
||||
<h3 class="mb-3 text-xs font-bold uppercase tracking-wide text-[var(--text-muted)]">Datos del cliente</h3>
|
||||
<dl class="grid grid-cols-[8rem_1fr] gap-x-3 gap-y-2 text-sm">
|
||||
<dt class="text-[var(--text-muted)]">Nombre</dt>
|
||||
<dd class="font-medium">{{ model.client.name }}</dd>
|
||||
<dt class="text-[var(--text-muted)]">Edad</dt>
|
||||
<dd>{{ model.client.ageYears }} años</dd>
|
||||
<dt class="text-[var(--text-muted)]">Género</dt>
|
||||
<dd>{{ model.client.gender }}</dd>
|
||||
<dt class="text-[var(--text-muted)]">Fumador/a</dt>
|
||||
<dd>{{ model.client.smoker ? 'Sí' : 'No' }}</dd>
|
||||
<dt class="text-[var(--text-muted)]">Clasificación</dt>
|
||||
<dd>{{ model.client.riskClass }}</dd>
|
||||
<dt class="text-[var(--text-muted)]">Ocupación</dt>
|
||||
<dd>{{ model.client.occupation }}</dd>
|
||||
</dl>
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="mb-3 text-xs font-bold uppercase tracking-wide text-[var(--text-muted)]">Lo que cotizamos</h3>
|
||||
<p class="text-3xl font-bold text-[var(--text-primary)]">{{ fmtUsd(model.request.sumAssuredUsd) }}</p>
|
||||
<p class="text-sm text-[var(--text-muted)]">Suma asegurada</p>
|
||||
<p class="mt-4 text-2xl font-semibold text-[var(--brand)]">
|
||||
{{ fmtUsd(model.request.monthlyPremiumUsd) }}
|
||||
<span class="text-base font-normal text-[var(--text-muted)]">/ mes</span>
|
||||
</p>
|
||||
<p class="text-sm text-[var(--text-muted)]">
|
||||
Prima anual equivalente: {{ fmtUsd(model.request.annualPremiumUsd) }} / año
|
||||
</p>
|
||||
<dl class="mt-4 space-y-1 text-sm">
|
||||
<div class="flex justify-between gap-4">
|
||||
<dt class="text-[var(--text-muted)]">Tipo de beneficio</dt>
|
||||
<dd>{{ model.request.benefitTypeLabel }}</dd>
|
||||
</div>
|
||||
<div class="flex justify-between gap-4">
|
||||
<dt class="text-[var(--text-muted)]">Coberturas adicionales</dt>
|
||||
<dd>{{ model.request.additionalCoverageLabel }}</dd>
|
||||
</div>
|
||||
<div class="flex justify-between gap-4">
|
||||
<dt class="text-[var(--text-muted)]">Depósito inicial</dt>
|
||||
<dd>{{ model.request.initialDepositLabel }}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section class="space-y-6">
|
||||
<h3 class="text-sm font-bold uppercase tracking-wide text-[var(--text-muted)]">
|
||||
2 · Comparativo de valores (rescate / ahorro)
|
||||
</h3>
|
||||
<div
|
||||
v-for="(row, idx) in model.carriers"
|
||||
:key="idx"
|
||||
class="overflow-hidden rounded-xl border border-[var(--card-border)] bg-[var(--surface)] shadow-sm"
|
||||
>
|
||||
<div
|
||||
class="border-b px-4 py-2 text-sm font-semibold text-white"
|
||||
:class="idx % 2 === 0 ? 'bg-slate-800' : 'bg-orange-600'"
|
||||
>
|
||||
{{ row.carrierName }} · {{ row.productName }}
|
||||
</div>
|
||||
<div class="p-4 text-xs text-[var(--text-muted)]">{{ row.ratesLine }}</div>
|
||||
<div class="overflow-x-auto px-2 pb-4">
|
||||
<table class="min-w-full text-center text-xs sm:text-sm">
|
||||
<thead>
|
||||
<tr class="border-b border-[var(--card-border)] text-[var(--text-muted)]">
|
||||
<th class="px-2 py-2">Suma asegurada</th>
|
||||
<th v-for="(c, ci) in row.cells" :key="ci" class="px-2 py-2">
|
||||
{{ c.yearLabel }}
|
||||
<span class="block text-[10px] font-normal text-[var(--text-muted)] opacity-70">Edad {{ c.ageLabel }}</span>
|
||||
</th>
|
||||
<th class="px-2 py-2 text-[var(--brand)]">Destacado</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr class="border-b border-[var(--divider)]">
|
||||
<td class="px-2 py-3 font-mono text-xs">{{ fmtUsd(row.sumAssuredUsd) }}</td>
|
||||
<td v-for="(c, ci) in row.cells" :key="ci" class="px-2 py-3 align-top">
|
||||
<span class="block text-base font-bold text-[var(--text-primary)]">{{ fmtUsd(c.guaranteed) }}</span>
|
||||
<span class="text-xs text-[var(--brand)]">{{ fmtUsd(c.projected) }}</span>
|
||||
</td>
|
||||
<td class="bg-[var(--surface)] px-3 py-3 align-top text-left text-xs text-[var(--text-primary)]">
|
||||
<p v-if="row.highlightProjectedUsd != null" class="text-lg font-bold text-[var(--text-primary)]">
|
||||
{{ fmtUsd(row.highlightProjectedUsd) }}
|
||||
</p>
|
||||
<p v-if="row.highlightNote" class="mt-1 text-[10px] text-amber-800">
|
||||
{{ row.highlightNote }}
|
||||
</p>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<p v-if="row.footnote" class="border-t border-[var(--divider)] px-4 py-2 text-[10px] text-[var(--text-muted)]">
|
||||
{{ row.footnote }}
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="rounded-xl border border-amber-200 bg-amber-50/50 p-4 text-sm">
|
||||
<p class="font-semibold text-[var(--text-primary)]">Primas acumuladas pagadas (referencia)</p>
|
||||
<div class="mt-2 flex flex-wrap gap-4 font-mono text-xs text-[var(--text-primary)]">
|
||||
<span v-for="(p, i) in model.accumulatedPremiumsUsd" :key="i">Hito {{ i + 1 }}: {{ fmtUsd(p) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="overflow-hidden rounded-xl border border-slate-800 bg-slate-900 text-white shadow-md">
|
||||
<div class="border-b border-slate-700 px-5 py-2 text-sm font-semibold">Análisis del asesor</div>
|
||||
<div class="grid gap-4 p-5 md:grid-cols-3">
|
||||
<div
|
||||
v-for="(col, i) in model.advisorColumns"
|
||||
:key="i"
|
||||
class="rounded-lg bg-[var(--surface)]/5 p-3 text-xs leading-relaxed text-[var(--text-muted)] opacity-50"
|
||||
>
|
||||
{{ col }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,121 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
AUTO_COVERAGE_PLANS,
|
||||
AUTO_MARCA_OPTIONS,
|
||||
AUTO_MODELO_OPTIONS,
|
||||
AUTO_QUOTE_CARRIERS,
|
||||
AUTO_SUB_RAMO_OPTIONS
|
||||
} from '~/data/auto-quote-intake'
|
||||
import type { AutoQuoteDraft, AutoQuoteMode, AutoQuoteSegment } from '~/types/auto-quote-intake'
|
||||
|
||||
const props = defineProps<{
|
||||
draft: AutoQuoteDraft
|
||||
quoteMode: AutoQuoteMode
|
||||
segment: AutoQuoteSegment
|
||||
}>()
|
||||
|
||||
function carrierName(id: string) {
|
||||
return AUTO_QUOTE_CARRIERS.find((c) => c.id === id)?.name ?? id
|
||||
}
|
||||
|
||||
function planLabel(id: string) {
|
||||
return AUTO_COVERAGE_PLANS.find((p) => p.id === id)?.label ?? id
|
||||
}
|
||||
|
||||
const segmentLabel: Record<AutoQuoteSegment, string> = {
|
||||
individual: 'Individual',
|
||||
corporate: 'Corporate',
|
||||
fleet: 'Fleet'
|
||||
}
|
||||
|
||||
const modeLabel: Record<AutoQuoteMode, string> = {
|
||||
single: 'Single quote',
|
||||
comparative_pdf: 'Comparative PDF'
|
||||
}
|
||||
|
||||
function optLabel(opts: { label: string; value: string }[], v: string) {
|
||||
if (!v) return '—'
|
||||
return opts.find((o) => o.value === v)?.label ?? v
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="space-y-6">
|
||||
<div>
|
||||
<p class="text-sm text-[var(--text-muted)]">Review and send quote requests to carrier quoting inboxes.</p>
|
||||
</div>
|
||||
|
||||
<div class="space-y-4 rounded-xl border border-[var(--sidebar-border)] bg-[var(--surface)] p-5 ring-1 ring-black/[0.04]">
|
||||
<div class="flex flex-wrap gap-x-6 gap-y-2 text-sm">
|
||||
<div>
|
||||
<span class="text-[var(--text-muted)]">Intent</span>
|
||||
<p class="font-medium text-[var(--text-primary)]">{{ modeLabel[quoteMode] }}</p>
|
||||
</div>
|
||||
<div>
|
||||
<span class="text-[var(--text-muted)]">Policy type</span>
|
||||
<p class="font-medium text-[var(--text-primary)]">{{ segmentLabel[segment] }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="border-t border-[var(--sidebar-border)] pt-4">
|
||||
<p class="text-xs font-semibold uppercase tracking-wide text-[var(--text-muted)]">Client</p>
|
||||
<dl class="mt-2 grid gap-2 text-sm sm:grid-cols-2">
|
||||
<div>
|
||||
<dt class="text-[var(--text-muted)]">Name</dt>
|
||||
<dd class="font-medium text-[var(--text-primary)]">{{ draft.client.fullName || '—' }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt class="text-[var(--text-muted)]">Email</dt>
|
||||
<dd class="font-medium text-[var(--text-primary)]">{{ draft.client.email || '—' }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt class="text-[var(--text-muted)]">Phone</dt>
|
||||
<dd class="font-medium text-[var(--text-primary)]">{{ draft.client.phone || '—' }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt class="text-[var(--text-muted)]">ID</dt>
|
||||
<dd class="font-medium text-[var(--text-primary)]">{{ draft.client.documentId || '—' }}</dd>
|
||||
</div>
|
||||
<div v-if="draft.client.organizationName" class="sm:col-span-2">
|
||||
<dt class="text-[var(--text-muted)]">Organization</dt>
|
||||
<dd class="font-medium text-[var(--text-primary)]">{{ draft.client.organizationName }}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
|
||||
<div class="border-t border-[var(--sidebar-border)] pt-4">
|
||||
<p class="text-xs font-semibold uppercase tracking-wide text-[var(--text-muted)]">Vehicle</p>
|
||||
<p class="mt-2 text-sm text-[var(--text-primary)]">
|
||||
{{ optLabel(AUTO_MARCA_OPTIONS, draft.vehicle.marca) }} {{ optLabel(AUTO_MODELO_OPTIONS, draft.vehicle.modelo) }}
|
||||
· Plate {{ draft.vehicle.placa || '—' }} · {{ draft.vehicle.year || '—' }}
|
||||
</p>
|
||||
<p class="mt-1 text-xs text-[var(--text-muted)]">
|
||||
Sub-line {{ optLabel(AUTO_SUB_RAMO_OPTIONS, draft.vehicle.subRamo) }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="border-t border-[var(--sidebar-border)] pt-4">
|
||||
<p class="text-xs font-semibold uppercase tracking-wide text-[var(--text-muted)]">Carriers</p>
|
||||
<ul class="mt-2 list-inside list-disc text-sm text-[var(--text-primary)]">
|
||||
<li v-for="id in draft.solicit.carrierIds" :key="id">{{ carrierName(id) }}</li>
|
||||
<li v-if="draft.solicit.carrierIds.length === 0" class="list-none text-[var(--text-muted)]">None selected</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="border-t border-[var(--sidebar-border)] pt-4">
|
||||
<p class="text-xs font-semibold uppercase tracking-wide text-[var(--text-muted)]">Plans</p>
|
||||
<ul class="mt-2 list-inside list-disc text-sm text-[var(--text-primary)]">
|
||||
<li v-for="id in draft.solicit.planIds" :key="id">{{ planLabel(id) }}</li>
|
||||
<li v-if="draft.solicit.planIds.length === 0" class="list-none text-[var(--text-muted)]">None selected</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<UAlert
|
||||
color="neutral"
|
||||
variant="soft"
|
||||
title="What happens next"
|
||||
description="We’ll send quote requests to each carrier’s registered quoting email (configured under Settings → Providers). For comparative quotes, coverage rows follow your selected plans; when you receive pricing by email, paste figures into the comparative view."
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,321 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
AUTO_CLASE_OPTIONS,
|
||||
AUTO_MARCA_OPTIONS,
|
||||
AUTO_MODELO_OPTIONS,
|
||||
AUTO_RAMO_LABEL,
|
||||
AUTO_SUB_RAMO_OPTIONS,
|
||||
AUTO_USO_OPTIONS,
|
||||
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
|
||||
/** Null until policy type is chosen — hides org field */
|
||||
segment: AutoQuoteSegment | null
|
||||
}>()
|
||||
|
||||
const showInterfaseBadge = computed(() => props.draft.vehicle.subRamo === 'cobertura_completa')
|
||||
|
||||
const showOrganization = computed(
|
||||
() => props.segment === 'corporate' || props.segment === 'fleet'
|
||||
)
|
||||
|
||||
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)]">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">
|
||||
<h2 class="text-lg font-semibold text-[var(--text-primary)]">Vehicle</h2>
|
||||
<p class="mt-1 text-sm text-[var(--text-muted)]">Risk details carriers use for auto rating.</p>
|
||||
|
||||
<div class="mt-5 grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<UFormField label="Line">
|
||||
<UInput :model-value="AUTO_RAMO_LABEL" disabled class="w-full opacity-90" />
|
||||
</UFormField>
|
||||
<div class="relative pt-1">
|
||||
<UBadge
|
||||
v-if="showInterfaseBadge"
|
||||
color="info"
|
||||
variant="soft"
|
||||
size="xs"
|
||||
class="pointer-events-none absolute -top-0 right-0 z-[1]"
|
||||
>
|
||||
Interfase
|
||||
</UBadge>
|
||||
<UFormField label="Sub-line">
|
||||
<USelect
|
||||
v-model="draft.vehicle.subRamo"
|
||||
:items="AUTO_SUB_RAMO_OPTIONS"
|
||||
value-key="value"
|
||||
label-key="label"
|
||||
placeholder="Select one"
|
||||
class="w-full"
|
||||
/>
|
||||
</UFormField>
|
||||
</div>
|
||||
<UFormField label="Class">
|
||||
<USelect
|
||||
v-model="draft.vehicle.clase"
|
||||
:items="AUTO_CLASE_OPTIONS"
|
||||
value-key="value"
|
||||
label-key="label"
|
||||
placeholder="Select one"
|
||||
class="w-full"
|
||||
/>
|
||||
</UFormField>
|
||||
<UFormField label="Use">
|
||||
<USelect
|
||||
v-model="draft.vehicle.uso"
|
||||
:items="AUTO_USO_OPTIONS"
|
||||
value-key="value"
|
||||
label-key="label"
|
||||
placeholder="Select one"
|
||||
class="w-full"
|
||||
/>
|
||||
</UFormField>
|
||||
</div>
|
||||
|
||||
<p class="mb-4 mt-8 text-xs font-semibold uppercase tracking-wide text-[var(--text-muted)]">Vehicle details</p>
|
||||
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<UFormField label="Make">
|
||||
<USelect
|
||||
v-model="draft.vehicle.marca"
|
||||
:items="AUTO_MARCA_OPTIONS"
|
||||
value-key="value"
|
||||
label-key="label"
|
||||
placeholder="Select one"
|
||||
class="w-full"
|
||||
/>
|
||||
</UFormField>
|
||||
<UFormField label="Model">
|
||||
<USelect
|
||||
v-model="draft.vehicle.modelo"
|
||||
:items="AUTO_MODELO_OPTIONS"
|
||||
value-key="value"
|
||||
label-key="label"
|
||||
placeholder="Select one"
|
||||
class="w-full"
|
||||
/>
|
||||
</UFormField>
|
||||
<UFormField label="License plate">
|
||||
<UInput v-model="draft.vehicle.placa" :class="inputPh" class="font-mono uppercase" placeholder="ABC-1234" />
|
||||
</UFormField>
|
||||
<UFormField label="Year">
|
||||
<USelect
|
||||
v-model="draft.vehicle.year"
|
||||
:items="AUTO_YEAR_OPTIONS"
|
||||
value-key="value"
|
||||
label-key="label"
|
||||
placeholder="Select one"
|
||||
class="w-full"
|
||||
/>
|
||||
</UFormField>
|
||||
<UFormField label="Capacity" description="Passengers">
|
||||
<UInput v-model="draft.vehicle.capacidadPasajeros" :class="inputPh" inputmode="numeric" placeholder="—" />
|
||||
</UFormField>
|
||||
<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>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,97 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import type { AutoQuoteDraft, AutoQuoteMode, AutoQuoteSegment } from '~/types/auto-quote-intake'
|
||||
|
||||
const props = defineProps<{
|
||||
draft: AutoQuoteDraft
|
||||
modeCards: { id: AutoQuoteMode; title: string; hint: string; icon: string }[]
|
||||
segmentCards: { id: AutoQuoteSegment; title: string; hint: string; icon: string }[]
|
||||
}>()
|
||||
|
||||
function setMode(m: AutoQuoteMode) {
|
||||
props.draft.quoteMode = m
|
||||
}
|
||||
|
||||
function setSegment(s: AutoQuoteSegment) {
|
||||
props.draft.segment = s
|
||||
}
|
||||
|
||||
/** Mount vehicle + selects after first paint — avoids blocking the main thread when the route opens */
|
||||
const showDetails = ref(false)
|
||||
onMounted(() => {
|
||||
requestAnimationFrame(() => {
|
||||
showDetails.value = true
|
||||
})
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="space-y-10">
|
||||
<section class="space-y-4">
|
||||
<div>
|
||||
<h3 class="text-base font-semibold text-[var(--text-primary)]">How can I help?</h3>
|
||||
<p class="mt-1 text-sm text-[var(--text-muted)]">Single quote or comparative — same workflow; comparative opens the comparison sheet after you send requests.</p>
|
||||
</div>
|
||||
<div class="grid gap-3 sm:grid-cols-2">
|
||||
<button
|
||||
v-for="card in modeCards"
|
||||
:key="card.id"
|
||||
type="button"
|
||||
class="group rounded-xl border p-5 text-left transition focus:outline-none focus-visible:ring-2 focus-visible:ring-[var(--brand)]"
|
||||
:class="
|
||||
draft.quoteMode === card.id
|
||||
? 'border-[var(--brand)] bg-[var(--brand-soft)] ring-1 ring-[var(--brand)]/30'
|
||||
: 'border-[var(--sidebar-border)] bg-[var(--surface)] hover:border-[var(--brand)]/40'
|
||||
"
|
||||
@click="setMode(card.id)"
|
||||
>
|
||||
<div class="flex items-start gap-3">
|
||||
<div
|
||||
class="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg bg-[var(--brand-faint)] text-[var(--brand)]"
|
||||
>
|
||||
<UIcon :name="card.icon" class="h-5 w-5" />
|
||||
</div>
|
||||
<div class="min-w-0">
|
||||
<p class="font-semibold text-[var(--text-primary)]">{{ card.title }}</p>
|
||||
<p class="mt-1 text-sm text-[var(--text-muted)]">{{ card.hint }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="space-y-4 border-t border-[var(--sidebar-border)] pt-10">
|
||||
<div>
|
||||
<h3 class="text-base font-semibold text-[var(--text-primary)]">Policy type</h3>
|
||||
<p class="mt-1 text-sm text-[var(--text-muted)]">Who is this policy for?</p>
|
||||
</div>
|
||||
<div class="grid gap-3 sm:grid-cols-3">
|
||||
<button
|
||||
v-for="card in segmentCards"
|
||||
:key="card.id"
|
||||
type="button"
|
||||
class="rounded-xl border p-4 text-left transition focus:outline-none focus-visible:ring-2 focus-visible:ring-[var(--brand)]"
|
||||
:class="
|
||||
draft.segment === card.id
|
||||
? 'border-[var(--brand)] bg-[var(--brand-soft)] ring-1 ring-[var(--brand)]/30'
|
||||
: 'border-[var(--sidebar-border)] bg-[var(--surface)] hover:border-[var(--brand)]/40'
|
||||
"
|
||||
@click="setSegment(card.id)"
|
||||
>
|
||||
<UIcon :name="card.icon" class="h-7 w-7 text-[var(--brand)]" />
|
||||
<p class="mt-2 font-semibold text-[var(--text-primary)]">{{ card.title }}</p>
|
||||
<p class="mt-0.5 text-xs text-[var(--text-muted)]">{{ card.hint }}</p>
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="border-t border-[var(--sidebar-border)] pt-10">
|
||||
<QuotesAutoCustomerVehicleStep v-if="showDetails" :draft="draft" :segment="draft.segment" />
|
||||
<div
|
||||
v-else
|
||||
class="min-h-[14rem] rounded-xl bg-[var(--sidebar-border)]/25 animate-pulse"
|
||||
aria-busy="true"
|
||||
aria-label="Loading form"
|
||||
/>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,97 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { AUTO_COVERAGE_PLANS, AUTO_QUOTE_CARRIERS } from '~/data/auto-quote-intake'
|
||||
import type { AutoQuoteDraft, AutoQuoteMode } from '~/types/auto-quote-intake'
|
||||
|
||||
const props = defineProps<{
|
||||
draft: AutoQuoteDraft
|
||||
quoteMode: AutoQuoteMode
|
||||
}>()
|
||||
|
||||
function setCarrier(id: string, checked: boolean) {
|
||||
const xs = props.draft.solicit.carrierIds
|
||||
if (checked && !xs.includes(id)) xs.push(id)
|
||||
if (!checked) {
|
||||
const i = xs.indexOf(id)
|
||||
if (i !== -1) xs.splice(i, 1)
|
||||
}
|
||||
}
|
||||
|
||||
function carrierChecked(id: string) {
|
||||
return props.draft.solicit.carrierIds.includes(id)
|
||||
}
|
||||
|
||||
function setPlan(id: string, checked: boolean) {
|
||||
const xs = props.draft.solicit.planIds
|
||||
if (checked && !xs.includes(id)) xs.push(id)
|
||||
if (!checked) {
|
||||
const i = xs.indexOf(id)
|
||||
if (i !== -1) xs.splice(i, 1)
|
||||
}
|
||||
}
|
||||
|
||||
function planChecked(id: string) {
|
||||
return props.draft.solicit.planIds.includes(id)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="space-y-6">
|
||||
<div>
|
||||
<p class="text-sm text-[var(--text-muted)]">
|
||||
Choose carriers (quoting emails are maintained per provider in Settings). Pick coverage packages to request.
|
||||
</p>
|
||||
<UAlert
|
||||
v-if="quoteMode === 'comparative_pdf'"
|
||||
color="info"
|
||||
variant="soft"
|
||||
class="mt-4"
|
||||
title="Comparative quote"
|
||||
description="We’ll prepare side-by-side comparisons using your predetermined plans. When premiums arrive by email, you can enter them into the comparative sheet."
|
||||
/>
|
||||
<UAlert
|
||||
v-else
|
||||
color="neutral"
|
||||
variant="soft"
|
||||
class="mt-4"
|
||||
title="Single quote"
|
||||
description="We’ll email each selected carrier’s quoting address on file. Attach the same vehicle and coverage ask in each request."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="rounded-xl border border-[var(--sidebar-border)] bg-[var(--surface)] p-4 ring-1 ring-black/[0.04]">
|
||||
<p class="text-xs font-semibold uppercase tracking-wide text-[var(--text-muted)]">Insurance companies</p>
|
||||
<ul class="mt-3 divide-y divide-[var(--sidebar-border)]">
|
||||
<li
|
||||
v-for="c in AUTO_QUOTE_CARRIERS"
|
||||
:key="c.id"
|
||||
class="flex flex-wrap items-start justify-between gap-3 py-3 first:pt-0"
|
||||
>
|
||||
<UCheckbox
|
||||
:model-value="carrierChecked(c.id)"
|
||||
:label="c.name"
|
||||
@update:model-value="(v: boolean) => setCarrier(c.id, v)"
|
||||
/>
|
||||
<span class="text-xs text-[var(--text-muted)]">{{ c.detail }}</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="rounded-xl border border-[var(--sidebar-border)] bg-[var(--surface)] p-4 ring-1 ring-black/[0.04]">
|
||||
<p class="text-xs font-semibold uppercase tracking-wide text-[var(--text-muted)]">Coverages / plans</p>
|
||||
<ul class="mt-3 space-y-3">
|
||||
<li
|
||||
v-for="p in AUTO_COVERAGE_PLANS"
|
||||
:key="p.id"
|
||||
class="flex flex-col gap-1 rounded-lg border border-[var(--sidebar-border)]/80 bg-[var(--page-bg)]/50 p-3 sm:flex-row sm:items-center sm:justify-between"
|
||||
>
|
||||
<UCheckbox
|
||||
:model-value="planChecked(p.id)"
|
||||
:label="p.label"
|
||||
@update:model-value="(v: boolean) => setPlan(p.id, v)"
|
||||
/>
|
||||
<span class="text-xs text-[var(--text-muted)] sm:text-right">{{ p.hint }}</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,127 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { HEALTH_COVERAGE_PLANS, HEALTH_QUOTE_CARRIERS } from '~/data/health-quote-intake'
|
||||
import type { HealthQuoteDraft, HealthQuoteMode, HealthQuoteSegment } from '~/types/health-quote-intake'
|
||||
|
||||
const props = defineProps<{
|
||||
draft: HealthQuoteDraft
|
||||
quoteMode: HealthQuoteMode
|
||||
segment: HealthQuoteSegment
|
||||
}>()
|
||||
|
||||
const { quoteRequestEmailEnabled } = useQuoteRequestEmailEnabled()
|
||||
|
||||
function carrierName(id: string) {
|
||||
return HEALTH_QUOTE_CARRIERS.find((c) => c.id === id)?.name ?? id
|
||||
}
|
||||
|
||||
function planLabel(id: string) {
|
||||
return HEALTH_COVERAGE_PLANS.find((p) => p.id === id)?.label ?? id
|
||||
}
|
||||
|
||||
const segmentLabel: Record<HealthQuoteSegment, string> = {
|
||||
individual: 'Individual',
|
||||
corporate: 'Corporate',
|
||||
group: 'Group'
|
||||
}
|
||||
|
||||
const modeLabel: Record<HealthQuoteMode, string> = {
|
||||
single: 'Single quote',
|
||||
comparative_pdf: 'Comparative PDF'
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="space-y-6">
|
||||
<div>
|
||||
<p class="text-sm text-[var(--text-muted)]">Review the health quote request before sending or saving.</p>
|
||||
</div>
|
||||
|
||||
<UAlert
|
||||
v-if="!quoteRequestEmailEnabled"
|
||||
color="warning"
|
||||
variant="soft"
|
||||
title="Provider emails are turned off"
|
||||
description="Settings → Quote requests: outbound emails disabled. This run saves the request locally (or uses table / AI pricing when connected) without emailing carriers."
|
||||
/>
|
||||
|
||||
<div class="space-y-4 rounded-xl border border-[var(--sidebar-border)] bg-[var(--surface)] p-5 ring-1 ring-black/[0.04]">
|
||||
<div class="flex flex-wrap gap-x-6 gap-y-2 text-sm">
|
||||
<div>
|
||||
<span class="text-[var(--text-muted)]">Intent</span>
|
||||
<p class="font-medium text-[var(--text-primary)]">{{ modeLabel[quoteMode] }}</p>
|
||||
</div>
|
||||
<div>
|
||||
<span class="text-[var(--text-muted)]">Policy type</span>
|
||||
<p class="font-medium text-[var(--text-primary)]">{{ segmentLabel[segment] }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="border-t border-[var(--sidebar-border)] pt-4">
|
||||
<p class="text-xs font-semibold uppercase tracking-wide text-[var(--text-muted)]">Subscriber</p>
|
||||
<dl class="mt-2 grid gap-2 text-sm sm:grid-cols-2">
|
||||
<div>
|
||||
<dt class="text-[var(--text-muted)]">Name</dt>
|
||||
<dd class="font-medium text-[var(--text-primary)]">{{ draft.client.fullName || '—' }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt class="text-[var(--text-muted)]">Email</dt>
|
||||
<dd class="font-medium text-[var(--text-primary)]">{{ draft.client.email || '—' }}</dd>
|
||||
</div>
|
||||
<div v-if="draft.client.organizationName" class="sm:col-span-2">
|
||||
<dt class="text-[var(--text-muted)]">Organization</dt>
|
||||
<dd class="font-medium text-[var(--text-primary)]">{{ draft.client.organizationName }}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
|
||||
<div class="border-t border-[var(--sidebar-border)] pt-4">
|
||||
<p class="text-xs font-semibold uppercase tracking-wide text-[var(--text-muted)]">Age & health</p>
|
||||
<dl class="mt-2 grid gap-2 text-sm sm:grid-cols-3">
|
||||
<div>
|
||||
<dt class="text-[var(--text-muted)]">Age</dt>
|
||||
<dd class="font-medium text-[var(--text-primary)]">{{ draft.health.age || '—' }}</dd>
|
||||
</div>
|
||||
<div v-if="draft.health.preexistingConditions" class="sm:col-span-2">
|
||||
<dt class="text-[var(--text-muted)]">Preexisting conditions</dt>
|
||||
<dd class="font-medium text-[var(--text-primary)]">{{ draft.health.preexistingDetails || 'Yes (no details provided)' }}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
|
||||
<div class="border-t border-[var(--sidebar-border)] pt-4">
|
||||
<p class="text-xs font-semibold uppercase tracking-wide text-[var(--text-muted)]">Coverage</p>
|
||||
<p class="mt-2 text-sm text-[var(--text-primary)]">
|
||||
Area {{ draft.health.coverageArea || '—' }} · Network {{ draft.health.networkTier || '—' }} · Deductible
|
||||
{{ draft.health.deductible || '—' }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="border-t border-[var(--sidebar-border)] pt-4">
|
||||
<p class="text-xs font-semibold uppercase tracking-wide text-[var(--text-muted)]">Carriers</p>
|
||||
<ul class="mt-2 list-inside list-disc text-sm text-[var(--text-primary)]">
|
||||
<li v-for="id in draft.solicit.carrierIds" :key="id">{{ carrierName(id) }}</li>
|
||||
<li v-if="draft.solicit.carrierIds.length === 0" class="list-none text-[var(--text-muted)]">None selected</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="border-t border-[var(--sidebar-border)] pt-4">
|
||||
<p class="text-xs font-semibold uppercase tracking-wide text-[var(--text-muted)]">Plans</p>
|
||||
<ul class="mt-2 list-inside list-disc text-sm text-[var(--text-primary)]">
|
||||
<li v-for="id in draft.solicit.planIds" :key="id">{{ planLabel(id) }}</li>
|
||||
<li v-if="draft.solicit.planIds.length === 0" class="list-none text-[var(--text-muted)]">None selected</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<UAlert
|
||||
color="neutral"
|
||||
variant="soft"
|
||||
title="What happens next"
|
||||
:description="
|
||||
quoteRequestEmailEnabled
|
||||
? 'We can queue emails to each carrier’s quoting address on file (Settings → Providers), unless your tenant uses published tables or AI instead.'
|
||||
: 'No outbound provider emails for this tenant — capture the request here and price via tables, APIs, or agentic workflows.'
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,250 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
HEALTH_AGE_BAND_REFERENCE,
|
||||
HEALTH_COVERAGE_AREA,
|
||||
HEALTH_DEDUCTIBLE,
|
||||
HEALTH_NETWORK_TIER,
|
||||
HEALTH_QUOTE_CARRIERS
|
||||
} from '~/data/health-quote-intake'
|
||||
import type { HealthQuoteDraft, HealthQuoteMode, HealthQuoteSegment } from '~/types/health-quote-intake'
|
||||
|
||||
const props = defineProps<{
|
||||
draft: HealthQuoteDraft
|
||||
modeCards: { id: HealthQuoteMode; title: string; hint: string; icon: string }[]
|
||||
segmentCards: { id: HealthQuoteSegment; title: string; hint: string; icon: string }[]
|
||||
}>()
|
||||
|
||||
function setMode(m: HealthQuoteMode) {
|
||||
props.draft.quoteMode = m
|
||||
}
|
||||
|
||||
function setSegment(s: HealthQuoteSegment) {
|
||||
props.draft.segment = s
|
||||
}
|
||||
|
||||
const showPublishedTable = computed(() =>
|
||||
HEALTH_QUOTE_CARRIERS.some((c) => c.hasPublishedRateTable)
|
||||
)
|
||||
|
||||
const inputPh =
|
||||
'w-full placeholder:text-[var(--text-muted)] placeholder:opacity-[0.55] text-[var(--text-primary)]'
|
||||
|
||||
const showOrganization = computed(
|
||||
() => props.draft.segment === 'corporate' || props.draft.segment === 'group'
|
||||
)
|
||||
|
||||
/** Compute age from date of birth */
|
||||
watch(
|
||||
() => props.draft.health.dateOfBirth,
|
||||
(dob) => {
|
||||
if (!dob) {
|
||||
props.draft.health.age = ''
|
||||
return
|
||||
}
|
||||
const birth = new Date(dob)
|
||||
const today = new Date()
|
||||
let age = today.getFullYear() - birth.getFullYear()
|
||||
const m = today.getMonth() - birth.getMonth()
|
||||
if (m < 0 || (m === 0 && today.getDate() < birth.getDate())) age--
|
||||
props.draft.health.age = String(age)
|
||||
}
|
||||
)
|
||||
|
||||
const showDetails = ref(false)
|
||||
onMounted(() => {
|
||||
requestAnimationFrame(() => {
|
||||
showDetails.value = true
|
||||
})
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="space-y-10">
|
||||
<section class="space-y-4">
|
||||
<div>
|
||||
<h3 class="text-base font-semibold text-[var(--text-primary)]">How can I help?</h3>
|
||||
<p class="mt-1 text-sm text-[var(--text-muted)]">Single quote or comparative PDF — same steps; comparative opens the comparison sheet after acceptance.</p>
|
||||
</div>
|
||||
<div class="grid gap-3 sm:grid-cols-2">
|
||||
<button
|
||||
v-for="card in modeCards"
|
||||
:key="card.id"
|
||||
type="button"
|
||||
class="group rounded-xl border p-5 text-left transition focus:outline-none focus-visible:ring-2 focus-visible:ring-[var(--brand)]"
|
||||
:class="
|
||||
draft.quoteMode === card.id
|
||||
? 'border-[var(--brand)] bg-[var(--brand-soft)] ring-1 ring-[var(--brand)]/30'
|
||||
: 'border-[var(--sidebar-border)] bg-[var(--surface)] hover:border-[var(--brand)]/40'
|
||||
"
|
||||
@click="setMode(card.id)"
|
||||
>
|
||||
<div class="flex items-start gap-3">
|
||||
<div class="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg bg-[var(--brand-faint)] text-[var(--brand)]">
|
||||
<UIcon :name="card.icon" class="h-5 w-5" />
|
||||
</div>
|
||||
<div class="min-w-0">
|
||||
<p class="font-semibold text-[var(--text-primary)]">{{ card.title }}</p>
|
||||
<p class="mt-1 text-sm text-[var(--text-muted)]">{{ card.hint }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="space-y-4 border-t border-[var(--sidebar-border)] pt-10">
|
||||
<div>
|
||||
<h3 class="text-base font-semibold text-[var(--text-primary)]">Policy type</h3>
|
||||
<p class="mt-1 text-sm text-[var(--text-muted)]">Individual, employer corporate, or group policy.</p>
|
||||
</div>
|
||||
<div class="grid gap-3 sm:grid-cols-3">
|
||||
<button
|
||||
v-for="card in segmentCards"
|
||||
:key="card.id"
|
||||
type="button"
|
||||
class="rounded-xl border p-4 text-left transition focus:outline-none focus-visible:ring-2 focus-visible:ring-[var(--brand)]"
|
||||
:class="
|
||||
draft.segment === card.id
|
||||
? 'border-[var(--brand)] bg-[var(--brand-soft)] ring-1 ring-[var(--brand)]/30'
|
||||
: 'border-[var(--sidebar-border)] bg-[var(--surface)] hover:border-[var(--brand)]/40'
|
||||
"
|
||||
@click="setSegment(card.id)"
|
||||
>
|
||||
<UIcon :name="card.icon" class="h-7 w-7 text-[var(--brand)]" />
|
||||
<p class="mt-2 font-semibold text-[var(--text-primary)]">{{ card.title }}</p>
|
||||
<p class="mt-0.5 text-xs text-[var(--text-muted)]">{{ card.hint }}</p>
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section v-if="showDetails" class="border-t border-[var(--sidebar-border)] pt-10">
|
||||
<h3 class="text-base font-semibold text-[var(--text-primary)]">Subscriber & contact</h3>
|
||||
<p class="mt-1 text-sm text-[var(--text-muted)]">Primary insured and notification email.</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 ID" />
|
||||
</UFormField>
|
||||
<UFormField label="Email" required>
|
||||
<UInput v-model="draft.client.email" type="email" :class="inputPh" placeholder="name@company.com" />
|
||||
</UFormField>
|
||||
<UFormField label="Phone">
|
||||
<UInput v-model="draft.client.phone" :class="inputPh" placeholder="+593 …" />
|
||||
</UFormField>
|
||||
<UFormField label="Government ID">
|
||||
<UInput v-model="draft.client.documentId" :class="inputPh" placeholder="ID or RUC" />
|
||||
</UFormField>
|
||||
<UFormField v-if="showOrganization" label="Organization / group name" class="md:col-span-2" required>
|
||||
<UInput v-model="draft.client.organizationName" :class="inputPh" placeholder="Employer or group trust" />
|
||||
</UFormField>
|
||||
</div>
|
||||
|
||||
<h3 class="mt-10 text-base font-semibold text-[var(--text-primary)]">Age & health screening</h3>
|
||||
<p class="mt-1 text-sm text-[var(--text-muted)]">Basic information carriers use for eligibility and rate bands.</p>
|
||||
<div class="mt-5 grid grid-cols-1 gap-4 md:grid-cols-3">
|
||||
<UFormField label="Date of birth" required>
|
||||
<UInput v-model="draft.health.dateOfBirth" type="date" :class="inputPh" />
|
||||
</UFormField>
|
||||
<UFormField label="Age">
|
||||
<UInput :model-value="draft.health.age" disabled :class="inputPh" placeholder="Auto-calculated" />
|
||||
</UFormField>
|
||||
<div />
|
||||
</div>
|
||||
<div class="mt-5 space-y-4 rounded-xl border border-[var(--sidebar-border)] bg-[var(--surface)] p-4">
|
||||
<UCheckbox v-model="draft.health.preexistingConditions" label="Preexisting medical conditions" />
|
||||
<div v-if="draft.health.preexistingConditions" class="ml-6">
|
||||
<UFormField label="Describe conditions" hint="Diabetes, hypertension, cardiac history, etc.">
|
||||
<UTextarea
|
||||
v-model="draft.health.preexistingDetails"
|
||||
:class="inputPh"
|
||||
placeholder="List conditions and approximate diagnosis dates"
|
||||
:rows="3"
|
||||
/>
|
||||
</UFormField>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h3 class="mt-10 text-base font-semibold text-[var(--text-primary)]">Coverage intent</h3>
|
||||
<p class="mt-1 text-sm text-[var(--text-muted)]">Product parameters carriers use before underwriting.</p>
|
||||
<div class="mt-5 grid grid-cols-1 gap-4 md:grid-cols-3">
|
||||
<UFormField label="Coverage area">
|
||||
<USelect
|
||||
v-model="draft.health.coverageArea"
|
||||
:items="HEALTH_COVERAGE_AREA"
|
||||
value-key="value"
|
||||
label-key="label"
|
||||
placeholder="Select one"
|
||||
class="w-full"
|
||||
/>
|
||||
</UFormField>
|
||||
<UFormField label="Network tier">
|
||||
<USelect
|
||||
v-model="draft.health.networkTier"
|
||||
:items="HEALTH_NETWORK_TIER"
|
||||
value-key="value"
|
||||
label-key="label"
|
||||
placeholder="Select one"
|
||||
class="w-full"
|
||||
/>
|
||||
</UFormField>
|
||||
<UFormField label="Deductible preference">
|
||||
<USelect
|
||||
v-model="draft.health.deductible"
|
||||
:items="HEALTH_DEDUCTIBLE"
|
||||
value-key="value"
|
||||
label-key="label"
|
||||
placeholder="Select one"
|
||||
class="w-full"
|
||||
/>
|
||||
</UFormField>
|
||||
</div>
|
||||
|
||||
<h3 class="mt-10 text-base font-semibold text-[var(--text-primary)]">Forms</h3>
|
||||
<p class="mt-1 text-sm text-[var(--text-muted)]">
|
||||
Confirm required templates are completed (uploads wire to the forms library later).
|
||||
</p>
|
||||
<div class="mt-4 space-y-3 rounded-xl border border-[var(--sidebar-border)] bg-[var(--surface)] p-4">
|
||||
<UCheckbox v-model="draft.forms.medicalQuestionnaire" label="Medical questionnaire (declaración de salud)" />
|
||||
<UCheckbox v-model="draft.forms.beneficiaryDesignation" label="Beneficiary designation" />
|
||||
<UCheckbox
|
||||
v-model="draft.forms.groupCensus"
|
||||
label="Group census / employee roster (required for group policies)"
|
||||
:disabled="draft.segment !== 'group'"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-if="showPublishedTable" class="mt-10">
|
||||
<h3 class="text-base font-semibold text-[var(--text-primary)]">Published rate reference (age bands)</h3>
|
||||
<p class="mt-1 text-sm text-[var(--text-muted)]">
|
||||
Some carriers publish indicative premiums by age band. Use as a guide; final quotes may still require
|
||||
underwriting. When your tenant uses table pricing or AI instead of email, turn off outbound emails under
|
||||
Settings → Quote requests.
|
||||
</p>
|
||||
<div class="mt-4 overflow-x-auto rounded-xl border border-[var(--sidebar-border)]">
|
||||
<table class="min-w-full text-left text-sm text-[var(--text-primary)]">
|
||||
<thead class="bg-[var(--page-bg)] text-xs font-semibold uppercase tracking-wide text-[var(--text-muted)]">
|
||||
<tr>
|
||||
<th class="px-3 py-2">Age band</th>
|
||||
<th class="px-3 py-2">Employee</th>
|
||||
<th class="px-3 py-2">Spouse</th>
|
||||
<th class="px-3 py-2">Child</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="row in HEALTH_AGE_BAND_REFERENCE" :key="row.ageBand" class="border-t border-[var(--sidebar-border)]">
|
||||
<td class="px-3 py-2 font-medium">{{ row.ageBand }}</td>
|
||||
<td class="px-3 py-2">${{ row.employee }}</td>
|
||||
<td class="px-3 py-2">${{ row.spouse }}</td>
|
||||
<td class="px-3 py-2">${{ row.children }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<div
|
||||
v-else
|
||||
class="mt-10 min-h-[8rem] rounded-xl bg-[var(--sidebar-border)]/25 animate-pulse"
|
||||
aria-busy="true"
|
||||
aria-label="Loading"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,90 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { HEALTH_COVERAGE_PLANS, HEALTH_QUOTE_CARRIERS } from '~/data/health-quote-intake'
|
||||
import type { HealthQuoteDraft, HealthQuoteMode } from '~/types/health-quote-intake'
|
||||
|
||||
const props = defineProps<{
|
||||
draft: HealthQuoteDraft
|
||||
quoteMode: HealthQuoteMode
|
||||
}>()
|
||||
|
||||
function setCarrier(id: string, checked: boolean) {
|
||||
const xs = props.draft.solicit.carrierIds
|
||||
if (checked && !xs.includes(id)) xs.push(id)
|
||||
if (!checked) {
|
||||
const i = xs.indexOf(id)
|
||||
if (i !== -1) xs.splice(i, 1)
|
||||
}
|
||||
}
|
||||
|
||||
function carrierChecked(id: string) {
|
||||
return props.draft.solicit.carrierIds.includes(id)
|
||||
}
|
||||
|
||||
function setPlan(id: string, checked: boolean) {
|
||||
const xs = props.draft.solicit.planIds
|
||||
if (checked && !xs.includes(id)) xs.push(id)
|
||||
if (!checked) {
|
||||
const i = xs.indexOf(id)
|
||||
if (i !== -1) xs.splice(i, 1)
|
||||
}
|
||||
}
|
||||
|
||||
function planChecked(id: string) {
|
||||
return props.draft.solicit.planIds.includes(id)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="space-y-6">
|
||||
<div>
|
||||
<p class="text-sm text-[var(--text-muted)]">
|
||||
Choose carriers and product shells to request. Quoting contacts live per provider in Settings.
|
||||
</p>
|
||||
<UAlert
|
||||
v-if="quoteMode === 'comparative_pdf'"
|
||||
color="info"
|
||||
variant="soft"
|
||||
class="mt-4"
|
||||
title="Comparative quote"
|
||||
description="We’ll align columns to your selected plan mix. Enter premiums from email, rate tables, or AI-assisted pricing when available."
|
||||
/>
|
||||
<UAlert v-else color="neutral" variant="soft" class="mt-4" title="Single quote" description="We’ll package one request per carrier with the same subscriber and coverage intent." />
|
||||
</div>
|
||||
|
||||
<div class="rounded-xl border border-[var(--sidebar-border)] bg-[var(--surface)] p-4 ring-1 ring-black/[0.04]">
|
||||
<p class="text-xs font-semibold uppercase tracking-wide text-[var(--text-muted)]">Carriers</p>
|
||||
<ul class="mt-3 divide-y divide-[var(--sidebar-border)]">
|
||||
<li
|
||||
v-for="c in HEALTH_QUOTE_CARRIERS"
|
||||
:key="c.id"
|
||||
class="flex flex-wrap items-start justify-between gap-3 py-3 first:pt-0"
|
||||
>
|
||||
<UCheckbox
|
||||
:model-value="carrierChecked(c.id)"
|
||||
:label="c.name"
|
||||
@update:model-value="(v: boolean) => setCarrier(c.id, v)"
|
||||
/>
|
||||
<span class="text-xs text-[var(--text-muted)]">{{ c.detail }}</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="rounded-xl border border-[var(--sidebar-border)] bg-[var(--surface)] p-4 ring-1 ring-black/[0.04]">
|
||||
<p class="text-xs font-semibold uppercase tracking-wide text-[var(--text-muted)]">Plans / benefit shells</p>
|
||||
<ul class="mt-3 space-y-3">
|
||||
<li
|
||||
v-for="p in HEALTH_COVERAGE_PLANS"
|
||||
:key="p.id"
|
||||
class="flex flex-col gap-1 rounded-lg border border-[var(--sidebar-border)]/80 bg-[var(--page-bg)]/50 p-3 sm:flex-row sm:items-center sm:justify-between"
|
||||
>
|
||||
<UCheckbox
|
||||
:model-value="planChecked(p.id)"
|
||||
:label="p.label"
|
||||
@update:model-value="(v: boolean) => setPlan(p.id, v)"
|
||||
/>
|
||||
<span class="text-xs text-[var(--text-muted)] sm:text-right">{{ p.hint }}</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,149 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { LIFE_COVERAGE_PLANS, LIFE_QUOTE_CARRIERS } from '~/data/life-quote-intake'
|
||||
import type { LifeQuoteDraft, LifeQuoteMode, LifeQuoteSegment } from '~/types/life-quote-intake'
|
||||
|
||||
const props = defineProps<{
|
||||
draft: LifeQuoteDraft
|
||||
quoteMode: LifeQuoteMode
|
||||
segment: LifeQuoteSegment
|
||||
}>()
|
||||
|
||||
const { quoteRequestEmailEnabled } = useQuoteRequestEmailEnabled()
|
||||
|
||||
function carrierName(id: string) {
|
||||
return LIFE_QUOTE_CARRIERS.find((c) => c.id === id)?.name ?? id
|
||||
}
|
||||
|
||||
function planLabel(id: string) {
|
||||
return LIFE_COVERAGE_PLANS.find((p) => p.id === id)?.label ?? id
|
||||
}
|
||||
|
||||
const segmentLabel: Record<LifeQuoteSegment, string> = {
|
||||
individual: 'Individual',
|
||||
corporate_keyman: 'Corporate / Key person',
|
||||
group: 'Group'
|
||||
}
|
||||
|
||||
const modeLabel: Record<LifeQuoteMode, string> = {
|
||||
single: 'Single quote',
|
||||
comparative_pdf: 'Comparative PDF'
|
||||
}
|
||||
|
||||
function formatAmount(val: string) {
|
||||
const n = Number(val)
|
||||
if (!n) return val || '—'
|
||||
return '$' + n.toLocaleString()
|
||||
}
|
||||
|
||||
function termLabel(val: string) {
|
||||
if (val === 'whole') return 'Whole life'
|
||||
return val ? `${val} years` : '—'
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="space-y-6">
|
||||
<div>
|
||||
<p class="text-sm text-[var(--text-muted)]">Review the life quote request before sending or saving.</p>
|
||||
</div>
|
||||
|
||||
<UAlert
|
||||
v-if="!quoteRequestEmailEnabled"
|
||||
color="warning"
|
||||
variant="soft"
|
||||
title="Provider emails are turned off"
|
||||
description="Settings -> Quote requests: outbound emails disabled. This run saves the request locally (or uses table / AI pricing when connected) without emailing carriers."
|
||||
/>
|
||||
|
||||
<div class="space-y-4 rounded-xl border border-[var(--sidebar-border)] bg-[var(--surface)] p-5 ring-1 ring-black/[0.04]">
|
||||
<div class="flex flex-wrap gap-x-6 gap-y-2 text-sm">
|
||||
<div>
|
||||
<span class="text-[var(--text-muted)]">Intent</span>
|
||||
<p class="font-medium text-[var(--text-primary)]">{{ modeLabel[quoteMode] }}</p>
|
||||
</div>
|
||||
<div>
|
||||
<span class="text-[var(--text-muted)]">Policy type</span>
|
||||
<p class="font-medium text-[var(--text-primary)]">{{ segmentLabel[segment] }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="border-t border-[var(--sidebar-border)] pt-4">
|
||||
<p class="text-xs font-semibold uppercase tracking-wide text-[var(--text-muted)]">Insured person</p>
|
||||
<dl class="mt-2 grid gap-2 text-sm sm:grid-cols-2">
|
||||
<div>
|
||||
<dt class="text-[var(--text-muted)]">Name</dt>
|
||||
<dd class="font-medium text-[var(--text-primary)]">{{ draft.client.fullName || '—' }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt class="text-[var(--text-muted)]">Email</dt>
|
||||
<dd class="font-medium text-[var(--text-primary)]">{{ draft.client.email || '—' }}</dd>
|
||||
</div>
|
||||
<div v-if="draft.client.organizationName" class="sm:col-span-2">
|
||||
<dt class="text-[var(--text-muted)]">Organization</dt>
|
||||
<dd class="font-medium text-[var(--text-primary)]">{{ draft.client.organizationName }}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
|
||||
<div class="border-t border-[var(--sidebar-border)] pt-4">
|
||||
<p class="text-xs font-semibold uppercase tracking-wide text-[var(--text-muted)]">Age & health</p>
|
||||
<dl class="mt-2 grid gap-2 text-sm sm:grid-cols-3">
|
||||
<div>
|
||||
<dt class="text-[var(--text-muted)]">Age</dt>
|
||||
<dd class="font-medium text-[var(--text-primary)]">{{ draft.life.age || '—' }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt class="text-[var(--text-muted)]">Gender</dt>
|
||||
<dd class="font-medium text-[var(--text-primary)]">{{ draft.life.gender || '—' }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt class="text-[var(--text-muted)]">Smoker</dt>
|
||||
<dd class="font-medium text-[var(--text-primary)]">{{ draft.life.smoker ? 'Yes' : 'No' }}</dd>
|
||||
</div>
|
||||
<div v-if="draft.life.preexistingConditions" class="sm:col-span-3">
|
||||
<dt class="text-[var(--text-muted)]">Preexisting conditions</dt>
|
||||
<dd class="font-medium text-[var(--text-primary)]">{{ draft.life.preexistingDetails || 'Yes (no details provided)' }}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
|
||||
<div class="border-t border-[var(--sidebar-border)] pt-4">
|
||||
<p class="text-xs font-semibold uppercase tracking-wide text-[var(--text-muted)]">Coverage</p>
|
||||
<p class="mt-2 text-sm text-[var(--text-primary)]">
|
||||
{{ formatAmount(draft.life.coverageAmount) }} · {{ termLabel(draft.life.coverageTerm) }}
|
||||
</p>
|
||||
<p v-if="draft.life.beneficiaryName" class="mt-1 text-sm text-[var(--text-muted)]">
|
||||
Beneficiary: {{ draft.life.beneficiaryName }}
|
||||
<span v-if="draft.life.beneficiaryRelationship"> ({{ draft.life.beneficiaryRelationship }})</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="border-t border-[var(--sidebar-border)] pt-4">
|
||||
<p class="text-xs font-semibold uppercase tracking-wide text-[var(--text-muted)]">Carriers</p>
|
||||
<ul class="mt-2 list-inside list-disc text-sm text-[var(--text-primary)]">
|
||||
<li v-for="id in draft.solicit.carrierIds" :key="id">{{ carrierName(id) }}</li>
|
||||
<li v-if="draft.solicit.carrierIds.length === 0" class="list-none text-[var(--text-muted)]">None selected</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="border-t border-[var(--sidebar-border)] pt-4">
|
||||
<p class="text-xs font-semibold uppercase tracking-wide text-[var(--text-muted)]">Plans</p>
|
||||
<ul class="mt-2 list-inside list-disc text-sm text-[var(--text-primary)]">
|
||||
<li v-for="id in draft.solicit.planIds" :key="id">{{ planLabel(id) }}</li>
|
||||
<li v-if="draft.solicit.planIds.length === 0" class="list-none text-[var(--text-muted)]">None selected</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<UAlert
|
||||
color="neutral"
|
||||
variant="soft"
|
||||
title="What happens next"
|
||||
:description="
|
||||
quoteRequestEmailEnabled
|
||||
? 'We can queue emails to each carrier\'s quoting address on file (Settings -> Providers), unless your tenant uses published tables or AI instead.'
|
||||
: 'No outbound provider emails for this tenant — capture the request here and price via tables, APIs, or agentic workflows.'
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,369 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
LIFE_COVERAGE_AMOUNT_OPTIONS,
|
||||
LIFE_COVERAGE_TERM_OPTIONS
|
||||
} from '~/data/life-quote-intake'
|
||||
import type { LifeQuoteDraft, LifeQuoteMode, LifeQuoteSegment } from '~/types/life-quote-intake'
|
||||
import { useCustomerSelection } from '~/composables/useCustomerSelection'
|
||||
|
||||
const props = defineProps<{
|
||||
draft: LifeQuoteDraft
|
||||
modeCards: { id: LifeQuoteMode; title: string; hint: string; icon: string }[]
|
||||
segmentCards: { id: LifeQuoteSegment; title: string; hint: string; icon: string }[]
|
||||
}>()
|
||||
|
||||
function setMode(m: LifeQuoteMode) {
|
||||
props.draft.quoteMode = m
|
||||
}
|
||||
|
||||
function setSegment(s: LifeQuoteSegment) {
|
||||
props.draft.segment = s
|
||||
}
|
||||
|
||||
const inputPh =
|
||||
'w-full placeholder:text-[var(--text-muted)] placeholder:opacity-[0.55] text-[var(--text-primary)]'
|
||||
|
||||
const showOrganization = computed(
|
||||
() => props.draft.segment === 'corporate_keyman' || props.draft.segment === 'group'
|
||||
)
|
||||
|
||||
// Use customer selection composable
|
||||
const {
|
||||
selectedCustomer,
|
||||
selectedBuyer,
|
||||
useSameForBuyer,
|
||||
insured,
|
||||
buyer,
|
||||
isInsuredValid,
|
||||
isBuyerValid,
|
||||
validationErrors
|
||||
} = useCustomerSelection()
|
||||
|
||||
// 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
|
||||
|
||||
const showDetails = ref(false)
|
||||
onMounted(() => {
|
||||
requestAnimationFrame(() => {
|
||||
showDetails.value = true
|
||||
})
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="space-y-10">
|
||||
<section class="space-y-4">
|
||||
<div>
|
||||
<h3 class="text-base font-semibold text-[var(--text-primary)]">How can I help?</h3>
|
||||
<p class="mt-1 text-sm text-[var(--text-muted)]">Single quote or comparative PDF — same steps; comparative opens the comparison sheet after acceptance.</p>
|
||||
</div>
|
||||
<div class="grid gap-3 sm:grid-cols-2">
|
||||
<button
|
||||
v-for="card in modeCards"
|
||||
:key="card.id"
|
||||
type="button"
|
||||
class="group rounded-xl border p-5 text-left transition focus:outline-none focus-visible:ring-2 focus-visible:ring-[var(--brand)]"
|
||||
:class="
|
||||
draft.quoteMode === card.id
|
||||
? 'border-[var(--brand)] bg-[var(--brand-soft)] ring-1 ring-[var(--brand)]/30'
|
||||
: 'border-[var(--sidebar-border)] bg-[var(--surface)] hover:border-[var(--brand)]/40'
|
||||
"
|
||||
@click="setMode(card.id)"
|
||||
>
|
||||
<div class="flex items-start gap-3">
|
||||
<div class="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg bg-[var(--brand-faint)] text-[var(--brand)]">
|
||||
<UIcon :name="card.icon" class="h-5 w-5" />
|
||||
</div>
|
||||
<div class="min-w-0">
|
||||
<p class="font-semibold text-[var(--text-primary)]">{{ card.title }}</p>
|
||||
<p class="mt-1 text-sm text-[var(--text-muted)]">{{ card.hint }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="space-y-4 border-t border-[var(--sidebar-border)] pt-10">
|
||||
<div>
|
||||
<h3 class="text-base font-semibold text-[var(--text-primary)]">Policy type</h3>
|
||||
<p class="mt-1 text-sm text-[var(--text-muted)]">Individual, corporate / key person, or group policy.</p>
|
||||
</div>
|
||||
<div class="grid gap-3 sm:grid-cols-3">
|
||||
<button
|
||||
v-for="card in segmentCards"
|
||||
:key="card.id"
|
||||
type="button"
|
||||
class="rounded-xl border p-4 text-left transition focus:outline-none focus-visible:ring-2 focus-visible:ring-[var(--brand)]"
|
||||
:class="
|
||||
draft.segment === card.id
|
||||
? 'border-[var(--brand)] bg-[var(--brand-soft)] ring-1 ring-[var(--brand)]/30'
|
||||
: 'border-[var(--sidebar-border)] bg-[var(--surface)] hover:border-[var(--brand)]/40'
|
||||
"
|
||||
@click="setSegment(card.id)"
|
||||
>
|
||||
<UIcon :name="card.icon" class="h-7 w-7 text-[var(--brand)]" />
|
||||
<p class="mt-2 font-semibold text-[var(--text-primary)]">{{ card.title }}</p>
|
||||
<p class="mt-0.5 text-xs text-[var(--text-muted)]">{{ card.hint }}</p>
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section v-if="showDetails" class="border-t border-[var(--sidebar-border)] pt-10">
|
||||
<!-- Insured Section -->
|
||||
<h3 class="text-base font-semibold text-[var(--text-primary)]">Insured</h3>
|
||||
<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>
|
||||
|
||||
<!-- Buyer Section -->
|
||||
<div class="mt-10 border-t border-[var(--sidebar-border)] pt-10">
|
||||
<div class="flex items-center gap-3 mb-4">
|
||||
<h3 class="text-base font-semibold text-[var(--text-primary)]">Buyer</h3>
|
||||
<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>
|
||||
|
||||
<!-- Health Questionnaire -->
|
||||
<h3 class="mt-10 text-base font-semibold text-[var(--text-primary)]">Health Information</h3>
|
||||
<p class="mt-1 text-sm text-[var(--text-muted)]">Basic underwriting inputs — carriers use these to determine eligibility and rate class.</p>
|
||||
<div class="mt-5 space-y-4 rounded-xl border border-[var(--sidebar-border)] bg-[var(--surface)] p-4">
|
||||
<UCheckbox v-model="draft.life.smoker" label="Smoker / tobacco user (within last 12 months)" />
|
||||
|
||||
<UFormField label="Medications">
|
||||
<UTextarea
|
||||
v-model="draft.life.medications"
|
||||
:class="inputPh"
|
||||
placeholder="List any current medications..."
|
||||
:rows="2"
|
||||
/>
|
||||
</UFormField>
|
||||
|
||||
<UFormField label="Surgeries">
|
||||
<UTextarea
|
||||
v-model="draft.life.surgeries"
|
||||
:class="inputPh"
|
||||
placeholder="List any past surgeries..."
|
||||
:rows="2"
|
||||
/>
|
||||
</UFormField>
|
||||
|
||||
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<UFormField label="Weight (kg)">
|
||||
<UInput v-model="draft.life.weight" type="number" :class="inputPh" placeholder="—" />
|
||||
</UFormField>
|
||||
<UFormField label="Height (cm)">
|
||||
<UInput v-model="draft.life.height" type="number" :class="inputPh" placeholder="—" />
|
||||
</UFormField>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Coverage parameters -->
|
||||
<h3 class="mt-10 text-base font-semibold text-[var(--text-primary)]">Coverage Details</h3>
|
||||
<p class="mt-1 text-sm text-[var(--text-muted)]">Sum assured, term, and coverage type.</p>
|
||||
<div class="mt-5 grid grid-cols-1 gap-4 md:grid-cols-3">
|
||||
<UFormField label="Coverage type" required>
|
||||
<USelect
|
||||
v-model="draft.life.coverage_type"
|
||||
:items="[
|
||||
{ label: 'Banking', value: 'banking' },
|
||||
{ label: 'Protection', value: 'protection' }
|
||||
]"
|
||||
placeholder="Select one"
|
||||
class="w-full"
|
||||
/>
|
||||
</UFormField>
|
||||
<UFormField label="Coverage amount (USD)" required>
|
||||
<USelect
|
||||
v-model="draft.life.coverage_amount"
|
||||
:items="LIFE_COVERAGE_AMOUNT_OPTIONS"
|
||||
value-key="value"
|
||||
label-key="label"
|
||||
placeholder="Select one"
|
||||
class="w-full"
|
||||
/>
|
||||
</UFormField>
|
||||
<UFormField label="Coverage years" required>
|
||||
<USelect
|
||||
v-model="draft.life.coverage_years"
|
||||
:items="LIFE_COVERAGE_TERM_OPTIONS"
|
||||
value-key="value"
|
||||
label-key="label"
|
||||
placeholder="Select one"
|
||||
class="w-full"
|
||||
/>
|
||||
</UFormField>
|
||||
</div>
|
||||
|
||||
<!-- Forms -->
|
||||
<h3 class="mt-10 text-base font-semibold text-[var(--text-primary)]">Forms</h3>
|
||||
<p class="mt-1 text-sm text-[var(--text-muted)]">
|
||||
Confirm required templates are completed (uploads wire to the forms library later).
|
||||
</p>
|
||||
<div class="mt-4 space-y-3 rounded-xl border border-[var(--sidebar-border)] bg-[var(--surface)] p-4">
|
||||
<UCheckbox v-model="draft.forms.medicalQuestionnaire" label="Medical questionnaire (declaracion de salud)" />
|
||||
<UCheckbox v-model="draft.forms.beneficiaryDesignation" label="Beneficiary designation form" />
|
||||
<UCheckbox
|
||||
v-model="draft.forms.groupCensus"
|
||||
label="Group census / employee roster (required for group policies)"
|
||||
:disabled="draft.segment !== 'group'"
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
<div
|
||||
v-else
|
||||
class="mt-10 min-h-[8rem] rounded-xl bg-[var(--sidebar-border)]/25 animate-pulse"
|
||||
aria-busy="true"
|
||||
aria-label="Loading"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,90 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { LIFE_COVERAGE_PLANS, LIFE_QUOTE_CARRIERS } from '~/data/life-quote-intake'
|
||||
import type { LifeQuoteDraft, LifeQuoteMode } from '~/types/life-quote-intake'
|
||||
|
||||
const props = defineProps<{
|
||||
draft: LifeQuoteDraft
|
||||
quoteMode: LifeQuoteMode
|
||||
}>()
|
||||
|
||||
function setCarrier(id: string, checked: boolean) {
|
||||
const xs = props.draft.solicit.carrierIds
|
||||
if (checked && !xs.includes(id)) xs.push(id)
|
||||
if (!checked) {
|
||||
const i = xs.indexOf(id)
|
||||
if (i !== -1) xs.splice(i, 1)
|
||||
}
|
||||
}
|
||||
|
||||
function carrierChecked(id: string) {
|
||||
return props.draft.solicit.carrierIds.includes(id)
|
||||
}
|
||||
|
||||
function setPlan(id: string, checked: boolean) {
|
||||
const xs = props.draft.solicit.planIds
|
||||
if (checked && !xs.includes(id)) xs.push(id)
|
||||
if (!checked) {
|
||||
const i = xs.indexOf(id)
|
||||
if (i !== -1) xs.splice(i, 1)
|
||||
}
|
||||
}
|
||||
|
||||
function planChecked(id: string) {
|
||||
return props.draft.solicit.planIds.includes(id)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="space-y-6">
|
||||
<div>
|
||||
<p class="text-sm text-[var(--text-muted)]">
|
||||
Choose carriers and plan shells to request. Quoting contacts live per provider in Settings.
|
||||
</p>
|
||||
<UAlert
|
||||
v-if="quoteMode === 'comparative_pdf'"
|
||||
color="info"
|
||||
variant="soft"
|
||||
class="mt-4"
|
||||
title="Comparative quote"
|
||||
description="We'll align columns to your selected plan mix. Enter premiums from email, rate tables, or AI-assisted pricing when available."
|
||||
/>
|
||||
<UAlert v-else color="neutral" variant="soft" class="mt-4" title="Single quote" description="We'll package one request per carrier with the same insured and coverage intent." />
|
||||
</div>
|
||||
|
||||
<div class="rounded-xl border border-[var(--sidebar-border)] bg-[var(--surface)] p-4 ring-1 ring-black/[0.04]">
|
||||
<p class="text-xs font-semibold uppercase tracking-wide text-[var(--text-muted)]">Carriers</p>
|
||||
<ul class="mt-3 divide-y divide-[var(--sidebar-border)]">
|
||||
<li
|
||||
v-for="c in LIFE_QUOTE_CARRIERS"
|
||||
:key="c.id"
|
||||
class="flex flex-wrap items-start justify-between gap-3 py-3 first:pt-0"
|
||||
>
|
||||
<UCheckbox
|
||||
:model-value="carrierChecked(c.id)"
|
||||
:label="c.name"
|
||||
@update:model-value="(v: boolean) => setCarrier(c.id, v)"
|
||||
/>
|
||||
<span class="text-xs text-[var(--text-muted)]">{{ c.detail }}</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="rounded-xl border border-[var(--sidebar-border)] bg-[var(--surface)] p-4 ring-1 ring-black/[0.04]">
|
||||
<p class="text-xs font-semibold uppercase tracking-wide text-[var(--text-muted)]">Plans / coverage shells</p>
|
||||
<ul class="mt-3 space-y-3">
|
||||
<li
|
||||
v-for="p in LIFE_COVERAGE_PLANS"
|
||||
:key="p.id"
|
||||
class="flex flex-col gap-1 rounded-lg border border-[var(--sidebar-border)]/80 bg-[var(--page-bg)]/50 p-3 sm:flex-row sm:items-center sm:justify-between"
|
||||
>
|
||||
<UCheckbox
|
||||
:model-value="planChecked(p.id)"
|
||||
:label="p.label"
|
||||
@update:model-value="(v: boolean) => setPlan(p.id, v)"
|
||||
/>
|
||||
<span class="text-xs text-[var(--text-muted)] sm:text-right">{{ p.hint }}</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -5,16 +5,13 @@
|
||||
*/
|
||||
const props = defineProps<{
|
||||
/** Which stage the current page represents */
|
||||
currentStage: 'quick_lead' | 'customer' | 'get_quotes' | 'present_quotes' | 'solicitud' | 'emission'
|
||||
currentStage: 'quick_lead' | 'customer' | 'new_quote'
|
||||
}>()
|
||||
|
||||
const stages = [
|
||||
{ id: 'quick_lead', label: 'Quick Lead', icon: 'i-heroicons-bolt', route: '/sales/quick-lead' },
|
||||
{ id: 'customer', label: 'Customer', icon: 'i-heroicons-user-plus', route: '/registration/client' },
|
||||
{ id: 'get_quotes', label: 'Get Quotes', icon: 'i-heroicons-document-magnifying-glass', route: '/quotes/new' },
|
||||
{ id: 'present_quotes', label: 'Present Quotes', icon: 'i-heroicons-presentation-chart-bar', route: '/quotes/compare' },
|
||||
{ id: 'solicitud', label: 'Solicitud', icon: 'i-heroicons-clipboard-document-check', route: '/onboarding/solicitud' },
|
||||
{ id: 'emission', label: 'Emission', icon: 'i-heroicons-check-badge', route: '/onboarding/emissions' },
|
||||
{ id: 'customer', label: 'Customer', icon: 'i-heroicons-user-plus', route: '/customers/new' },
|
||||
{ id: 'new_quote', label: 'New Quote', icon: 'i-heroicons-document-magnifying-glass', route: '/quotes/new' },
|
||||
] as const
|
||||
|
||||
type StageId = typeof stages[number]['id']
|
||||
|
||||
Reference in New Issue
Block a user