big refactor

This commit is contained in:
2026-04-29 16:25:11 -05:00
parent 6c411ce2b6
commit 8265fb689a
156 changed files with 15845 additions and 50373 deletions

View 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>

View 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>

View 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>

View File

@@ -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>

View 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>

View 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>

View 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>

View 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>

View 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>

View File

@@ -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 ── */

View File

@@ -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">

View 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>

View 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>

View File

@@ -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>

View File

@@ -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="Well send quote requests to each carriers 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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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="Well 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="Well email each selected carriers 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>

View File

@@ -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 carriers 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>

View File

@@ -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>

View File

@@ -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="Well 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="Well 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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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']