big refactor
This commit is contained in:
53
app/components/ArrayInput.vue
Normal file
53
app/components/ArrayInput.vue
Normal file
@@ -0,0 +1,53 @@
|
||||
<script setup lang="ts">
|
||||
const props = defineProps<{
|
||||
modelValue: string[]
|
||||
placeholder?: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [string[]]
|
||||
}>()
|
||||
|
||||
const textValue = computed({
|
||||
get: () => props.modelValue.join('\n'),
|
||||
set: (val: string) => {
|
||||
const array = val.split('\n').map(s => s.trim()).filter(s => s.length > 0)
|
||||
emit('update:modelValue', array)
|
||||
}
|
||||
})
|
||||
|
||||
function removeTag(index: number) {
|
||||
const newValue = [...props.modelValue]
|
||||
newValue.splice(index, 1)
|
||||
emit('update:modelValue', newValue)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="space-y-2">
|
||||
<div v-if="modelValue.length > 0" class="flex flex-wrap gap-2">
|
||||
<UBadge
|
||||
v-for="(item, index) in modelValue"
|
||||
:key="index"
|
||||
color="neutral"
|
||||
variant="soft"
|
||||
class="flex items-center gap-1"
|
||||
>
|
||||
{{ item }}
|
||||
<UButton
|
||||
icon="i-heroicons-x-mark"
|
||||
size="xs"
|
||||
color="neutral"
|
||||
variant="ghost"
|
||||
@click="removeTag(index)"
|
||||
/>
|
||||
</UBadge>
|
||||
</div>
|
||||
<UTextarea
|
||||
:model-value="textValue"
|
||||
@update:model-value="textValue = $event"
|
||||
:placeholder="placeholder"
|
||||
:rows="4"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
189
app/components/CustomerSelector.vue
Normal file
189
app/components/CustomerSelector.vue
Normal file
@@ -0,0 +1,189 @@
|
||||
<script setup lang="ts">
|
||||
const props = defineProps<{
|
||||
modelValue: {
|
||||
selectedCustomer: any
|
||||
useSameForBuyer: boolean
|
||||
selectedBuyer: any
|
||||
}
|
||||
search: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: {
|
||||
selectedCustomer: any
|
||||
useSameForBuyer: boolean
|
||||
selectedBuyer: any
|
||||
}]
|
||||
'update:search': [value: string]
|
||||
}>()
|
||||
|
||||
const customerPage = ref(1)
|
||||
const localSearch = ref(props.search)
|
||||
const debouncedSearch = refDebounced(localSearch, 300)
|
||||
|
||||
watch(() => props.search, (newVal) => {
|
||||
localSearch.value = newVal
|
||||
})
|
||||
|
||||
const { data: customersData, pending: customersPending } = useCustomer('/customers', {
|
||||
query: computed(() => ({
|
||||
'page_size': 12,
|
||||
'page': customerPage.value,
|
||||
...(debouncedSearch.value && {
|
||||
'filters[0][field]': 'search',
|
||||
'filters[0][op]': '==',
|
||||
'filters[0][value]': debouncedSearch.value
|
||||
})
|
||||
}))
|
||||
})
|
||||
|
||||
function customerDisplayName(c: any) {
|
||||
if (!c) return ''
|
||||
return c.customer_type === 'corporate'
|
||||
? (c.commercial_name || c.legal_name)
|
||||
: `${c.first_name} ${c.last_name}`
|
||||
}
|
||||
|
||||
function customerSubtitle(c: any) {
|
||||
if (!c) return ''
|
||||
return c.customer_type === 'corporate' ? c.ruc : c.email
|
||||
}
|
||||
|
||||
function updateSelectedCustomer(customer: any) {
|
||||
emit('update:modelValue', {
|
||||
...props.modelValue,
|
||||
selectedCustomer: customer,
|
||||
selectedBuyer: props.modelValue.useSameForBuyer ? customer : props.modelValue.selectedBuyer
|
||||
})
|
||||
}
|
||||
|
||||
function updateSelectedBuyer(customer: any) {
|
||||
emit('update:modelValue', {
|
||||
...props.modelValue,
|
||||
selectedBuyer: customer
|
||||
})
|
||||
}
|
||||
|
||||
function updateUseSameForBuyer(value: boolean) {
|
||||
emit('update:modelValue', {
|
||||
...props.modelValue,
|
||||
useSameForBuyer: value,
|
||||
selectedBuyer: value ? props.modelValue.selectedCustomer : props.modelValue.selectedBuyer
|
||||
})
|
||||
}
|
||||
|
||||
function onSearchInput(value: string) {
|
||||
localSearch.value = value
|
||||
emit('update:search', value)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="space-y-6">
|
||||
<!-- Insured Section -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-[var(--text-primary)] mb-2">Insured</label>
|
||||
<p class="text-sm text-[var(--text-muted)] mb-4">Person or entity being insured</p>
|
||||
|
||||
<div v-if="!modelValue.selectedCustomer">
|
||||
<UInput
|
||||
:model-value="localSearch"
|
||||
@update:model-value="onSearchInput"
|
||||
placeholder="Search by name, email, RUC..."
|
||||
size="lg"
|
||||
icon="i-heroicons-magnifying-glass"
|
||||
/>
|
||||
|
||||
<div v-if="customersPending" class="mt-4 text-center text-[var(--text-muted)]">
|
||||
Loading customers...
|
||||
</div>
|
||||
|
||||
<div v-else-if="customersData?.data && customersData.data.length > 0" class="mt-4 space-y-2">
|
||||
<div
|
||||
v-for="c in customersData.data"
|
||||
:key="c.id"
|
||||
class="p-4 border border-[var(--card-border)] rounded-lg cursor-pointer hover:bg-[var(--card-border)] transition-colors"
|
||||
@click="updateSelectedCustomer(c)"
|
||||
>
|
||||
<div class="flex items-center gap-3">
|
||||
<UBadge :color="c.customer_type === 'corporate' ? 'purple' : 'blue'" variant="soft" size="sm">
|
||||
{{ c.customer_type === 'corporate' ? 'Corp' : 'Ind' }}
|
||||
</UBadge>
|
||||
<span class="font-medium text-[var(--text-primary)]">{{ customerDisplayName(c) }}</span>
|
||||
</div>
|
||||
<div class="text-sm text-[var(--text-muted)] mt-1">{{ customerSubtitle(c) }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else class="mt-4 text-center text-[var(--text-muted)]">
|
||||
No customers found. Try a different search term.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else class="p-4 bg-[var(--brand-soft)] border border-[var(--brand)] rounded-lg">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<div class="flex items-center gap-3">
|
||||
<UBadge :color="modelValue.selectedCustomer.customer_type === 'corporate' ? 'purple' : 'blue'" variant="soft" size="sm">
|
||||
{{ modelValue.selectedCustomer.customer_type === 'corporate' ? 'Corp' : 'Ind' }}
|
||||
</UBadge>
|
||||
<span class="font-medium text-[var(--text-primary)]">{{ customerDisplayName(modelValue.selectedCustomer) }}</span>
|
||||
</div>
|
||||
<div class="text-sm text-[var(--brand)] mt-1">{{ customerSubtitle(modelValue.selectedCustomer) }}</div>
|
||||
</div>
|
||||
<UButton size="sm" color="neutral" variant="outline" @click="updateSelectedCustomer(null)">Change</UButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Buyer Section -->
|
||||
<div>
|
||||
<div class="flex items-center gap-3 mb-2">
|
||||
<label class="block text-sm font-medium text-[var(--text-primary)]">Buyer</label>
|
||||
<div class="flex items-center gap-2">
|
||||
<UToggle :model-value="modelValue.useSameForBuyer" @update:model-value="updateUseSameForBuyer" />
|
||||
<span class="text-sm text-[var(--text-muted)]">Same as insured</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p v-if="modelValue.useSameForBuyer" class="text-sm text-[var(--text-muted)]">
|
||||
Using same person as insured
|
||||
</p>
|
||||
|
||||
<div v-else>
|
||||
<UInput
|
||||
:model-value="localSearch"
|
||||
@update:model-value="onSearchInput"
|
||||
placeholder="Search by name, email, RUC..."
|
||||
size="lg"
|
||||
icon="i-heroicons-magnifying-glass"
|
||||
/>
|
||||
|
||||
<div v-if="customersPending" class="mt-4 text-center text-[var(--text-muted)]">
|
||||
Loading customers...
|
||||
</div>
|
||||
|
||||
<div v-else-if="customersData?.data && customersData.data.length > 0" class="mt-4 space-y-2">
|
||||
<div
|
||||
v-for="c in customersData.data"
|
||||
:key="c.id"
|
||||
class="p-4 border border-[var(--card-border)] rounded-lg cursor-pointer hover:bg-[var(--card-border)] transition-colors"
|
||||
@click="updateSelectedBuyer(c)"
|
||||
>
|
||||
<div class="flex items-center gap-3">
|
||||
<UBadge :color="c.customer_type === 'corporate' ? 'purple' : 'blue'" variant="soft" size="sm">
|
||||
{{ c.customer_type === 'corporate' ? 'Corp' : 'Ind' }}
|
||||
</UBadge>
|
||||
<span class="font-medium text-[var(--text-primary)]">{{ customerDisplayName(c) }}</span>
|
||||
</div>
|
||||
<div class="text-sm text-[var(--text-muted)] mt-1">{{ customerSubtitle(c) }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else class="mt-4 text-center text-[var(--text-muted)]">
|
||||
No customers found. Try a different search term.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
81
app/components/ProviderSelector.vue
Normal file
81
app/components/ProviderSelector.vue
Normal file
@@ -0,0 +1,81 @@
|
||||
<script setup lang="ts">
|
||||
const props = defineProps<{
|
||||
selected: string[]
|
||||
search: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:selected': [value: string[]]
|
||||
'update:search': [value: string]
|
||||
}>()
|
||||
|
||||
const providerPage = ref(1)
|
||||
const localSearch = ref(props.search)
|
||||
const debouncedSearch = refDebounced(localSearch, 300)
|
||||
|
||||
watch(() => props.search, (newVal) => {
|
||||
localSearch.value = newVal
|
||||
})
|
||||
|
||||
const { data: providersData, pending: providersPending } = useProviders('/providers', {
|
||||
query: computed(() => ({
|
||||
'page_size': 12,
|
||||
'page': providerPage.value,
|
||||
...(debouncedSearch.value && {
|
||||
'filters[0][field]': 'search',
|
||||
'filters[0][op]': '==',
|
||||
'filters[0][value]': debouncedSearch.value
|
||||
})
|
||||
}))
|
||||
})
|
||||
|
||||
function toggleProvider(providerId: string) {
|
||||
const newValue = props.selected.includes(providerId)
|
||||
? props.selected.filter(id => id !== providerId)
|
||||
: [...props.selected, providerId]
|
||||
emit('update:selected', newValue)
|
||||
}
|
||||
|
||||
function onSearchInput(value: string) {
|
||||
localSearch.value = value
|
||||
emit('update:search', value)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="space-y-4">
|
||||
<label class="block text-sm font-medium text-[var(--text-primary)] mb-2">Select Providers</label>
|
||||
<p class="text-sm text-[var(--text-muted)] mb-4">Choose insurance providers to get quotes from</p>
|
||||
|
||||
<UInput
|
||||
:model-value="localSearch"
|
||||
@update:model-value="onSearchInput"
|
||||
placeholder="Search providers..."
|
||||
size="lg"
|
||||
icon="i-heroicons-magnifying-glass"
|
||||
/>
|
||||
|
||||
<div v-if="providersPending" class="text-center text-[var(--text-muted)] py-4">
|
||||
Loading providers...
|
||||
</div>
|
||||
|
||||
<div v-else-if="providersData?.data && providersData.data.length > 0" class="space-y-2">
|
||||
<div
|
||||
v-for="provider in providersData.data"
|
||||
:key="provider.provider_id"
|
||||
class="flex items-center gap-3 p-4 border border-[var(--card-border)] rounded-lg hover:bg-[var(--card-border)] transition-colors"
|
||||
>
|
||||
<UCheckbox
|
||||
:model-value="selected.includes(provider.provider_id)"
|
||||
@update:model-value="toggleProvider(provider.provider_id)"
|
||||
size="lg"
|
||||
/>
|
||||
<span class="font-medium text-[var(--text-primary)]">{{ provider.name }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else class="text-center text-[var(--text-muted)] py-4">
|
||||
No providers found. Try a different search term.
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,20 +1,27 @@
|
||||
<script setup lang="ts">
|
||||
import type { AppThemeId } from '~/types/app-theme'
|
||||
const colorMode = useColorMode()
|
||||
const isDark = computed({
|
||||
get () {
|
||||
return colorMode.value === 'dark'
|
||||
},
|
||||
set () {
|
||||
colorMode.preference = colorMode.value === 'dark' ? 'light' : 'dark'
|
||||
}
|
||||
})
|
||||
|
||||
const { themeId, themeOptions, applyTheme } = useAppTheme()
|
||||
const themeOptions = [
|
||||
{ id: 'light', label: 'Light', description: 'Clean and bright interface' },
|
||||
{ id: 'dark', label: 'Dark', description: 'Easy on the eyes in low light' },
|
||||
]
|
||||
|
||||
const themeGradients: Record<string, string> = {
|
||||
light: 'from-sky-100 via-blue-50 to-indigo-100',
|
||||
purple: 'from-violet-100 via-fuchsia-50 to-purple-100',
|
||||
dark: 'from-slate-700 via-slate-800 to-slate-900',
|
||||
'dark-purple': 'from-violet-900 via-purple-950 to-slate-900'
|
||||
}
|
||||
|
||||
const themeIcons: Record<string, string> = {
|
||||
light: 'i-heroicons-sun',
|
||||
purple: 'i-heroicons-sparkles',
|
||||
dark: 'i-heroicons-moon',
|
||||
'dark-purple': 'i-heroicons-star'
|
||||
dark: 'i-heroicons-moon'
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -34,15 +41,15 @@ const themeIcons: Record<string, string> = {
|
||||
:key="opt.id"
|
||||
type="button"
|
||||
class="app-theme-card text-left"
|
||||
:class="themeId === opt.id ? 'app-theme-card-selected' : ''"
|
||||
@click="applyTheme(opt.id as AppThemeId)"
|
||||
:class="colorMode === opt.id ? 'app-theme-card-selected' : ''"
|
||||
@click="colorMode.preference = opt.id"
|
||||
>
|
||||
<!-- Header -->
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<div class="flex items-center gap-2.5">
|
||||
<div
|
||||
class="flex h-8 w-8 items-center justify-center rounded-lg"
|
||||
:class="themeId === opt.id ? 'bg-[var(--brand-soft)] text-[var(--brand)]' : 'bg-[var(--badge-muted-bg)] text-[var(--text-muted)]'"
|
||||
:class="colorMode === opt.id ? 'bg-[var(--brand-soft)] text-[var(--brand)]' : 'bg-[var(--badge-muted-bg)] text-[var(--text-muted)]'"
|
||||
>
|
||||
<UIcon :name="themeIcons[opt.id]" class="h-4 w-4" />
|
||||
</div>
|
||||
@@ -60,7 +67,7 @@ const themeIcons: Record<string, string> = {
|
||||
leave-to-class="opacity-0 scale-75"
|
||||
>
|
||||
<UIcon
|
||||
v-if="themeId === opt.id"
|
||||
v-if="colorMode === opt.id"
|
||||
name="i-heroicons-check-circle-solid"
|
||||
class="h-6 w-6 shrink-0 text-[var(--brand)]"
|
||||
/>
|
||||
@@ -76,7 +83,7 @@ const themeIcons: Record<string, string> = {
|
||||
<!-- Component preview (scoped to this theme) -->
|
||||
<div
|
||||
class="mt-3 rounded-lg border border-[var(--sidebar-border)] bg-[var(--page-bg)] p-3"
|
||||
:data-theme="opt.id"
|
||||
:class="opt.id === 'dark' ? 'dark' : ''"
|
||||
>
|
||||
<div class="space-y-3">
|
||||
<!-- Buttons row -->
|
||||
@@ -119,7 +126,9 @@ const themeIcons: Record<string, string> = {
|
||||
<p>
|
||||
Theme applies instantly to all pages including sidebar navigation, cards, inputs, buttons, badges, and KPI panels.
|
||||
You can also quickly switch themes from the
|
||||
<UIcon name="i-heroicons-swatch" class="inline h-3.5 w-3.5" />
|
||||
<UIcon name="i-heroicons-sun" class="inline h-3.5 w-3.5" />
|
||||
/
|
||||
<UIcon name="i-heroicons-moon" class="inline h-3.5 w-3.5" />
|
||||
icon in the top bar.
|
||||
</p>
|
||||
</div>
|
||||
@@ -127,3 +136,27 @@ const themeIcons: Record<string, string> = {
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.app-theme-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
padding: 1rem;
|
||||
border-radius: 0.75rem;
|
||||
border: 1px solid var(--card-border);
|
||||
background: var(--surface);
|
||||
transition: all 0.15s ease;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.app-theme-card:hover {
|
||||
border-color: rgba(1, 105, 111, 0.2);
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04);
|
||||
}
|
||||
|
||||
.app-theme-card-selected {
|
||||
border-color: var(--brand);
|
||||
background: rgba(1, 105, 111, 0.02);
|
||||
}
|
||||
</style>
|
||||
|
||||
153
app/components/back-office/KanbanColumn.vue
Normal file
153
app/components/back-office/KanbanColumn.vue
Normal file
@@ -0,0 +1,153 @@
|
||||
<script setup lang="ts">
|
||||
import KanbanTaskCard from './KanbanTaskCard.vue'
|
||||
|
||||
const props = defineProps<{
|
||||
title: string
|
||||
status: string
|
||||
tasks: any[]
|
||||
loading: boolean
|
||||
}>()
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
function navigateToTask(task: any) {
|
||||
router.push(`/back-office/workload/${encodeURIComponent(task.id)}`)
|
||||
}
|
||||
|
||||
// Stage configuration for colors
|
||||
const stageConfig: Record<string, {
|
||||
color: string;
|
||||
dot: string;
|
||||
headerBg: string;
|
||||
}> = {
|
||||
created: {
|
||||
color: 'text-[var(--text-muted)]',
|
||||
dot: 'bg-[var(--text-muted)]',
|
||||
headerBg: 'bg-[var(--surface)] border-[var(--card-border)]'
|
||||
},
|
||||
draft: {
|
||||
color: 'text-[var(--brand)]',
|
||||
dot: 'bg-[var(--brand)]',
|
||||
headerBg: 'bg-[var(--brand-faint)] border-[var(--brand-soft)]'
|
||||
},
|
||||
approved: {
|
||||
color: 'text-emerald-700',
|
||||
dot: 'bg-emerald-500',
|
||||
headerBg: 'bg-emerald-50 border-emerald-200'
|
||||
},
|
||||
completed: {
|
||||
color: 'text-gray-500',
|
||||
dot: 'bg-gray-400',
|
||||
headerBg: 'bg-gray-50 border-gray-200'
|
||||
}
|
||||
}
|
||||
|
||||
function daysInStage(createdAt: string): string {
|
||||
const days = Math.floor((Date.now() - new Date(createdAt).getTime()) / (1000 * 60 * 60 * 24))
|
||||
return days === 0 ? 'Today' : `${days}d`
|
||||
}
|
||||
|
||||
function taskUrgent(task: any): boolean {
|
||||
// Placeholder - will be based on priority/due_date from backend
|
||||
return false
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="kanban-column">
|
||||
<div class="column-header" :class="stageConfig[status].headerBg">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="h-2 w-2 rounded-full" :class="stageConfig[status].dot" />
|
||||
<span class="text-[13px] font-semibold" :class="stageConfig[status].color">
|
||||
{{ title }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-1.5">
|
||||
<span class="flex h-5 min-w-[20px] items-center justify-center rounded-full bg-[var(--surface)]/70 px-1.5 text-[11px] font-semibold text-[var(--text-muted)] ring-1 ring-[var(--card-border)]/60">
|
||||
{{ tasks.length }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="task-list">
|
||||
<KanbanTaskCard
|
||||
v-for="task in tasks"
|
||||
:key="task.id"
|
||||
:task="task"
|
||||
:urgent="taskUrgent(task)"
|
||||
@click="navigateToTask(task)"
|
||||
/>
|
||||
|
||||
<div v-if="tasks.length === 0 && !loading" class="empty-state">
|
||||
<UIcon name="i-heroicons-inbox" class="w-8 h-8" />
|
||||
</div>
|
||||
|
||||
<div v-if="loading" class="loading-state">
|
||||
<div v-for="n in 3" :key="n" class="skeleton-card" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.kanban-column {
|
||||
flex: 0 0 240px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: var(--surface);
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--card-border);
|
||||
overflow: hidden;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.column-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 8px 12px;
|
||||
border-bottom: 1px solid var(--card-border);
|
||||
background: var(--surface);
|
||||
border-radius: 8px 8px 0 0;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.task-list {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
padding: 8px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 32px 16px;
|
||||
color: var(--text-muted);
|
||||
min-height: 100%;
|
||||
}
|
||||
|
||||
.loading-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.skeleton-card {
|
||||
height: 100px;
|
||||
background: var(--card-border);
|
||||
border-radius: 6px;
|
||||
animation: pulse 1.5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%, 100% { opacity: 0.6; }
|
||||
50% { opacity: 1; }
|
||||
}
|
||||
</style>
|
||||
110
app/components/back-office/KanbanTaskCard.vue
Normal file
110
app/components/back-office/KanbanTaskCard.vue
Normal file
@@ -0,0 +1,110 @@
|
||||
<script setup lang="ts">
|
||||
const props = defineProps<{
|
||||
task: any
|
||||
urgent?: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
click: [task: any]
|
||||
}>()
|
||||
|
||||
const policyTypeColors: Record<string, string> = {
|
||||
car: 'bg-blue-100 text-blue-700',
|
||||
life: 'bg-violet-100 text-violet-700',
|
||||
fire: 'bg-amber-100 text-amber-700'
|
||||
}
|
||||
|
||||
function onClick() {
|
||||
emit('click', props.task)
|
||||
}
|
||||
|
||||
function daysInStage(createdAt: string): string {
|
||||
const days = Math.floor((Date.now() - new Date(createdAt).getTime()) / (1000 * 60 * 60 * 24))
|
||||
return days === 0 ? 'Today' : `${days}d`
|
||||
}
|
||||
|
||||
function formatDate(date: string, format: 'short' = 'short'): string {
|
||||
if (!date) return '—'
|
||||
const d = new Date(date)
|
||||
if (format === 'short') {
|
||||
return d.toLocaleDateString('es-PA', { month: 'short', day: 'numeric' })
|
||||
}
|
||||
return d.toLocaleDateString('es-PA', {
|
||||
day: '2-digit',
|
||||
month: 'short',
|
||||
year: 'numeric'
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="kanban-task-card rounded-xl border px-3.5 py-3 shadow-sm ring-1 transition hover:shadow-md"
|
||||
:class="urgent
|
||||
? 'border-rose-300 bg-rose-50/50 ring-rose-100 hover:border-rose-400'
|
||||
: 'border-[var(--card-border)] bg-[var(--surface)] ring-[var(--surface)] hover:border-[var(--brand)]/30'"
|
||||
@click="onClick"
|
||||
>
|
||||
<!-- Header row: Application ID + Policy type badge -->
|
||||
<div class="flex items-start justify-between gap-1 mb-1">
|
||||
<p class="text-[13px] font-mono font-semibold leading-tight text-[var(--text-primary)] truncate">
|
||||
{{ task.application_id }}
|
||||
</p>
|
||||
<span
|
||||
class="ml-1 shrink-0 rounded-md px-1.5 py-0.5 text-[10px] font-semibold"
|
||||
:class="policyTypeColors[task.policy_type] || 'bg-gray-100 text-gray-700'"
|
||||
>
|
||||
{{ task.policy_type?.toUpperCase() }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Provider name -->
|
||||
<p class="text-[12px] text-[var(--text-muted)] truncate">
|
||||
{{ task.provider_name || 'Unknown Provider' }}
|
||||
</p>
|
||||
|
||||
<!-- Urgent flag -->
|
||||
<span v-if="urgent" class="mt-1.5 inline-flex items-center gap-1 rounded-full bg-rose-100 px-1.5 py-0.5 text-[9px] font-bold uppercase tracking-wide text-rose-600">
|
||||
<span class="h-1.5 w-1.5 rounded-full bg-rose-500" />
|
||||
Urgent
|
||||
</span>
|
||||
|
||||
<!-- Meta row -->
|
||||
<div class="mt-2.5 flex items-center justify-between border-t border-[var(--divider)] pt-2">
|
||||
<div class="flex items-center gap-1">
|
||||
<!-- Assignee -->
|
||||
<div v-if="task.assignee" class="flex items-center gap-1 text-[11px] text-[var(--text-muted)]">
|
||||
<UIcon name="i-heroicons-user" class="w-3 h-3" />
|
||||
<span class="truncate max-w-[60px]">{{ task.assignee.name }}</span>
|
||||
</div>
|
||||
<div v-else class="flex items-center gap-1 text-[11px] text-[var(--text-muted)] opacity-50">
|
||||
<UIcon name="i-heroicons-user-minus" class="w-3 h-3" />
|
||||
<span>Unassigned</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-1.5 text-[11px] text-[var(--text-muted)]">
|
||||
<!-- Due date if present -->
|
||||
<span v-if="task.due_date" class="truncate">
|
||||
Due: {{ formatDate(task.due_date, 'short') }}
|
||||
</span>
|
||||
<!-- Days in stage -->
|
||||
<span class="flex items-center gap-0.5">
|
||||
<UIcon name="i-heroicons-clock" class="w-3 h-3" />
|
||||
{{ daysInStage(task.created_at) }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.kanban-task-card {
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.kanban-task-card:hover {
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
</style>
|
||||
|
||||
118
app/components/back-office/NestedJsonViewer.vue
Normal file
118
app/components/back-office/NestedJsonViewer.vue
Normal file
@@ -0,0 +1,118 @@
|
||||
<script setup lang="ts">
|
||||
defineOptions({ name: 'NestedJsonViewer' })
|
||||
|
||||
interface Props {
|
||||
data: any
|
||||
depth?: number
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
|
||||
const depth = computed(() => props.depth || 0)
|
||||
|
||||
// Font size decreases with depth: 12px base, -1px per level
|
||||
const fontSize = computed(() => Math.max(10, 12 - depth.value * 1))
|
||||
|
||||
// Padding decreases with depth
|
||||
const padding = computed(() => Math.max(1, 4 - depth.value))
|
||||
|
||||
function isObject(value: any): boolean {
|
||||
return value !== null && typeof value === 'object' && !Array.isArray(value)
|
||||
}
|
||||
|
||||
function isArray(value: any): boolean {
|
||||
return Array.isArray(value)
|
||||
}
|
||||
|
||||
function isDateString(value: string): boolean {
|
||||
if (typeof value !== 'string') return false
|
||||
return /^\d{4}-\d{2}-\d{2}/.test(value) && !isNaN(Date.parse(value))
|
||||
}
|
||||
|
||||
function formatValue(value: any): string {
|
||||
if (value === null || value === undefined) return '—'
|
||||
if (typeof value === 'boolean') return value ? 'Yes' : 'No'
|
||||
if (typeof value === 'number') return value.toLocaleString()
|
||||
if (isDateString(value)) {
|
||||
try {
|
||||
return new Date(value).toLocaleDateString('es-PA', {
|
||||
day: '2-digit',
|
||||
month: 'short',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
})
|
||||
} catch {
|
||||
return value
|
||||
}
|
||||
}
|
||||
return String(value)
|
||||
}
|
||||
|
||||
function formatKey(key: string): string {
|
||||
return key.replace(/_/g, ' ').replace(/\b\w/g, l => l.toUpperCase())
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="nested-json-viewer" :style="{ fontSize: fontSize + 'px' }">
|
||||
<!-- Object: render as table -->
|
||||
<table v-if="isObject(data) && Object.keys(data).length > 0" class="json-table border rounded overflow-hidden mb-2">
|
||||
<tbody>
|
||||
<tr v-for="(value, key) in data" :key="key" class="border-t border-gray-200 hover:bg-gray-50">
|
||||
<td class="field-name px-3 py-2 font-medium text-gray-600 align-top whitespace-nowrap" :style="{ padding: padding + 'px', fontSize: (fontSize - 1) + 'px' }">
|
||||
{{ formatKey(key) }}
|
||||
</td>
|
||||
<td class="field-value px-3 py-2 text-gray-900 align-top" :style="{ padding: padding + 'px' }">
|
||||
<NestedJsonViewer :data="value" :depth="depth + 1" />
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<div v-else-if="isObject(data) && Object.keys(data).length === 0" class="text-gray-400 italic" :style="{ fontSize: fontSize + 'px' }">
|
||||
Empty object
|
||||
</div>
|
||||
|
||||
<!-- Array -->
|
||||
<div v-else-if="isArray(data)" class="array-container">
|
||||
<div v-for="(item, index) in data" :key="index" class="array-item mb-2 border rounded p-2">
|
||||
<div class="text-xs font-semibold text-gray-500 mb-1">[{{ index }}]</div>
|
||||
<NestedJsonViewer :data="item" :depth="depth + 1" />
|
||||
</div>
|
||||
<div v-if="data.length === 0" class="text-gray-400 italic" :style="{ fontSize: fontSize + 'px' }">
|
||||
Empty array
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Primitive -->
|
||||
<span v-else class="primitive-value" :class="{ 'text-gray-400': data === '—' }">
|
||||
{{ formatValue(data) }}
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.nested-json-viewer {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.json-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
background: white;
|
||||
}
|
||||
|
||||
.field-name {
|
||||
min-width: 120px;
|
||||
background: #f9fafb;
|
||||
}
|
||||
|
||||
.field-value {
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.array-item {
|
||||
background: white;
|
||||
border-color: var(--card-border, #e5e7eb);
|
||||
}
|
||||
</style>
|
||||
42
app/components/back-office/QuoteTaskInfoDisplay.vue
Normal file
42
app/components/back-office/QuoteTaskInfoDisplay.vue
Normal file
@@ -0,0 +1,42 @@
|
||||
<script setup lang="ts">
|
||||
interface Props {
|
||||
task: any // TODO: Type properly
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
|
||||
const formatDate = (d: string) => d
|
||||
? new Date(d).toLocaleDateString('es-PA', { day: '2-digit', month: 'short', year: 'numeric', hour: '2-digit', minute: '2-digit' })
|
||||
: '—'
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="space-y-4 text-sm">
|
||||
<!-- Basic Info -->
|
||||
<div class="grid grid-cols-2 gap-x-4 gap-y-2">
|
||||
<div class="text-gray-500">Task ID</div>
|
||||
<div class="font-mono text-xs text-right">{{ task.id }}</div>
|
||||
|
||||
<div class="text-gray-500">Application ID</div>
|
||||
<div class="font-mono text-xs text-right">{{ task.application_id }}</div>
|
||||
|
||||
<div class="text-gray-500">Provider ID</div>
|
||||
<div class="font-mono text-xs text-right">{{ task.task_info?.provider_id }}</div>
|
||||
|
||||
<div v-if="task.task_info?.provider_name" class="text-gray-500">Provider</div>
|
||||
<div v-if="task.task_info?.provider_name" class="text-right">{{ task.task_info.provider_name }}</div>
|
||||
|
||||
<div class="text-gray-500">Org</div>
|
||||
<div class="font-mono text-xs text-right">{{ task.org_id }}</div>
|
||||
|
||||
<div class="text-gray-500">Created</div>
|
||||
<div class="text-right">{{ formatDate(task.created_at) }}</div>
|
||||
|
||||
<div class="text-gray-500">Updated</div>
|
||||
<div class="text-right">{{ formatDate(task.updated_at) }}</div>
|
||||
|
||||
<div class="text-gray-500">Policy Type</div>
|
||||
<div class="text-right uppercase text-xs">{{ task.task_info?.policy_type || '—' }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
74
app/components/back-office/SolicitationTaskInfoDisplay.vue
Normal file
74
app/components/back-office/SolicitationTaskInfoDisplay.vue
Normal file
@@ -0,0 +1,74 @@
|
||||
<script setup lang="ts">
|
||||
interface Props {
|
||||
task: any // TODO: Type properly
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="space-y-6">
|
||||
<!-- Applicant Info -->
|
||||
<div v-if="task.task_info?.applicant_info">
|
||||
<h3 class="text-xs font-semibold text-gray-500 uppercase mb-2">Applicant Information</h3>
|
||||
<div class="border rounded-lg overflow-hidden">
|
||||
<table class="w-full text-sm">
|
||||
<thead class="bg-gray-50">
|
||||
<tr>
|
||||
<th class="px-3 py-2 text-left text-xs font-medium text-gray-500 uppercase">Field</th>
|
||||
<th class="px-3 py-2 text-left text-xs font-medium text-gray-500 uppercase">Value</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-200">
|
||||
<tr v-for="(value, key) in task.task_info.applicant_info" :key="key">
|
||||
<td class="px-3 py-2 text-gray-600 font-medium capitalize">{{ key.replace(/_/g, ' ') }}</td>
|
||||
<td class="px-3 py-2 text-gray-900">{{ value || '—' }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Vehicle Info -->
|
||||
<div v-if="task.task_info?.vehicle_info">
|
||||
<h3 class="text-xs font-semibold text-gray-500 uppercase mb-2">Vehicle Information</h3>
|
||||
<div class="border rounded-lg overflow-hidden">
|
||||
<table class="w-full text-sm">
|
||||
<thead class="bg-gray-50">
|
||||
<tr>
|
||||
<th class="px-3 py-2 text-left text-xs font-medium text-gray-500 uppercase">Field</th>
|
||||
<th class="px-3 py-2 text-left text-xs font-medium text-gray-500 uppercase">Value</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-200">
|
||||
<tr v-for="(value, key) in task.task_info.vehicle_info" :key="key">
|
||||
<td class="px-3 py-2 text-gray-600 font-medium capitalize">{{ key.replace(/_/g, ' ') }}</td>
|
||||
<td class="px-3 py-2 text-gray-900">{{ value || '—' }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Policy Details -->
|
||||
<div v-if="task.task_info?.policy_details">
|
||||
<h3 class="text-xs font-semibold text-gray-500 uppercase mb-2">Policy Details</h3>
|
||||
<div class="border rounded-lg overflow-hidden">
|
||||
<table class="w-full text-sm">
|
||||
<thead class="bg-gray-50">
|
||||
<tr>
|
||||
<th class="px-3 py-2 text-left text-xs font-medium text-gray-500 uppercase">Field</th>
|
||||
<th class="px-3 py-2 text-left text-xs font-medium text-gray-500 uppercase">Value</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-200">
|
||||
<tr v-for="(value, key) in task.task_info.policy_details" :key="key">
|
||||
<td class="px-3 py-2 text-gray-600 font-medium capitalize">{{ key.replace(/_/g, ' ') }}</td>
|
||||
<td class="px-3 py-2 text-gray-900">{{ value || '—' }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -3,15 +3,13 @@
|
||||
* Global command palette — searches customers, policies, claims, and app pages.
|
||||
* Keyboard: Ctrl/Cmd+K to focus, Escape to close, ↑↓ to navigate, Enter to go.
|
||||
*/
|
||||
import { MOCK_CUSTOMERS, fmtMoney } from '~/data/mock-customers'
|
||||
|
||||
const router = useRouter()
|
||||
const open = ref(false)
|
||||
const q = ref('')
|
||||
const inputRef = ref<HTMLElement | null>(null)
|
||||
const activeIndex = ref(-1)
|
||||
|
||||
/* ── Build searchable records from mock data ── */
|
||||
/* ── Build searchable records from API data ── */
|
||||
|
||||
type SearchHit = {
|
||||
id: string
|
||||
@@ -24,48 +22,9 @@ type SearchHit = {
|
||||
}
|
||||
|
||||
const allRecords = computed<SearchHit[]>(() => {
|
||||
const hits: SearchHit[] = []
|
||||
|
||||
for (const c of MOCK_CUSTOMERS) {
|
||||
// Customer record
|
||||
hits.push({
|
||||
id: `cust-${c.id}`,
|
||||
kind: 'customer',
|
||||
icon: 'i-heroicons-user',
|
||||
title: c.name,
|
||||
meta: `${c.type} · ${c.documentId}`,
|
||||
detail: `${c.policies.length} policies · ${fmtMoney(c.policies.reduce((s, p) => s + p.premium, 0))}/yr · Agent: ${c.agent}`,
|
||||
to: `/customers/${c.id}`
|
||||
})
|
||||
|
||||
// Each policy
|
||||
for (const p of c.policies) {
|
||||
hits.push({
|
||||
id: `pol-${p.id}`,
|
||||
kind: 'policy',
|
||||
icon: p.icon,
|
||||
title: p.id,
|
||||
meta: `${p.line} · ${p.carrier} · ${c.name}`,
|
||||
detail: p.product,
|
||||
to: `/customers/${c.id}`
|
||||
})
|
||||
}
|
||||
|
||||
// Each claim
|
||||
for (const cl of c.claims) {
|
||||
hits.push({
|
||||
id: `claim-${cl.id}`,
|
||||
kind: 'claim',
|
||||
icon: 'i-heroicons-shield-exclamation',
|
||||
title: cl.id,
|
||||
meta: `${cl.type} · ${cl.status} · ${c.name}`,
|
||||
detail: `Policy ${cl.policy} · $${cl.amount.toLocaleString()}`,
|
||||
to: `/customers/${c.id}`
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return hits
|
||||
// TODO: Replace with API calls to fetch customers, policies, and claims
|
||||
// For now, return empty array since mock data has been removed
|
||||
return []
|
||||
})
|
||||
|
||||
/* ── App pages / destinations ── */
|
||||
|
||||
@@ -1,7 +1,4 @@
|
||||
<script setup lang="ts">
|
||||
import type { AppThemeId } from '~/types/app-theme'
|
||||
import { APP_THEME_OPTIONS } from '~/types/app-theme'
|
||||
|
||||
defineProps<{
|
||||
sidebarCollapsed: boolean
|
||||
brandTitle?: string
|
||||
@@ -15,19 +12,18 @@ const emit = defineEmits<{
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const isHome = computed(() => route.path === '/')
|
||||
const { themeId, applyTheme } = useAppTheme()
|
||||
|
||||
const themeIcons: Record<string, string> = {
|
||||
light: 'i-heroicons-sun',
|
||||
purple: 'i-heroicons-sparkles',
|
||||
dark: 'i-heroicons-moon',
|
||||
'dark-purple': 'i-heroicons-star',
|
||||
}
|
||||
const colorMode = useColorMode()
|
||||
const isDark = computed({
|
||||
get () {
|
||||
return colorMode.value === 'dark'
|
||||
},
|
||||
set () {
|
||||
colorMode.preference = colorMode.value === 'dark' ? 'light' : 'dark'
|
||||
}
|
||||
})
|
||||
|
||||
const userMenuOpen = ref(false)
|
||||
const userMenuRoot = ref<HTMLElement | null>(null)
|
||||
const themeMenuOpen = ref(false)
|
||||
const themeMenuRoot = ref<HTMLElement | null>(null)
|
||||
|
||||
function closeUserMenu() {
|
||||
userMenuOpen.value = false
|
||||
@@ -38,10 +34,6 @@ function onDocClick(e: MouseEvent) {
|
||||
if (userEl && userMenuOpen.value && !userEl.contains(e.target as Node)) {
|
||||
userMenuOpen.value = false
|
||||
}
|
||||
const themeEl = themeMenuRoot.value
|
||||
if (themeEl && themeMenuOpen.value && !themeEl.contains(e.target as Node)) {
|
||||
themeMenuOpen.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => document.addEventListener('click', onDocClick))
|
||||
@@ -107,58 +99,19 @@ onUnmounted(() => document.removeEventListener('click', onDocClick))
|
||||
<UIcon name="i-heroicons-arrow-path" style="width: 16px; height: 16px;" />
|
||||
</button>
|
||||
|
||||
<!-- Quick theme switcher -->
|
||||
<div ref="themeMenuRoot" class="relative">
|
||||
<button
|
||||
type="button"
|
||||
class="app-topbar-icon-btn"
|
||||
title="Switch theme"
|
||||
<!-- Theme toggle -->
|
||||
<ClientOnly>
|
||||
<UButton
|
||||
:icon="isDark ? 'i-heroicons-moon-20-solid' : 'i-heroicons-sun-20-solid'"
|
||||
color="gray"
|
||||
variant="ghost"
|
||||
aria-label="Theme"
|
||||
@click.stop="themeMenuOpen = !themeMenuOpen"
|
||||
>
|
||||
<UIcon :name="themeIcons[themeId] ?? 'i-heroicons-swatch'" style="width: 16px; height: 16px;" />
|
||||
</button>
|
||||
<Transition
|
||||
enter-active-class="transition duration-150 ease-out"
|
||||
enter-from-class="opacity-0 scale-95 translate-y-1"
|
||||
enter-to-class="opacity-100 scale-100 translate-y-0"
|
||||
leave-active-class="transition duration-100 ease-in"
|
||||
leave-from-class="opacity-100"
|
||||
leave-to-class="opacity-0 scale-95"
|
||||
>
|
||||
<div
|
||||
v-show="themeMenuOpen"
|
||||
class="absolute right-0 top-[calc(100%+8px)] z-50 w-52 overflow-hidden rounded-xl border border-[var(--sidebar-border)] bg-[var(--surface)] py-1.5 shadow-xl ring-1 ring-black/5"
|
||||
>
|
||||
<p class="px-3 py-1 text-[10px] font-semibold uppercase tracking-wider text-[var(--text-muted)]">Theme</p>
|
||||
<button
|
||||
v-for="opt in APP_THEME_OPTIONS"
|
||||
:key="opt.id"
|
||||
type="button"
|
||||
class="flex w-full items-center gap-2.5 px-3 py-2 text-left text-sm transition hover:bg-[var(--brand-faint)]"
|
||||
:class="themeId === opt.id ? 'text-[var(--brand)] font-medium' : 'text-[var(--text-primary)]'"
|
||||
@click="applyTheme(opt.id as AppThemeId); themeMenuOpen = false"
|
||||
>
|
||||
<UIcon :name="themeIcons[opt.id]" class="h-4 w-4 shrink-0" :class="themeId === opt.id ? 'text-[var(--brand)]' : 'opacity-60'" />
|
||||
<span class="flex-1">{{ opt.label }}</span>
|
||||
<UIcon
|
||||
v-if="themeId === opt.id"
|
||||
name="i-heroicons-check"
|
||||
class="h-3.5 w-3.5 text-[var(--brand)]"
|
||||
/>
|
||||
</button>
|
||||
<div class="mx-3 my-1.5 border-t border-[var(--sidebar-border)]" />
|
||||
<NuxtLink
|
||||
to="/account"
|
||||
class="flex items-center gap-2.5 px-3 py-1.5 text-xs text-[var(--text-muted)] transition hover:text-[var(--brand)]"
|
||||
@click="themeMenuOpen = false"
|
||||
>
|
||||
<UIcon name="i-heroicons-cog-6-tooth" class="h-3.5 w-3.5" />
|
||||
All appearance settings
|
||||
</NuxtLink>
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
@click="isDark = !isDark"
|
||||
/>
|
||||
<template #fallback>
|
||||
<div class="w-8 h-8" />
|
||||
</template>
|
||||
</ClientOnly>
|
||||
|
||||
<NuxtLink to="/settings" class="inline-flex" title="Software settings">
|
||||
<span class="app-topbar-icon-btn">
|
||||
|
||||
148
app/components/policies/ConfirmAcceptModal.vue
Normal file
148
app/components/policies/ConfirmAcceptModal.vue
Normal file
@@ -0,0 +1,148 @@
|
||||
<script setup lang="ts">
|
||||
interface Plan {
|
||||
provider_id: string
|
||||
plan_id: string
|
||||
name: string
|
||||
premium: number
|
||||
}
|
||||
|
||||
interface Props {
|
||||
open: boolean
|
||||
plan: Plan | null
|
||||
loading?: boolean
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
loading: false
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:open': [value: boolean]
|
||||
accept: []
|
||||
cancel: []
|
||||
}>()
|
||||
|
||||
function close() {
|
||||
emit('update:open', false)
|
||||
emit('cancel')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<Transition
|
||||
enter-active-class="transition duration-200 ease-out"
|
||||
enter-from-class="opacity-0"
|
||||
enter-to-class="opacity-100"
|
||||
leave-active-class="transition duration-150 ease-in"
|
||||
leave-from-class="opacity-100"
|
||||
leave-to-class="opacity-0"
|
||||
>
|
||||
<div v-if="open" class="confirm-modal-overlay" @click.self="close">
|
||||
<Transition
|
||||
enter-active-class="transition duration-200 ease-out"
|
||||
enter-from-class="opacity-0 scale-95 translate-y-2"
|
||||
enter-to-class="opacity-100 scale-100 translate-y-0"
|
||||
leave-active-class="transition duration-150 ease-in"
|
||||
leave-from-class="opacity-100 scale-100 translate-y-0"
|
||||
leave-to-class="opacity-0 scale-95 translate-y-2"
|
||||
>
|
||||
<div v-if="open" class="confirm-modal">
|
||||
<div class="confirm-modal-head">
|
||||
<h3 class="text-lg font-semibold text-[var(--text-primary)]">Accept Quote</h3>
|
||||
<button type="button" class="confirm-modal-close" @click="close">
|
||||
<UIcon name="i-heroicons-x-mark" style="width: 16px; height: 16px;" />
|
||||
</button>
|
||||
</div>
|
||||
<div class="confirm-modal-body">
|
||||
<div v-if="plan" class="space-y-3">
|
||||
<div class="bg-gray-50 rounded-lg p-4">
|
||||
<div class="text-sm text-gray-500">Selected Plan</div>
|
||||
<div class="text-lg font-semibold text-[var(--text-primary)] mt-1">{{ plan.name }}</div>
|
||||
<div class="text-2xl font-bold text-green-600 mt-2">${{ plan.premium?.toLocaleString() }}</div>
|
||||
<div class="text-xs text-gray-500 mt-1">Provider: {{ plan.provider_id }}</div>
|
||||
</div>
|
||||
<p class="text-sm text-gray-600">
|
||||
Are you sure you want to accept this plan? This will trigger the solicitation process.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="confirm-modal-foot">
|
||||
<div class="flex justify-end gap-3">
|
||||
<UButton color="gray" variant="soft" @click="close">Cancel</UButton>
|
||||
<UButton color="primary" :loading="loading" @click="emit('accept')">Accept</UButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* Modal overlay */
|
||||
.confirm-modal-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 100;
|
||||
background: rgba(0, 0, 0, 0.25);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 20px;
|
||||
backdrop-filter: blur(2px);
|
||||
}
|
||||
|
||||
/* Modal container */
|
||||
.confirm-modal {
|
||||
width: 100%;
|
||||
max-width: 480px;
|
||||
border-radius: 12px;
|
||||
background: #ffffff;
|
||||
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.15), 0 4px 16px rgba(0, 0, 0, 0.08);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Modal header */
|
||||
.confirm-modal-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 18px 20px 0;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* Modal body */
|
||||
.confirm-modal-body {
|
||||
padding: 16px 20px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
/* Modal footer */
|
||||
.confirm-modal-foot {
|
||||
padding: 12px 20px 16px;
|
||||
border-top: 1px solid rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
|
||||
/* Close button */
|
||||
.confirm-modal-close {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 6px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: #8a8a86;
|
||||
cursor: pointer;
|
||||
transition: all 150ms ease;
|
||||
}
|
||||
|
||||
.confirm-modal-close:hover {
|
||||
background: rgba(0, 0, 0, 0.05);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
</style>
|
||||
99
app/components/policies/QuoteSelectionBoard.vue
Normal file
99
app/components/policies/QuoteSelectionBoard.vue
Normal file
@@ -0,0 +1,99 @@
|
||||
<script setup lang="ts">
|
||||
interface PlanItem {
|
||||
provider_id: string
|
||||
quote_id: string
|
||||
valid_until: string | null
|
||||
received_at: string
|
||||
plan_id: string
|
||||
name: string
|
||||
premium: number
|
||||
coverage_details: string
|
||||
deductible?: number
|
||||
coverage_limit?: number
|
||||
}
|
||||
|
||||
interface Props {
|
||||
items: PlanItem[]
|
||||
selectedPlanId: string | null
|
||||
policyApplicationId: string
|
||||
loading?: boolean
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
loading: false
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
select: [planId: string]
|
||||
}>()
|
||||
|
||||
function formatPremium(value: number): string {
|
||||
return '$' + value.toLocaleString()
|
||||
}
|
||||
|
||||
function formatDate(dateStr: string | null): string {
|
||||
if (!dateStr) return ''
|
||||
return new Date(dateStr).toLocaleDateString('es-PA', {
|
||||
day: '2-digit',
|
||||
month: 'short',
|
||||
year: 'numeric'
|
||||
})
|
||||
}
|
||||
|
||||
function handleSelect(planId: string) {
|
||||
if (props.loading) return
|
||||
emit('select', planId)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="quote-selection-board grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
|
||||
<div
|
||||
v-for="item in items"
|
||||
:key="item.plan_id"
|
||||
class="quote-card rounded-xl border px-3.5 py-3 shadow-sm transition cursor-pointer"
|
||||
:class="selectedPlanId === item.plan_id
|
||||
? 'border-primary ring-2 ring-primary/30 bg-primary/5'
|
||||
: 'border-[var(--card-border)] bg-[var(--surface)] hover:border-[var(--brand)]/30 hover:shadow-md'"
|
||||
@click="handleSelect(item.plan_id)"
|
||||
>
|
||||
<!-- Provider ID -->
|
||||
<p class="text-[11px] font-mono text-gray-500 truncate">{{ item.provider_id }}</p>
|
||||
|
||||
<!-- Plan name -->
|
||||
<p class="text-sm font-semibold text-[var(--text-primary)] mt-1 truncate">{{ item.name }}</p>
|
||||
|
||||
<!-- Premium -->
|
||||
<p class="text-lg font-bold text-green-600 mt-2">{{ formatPremium(item.premium) }}</p>
|
||||
|
||||
<!-- Valid until -->
|
||||
<p v-if="item.valid_until" class="text-[11px] text-gray-500 mt-1">
|
||||
Valid until {{ formatDate(item.valid_until) }}
|
||||
</p>
|
||||
|
||||
<!-- Coverage details preview -->
|
||||
<p v-if="item.coverage_details" class="text-xs text-gray-600 mt-2 line-clamp-2">
|
||||
{{ item.coverage_details }}
|
||||
</p>
|
||||
|
||||
<!-- Footer -->
|
||||
<div class="mt-3 flex justify-end">
|
||||
<UBadge v-if="selectedPlanId === item.plan_id" color="green" variant="soft" size="xs">Selected</UBadge>
|
||||
<UButton v-else-if="!loading" size="xs" color="primary" @click.stop="handleSelect(item.plan_id)">Select</UButton>
|
||||
<UBadge v-else color="gray" variant="soft" size="xs">Processing...</UBadge>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.quote-card {
|
||||
transition: all 0.15s ease;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.quote-card:hover {
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
</style>
|
||||
@@ -1,168 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import type { QuoteComparativeView } from '~/types/quote-view-model'
|
||||
|
||||
defineProps<{
|
||||
model: QuoteComparativeView
|
||||
}>()
|
||||
|
||||
function fmtUsd(n: number) {
|
||||
return new Intl.NumberFormat('en-US', {
|
||||
style: 'currency',
|
||||
currency: 'USD',
|
||||
maximumFractionDigits: 0
|
||||
}).format(n)
|
||||
}
|
||||
|
||||
function fmtDate(iso: string) {
|
||||
try {
|
||||
return new Intl.DateTimeFormat('es-PA', { dateStyle: 'long' }).format(new Date(`${iso}T12:00:00`))
|
||||
} catch {
|
||||
return iso
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="quote-comparative space-y-8 text-[var(--text-primary)]">
|
||||
<div
|
||||
class="flex flex-wrap items-start justify-between gap-4 rounded-xl border border-[var(--card-border)] bg-gradient-to-br from-[var(--surface)] to-white p-6 shadow-sm"
|
||||
>
|
||||
<div class="min-w-0">
|
||||
<p class="text-xs font-semibold uppercase tracking-wide text-[var(--brand)]">{{ model.title }}</p>
|
||||
<h2 class="mt-1 text-2xl font-bold tracking-tight text-[var(--text-primary)]">{{ model.subtitle }}</h2>
|
||||
<p class="mt-1 text-sm text-[var(--text-muted)]">{{ model.tagline }}</p>
|
||||
</div>
|
||||
<div class="rounded-lg border border-[var(--brand-soft)] bg-[var(--brand-faint)] px-4 py-2 text-right text-sm">
|
||||
<p class="text-[var(--text-muted)]">Cotización</p>
|
||||
<p class="font-medium text-[var(--text-primary)]">{{ fmtDate(model.quoteDateIso) }}</p>
|
||||
<UBadge color="primary" variant="soft" class="mt-1">Válida {{ model.validDays }} días</UBadge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="overflow-hidden rounded-xl border border-[var(--card-border)] bg-[var(--surface)] shadow-sm">
|
||||
<div
|
||||
class="border-b border-[var(--brand-soft)] bg-gradient-to-r from-[var(--brand)] to-[var(--brand)] px-5 py-2 text-sm font-semibold text-white"
|
||||
>
|
||||
1 · Cliente y cotización solicitada
|
||||
</div>
|
||||
<div class="grid gap-6 p-5 md:grid-cols-2">
|
||||
<div>
|
||||
<h3 class="mb-3 text-xs font-bold uppercase tracking-wide text-[var(--text-muted)]">Datos del cliente</h3>
|
||||
<dl class="grid grid-cols-[8rem_1fr] gap-x-3 gap-y-2 text-sm">
|
||||
<dt class="text-[var(--text-muted)]">Nombre</dt>
|
||||
<dd class="font-medium">{{ model.client.name }}</dd>
|
||||
<dt class="text-[var(--text-muted)]">Edad</dt>
|
||||
<dd>{{ model.client.ageYears }} años</dd>
|
||||
<dt class="text-[var(--text-muted)]">Género</dt>
|
||||
<dd>{{ model.client.gender }}</dd>
|
||||
<dt class="text-[var(--text-muted)]">Fumador/a</dt>
|
||||
<dd>{{ model.client.smoker ? 'Sí' : 'No' }}</dd>
|
||||
<dt class="text-[var(--text-muted)]">Clasificación</dt>
|
||||
<dd>{{ model.client.riskClass }}</dd>
|
||||
<dt class="text-[var(--text-muted)]">Ocupación</dt>
|
||||
<dd>{{ model.client.occupation }}</dd>
|
||||
</dl>
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="mb-3 text-xs font-bold uppercase tracking-wide text-[var(--text-muted)]">Lo que cotizamos</h3>
|
||||
<p class="text-3xl font-bold text-[var(--text-primary)]">{{ fmtUsd(model.request.sumAssuredUsd) }}</p>
|
||||
<p class="text-sm text-[var(--text-muted)]">Suma asegurada</p>
|
||||
<p class="mt-4 text-2xl font-semibold text-[var(--brand)]">
|
||||
{{ fmtUsd(model.request.monthlyPremiumUsd) }}
|
||||
<span class="text-base font-normal text-[var(--text-muted)]">/ mes</span>
|
||||
</p>
|
||||
<p class="text-sm text-[var(--text-muted)]">
|
||||
Prima anual equivalente: {{ fmtUsd(model.request.annualPremiumUsd) }} / año
|
||||
</p>
|
||||
<dl class="mt-4 space-y-1 text-sm">
|
||||
<div class="flex justify-between gap-4">
|
||||
<dt class="text-[var(--text-muted)]">Tipo de beneficio</dt>
|
||||
<dd>{{ model.request.benefitTypeLabel }}</dd>
|
||||
</div>
|
||||
<div class="flex justify-between gap-4">
|
||||
<dt class="text-[var(--text-muted)]">Coberturas adicionales</dt>
|
||||
<dd>{{ model.request.additionalCoverageLabel }}</dd>
|
||||
</div>
|
||||
<div class="flex justify-between gap-4">
|
||||
<dt class="text-[var(--text-muted)]">Depósito inicial</dt>
|
||||
<dd>{{ model.request.initialDepositLabel }}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section class="space-y-6">
|
||||
<h3 class="text-sm font-bold uppercase tracking-wide text-[var(--text-muted)]">
|
||||
2 · Comparativo de valores (rescate / ahorro)
|
||||
</h3>
|
||||
<div
|
||||
v-for="(row, idx) in model.carriers"
|
||||
:key="idx"
|
||||
class="overflow-hidden rounded-xl border border-[var(--card-border)] bg-[var(--surface)] shadow-sm"
|
||||
>
|
||||
<div
|
||||
class="border-b px-4 py-2 text-sm font-semibold text-white"
|
||||
:class="idx % 2 === 0 ? 'bg-slate-800' : 'bg-orange-600'"
|
||||
>
|
||||
{{ row.carrierName }} · {{ row.productName }}
|
||||
</div>
|
||||
<div class="p-4 text-xs text-[var(--text-muted)]">{{ row.ratesLine }}</div>
|
||||
<div class="overflow-x-auto px-2 pb-4">
|
||||
<table class="min-w-full text-center text-xs sm:text-sm">
|
||||
<thead>
|
||||
<tr class="border-b border-[var(--card-border)] text-[var(--text-muted)]">
|
||||
<th class="px-2 py-2">Suma asegurada</th>
|
||||
<th v-for="(c, ci) in row.cells" :key="ci" class="px-2 py-2">
|
||||
{{ c.yearLabel }}
|
||||
<span class="block text-[10px] font-normal text-[var(--text-muted)] opacity-70">Edad {{ c.ageLabel }}</span>
|
||||
</th>
|
||||
<th class="px-2 py-2 text-[var(--brand)]">Destacado</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr class="border-b border-[var(--divider)]">
|
||||
<td class="px-2 py-3 font-mono text-xs">{{ fmtUsd(row.sumAssuredUsd) }}</td>
|
||||
<td v-for="(c, ci) in row.cells" :key="ci" class="px-2 py-3 align-top">
|
||||
<span class="block text-base font-bold text-[var(--text-primary)]">{{ fmtUsd(c.guaranteed) }}</span>
|
||||
<span class="text-xs text-[var(--brand)]">{{ fmtUsd(c.projected) }}</span>
|
||||
</td>
|
||||
<td class="bg-[var(--surface)] px-3 py-3 align-top text-left text-xs text-[var(--text-primary)]">
|
||||
<p v-if="row.highlightProjectedUsd != null" class="text-lg font-bold text-[var(--text-primary)]">
|
||||
{{ fmtUsd(row.highlightProjectedUsd) }}
|
||||
</p>
|
||||
<p v-if="row.highlightNote" class="mt-1 text-[10px] text-amber-800">
|
||||
{{ row.highlightNote }}
|
||||
</p>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<p v-if="row.footnote" class="border-t border-[var(--divider)] px-4 py-2 text-[10px] text-[var(--text-muted)]">
|
||||
{{ row.footnote }}
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="rounded-xl border border-amber-200 bg-amber-50/50 p-4 text-sm">
|
||||
<p class="font-semibold text-[var(--text-primary)]">Primas acumuladas pagadas (referencia)</p>
|
||||
<div class="mt-2 flex flex-wrap gap-4 font-mono text-xs text-[var(--text-primary)]">
|
||||
<span v-for="(p, i) in model.accumulatedPremiumsUsd" :key="i">Hito {{ i + 1 }}: {{ fmtUsd(p) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="overflow-hidden rounded-xl border border-slate-800 bg-slate-900 text-white shadow-md">
|
||||
<div class="border-b border-slate-700 px-5 py-2 text-sm font-semibold">Análisis del asesor</div>
|
||||
<div class="grid gap-4 p-5 md:grid-cols-3">
|
||||
<div
|
||||
v-for="(col, i) in model.advisorColumns"
|
||||
:key="i"
|
||||
class="rounded-lg bg-[var(--surface)]/5 p-3 text-xs leading-relaxed text-[var(--text-muted)] opacity-50"
|
||||
>
|
||||
{{ col }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,121 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
AUTO_COVERAGE_PLANS,
|
||||
AUTO_MARCA_OPTIONS,
|
||||
AUTO_MODELO_OPTIONS,
|
||||
AUTO_QUOTE_CARRIERS,
|
||||
AUTO_SUB_RAMO_OPTIONS
|
||||
} from '~/data/auto-quote-intake'
|
||||
import type { AutoQuoteDraft, AutoQuoteMode, AutoQuoteSegment } from '~/types/auto-quote-intake'
|
||||
|
||||
const props = defineProps<{
|
||||
draft: AutoQuoteDraft
|
||||
quoteMode: AutoQuoteMode
|
||||
segment: AutoQuoteSegment
|
||||
}>()
|
||||
|
||||
function carrierName(id: string) {
|
||||
return AUTO_QUOTE_CARRIERS.find((c) => c.id === id)?.name ?? id
|
||||
}
|
||||
|
||||
function planLabel(id: string) {
|
||||
return AUTO_COVERAGE_PLANS.find((p) => p.id === id)?.label ?? id
|
||||
}
|
||||
|
||||
const segmentLabel: Record<AutoQuoteSegment, string> = {
|
||||
individual: 'Individual',
|
||||
corporate: 'Corporate',
|
||||
fleet: 'Fleet'
|
||||
}
|
||||
|
||||
const modeLabel: Record<AutoQuoteMode, string> = {
|
||||
single: 'Single quote',
|
||||
comparative_pdf: 'Comparative PDF'
|
||||
}
|
||||
|
||||
function optLabel(opts: { label: string; value: string }[], v: string) {
|
||||
if (!v) return '—'
|
||||
return opts.find((o) => o.value === v)?.label ?? v
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="space-y-6">
|
||||
<div>
|
||||
<p class="text-sm text-[var(--text-muted)]">Review and send quote requests to carrier quoting inboxes.</p>
|
||||
</div>
|
||||
|
||||
<div class="space-y-4 rounded-xl border border-[var(--sidebar-border)] bg-[var(--surface)] p-5 ring-1 ring-black/[0.04]">
|
||||
<div class="flex flex-wrap gap-x-6 gap-y-2 text-sm">
|
||||
<div>
|
||||
<span class="text-[var(--text-muted)]">Intent</span>
|
||||
<p class="font-medium text-[var(--text-primary)]">{{ modeLabel[quoteMode] }}</p>
|
||||
</div>
|
||||
<div>
|
||||
<span class="text-[var(--text-muted)]">Policy type</span>
|
||||
<p class="font-medium text-[var(--text-primary)]">{{ segmentLabel[segment] }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="border-t border-[var(--sidebar-border)] pt-4">
|
||||
<p class="text-xs font-semibold uppercase tracking-wide text-[var(--text-muted)]">Client</p>
|
||||
<dl class="mt-2 grid gap-2 text-sm sm:grid-cols-2">
|
||||
<div>
|
||||
<dt class="text-[var(--text-muted)]">Name</dt>
|
||||
<dd class="font-medium text-[var(--text-primary)]">{{ draft.client.fullName || '—' }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt class="text-[var(--text-muted)]">Email</dt>
|
||||
<dd class="font-medium text-[var(--text-primary)]">{{ draft.client.email || '—' }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt class="text-[var(--text-muted)]">Phone</dt>
|
||||
<dd class="font-medium text-[var(--text-primary)]">{{ draft.client.phone || '—' }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt class="text-[var(--text-muted)]">ID</dt>
|
||||
<dd class="font-medium text-[var(--text-primary)]">{{ draft.client.documentId || '—' }}</dd>
|
||||
</div>
|
||||
<div v-if="draft.client.organizationName" class="sm:col-span-2">
|
||||
<dt class="text-[var(--text-muted)]">Organization</dt>
|
||||
<dd class="font-medium text-[var(--text-primary)]">{{ draft.client.organizationName }}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
|
||||
<div class="border-t border-[var(--sidebar-border)] pt-4">
|
||||
<p class="text-xs font-semibold uppercase tracking-wide text-[var(--text-muted)]">Vehicle</p>
|
||||
<p class="mt-2 text-sm text-[var(--text-primary)]">
|
||||
{{ optLabel(AUTO_MARCA_OPTIONS, draft.vehicle.marca) }} {{ optLabel(AUTO_MODELO_OPTIONS, draft.vehicle.modelo) }}
|
||||
· Plate {{ draft.vehicle.placa || '—' }} · {{ draft.vehicle.year || '—' }}
|
||||
</p>
|
||||
<p class="mt-1 text-xs text-[var(--text-muted)]">
|
||||
Sub-line {{ optLabel(AUTO_SUB_RAMO_OPTIONS, draft.vehicle.subRamo) }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="border-t border-[var(--sidebar-border)] pt-4">
|
||||
<p class="text-xs font-semibold uppercase tracking-wide text-[var(--text-muted)]">Carriers</p>
|
||||
<ul class="mt-2 list-inside list-disc text-sm text-[var(--text-primary)]">
|
||||
<li v-for="id in draft.solicit.carrierIds" :key="id">{{ carrierName(id) }}</li>
|
||||
<li v-if="draft.solicit.carrierIds.length === 0" class="list-none text-[var(--text-muted)]">None selected</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="border-t border-[var(--sidebar-border)] pt-4">
|
||||
<p class="text-xs font-semibold uppercase tracking-wide text-[var(--text-muted)]">Plans</p>
|
||||
<ul class="mt-2 list-inside list-disc text-sm text-[var(--text-primary)]">
|
||||
<li v-for="id in draft.solicit.planIds" :key="id">{{ planLabel(id) }}</li>
|
||||
<li v-if="draft.solicit.planIds.length === 0" class="list-none text-[var(--text-muted)]">None selected</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<UAlert
|
||||
color="neutral"
|
||||
variant="soft"
|
||||
title="What happens next"
|
||||
description="We’ll send quote requests to each carrier’s registered quoting email (configured under Settings → Providers). For comparative quotes, coverage rows follow your selected plans; when you receive pricing by email, paste figures into the comparative view."
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,321 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
AUTO_CLASE_OPTIONS,
|
||||
AUTO_MARCA_OPTIONS,
|
||||
AUTO_MODELO_OPTIONS,
|
||||
AUTO_RAMO_LABEL,
|
||||
AUTO_SUB_RAMO_OPTIONS,
|
||||
AUTO_USO_OPTIONS,
|
||||
AUTO_YEAR_OPTIONS
|
||||
} from '~/data/auto-quote-intake'
|
||||
import type { AutoQuoteDraft, AutoQuoteSegment } from '~/types/auto-quote-intake'
|
||||
import { useCustomerSelection } from '~/composables/useCustomerSelection'
|
||||
|
||||
const props = defineProps<{
|
||||
draft: AutoQuoteDraft
|
||||
/** Null until policy type is chosen — hides org field */
|
||||
segment: AutoQuoteSegment | null
|
||||
}>()
|
||||
|
||||
const showInterfaseBadge = computed(() => props.draft.vehicle.subRamo === 'cobertura_completa')
|
||||
|
||||
const showOrganization = computed(
|
||||
() => props.segment === 'corporate' || props.segment === 'fleet'
|
||||
)
|
||||
|
||||
const inputPh =
|
||||
'w-full placeholder:text-[var(--text-muted)] placeholder:opacity-[0.55] text-[var(--text-primary)]'
|
||||
|
||||
// Customer selection
|
||||
const customerSearch = ref('')
|
||||
const debouncedCustomerSearch = refDebounced(customerSearch, 300)
|
||||
const customerPage = ref(1)
|
||||
|
||||
const { data: customersData, pending: customersPending } = useCustomer('/customers', {
|
||||
query: computed(() => ({
|
||||
'page[number]': customerPage.value,
|
||||
'page[size]': 12,
|
||||
...(debouncedCustomerSearch.value && {
|
||||
'filters[0][field]': 'search',
|
||||
'filters[0][op]': '==',
|
||||
'filters[0][value]': debouncedCustomerSearch.value
|
||||
})
|
||||
}))
|
||||
})
|
||||
|
||||
watch(debouncedCustomerSearch, () => { customerPage.value = 1 })
|
||||
|
||||
const customerItems = computed(() => customersData.value?.data ?? [])
|
||||
|
||||
function selectCustomer(customer: any) {
|
||||
selectedCustomer.value = customer
|
||||
}
|
||||
|
||||
function selectBuyer(customer: any) {
|
||||
selectedBuyer.value = customer
|
||||
}
|
||||
|
||||
const customerDisplayName = (c: any) =>
|
||||
c.customer_type === 'corporate'
|
||||
? (c.commercial_name || c.legal_name)
|
||||
: `${c.first_name} ${c.last_name}`
|
||||
|
||||
const customerSubtitle = (c: any) =>
|
||||
c.customer_type === 'corporate' ? c.ruc : c.email
|
||||
|
||||
// Use customer selection composable
|
||||
const {
|
||||
selectedCustomer,
|
||||
selectedBuyer,
|
||||
useSameForBuyer,
|
||||
insured,
|
||||
buyer,
|
||||
isInsuredValid,
|
||||
isBuyerValid,
|
||||
validationErrors
|
||||
} = useCustomerSelection()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="space-y-8">
|
||||
<!-- Insured Section -->
|
||||
<div>
|
||||
<h2 class="text-lg font-semibold text-[var(--text-primary)]">Insured</h2>
|
||||
<p class="mt-1 text-sm text-[var(--text-muted)]">Person or entity being insured — we'll use this for carrier notifications.</p>
|
||||
|
||||
<div v-if="!selectedCustomer" class="mt-5">
|
||||
<UInput
|
||||
v-model="customerSearch"
|
||||
icon="i-heroicons-magnifying-glass"
|
||||
placeholder="Search by name, email, RUC..."
|
||||
class="w-full max-w-sm mb-4"
|
||||
/>
|
||||
|
||||
<div v-if="customersPending" class="grid grid-cols-1 md:grid-cols-3 gap-3">
|
||||
<div v-for="n in 3" :key="n" class="h-16 animate-pulse bg-gray-100 rounded-lg" />
|
||||
</div>
|
||||
|
||||
<div v-else class="space-y-3 max-h-72 overflow-y-auto">
|
||||
<div
|
||||
v-for="c in customerItems"
|
||||
:key="c.id"
|
||||
class="flex items-center gap-3 p-3 border-2 rounded-lg cursor-pointer transition-all"
|
||||
:class="selectedCustomer?.id === c.id
|
||||
? 'border-primary-500 bg-primary-50'
|
||||
: 'border-gray-200 hover:border-gray-300 bg-white'"
|
||||
@click="selectCustomer(c)"
|
||||
>
|
||||
<UAvatar :alt="customerDisplayName(c)" size="sm" />
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="flex items-center gap-1.5">
|
||||
<p class="font-medium text-sm text-slate-800 truncate">{{ customerDisplayName(c) }}</p>
|
||||
<UBadge
|
||||
:color="c.customer_type === 'corporate' ? 'purple' : 'blue'"
|
||||
variant="soft" size="xs" class="flex-shrink-0"
|
||||
>
|
||||
{{ c.customer_type === 'corporate' ? 'Corp' : 'Ind' }}
|
||||
</UBadge>
|
||||
</div>
|
||||
<p class="text-xs text-gray-400 truncate">{{ customerSubtitle(c) }}</p>
|
||||
</div>
|
||||
<UIcon
|
||||
v-if="selectedCustomer?.id === c.id"
|
||||
name="i-heroicons-check-circle"
|
||||
class="w-5 h-5 text-primary-500 flex-shrink-0"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-if="customerItems.length === 0" class="text-center py-6 text-gray-400 text-sm">
|
||||
No customers found.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else class="mt-5 p-4 bg-primary-50 border border-primary-200 rounded-lg">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-3">
|
||||
<UAvatar :alt="customerDisplayName(selectedCustomer)" size="sm" />
|
||||
<div>
|
||||
<p class="font-medium text-primary-800">{{ customerDisplayName(selectedCustomer) }}</p>
|
||||
<p class="text-xs text-primary-600">{{ customerSubtitle(selectedCustomer) }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<UButton size="sm" color="neutral" variant="ghost" @click="selectedCustomer = null">
|
||||
Change
|
||||
</UButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Buyer Section -->
|
||||
<div class="border-t border-[var(--sidebar-border)] pt-8">
|
||||
<div class="flex items-center gap-3 mb-4">
|
||||
<h2 class="text-lg font-semibold text-[var(--text-primary)]">Buyer</h2>
|
||||
<div class="flex items-center gap-2">
|
||||
<UToggle v-model="useSameForBuyer" />
|
||||
<span class="text-sm text-[var(--text-muted)]">Same as insured</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p v-if="useSameForBuyer" class="mt-1 text-sm text-[var(--text-muted)]">
|
||||
Using same person as insured
|
||||
</p>
|
||||
|
||||
<div v-else class="mt-5">
|
||||
<UInput
|
||||
v-model="customerSearch"
|
||||
icon="i-heroicons-magnifying-glass"
|
||||
placeholder="Search by name, email, RUC..."
|
||||
class="w-full max-w-sm mb-4"
|
||||
/>
|
||||
|
||||
<div v-if="customersPending" class="grid grid-cols-1 md:grid-cols-3 gap-3">
|
||||
<div v-for="n in 3" :key="n" class="h-16 animate-pulse bg-gray-100 rounded-lg" />
|
||||
</div>
|
||||
|
||||
<div v-else class="space-y-3 max-h-72 overflow-y-auto">
|
||||
<div
|
||||
v-for="c in customerItems"
|
||||
:key="c.id"
|
||||
class="flex items-center gap-3 p-3 border-2 rounded-lg cursor-pointer transition-all"
|
||||
:class="selectedBuyer?.id === c.id
|
||||
? 'border-primary-500 bg-primary-50'
|
||||
: 'border-gray-200 hover:border-gray-300 bg-white'"
|
||||
@click="selectBuyer(c)"
|
||||
>
|
||||
<UAvatar :alt="customerDisplayName(c)" size="sm" />
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="flex items-center gap-1.5">
|
||||
<p class="font-medium text-sm text-slate-800 truncate">{{ customerDisplayName(c) }}</p>
|
||||
<UBadge
|
||||
:color="c.customer_type === 'corporate' ? 'purple' : 'blue'"
|
||||
variant="soft" size="xs" class="flex-shrink-0"
|
||||
>
|
||||
{{ c.customer_type === 'corporate' ? 'Corp' : 'Ind' }}
|
||||
</UBadge>
|
||||
</div>
|
||||
<p class="text-xs text-gray-400 truncate">{{ customerSubtitle(c) }}</p>
|
||||
</div>
|
||||
<UIcon
|
||||
v-if="selectedBuyer?.id === c.id"
|
||||
name="i-heroicons-check-circle"
|
||||
class="w-5 h-5 text-primary-500 flex-shrink-0"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-if="customerItems.length === 0" class="text-center py-6 text-gray-400 text-sm">
|
||||
No customers found.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="border-t border-[var(--sidebar-border)] pt-8">
|
||||
<h2 class="text-lg font-semibold text-[var(--text-primary)]">Vehicle</h2>
|
||||
<p class="mt-1 text-sm text-[var(--text-muted)]">Risk details carriers use for auto rating.</p>
|
||||
|
||||
<div class="mt-5 grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<UFormField label="Line">
|
||||
<UInput :model-value="AUTO_RAMO_LABEL" disabled class="w-full opacity-90" />
|
||||
</UFormField>
|
||||
<div class="relative pt-1">
|
||||
<UBadge
|
||||
v-if="showInterfaseBadge"
|
||||
color="info"
|
||||
variant="soft"
|
||||
size="xs"
|
||||
class="pointer-events-none absolute -top-0 right-0 z-[1]"
|
||||
>
|
||||
Interfase
|
||||
</UBadge>
|
||||
<UFormField label="Sub-line">
|
||||
<USelect
|
||||
v-model="draft.vehicle.subRamo"
|
||||
:items="AUTO_SUB_RAMO_OPTIONS"
|
||||
value-key="value"
|
||||
label-key="label"
|
||||
placeholder="Select one"
|
||||
class="w-full"
|
||||
/>
|
||||
</UFormField>
|
||||
</div>
|
||||
<UFormField label="Class">
|
||||
<USelect
|
||||
v-model="draft.vehicle.clase"
|
||||
:items="AUTO_CLASE_OPTIONS"
|
||||
value-key="value"
|
||||
label-key="label"
|
||||
placeholder="Select one"
|
||||
class="w-full"
|
||||
/>
|
||||
</UFormField>
|
||||
<UFormField label="Use">
|
||||
<USelect
|
||||
v-model="draft.vehicle.uso"
|
||||
:items="AUTO_USO_OPTIONS"
|
||||
value-key="value"
|
||||
label-key="label"
|
||||
placeholder="Select one"
|
||||
class="w-full"
|
||||
/>
|
||||
</UFormField>
|
||||
</div>
|
||||
|
||||
<p class="mb-4 mt-8 text-xs font-semibold uppercase tracking-wide text-[var(--text-muted)]">Vehicle details</p>
|
||||
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<UFormField label="Make">
|
||||
<USelect
|
||||
v-model="draft.vehicle.marca"
|
||||
:items="AUTO_MARCA_OPTIONS"
|
||||
value-key="value"
|
||||
label-key="label"
|
||||
placeholder="Select one"
|
||||
class="w-full"
|
||||
/>
|
||||
</UFormField>
|
||||
<UFormField label="Model">
|
||||
<USelect
|
||||
v-model="draft.vehicle.modelo"
|
||||
:items="AUTO_MODELO_OPTIONS"
|
||||
value-key="value"
|
||||
label-key="label"
|
||||
placeholder="Select one"
|
||||
class="w-full"
|
||||
/>
|
||||
</UFormField>
|
||||
<UFormField label="License plate">
|
||||
<UInput v-model="draft.vehicle.placa" :class="inputPh" class="font-mono uppercase" placeholder="ABC-1234" />
|
||||
</UFormField>
|
||||
<UFormField label="Year">
|
||||
<USelect
|
||||
v-model="draft.vehicle.year"
|
||||
:items="AUTO_YEAR_OPTIONS"
|
||||
value-key="value"
|
||||
label-key="label"
|
||||
placeholder="Select one"
|
||||
class="w-full"
|
||||
/>
|
||||
</UFormField>
|
||||
<UFormField label="Capacity" description="Passengers">
|
||||
<UInput v-model="draft.vehicle.capacidadPasajeros" :class="inputPh" inputmode="numeric" placeholder="—" />
|
||||
</UFormField>
|
||||
<UFormField label="RC limits">
|
||||
<UInput v-model="draft.vehicle.rc_limits" :class="inputPh" placeholder="e.g., 100,000" />
|
||||
</UFormField>
|
||||
<UFormField label="Market value" description="USD">
|
||||
<UInput v-model="draft.vehicle.market_value" :class="inputPh" inputmode="decimal" placeholder="—" />
|
||||
</UFormField>
|
||||
<UFormField label="Requested value" description="USD">
|
||||
<UInput v-model="draft.vehicle.requested_value" :class="inputPh" inputmode="decimal" placeholder="—" />
|
||||
</UFormField>
|
||||
<UFormField label="Chassis number" description="Optional">
|
||||
<UInput v-model="draft.vehicle.chassis_number" :class="inputPh" placeholder="—" />
|
||||
</UFormField>
|
||||
<UFormField label="Engine number" description="Optional">
|
||||
<UInput v-model="draft.vehicle.engine_number" :class="inputPh" placeholder="—" />
|
||||
</UFormField>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,97 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import type { AutoQuoteDraft, AutoQuoteMode, AutoQuoteSegment } from '~/types/auto-quote-intake'
|
||||
|
||||
const props = defineProps<{
|
||||
draft: AutoQuoteDraft
|
||||
modeCards: { id: AutoQuoteMode; title: string; hint: string; icon: string }[]
|
||||
segmentCards: { id: AutoQuoteSegment; title: string; hint: string; icon: string }[]
|
||||
}>()
|
||||
|
||||
function setMode(m: AutoQuoteMode) {
|
||||
props.draft.quoteMode = m
|
||||
}
|
||||
|
||||
function setSegment(s: AutoQuoteSegment) {
|
||||
props.draft.segment = s
|
||||
}
|
||||
|
||||
/** Mount vehicle + selects after first paint — avoids blocking the main thread when the route opens */
|
||||
const showDetails = ref(false)
|
||||
onMounted(() => {
|
||||
requestAnimationFrame(() => {
|
||||
showDetails.value = true
|
||||
})
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="space-y-10">
|
||||
<section class="space-y-4">
|
||||
<div>
|
||||
<h3 class="text-base font-semibold text-[var(--text-primary)]">How can I help?</h3>
|
||||
<p class="mt-1 text-sm text-[var(--text-muted)]">Single quote or comparative — same workflow; comparative opens the comparison sheet after you send requests.</p>
|
||||
</div>
|
||||
<div class="grid gap-3 sm:grid-cols-2">
|
||||
<button
|
||||
v-for="card in modeCards"
|
||||
:key="card.id"
|
||||
type="button"
|
||||
class="group rounded-xl border p-5 text-left transition focus:outline-none focus-visible:ring-2 focus-visible:ring-[var(--brand)]"
|
||||
:class="
|
||||
draft.quoteMode === card.id
|
||||
? 'border-[var(--brand)] bg-[var(--brand-soft)] ring-1 ring-[var(--brand)]/30'
|
||||
: 'border-[var(--sidebar-border)] bg-[var(--surface)] hover:border-[var(--brand)]/40'
|
||||
"
|
||||
@click="setMode(card.id)"
|
||||
>
|
||||
<div class="flex items-start gap-3">
|
||||
<div
|
||||
class="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg bg-[var(--brand-faint)] text-[var(--brand)]"
|
||||
>
|
||||
<UIcon :name="card.icon" class="h-5 w-5" />
|
||||
</div>
|
||||
<div class="min-w-0">
|
||||
<p class="font-semibold text-[var(--text-primary)]">{{ card.title }}</p>
|
||||
<p class="mt-1 text-sm text-[var(--text-muted)]">{{ card.hint }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="space-y-4 border-t border-[var(--sidebar-border)] pt-10">
|
||||
<div>
|
||||
<h3 class="text-base font-semibold text-[var(--text-primary)]">Policy type</h3>
|
||||
<p class="mt-1 text-sm text-[var(--text-muted)]">Who is this policy for?</p>
|
||||
</div>
|
||||
<div class="grid gap-3 sm:grid-cols-3">
|
||||
<button
|
||||
v-for="card in segmentCards"
|
||||
:key="card.id"
|
||||
type="button"
|
||||
class="rounded-xl border p-4 text-left transition focus:outline-none focus-visible:ring-2 focus-visible:ring-[var(--brand)]"
|
||||
:class="
|
||||
draft.segment === card.id
|
||||
? 'border-[var(--brand)] bg-[var(--brand-soft)] ring-1 ring-[var(--brand)]/30'
|
||||
: 'border-[var(--sidebar-border)] bg-[var(--surface)] hover:border-[var(--brand)]/40'
|
||||
"
|
||||
@click="setSegment(card.id)"
|
||||
>
|
||||
<UIcon :name="card.icon" class="h-7 w-7 text-[var(--brand)]" />
|
||||
<p class="mt-2 font-semibold text-[var(--text-primary)]">{{ card.title }}</p>
|
||||
<p class="mt-0.5 text-xs text-[var(--text-muted)]">{{ card.hint }}</p>
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="border-t border-[var(--sidebar-border)] pt-10">
|
||||
<QuotesAutoCustomerVehicleStep v-if="showDetails" :draft="draft" :segment="draft.segment" />
|
||||
<div
|
||||
v-else
|
||||
class="min-h-[14rem] rounded-xl bg-[var(--sidebar-border)]/25 animate-pulse"
|
||||
aria-busy="true"
|
||||
aria-label="Loading form"
|
||||
/>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,97 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { AUTO_COVERAGE_PLANS, AUTO_QUOTE_CARRIERS } from '~/data/auto-quote-intake'
|
||||
import type { AutoQuoteDraft, AutoQuoteMode } from '~/types/auto-quote-intake'
|
||||
|
||||
const props = defineProps<{
|
||||
draft: AutoQuoteDraft
|
||||
quoteMode: AutoQuoteMode
|
||||
}>()
|
||||
|
||||
function setCarrier(id: string, checked: boolean) {
|
||||
const xs = props.draft.solicit.carrierIds
|
||||
if (checked && !xs.includes(id)) xs.push(id)
|
||||
if (!checked) {
|
||||
const i = xs.indexOf(id)
|
||||
if (i !== -1) xs.splice(i, 1)
|
||||
}
|
||||
}
|
||||
|
||||
function carrierChecked(id: string) {
|
||||
return props.draft.solicit.carrierIds.includes(id)
|
||||
}
|
||||
|
||||
function setPlan(id: string, checked: boolean) {
|
||||
const xs = props.draft.solicit.planIds
|
||||
if (checked && !xs.includes(id)) xs.push(id)
|
||||
if (!checked) {
|
||||
const i = xs.indexOf(id)
|
||||
if (i !== -1) xs.splice(i, 1)
|
||||
}
|
||||
}
|
||||
|
||||
function planChecked(id: string) {
|
||||
return props.draft.solicit.planIds.includes(id)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="space-y-6">
|
||||
<div>
|
||||
<p class="text-sm text-[var(--text-muted)]">
|
||||
Choose carriers (quoting emails are maintained per provider in Settings). Pick coverage packages to request.
|
||||
</p>
|
||||
<UAlert
|
||||
v-if="quoteMode === 'comparative_pdf'"
|
||||
color="info"
|
||||
variant="soft"
|
||||
class="mt-4"
|
||||
title="Comparative quote"
|
||||
description="We’ll prepare side-by-side comparisons using your predetermined plans. When premiums arrive by email, you can enter them into the comparative sheet."
|
||||
/>
|
||||
<UAlert
|
||||
v-else
|
||||
color="neutral"
|
||||
variant="soft"
|
||||
class="mt-4"
|
||||
title="Single quote"
|
||||
description="We’ll email each selected carrier’s quoting address on file. Attach the same vehicle and coverage ask in each request."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="rounded-xl border border-[var(--sidebar-border)] bg-[var(--surface)] p-4 ring-1 ring-black/[0.04]">
|
||||
<p class="text-xs font-semibold uppercase tracking-wide text-[var(--text-muted)]">Insurance companies</p>
|
||||
<ul class="mt-3 divide-y divide-[var(--sidebar-border)]">
|
||||
<li
|
||||
v-for="c in AUTO_QUOTE_CARRIERS"
|
||||
:key="c.id"
|
||||
class="flex flex-wrap items-start justify-between gap-3 py-3 first:pt-0"
|
||||
>
|
||||
<UCheckbox
|
||||
:model-value="carrierChecked(c.id)"
|
||||
:label="c.name"
|
||||
@update:model-value="(v: boolean) => setCarrier(c.id, v)"
|
||||
/>
|
||||
<span class="text-xs text-[var(--text-muted)]">{{ c.detail }}</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="rounded-xl border border-[var(--sidebar-border)] bg-[var(--surface)] p-4 ring-1 ring-black/[0.04]">
|
||||
<p class="text-xs font-semibold uppercase tracking-wide text-[var(--text-muted)]">Coverages / plans</p>
|
||||
<ul class="mt-3 space-y-3">
|
||||
<li
|
||||
v-for="p in AUTO_COVERAGE_PLANS"
|
||||
:key="p.id"
|
||||
class="flex flex-col gap-1 rounded-lg border border-[var(--sidebar-border)]/80 bg-[var(--page-bg)]/50 p-3 sm:flex-row sm:items-center sm:justify-between"
|
||||
>
|
||||
<UCheckbox
|
||||
:model-value="planChecked(p.id)"
|
||||
:label="p.label"
|
||||
@update:model-value="(v: boolean) => setPlan(p.id, v)"
|
||||
/>
|
||||
<span class="text-xs text-[var(--text-muted)] sm:text-right">{{ p.hint }}</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,127 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { HEALTH_COVERAGE_PLANS, HEALTH_QUOTE_CARRIERS } from '~/data/health-quote-intake'
|
||||
import type { HealthQuoteDraft, HealthQuoteMode, HealthQuoteSegment } from '~/types/health-quote-intake'
|
||||
|
||||
const props = defineProps<{
|
||||
draft: HealthQuoteDraft
|
||||
quoteMode: HealthQuoteMode
|
||||
segment: HealthQuoteSegment
|
||||
}>()
|
||||
|
||||
const { quoteRequestEmailEnabled } = useQuoteRequestEmailEnabled()
|
||||
|
||||
function carrierName(id: string) {
|
||||
return HEALTH_QUOTE_CARRIERS.find((c) => c.id === id)?.name ?? id
|
||||
}
|
||||
|
||||
function planLabel(id: string) {
|
||||
return HEALTH_COVERAGE_PLANS.find((p) => p.id === id)?.label ?? id
|
||||
}
|
||||
|
||||
const segmentLabel: Record<HealthQuoteSegment, string> = {
|
||||
individual: 'Individual',
|
||||
corporate: 'Corporate',
|
||||
group: 'Group'
|
||||
}
|
||||
|
||||
const modeLabel: Record<HealthQuoteMode, string> = {
|
||||
single: 'Single quote',
|
||||
comparative_pdf: 'Comparative PDF'
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="space-y-6">
|
||||
<div>
|
||||
<p class="text-sm text-[var(--text-muted)]">Review the health quote request before sending or saving.</p>
|
||||
</div>
|
||||
|
||||
<UAlert
|
||||
v-if="!quoteRequestEmailEnabled"
|
||||
color="warning"
|
||||
variant="soft"
|
||||
title="Provider emails are turned off"
|
||||
description="Settings → Quote requests: outbound emails disabled. This run saves the request locally (or uses table / AI pricing when connected) without emailing carriers."
|
||||
/>
|
||||
|
||||
<div class="space-y-4 rounded-xl border border-[var(--sidebar-border)] bg-[var(--surface)] p-5 ring-1 ring-black/[0.04]">
|
||||
<div class="flex flex-wrap gap-x-6 gap-y-2 text-sm">
|
||||
<div>
|
||||
<span class="text-[var(--text-muted)]">Intent</span>
|
||||
<p class="font-medium text-[var(--text-primary)]">{{ modeLabel[quoteMode] }}</p>
|
||||
</div>
|
||||
<div>
|
||||
<span class="text-[var(--text-muted)]">Policy type</span>
|
||||
<p class="font-medium text-[var(--text-primary)]">{{ segmentLabel[segment] }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="border-t border-[var(--sidebar-border)] pt-4">
|
||||
<p class="text-xs font-semibold uppercase tracking-wide text-[var(--text-muted)]">Subscriber</p>
|
||||
<dl class="mt-2 grid gap-2 text-sm sm:grid-cols-2">
|
||||
<div>
|
||||
<dt class="text-[var(--text-muted)]">Name</dt>
|
||||
<dd class="font-medium text-[var(--text-primary)]">{{ draft.client.fullName || '—' }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt class="text-[var(--text-muted)]">Email</dt>
|
||||
<dd class="font-medium text-[var(--text-primary)]">{{ draft.client.email || '—' }}</dd>
|
||||
</div>
|
||||
<div v-if="draft.client.organizationName" class="sm:col-span-2">
|
||||
<dt class="text-[var(--text-muted)]">Organization</dt>
|
||||
<dd class="font-medium text-[var(--text-primary)]">{{ draft.client.organizationName }}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
|
||||
<div class="border-t border-[var(--sidebar-border)] pt-4">
|
||||
<p class="text-xs font-semibold uppercase tracking-wide text-[var(--text-muted)]">Age & health</p>
|
||||
<dl class="mt-2 grid gap-2 text-sm sm:grid-cols-3">
|
||||
<div>
|
||||
<dt class="text-[var(--text-muted)]">Age</dt>
|
||||
<dd class="font-medium text-[var(--text-primary)]">{{ draft.health.age || '—' }}</dd>
|
||||
</div>
|
||||
<div v-if="draft.health.preexistingConditions" class="sm:col-span-2">
|
||||
<dt class="text-[var(--text-muted)]">Preexisting conditions</dt>
|
||||
<dd class="font-medium text-[var(--text-primary)]">{{ draft.health.preexistingDetails || 'Yes (no details provided)' }}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
|
||||
<div class="border-t border-[var(--sidebar-border)] pt-4">
|
||||
<p class="text-xs font-semibold uppercase tracking-wide text-[var(--text-muted)]">Coverage</p>
|
||||
<p class="mt-2 text-sm text-[var(--text-primary)]">
|
||||
Area {{ draft.health.coverageArea || '—' }} · Network {{ draft.health.networkTier || '—' }} · Deductible
|
||||
{{ draft.health.deductible || '—' }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="border-t border-[var(--sidebar-border)] pt-4">
|
||||
<p class="text-xs font-semibold uppercase tracking-wide text-[var(--text-muted)]">Carriers</p>
|
||||
<ul class="mt-2 list-inside list-disc text-sm text-[var(--text-primary)]">
|
||||
<li v-for="id in draft.solicit.carrierIds" :key="id">{{ carrierName(id) }}</li>
|
||||
<li v-if="draft.solicit.carrierIds.length === 0" class="list-none text-[var(--text-muted)]">None selected</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="border-t border-[var(--sidebar-border)] pt-4">
|
||||
<p class="text-xs font-semibold uppercase tracking-wide text-[var(--text-muted)]">Plans</p>
|
||||
<ul class="mt-2 list-inside list-disc text-sm text-[var(--text-primary)]">
|
||||
<li v-for="id in draft.solicit.planIds" :key="id">{{ planLabel(id) }}</li>
|
||||
<li v-if="draft.solicit.planIds.length === 0" class="list-none text-[var(--text-muted)]">None selected</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<UAlert
|
||||
color="neutral"
|
||||
variant="soft"
|
||||
title="What happens next"
|
||||
:description="
|
||||
quoteRequestEmailEnabled
|
||||
? 'We can queue emails to each carrier’s quoting address on file (Settings → Providers), unless your tenant uses published tables or AI instead.'
|
||||
: 'No outbound provider emails for this tenant — capture the request here and price via tables, APIs, or agentic workflows.'
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,250 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
HEALTH_AGE_BAND_REFERENCE,
|
||||
HEALTH_COVERAGE_AREA,
|
||||
HEALTH_DEDUCTIBLE,
|
||||
HEALTH_NETWORK_TIER,
|
||||
HEALTH_QUOTE_CARRIERS
|
||||
} from '~/data/health-quote-intake'
|
||||
import type { HealthQuoteDraft, HealthQuoteMode, HealthQuoteSegment } from '~/types/health-quote-intake'
|
||||
|
||||
const props = defineProps<{
|
||||
draft: HealthQuoteDraft
|
||||
modeCards: { id: HealthQuoteMode; title: string; hint: string; icon: string }[]
|
||||
segmentCards: { id: HealthQuoteSegment; title: string; hint: string; icon: string }[]
|
||||
}>()
|
||||
|
||||
function setMode(m: HealthQuoteMode) {
|
||||
props.draft.quoteMode = m
|
||||
}
|
||||
|
||||
function setSegment(s: HealthQuoteSegment) {
|
||||
props.draft.segment = s
|
||||
}
|
||||
|
||||
const showPublishedTable = computed(() =>
|
||||
HEALTH_QUOTE_CARRIERS.some((c) => c.hasPublishedRateTable)
|
||||
)
|
||||
|
||||
const inputPh =
|
||||
'w-full placeholder:text-[var(--text-muted)] placeholder:opacity-[0.55] text-[var(--text-primary)]'
|
||||
|
||||
const showOrganization = computed(
|
||||
() => props.draft.segment === 'corporate' || props.draft.segment === 'group'
|
||||
)
|
||||
|
||||
/** Compute age from date of birth */
|
||||
watch(
|
||||
() => props.draft.health.dateOfBirth,
|
||||
(dob) => {
|
||||
if (!dob) {
|
||||
props.draft.health.age = ''
|
||||
return
|
||||
}
|
||||
const birth = new Date(dob)
|
||||
const today = new Date()
|
||||
let age = today.getFullYear() - birth.getFullYear()
|
||||
const m = today.getMonth() - birth.getMonth()
|
||||
if (m < 0 || (m === 0 && today.getDate() < birth.getDate())) age--
|
||||
props.draft.health.age = String(age)
|
||||
}
|
||||
)
|
||||
|
||||
const showDetails = ref(false)
|
||||
onMounted(() => {
|
||||
requestAnimationFrame(() => {
|
||||
showDetails.value = true
|
||||
})
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="space-y-10">
|
||||
<section class="space-y-4">
|
||||
<div>
|
||||
<h3 class="text-base font-semibold text-[var(--text-primary)]">How can I help?</h3>
|
||||
<p class="mt-1 text-sm text-[var(--text-muted)]">Single quote or comparative PDF — same steps; comparative opens the comparison sheet after acceptance.</p>
|
||||
</div>
|
||||
<div class="grid gap-3 sm:grid-cols-2">
|
||||
<button
|
||||
v-for="card in modeCards"
|
||||
:key="card.id"
|
||||
type="button"
|
||||
class="group rounded-xl border p-5 text-left transition focus:outline-none focus-visible:ring-2 focus-visible:ring-[var(--brand)]"
|
||||
:class="
|
||||
draft.quoteMode === card.id
|
||||
? 'border-[var(--brand)] bg-[var(--brand-soft)] ring-1 ring-[var(--brand)]/30'
|
||||
: 'border-[var(--sidebar-border)] bg-[var(--surface)] hover:border-[var(--brand)]/40'
|
||||
"
|
||||
@click="setMode(card.id)"
|
||||
>
|
||||
<div class="flex items-start gap-3">
|
||||
<div class="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg bg-[var(--brand-faint)] text-[var(--brand)]">
|
||||
<UIcon :name="card.icon" class="h-5 w-5" />
|
||||
</div>
|
||||
<div class="min-w-0">
|
||||
<p class="font-semibold text-[var(--text-primary)]">{{ card.title }}</p>
|
||||
<p class="mt-1 text-sm text-[var(--text-muted)]">{{ card.hint }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="space-y-4 border-t border-[var(--sidebar-border)] pt-10">
|
||||
<div>
|
||||
<h3 class="text-base font-semibold text-[var(--text-primary)]">Policy type</h3>
|
||||
<p class="mt-1 text-sm text-[var(--text-muted)]">Individual, employer corporate, or group policy.</p>
|
||||
</div>
|
||||
<div class="grid gap-3 sm:grid-cols-3">
|
||||
<button
|
||||
v-for="card in segmentCards"
|
||||
:key="card.id"
|
||||
type="button"
|
||||
class="rounded-xl border p-4 text-left transition focus:outline-none focus-visible:ring-2 focus-visible:ring-[var(--brand)]"
|
||||
:class="
|
||||
draft.segment === card.id
|
||||
? 'border-[var(--brand)] bg-[var(--brand-soft)] ring-1 ring-[var(--brand)]/30'
|
||||
: 'border-[var(--sidebar-border)] bg-[var(--surface)] hover:border-[var(--brand)]/40'
|
||||
"
|
||||
@click="setSegment(card.id)"
|
||||
>
|
||||
<UIcon :name="card.icon" class="h-7 w-7 text-[var(--brand)]" />
|
||||
<p class="mt-2 font-semibold text-[var(--text-primary)]">{{ card.title }}</p>
|
||||
<p class="mt-0.5 text-xs text-[var(--text-muted)]">{{ card.hint }}</p>
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section v-if="showDetails" class="border-t border-[var(--sidebar-border)] pt-10">
|
||||
<h3 class="text-base font-semibold text-[var(--text-primary)]">Subscriber & contact</h3>
|
||||
<p class="mt-1 text-sm text-[var(--text-muted)]">Primary insured and notification email.</p>
|
||||
<div class="mt-5 grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<UFormField label="Legal name" required>
|
||||
<UInput v-model="draft.client.fullName" :class="inputPh" placeholder="As on ID" />
|
||||
</UFormField>
|
||||
<UFormField label="Email" required>
|
||||
<UInput v-model="draft.client.email" type="email" :class="inputPh" placeholder="name@company.com" />
|
||||
</UFormField>
|
||||
<UFormField label="Phone">
|
||||
<UInput v-model="draft.client.phone" :class="inputPh" placeholder="+593 …" />
|
||||
</UFormField>
|
||||
<UFormField label="Government ID">
|
||||
<UInput v-model="draft.client.documentId" :class="inputPh" placeholder="ID or RUC" />
|
||||
</UFormField>
|
||||
<UFormField v-if="showOrganization" label="Organization / group name" class="md:col-span-2" required>
|
||||
<UInput v-model="draft.client.organizationName" :class="inputPh" placeholder="Employer or group trust" />
|
||||
</UFormField>
|
||||
</div>
|
||||
|
||||
<h3 class="mt-10 text-base font-semibold text-[var(--text-primary)]">Age & health screening</h3>
|
||||
<p class="mt-1 text-sm text-[var(--text-muted)]">Basic information carriers use for eligibility and rate bands.</p>
|
||||
<div class="mt-5 grid grid-cols-1 gap-4 md:grid-cols-3">
|
||||
<UFormField label="Date of birth" required>
|
||||
<UInput v-model="draft.health.dateOfBirth" type="date" :class="inputPh" />
|
||||
</UFormField>
|
||||
<UFormField label="Age">
|
||||
<UInput :model-value="draft.health.age" disabled :class="inputPh" placeholder="Auto-calculated" />
|
||||
</UFormField>
|
||||
<div />
|
||||
</div>
|
||||
<div class="mt-5 space-y-4 rounded-xl border border-[var(--sidebar-border)] bg-[var(--surface)] p-4">
|
||||
<UCheckbox v-model="draft.health.preexistingConditions" label="Preexisting medical conditions" />
|
||||
<div v-if="draft.health.preexistingConditions" class="ml-6">
|
||||
<UFormField label="Describe conditions" hint="Diabetes, hypertension, cardiac history, etc.">
|
||||
<UTextarea
|
||||
v-model="draft.health.preexistingDetails"
|
||||
:class="inputPh"
|
||||
placeholder="List conditions and approximate diagnosis dates"
|
||||
:rows="3"
|
||||
/>
|
||||
</UFormField>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h3 class="mt-10 text-base font-semibold text-[var(--text-primary)]">Coverage intent</h3>
|
||||
<p class="mt-1 text-sm text-[var(--text-muted)]">Product parameters carriers use before underwriting.</p>
|
||||
<div class="mt-5 grid grid-cols-1 gap-4 md:grid-cols-3">
|
||||
<UFormField label="Coverage area">
|
||||
<USelect
|
||||
v-model="draft.health.coverageArea"
|
||||
:items="HEALTH_COVERAGE_AREA"
|
||||
value-key="value"
|
||||
label-key="label"
|
||||
placeholder="Select one"
|
||||
class="w-full"
|
||||
/>
|
||||
</UFormField>
|
||||
<UFormField label="Network tier">
|
||||
<USelect
|
||||
v-model="draft.health.networkTier"
|
||||
:items="HEALTH_NETWORK_TIER"
|
||||
value-key="value"
|
||||
label-key="label"
|
||||
placeholder="Select one"
|
||||
class="w-full"
|
||||
/>
|
||||
</UFormField>
|
||||
<UFormField label="Deductible preference">
|
||||
<USelect
|
||||
v-model="draft.health.deductible"
|
||||
:items="HEALTH_DEDUCTIBLE"
|
||||
value-key="value"
|
||||
label-key="label"
|
||||
placeholder="Select one"
|
||||
class="w-full"
|
||||
/>
|
||||
</UFormField>
|
||||
</div>
|
||||
|
||||
<h3 class="mt-10 text-base font-semibold text-[var(--text-primary)]">Forms</h3>
|
||||
<p class="mt-1 text-sm text-[var(--text-muted)]">
|
||||
Confirm required templates are completed (uploads wire to the forms library later).
|
||||
</p>
|
||||
<div class="mt-4 space-y-3 rounded-xl border border-[var(--sidebar-border)] bg-[var(--surface)] p-4">
|
||||
<UCheckbox v-model="draft.forms.medicalQuestionnaire" label="Medical questionnaire (declaración de salud)" />
|
||||
<UCheckbox v-model="draft.forms.beneficiaryDesignation" label="Beneficiary designation" />
|
||||
<UCheckbox
|
||||
v-model="draft.forms.groupCensus"
|
||||
label="Group census / employee roster (required for group policies)"
|
||||
:disabled="draft.segment !== 'group'"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-if="showPublishedTable" class="mt-10">
|
||||
<h3 class="text-base font-semibold text-[var(--text-primary)]">Published rate reference (age bands)</h3>
|
||||
<p class="mt-1 text-sm text-[var(--text-muted)]">
|
||||
Some carriers publish indicative premiums by age band. Use as a guide; final quotes may still require
|
||||
underwriting. When your tenant uses table pricing or AI instead of email, turn off outbound emails under
|
||||
Settings → Quote requests.
|
||||
</p>
|
||||
<div class="mt-4 overflow-x-auto rounded-xl border border-[var(--sidebar-border)]">
|
||||
<table class="min-w-full text-left text-sm text-[var(--text-primary)]">
|
||||
<thead class="bg-[var(--page-bg)] text-xs font-semibold uppercase tracking-wide text-[var(--text-muted)]">
|
||||
<tr>
|
||||
<th class="px-3 py-2">Age band</th>
|
||||
<th class="px-3 py-2">Employee</th>
|
||||
<th class="px-3 py-2">Spouse</th>
|
||||
<th class="px-3 py-2">Child</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="row in HEALTH_AGE_BAND_REFERENCE" :key="row.ageBand" class="border-t border-[var(--sidebar-border)]">
|
||||
<td class="px-3 py-2 font-medium">{{ row.ageBand }}</td>
|
||||
<td class="px-3 py-2">${{ row.employee }}</td>
|
||||
<td class="px-3 py-2">${{ row.spouse }}</td>
|
||||
<td class="px-3 py-2">${{ row.children }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<div
|
||||
v-else
|
||||
class="mt-10 min-h-[8rem] rounded-xl bg-[var(--sidebar-border)]/25 animate-pulse"
|
||||
aria-busy="true"
|
||||
aria-label="Loading"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,90 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { HEALTH_COVERAGE_PLANS, HEALTH_QUOTE_CARRIERS } from '~/data/health-quote-intake'
|
||||
import type { HealthQuoteDraft, HealthQuoteMode } from '~/types/health-quote-intake'
|
||||
|
||||
const props = defineProps<{
|
||||
draft: HealthQuoteDraft
|
||||
quoteMode: HealthQuoteMode
|
||||
}>()
|
||||
|
||||
function setCarrier(id: string, checked: boolean) {
|
||||
const xs = props.draft.solicit.carrierIds
|
||||
if (checked && !xs.includes(id)) xs.push(id)
|
||||
if (!checked) {
|
||||
const i = xs.indexOf(id)
|
||||
if (i !== -1) xs.splice(i, 1)
|
||||
}
|
||||
}
|
||||
|
||||
function carrierChecked(id: string) {
|
||||
return props.draft.solicit.carrierIds.includes(id)
|
||||
}
|
||||
|
||||
function setPlan(id: string, checked: boolean) {
|
||||
const xs = props.draft.solicit.planIds
|
||||
if (checked && !xs.includes(id)) xs.push(id)
|
||||
if (!checked) {
|
||||
const i = xs.indexOf(id)
|
||||
if (i !== -1) xs.splice(i, 1)
|
||||
}
|
||||
}
|
||||
|
||||
function planChecked(id: string) {
|
||||
return props.draft.solicit.planIds.includes(id)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="space-y-6">
|
||||
<div>
|
||||
<p class="text-sm text-[var(--text-muted)]">
|
||||
Choose carriers and product shells to request. Quoting contacts live per provider in Settings.
|
||||
</p>
|
||||
<UAlert
|
||||
v-if="quoteMode === 'comparative_pdf'"
|
||||
color="info"
|
||||
variant="soft"
|
||||
class="mt-4"
|
||||
title="Comparative quote"
|
||||
description="We’ll align columns to your selected plan mix. Enter premiums from email, rate tables, or AI-assisted pricing when available."
|
||||
/>
|
||||
<UAlert v-else color="neutral" variant="soft" class="mt-4" title="Single quote" description="We’ll package one request per carrier with the same subscriber and coverage intent." />
|
||||
</div>
|
||||
|
||||
<div class="rounded-xl border border-[var(--sidebar-border)] bg-[var(--surface)] p-4 ring-1 ring-black/[0.04]">
|
||||
<p class="text-xs font-semibold uppercase tracking-wide text-[var(--text-muted)]">Carriers</p>
|
||||
<ul class="mt-3 divide-y divide-[var(--sidebar-border)]">
|
||||
<li
|
||||
v-for="c in HEALTH_QUOTE_CARRIERS"
|
||||
:key="c.id"
|
||||
class="flex flex-wrap items-start justify-between gap-3 py-3 first:pt-0"
|
||||
>
|
||||
<UCheckbox
|
||||
:model-value="carrierChecked(c.id)"
|
||||
:label="c.name"
|
||||
@update:model-value="(v: boolean) => setCarrier(c.id, v)"
|
||||
/>
|
||||
<span class="text-xs text-[var(--text-muted)]">{{ c.detail }}</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="rounded-xl border border-[var(--sidebar-border)] bg-[var(--surface)] p-4 ring-1 ring-black/[0.04]">
|
||||
<p class="text-xs font-semibold uppercase tracking-wide text-[var(--text-muted)]">Plans / benefit shells</p>
|
||||
<ul class="mt-3 space-y-3">
|
||||
<li
|
||||
v-for="p in HEALTH_COVERAGE_PLANS"
|
||||
:key="p.id"
|
||||
class="flex flex-col gap-1 rounded-lg border border-[var(--sidebar-border)]/80 bg-[var(--page-bg)]/50 p-3 sm:flex-row sm:items-center sm:justify-between"
|
||||
>
|
||||
<UCheckbox
|
||||
:model-value="planChecked(p.id)"
|
||||
:label="p.label"
|
||||
@update:model-value="(v: boolean) => setPlan(p.id, v)"
|
||||
/>
|
||||
<span class="text-xs text-[var(--text-muted)] sm:text-right">{{ p.hint }}</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,149 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { LIFE_COVERAGE_PLANS, LIFE_QUOTE_CARRIERS } from '~/data/life-quote-intake'
|
||||
import type { LifeQuoteDraft, LifeQuoteMode, LifeQuoteSegment } from '~/types/life-quote-intake'
|
||||
|
||||
const props = defineProps<{
|
||||
draft: LifeQuoteDraft
|
||||
quoteMode: LifeQuoteMode
|
||||
segment: LifeQuoteSegment
|
||||
}>()
|
||||
|
||||
const { quoteRequestEmailEnabled } = useQuoteRequestEmailEnabled()
|
||||
|
||||
function carrierName(id: string) {
|
||||
return LIFE_QUOTE_CARRIERS.find((c) => c.id === id)?.name ?? id
|
||||
}
|
||||
|
||||
function planLabel(id: string) {
|
||||
return LIFE_COVERAGE_PLANS.find((p) => p.id === id)?.label ?? id
|
||||
}
|
||||
|
||||
const segmentLabel: Record<LifeQuoteSegment, string> = {
|
||||
individual: 'Individual',
|
||||
corporate_keyman: 'Corporate / Key person',
|
||||
group: 'Group'
|
||||
}
|
||||
|
||||
const modeLabel: Record<LifeQuoteMode, string> = {
|
||||
single: 'Single quote',
|
||||
comparative_pdf: 'Comparative PDF'
|
||||
}
|
||||
|
||||
function formatAmount(val: string) {
|
||||
const n = Number(val)
|
||||
if (!n) return val || '—'
|
||||
return '$' + n.toLocaleString()
|
||||
}
|
||||
|
||||
function termLabel(val: string) {
|
||||
if (val === 'whole') return 'Whole life'
|
||||
return val ? `${val} years` : '—'
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="space-y-6">
|
||||
<div>
|
||||
<p class="text-sm text-[var(--text-muted)]">Review the life quote request before sending or saving.</p>
|
||||
</div>
|
||||
|
||||
<UAlert
|
||||
v-if="!quoteRequestEmailEnabled"
|
||||
color="warning"
|
||||
variant="soft"
|
||||
title="Provider emails are turned off"
|
||||
description="Settings -> Quote requests: outbound emails disabled. This run saves the request locally (or uses table / AI pricing when connected) without emailing carriers."
|
||||
/>
|
||||
|
||||
<div class="space-y-4 rounded-xl border border-[var(--sidebar-border)] bg-[var(--surface)] p-5 ring-1 ring-black/[0.04]">
|
||||
<div class="flex flex-wrap gap-x-6 gap-y-2 text-sm">
|
||||
<div>
|
||||
<span class="text-[var(--text-muted)]">Intent</span>
|
||||
<p class="font-medium text-[var(--text-primary)]">{{ modeLabel[quoteMode] }}</p>
|
||||
</div>
|
||||
<div>
|
||||
<span class="text-[var(--text-muted)]">Policy type</span>
|
||||
<p class="font-medium text-[var(--text-primary)]">{{ segmentLabel[segment] }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="border-t border-[var(--sidebar-border)] pt-4">
|
||||
<p class="text-xs font-semibold uppercase tracking-wide text-[var(--text-muted)]">Insured person</p>
|
||||
<dl class="mt-2 grid gap-2 text-sm sm:grid-cols-2">
|
||||
<div>
|
||||
<dt class="text-[var(--text-muted)]">Name</dt>
|
||||
<dd class="font-medium text-[var(--text-primary)]">{{ draft.client.fullName || '—' }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt class="text-[var(--text-muted)]">Email</dt>
|
||||
<dd class="font-medium text-[var(--text-primary)]">{{ draft.client.email || '—' }}</dd>
|
||||
</div>
|
||||
<div v-if="draft.client.organizationName" class="sm:col-span-2">
|
||||
<dt class="text-[var(--text-muted)]">Organization</dt>
|
||||
<dd class="font-medium text-[var(--text-primary)]">{{ draft.client.organizationName }}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
|
||||
<div class="border-t border-[var(--sidebar-border)] pt-4">
|
||||
<p class="text-xs font-semibold uppercase tracking-wide text-[var(--text-muted)]">Age & health</p>
|
||||
<dl class="mt-2 grid gap-2 text-sm sm:grid-cols-3">
|
||||
<div>
|
||||
<dt class="text-[var(--text-muted)]">Age</dt>
|
||||
<dd class="font-medium text-[var(--text-primary)]">{{ draft.life.age || '—' }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt class="text-[var(--text-muted)]">Gender</dt>
|
||||
<dd class="font-medium text-[var(--text-primary)]">{{ draft.life.gender || '—' }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt class="text-[var(--text-muted)]">Smoker</dt>
|
||||
<dd class="font-medium text-[var(--text-primary)]">{{ draft.life.smoker ? 'Yes' : 'No' }}</dd>
|
||||
</div>
|
||||
<div v-if="draft.life.preexistingConditions" class="sm:col-span-3">
|
||||
<dt class="text-[var(--text-muted)]">Preexisting conditions</dt>
|
||||
<dd class="font-medium text-[var(--text-primary)]">{{ draft.life.preexistingDetails || 'Yes (no details provided)' }}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
|
||||
<div class="border-t border-[var(--sidebar-border)] pt-4">
|
||||
<p class="text-xs font-semibold uppercase tracking-wide text-[var(--text-muted)]">Coverage</p>
|
||||
<p class="mt-2 text-sm text-[var(--text-primary)]">
|
||||
{{ formatAmount(draft.life.coverageAmount) }} · {{ termLabel(draft.life.coverageTerm) }}
|
||||
</p>
|
||||
<p v-if="draft.life.beneficiaryName" class="mt-1 text-sm text-[var(--text-muted)]">
|
||||
Beneficiary: {{ draft.life.beneficiaryName }}
|
||||
<span v-if="draft.life.beneficiaryRelationship"> ({{ draft.life.beneficiaryRelationship }})</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="border-t border-[var(--sidebar-border)] pt-4">
|
||||
<p class="text-xs font-semibold uppercase tracking-wide text-[var(--text-muted)]">Carriers</p>
|
||||
<ul class="mt-2 list-inside list-disc text-sm text-[var(--text-primary)]">
|
||||
<li v-for="id in draft.solicit.carrierIds" :key="id">{{ carrierName(id) }}</li>
|
||||
<li v-if="draft.solicit.carrierIds.length === 0" class="list-none text-[var(--text-muted)]">None selected</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="border-t border-[var(--sidebar-border)] pt-4">
|
||||
<p class="text-xs font-semibold uppercase tracking-wide text-[var(--text-muted)]">Plans</p>
|
||||
<ul class="mt-2 list-inside list-disc text-sm text-[var(--text-primary)]">
|
||||
<li v-for="id in draft.solicit.planIds" :key="id">{{ planLabel(id) }}</li>
|
||||
<li v-if="draft.solicit.planIds.length === 0" class="list-none text-[var(--text-muted)]">None selected</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<UAlert
|
||||
color="neutral"
|
||||
variant="soft"
|
||||
title="What happens next"
|
||||
:description="
|
||||
quoteRequestEmailEnabled
|
||||
? 'We can queue emails to each carrier\'s quoting address on file (Settings -> Providers), unless your tenant uses published tables or AI instead.'
|
||||
: 'No outbound provider emails for this tenant — capture the request here and price via tables, APIs, or agentic workflows.'
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,369 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
LIFE_COVERAGE_AMOUNT_OPTIONS,
|
||||
LIFE_COVERAGE_TERM_OPTIONS
|
||||
} from '~/data/life-quote-intake'
|
||||
import type { LifeQuoteDraft, LifeQuoteMode, LifeQuoteSegment } from '~/types/life-quote-intake'
|
||||
import { useCustomerSelection } from '~/composables/useCustomerSelection'
|
||||
|
||||
const props = defineProps<{
|
||||
draft: LifeQuoteDraft
|
||||
modeCards: { id: LifeQuoteMode; title: string; hint: string; icon: string }[]
|
||||
segmentCards: { id: LifeQuoteSegment; title: string; hint: string; icon: string }[]
|
||||
}>()
|
||||
|
||||
function setMode(m: LifeQuoteMode) {
|
||||
props.draft.quoteMode = m
|
||||
}
|
||||
|
||||
function setSegment(s: LifeQuoteSegment) {
|
||||
props.draft.segment = s
|
||||
}
|
||||
|
||||
const inputPh =
|
||||
'w-full placeholder:text-[var(--text-muted)] placeholder:opacity-[0.55] text-[var(--text-primary)]'
|
||||
|
||||
const showOrganization = computed(
|
||||
() => props.draft.segment === 'corporate_keyman' || props.draft.segment === 'group'
|
||||
)
|
||||
|
||||
// Use customer selection composable
|
||||
const {
|
||||
selectedCustomer,
|
||||
selectedBuyer,
|
||||
useSameForBuyer,
|
||||
insured,
|
||||
buyer,
|
||||
isInsuredValid,
|
||||
isBuyerValid,
|
||||
validationErrors
|
||||
} = useCustomerSelection()
|
||||
|
||||
// Customer selection
|
||||
const customerSearch = ref('')
|
||||
const debouncedCustomerSearch = refDebounced(customerSearch, 300)
|
||||
const customerPage = ref(1)
|
||||
|
||||
const { data: customersData, pending: customersPending } = useCustomer('/customers', {
|
||||
query: computed(() => ({
|
||||
'page[number]': customerPage.value,
|
||||
'page[size]': 12,
|
||||
...(debouncedCustomerSearch.value && {
|
||||
'filters[0][field]': 'search',
|
||||
'filters[0][op]': '==',
|
||||
'filters[0][value]': debouncedCustomerSearch.value
|
||||
})
|
||||
}))
|
||||
})
|
||||
|
||||
watch(debouncedCustomerSearch, () => { customerPage.value = 1 })
|
||||
|
||||
const customerItems = computed(() => customersData.value?.data ?? [])
|
||||
|
||||
function selectCustomer(customer: any) {
|
||||
selectedCustomer.value = customer
|
||||
}
|
||||
|
||||
function selectBuyer(customer: any) {
|
||||
selectedBuyer.value = customer
|
||||
}
|
||||
|
||||
const customerDisplayName = (c: any) =>
|
||||
c.customer_type === 'corporate'
|
||||
? (c.commercial_name || c.legal_name)
|
||||
: `${c.first_name} ${c.last_name}`
|
||||
|
||||
const customerSubtitle = (c: any) =>
|
||||
c.customer_type === 'corporate' ? c.ruc : c.email
|
||||
|
||||
const showDetails = ref(false)
|
||||
onMounted(() => {
|
||||
requestAnimationFrame(() => {
|
||||
showDetails.value = true
|
||||
})
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="space-y-10">
|
||||
<section class="space-y-4">
|
||||
<div>
|
||||
<h3 class="text-base font-semibold text-[var(--text-primary)]">How can I help?</h3>
|
||||
<p class="mt-1 text-sm text-[var(--text-muted)]">Single quote or comparative PDF — same steps; comparative opens the comparison sheet after acceptance.</p>
|
||||
</div>
|
||||
<div class="grid gap-3 sm:grid-cols-2">
|
||||
<button
|
||||
v-for="card in modeCards"
|
||||
:key="card.id"
|
||||
type="button"
|
||||
class="group rounded-xl border p-5 text-left transition focus:outline-none focus-visible:ring-2 focus-visible:ring-[var(--brand)]"
|
||||
:class="
|
||||
draft.quoteMode === card.id
|
||||
? 'border-[var(--brand)] bg-[var(--brand-soft)] ring-1 ring-[var(--brand)]/30'
|
||||
: 'border-[var(--sidebar-border)] bg-[var(--surface)] hover:border-[var(--brand)]/40'
|
||||
"
|
||||
@click="setMode(card.id)"
|
||||
>
|
||||
<div class="flex items-start gap-3">
|
||||
<div class="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg bg-[var(--brand-faint)] text-[var(--brand)]">
|
||||
<UIcon :name="card.icon" class="h-5 w-5" />
|
||||
</div>
|
||||
<div class="min-w-0">
|
||||
<p class="font-semibold text-[var(--text-primary)]">{{ card.title }}</p>
|
||||
<p class="mt-1 text-sm text-[var(--text-muted)]">{{ card.hint }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="space-y-4 border-t border-[var(--sidebar-border)] pt-10">
|
||||
<div>
|
||||
<h3 class="text-base font-semibold text-[var(--text-primary)]">Policy type</h3>
|
||||
<p class="mt-1 text-sm text-[var(--text-muted)]">Individual, corporate / key person, or group policy.</p>
|
||||
</div>
|
||||
<div class="grid gap-3 sm:grid-cols-3">
|
||||
<button
|
||||
v-for="card in segmentCards"
|
||||
:key="card.id"
|
||||
type="button"
|
||||
class="rounded-xl border p-4 text-left transition focus:outline-none focus-visible:ring-2 focus-visible:ring-[var(--brand)]"
|
||||
:class="
|
||||
draft.segment === card.id
|
||||
? 'border-[var(--brand)] bg-[var(--brand-soft)] ring-1 ring-[var(--brand)]/30'
|
||||
: 'border-[var(--sidebar-border)] bg-[var(--surface)] hover:border-[var(--brand)]/40'
|
||||
"
|
||||
@click="setSegment(card.id)"
|
||||
>
|
||||
<UIcon :name="card.icon" class="h-7 w-7 text-[var(--brand)]" />
|
||||
<p class="mt-2 font-semibold text-[var(--text-primary)]">{{ card.title }}</p>
|
||||
<p class="mt-0.5 text-xs text-[var(--text-muted)]">{{ card.hint }}</p>
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section v-if="showDetails" class="border-t border-[var(--sidebar-border)] pt-10">
|
||||
<!-- Insured Section -->
|
||||
<h3 class="text-base font-semibold text-[var(--text-primary)]">Insured</h3>
|
||||
<p class="mt-1 text-sm text-[var(--text-muted)]">Person or entity being insured — we'll use this for carrier notifications.</p>
|
||||
|
||||
<div v-if="!selectedCustomer" class="mt-5">
|
||||
<UInput
|
||||
v-model="customerSearch"
|
||||
icon="i-heroicons-magnifying-glass"
|
||||
placeholder="Search by name, email, RUC..."
|
||||
class="w-full max-w-sm mb-4"
|
||||
/>
|
||||
|
||||
<div v-if="customersPending" class="grid grid-cols-1 md:grid-cols-3 gap-3">
|
||||
<div v-for="n in 3" :key="n" class="h-16 animate-pulse bg-gray-100 rounded-lg" />
|
||||
</div>
|
||||
|
||||
<div v-else class="space-y-3 max-h-72 overflow-y-auto">
|
||||
<div
|
||||
v-for="c in customerItems"
|
||||
:key="c.id"
|
||||
class="flex items-center gap-3 p-3 border-2 rounded-lg cursor-pointer transition-all"
|
||||
:class="selectedCustomer?.id === c.id
|
||||
? 'border-primary-500 bg-primary-50'
|
||||
: 'border-gray-200 hover:border-gray-300 bg-white'"
|
||||
@click="selectCustomer(c)"
|
||||
>
|
||||
<UAvatar :alt="customerDisplayName(c)" size="sm" />
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="flex items-center gap-1.5">
|
||||
<p class="font-medium text-sm text-slate-800 truncate">{{ customerDisplayName(c) }}</p>
|
||||
<UBadge
|
||||
:color="c.customer_type === 'corporate' ? 'purple' : 'blue'"
|
||||
variant="soft" size="xs" class="flex-shrink-0"
|
||||
>
|
||||
{{ c.customer_type === 'corporate' ? 'Corp' : 'Ind' }}
|
||||
</UBadge>
|
||||
</div>
|
||||
<p class="text-xs text-gray-400 truncate">{{ customerSubtitle(c) }}</p>
|
||||
</div>
|
||||
<UIcon
|
||||
v-if="selectedCustomer?.id === c.id"
|
||||
name="i-heroicons-check-circle"
|
||||
class="w-5 h-5 text-primary-500 flex-shrink-0"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-if="customerItems.length === 0" class="text-center py-6 text-gray-400 text-sm">
|
||||
No customers found.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else class="mt-5 p-4 bg-primary-50 border border-primary-200 rounded-lg">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-3">
|
||||
<UAvatar :alt="customerDisplayName(selectedCustomer)" size="sm" />
|
||||
<div>
|
||||
<p class="font-medium text-primary-800">{{ customerDisplayName(selectedCustomer) }}</p>
|
||||
<p class="text-xs text-primary-600">{{ customerSubtitle(selectedCustomer) }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<UButton size="sm" color="neutral" variant="ghost" @click="selectedCustomer = null">
|
||||
Change
|
||||
</UButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Buyer Section -->
|
||||
<div class="mt-10 border-t border-[var(--sidebar-border)] pt-10">
|
||||
<div class="flex items-center gap-3 mb-4">
|
||||
<h3 class="text-base font-semibold text-[var(--text-primary)]">Buyer</h3>
|
||||
<div class="flex items-center gap-2">
|
||||
<UToggle v-model="useSameForBuyer" />
|
||||
<span class="text-sm text-[var(--text-muted)]">Same as insured</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p v-if="useSameForBuyer" class="mt-1 text-sm text-[var(--text-muted)]">
|
||||
Using same person as insured
|
||||
</p>
|
||||
|
||||
<div v-else class="mt-5">
|
||||
<UInput
|
||||
v-model="customerSearch"
|
||||
icon="i-heroicons-magnifying-glass"
|
||||
placeholder="Search by name, email, RUC..."
|
||||
class="w-full max-w-sm mb-4"
|
||||
/>
|
||||
|
||||
<div v-if="customersPending" class="grid grid-cols-1 md:grid-cols-3 gap-3">
|
||||
<div v-for="n in 3" :key="n" class="h-16 animate-pulse bg-gray-100 rounded-lg" />
|
||||
</div>
|
||||
|
||||
<div v-else class="space-y-3 max-h-72 overflow-y-auto">
|
||||
<div
|
||||
v-for="c in customerItems"
|
||||
:key="c.id"
|
||||
class="flex items-center gap-3 p-3 border-2 rounded-lg cursor-pointer transition-all"
|
||||
:class="selectedBuyer?.id === c.id
|
||||
? 'border-primary-500 bg-primary-50'
|
||||
: 'border-gray-200 hover:border-gray-300 bg-white'"
|
||||
@click="selectBuyer(c)"
|
||||
>
|
||||
<UAvatar :alt="customerDisplayName(c)" size="sm" />
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="flex items-center gap-1.5">
|
||||
<p class="font-medium text-sm text-slate-800 truncate">{{ customerDisplayName(c) }}</p>
|
||||
<UBadge
|
||||
:color="c.customer_type === 'corporate' ? 'purple' : 'blue'"
|
||||
variant="soft" size="xs" class="flex-shrink-0"
|
||||
>
|
||||
{{ c.customer_type === 'corporate' ? 'Corp' : 'Ind' }}
|
||||
</UBadge>
|
||||
</div>
|
||||
<p class="text-xs text-gray-400 truncate">{{ customerSubtitle(c) }}</p>
|
||||
</div>
|
||||
<UIcon
|
||||
v-if="selectedBuyer?.id === c.id"
|
||||
name="i-heroicons-check-circle"
|
||||
class="w-5 h-5 text-primary-500 flex-shrink-0"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-if="customerItems.length === 0" class="text-center py-6 text-gray-400 text-sm">
|
||||
No customers found.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Health Questionnaire -->
|
||||
<h3 class="mt-10 text-base font-semibold text-[var(--text-primary)]">Health Information</h3>
|
||||
<p class="mt-1 text-sm text-[var(--text-muted)]">Basic underwriting inputs — carriers use these to determine eligibility and rate class.</p>
|
||||
<div class="mt-5 space-y-4 rounded-xl border border-[var(--sidebar-border)] bg-[var(--surface)] p-4">
|
||||
<UCheckbox v-model="draft.life.smoker" label="Smoker / tobacco user (within last 12 months)" />
|
||||
|
||||
<UFormField label="Medications">
|
||||
<UTextarea
|
||||
v-model="draft.life.medications"
|
||||
:class="inputPh"
|
||||
placeholder="List any current medications..."
|
||||
:rows="2"
|
||||
/>
|
||||
</UFormField>
|
||||
|
||||
<UFormField label="Surgeries">
|
||||
<UTextarea
|
||||
v-model="draft.life.surgeries"
|
||||
:class="inputPh"
|
||||
placeholder="List any past surgeries..."
|
||||
:rows="2"
|
||||
/>
|
||||
</UFormField>
|
||||
|
||||
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<UFormField label="Weight (kg)">
|
||||
<UInput v-model="draft.life.weight" type="number" :class="inputPh" placeholder="—" />
|
||||
</UFormField>
|
||||
<UFormField label="Height (cm)">
|
||||
<UInput v-model="draft.life.height" type="number" :class="inputPh" placeholder="—" />
|
||||
</UFormField>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Coverage parameters -->
|
||||
<h3 class="mt-10 text-base font-semibold text-[var(--text-primary)]">Coverage Details</h3>
|
||||
<p class="mt-1 text-sm text-[var(--text-muted)]">Sum assured, term, and coverage type.</p>
|
||||
<div class="mt-5 grid grid-cols-1 gap-4 md:grid-cols-3">
|
||||
<UFormField label="Coverage type" required>
|
||||
<USelect
|
||||
v-model="draft.life.coverage_type"
|
||||
:items="[
|
||||
{ label: 'Banking', value: 'banking' },
|
||||
{ label: 'Protection', value: 'protection' }
|
||||
]"
|
||||
placeholder="Select one"
|
||||
class="w-full"
|
||||
/>
|
||||
</UFormField>
|
||||
<UFormField label="Coverage amount (USD)" required>
|
||||
<USelect
|
||||
v-model="draft.life.coverage_amount"
|
||||
:items="LIFE_COVERAGE_AMOUNT_OPTIONS"
|
||||
value-key="value"
|
||||
label-key="label"
|
||||
placeholder="Select one"
|
||||
class="w-full"
|
||||
/>
|
||||
</UFormField>
|
||||
<UFormField label="Coverage years" required>
|
||||
<USelect
|
||||
v-model="draft.life.coverage_years"
|
||||
:items="LIFE_COVERAGE_TERM_OPTIONS"
|
||||
value-key="value"
|
||||
label-key="label"
|
||||
placeholder="Select one"
|
||||
class="w-full"
|
||||
/>
|
||||
</UFormField>
|
||||
</div>
|
||||
|
||||
<!-- Forms -->
|
||||
<h3 class="mt-10 text-base font-semibold text-[var(--text-primary)]">Forms</h3>
|
||||
<p class="mt-1 text-sm text-[var(--text-muted)]">
|
||||
Confirm required templates are completed (uploads wire to the forms library later).
|
||||
</p>
|
||||
<div class="mt-4 space-y-3 rounded-xl border border-[var(--sidebar-border)] bg-[var(--surface)] p-4">
|
||||
<UCheckbox v-model="draft.forms.medicalQuestionnaire" label="Medical questionnaire (declaracion de salud)" />
|
||||
<UCheckbox v-model="draft.forms.beneficiaryDesignation" label="Beneficiary designation form" />
|
||||
<UCheckbox
|
||||
v-model="draft.forms.groupCensus"
|
||||
label="Group census / employee roster (required for group policies)"
|
||||
:disabled="draft.segment !== 'group'"
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
<div
|
||||
v-else
|
||||
class="mt-10 min-h-[8rem] rounded-xl bg-[var(--sidebar-border)]/25 animate-pulse"
|
||||
aria-busy="true"
|
||||
aria-label="Loading"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,90 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { LIFE_COVERAGE_PLANS, LIFE_QUOTE_CARRIERS } from '~/data/life-quote-intake'
|
||||
import type { LifeQuoteDraft, LifeQuoteMode } from '~/types/life-quote-intake'
|
||||
|
||||
const props = defineProps<{
|
||||
draft: LifeQuoteDraft
|
||||
quoteMode: LifeQuoteMode
|
||||
}>()
|
||||
|
||||
function setCarrier(id: string, checked: boolean) {
|
||||
const xs = props.draft.solicit.carrierIds
|
||||
if (checked && !xs.includes(id)) xs.push(id)
|
||||
if (!checked) {
|
||||
const i = xs.indexOf(id)
|
||||
if (i !== -1) xs.splice(i, 1)
|
||||
}
|
||||
}
|
||||
|
||||
function carrierChecked(id: string) {
|
||||
return props.draft.solicit.carrierIds.includes(id)
|
||||
}
|
||||
|
||||
function setPlan(id: string, checked: boolean) {
|
||||
const xs = props.draft.solicit.planIds
|
||||
if (checked && !xs.includes(id)) xs.push(id)
|
||||
if (!checked) {
|
||||
const i = xs.indexOf(id)
|
||||
if (i !== -1) xs.splice(i, 1)
|
||||
}
|
||||
}
|
||||
|
||||
function planChecked(id: string) {
|
||||
return props.draft.solicit.planIds.includes(id)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="space-y-6">
|
||||
<div>
|
||||
<p class="text-sm text-[var(--text-muted)]">
|
||||
Choose carriers and plan shells to request. Quoting contacts live per provider in Settings.
|
||||
</p>
|
||||
<UAlert
|
||||
v-if="quoteMode === 'comparative_pdf'"
|
||||
color="info"
|
||||
variant="soft"
|
||||
class="mt-4"
|
||||
title="Comparative quote"
|
||||
description="We'll align columns to your selected plan mix. Enter premiums from email, rate tables, or AI-assisted pricing when available."
|
||||
/>
|
||||
<UAlert v-else color="neutral" variant="soft" class="mt-4" title="Single quote" description="We'll package one request per carrier with the same insured and coverage intent." />
|
||||
</div>
|
||||
|
||||
<div class="rounded-xl border border-[var(--sidebar-border)] bg-[var(--surface)] p-4 ring-1 ring-black/[0.04]">
|
||||
<p class="text-xs font-semibold uppercase tracking-wide text-[var(--text-muted)]">Carriers</p>
|
||||
<ul class="mt-3 divide-y divide-[var(--sidebar-border)]">
|
||||
<li
|
||||
v-for="c in LIFE_QUOTE_CARRIERS"
|
||||
:key="c.id"
|
||||
class="flex flex-wrap items-start justify-between gap-3 py-3 first:pt-0"
|
||||
>
|
||||
<UCheckbox
|
||||
:model-value="carrierChecked(c.id)"
|
||||
:label="c.name"
|
||||
@update:model-value="(v: boolean) => setCarrier(c.id, v)"
|
||||
/>
|
||||
<span class="text-xs text-[var(--text-muted)]">{{ c.detail }}</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="rounded-xl border border-[var(--sidebar-border)] bg-[var(--surface)] p-4 ring-1 ring-black/[0.04]">
|
||||
<p class="text-xs font-semibold uppercase tracking-wide text-[var(--text-muted)]">Plans / coverage shells</p>
|
||||
<ul class="mt-3 space-y-3">
|
||||
<li
|
||||
v-for="p in LIFE_COVERAGE_PLANS"
|
||||
:key="p.id"
|
||||
class="flex flex-col gap-1 rounded-lg border border-[var(--sidebar-border)]/80 bg-[var(--page-bg)]/50 p-3 sm:flex-row sm:items-center sm:justify-between"
|
||||
>
|
||||
<UCheckbox
|
||||
:model-value="planChecked(p.id)"
|
||||
:label="p.label"
|
||||
@update:model-value="(v: boolean) => setPlan(p.id, v)"
|
||||
/>
|
||||
<span class="text-xs text-[var(--text-muted)] sm:text-right">{{ p.hint }}</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -5,16 +5,13 @@
|
||||
*/
|
||||
const props = defineProps<{
|
||||
/** Which stage the current page represents */
|
||||
currentStage: 'quick_lead' | 'customer' | 'get_quotes' | 'present_quotes' | 'solicitud' | 'emission'
|
||||
currentStage: 'quick_lead' | 'customer' | 'new_quote'
|
||||
}>()
|
||||
|
||||
const stages = [
|
||||
{ id: 'quick_lead', label: 'Quick Lead', icon: 'i-heroicons-bolt', route: '/sales/quick-lead' },
|
||||
{ id: 'customer', label: 'Customer', icon: 'i-heroicons-user-plus', route: '/registration/client' },
|
||||
{ id: 'get_quotes', label: 'Get Quotes', icon: 'i-heroicons-document-magnifying-glass', route: '/quotes/new' },
|
||||
{ id: 'present_quotes', label: 'Present Quotes', icon: 'i-heroicons-presentation-chart-bar', route: '/quotes/compare' },
|
||||
{ id: 'solicitud', label: 'Solicitud', icon: 'i-heroicons-clipboard-document-check', route: '/onboarding/solicitud' },
|
||||
{ id: 'emission', label: 'Emission', icon: 'i-heroicons-check-badge', route: '/onboarding/emissions' },
|
||||
{ id: 'customer', label: 'Customer', icon: 'i-heroicons-user-plus', route: '/customers/new' },
|
||||
{ id: 'new_quote', label: 'New Quote', icon: 'i-heroicons-document-magnifying-glass', route: '/quotes/new' },
|
||||
] as const
|
||||
|
||||
type StageId = typeof stages[number]['id']
|
||||
|
||||
@@ -1,176 +0,0 @@
|
||||
import { useLocalStorageRef } from '~/utils/useLocalStorageRef'
|
||||
|
||||
/* ── Types ── */
|
||||
|
||||
export type AlertRecipient = 'handler' | 'manager' | 'customer' | 'custom'
|
||||
|
||||
export interface EmailSenderConfig {
|
||||
senderEmail: string
|
||||
senderDisplayName: string
|
||||
replyToEmail: string
|
||||
}
|
||||
|
||||
export interface AlertThresholdEntry {
|
||||
id: string
|
||||
daysBefore: number
|
||||
enabled: boolean
|
||||
}
|
||||
|
||||
export interface EscalationTier {
|
||||
id: string
|
||||
daysOverdue: number
|
||||
recipients: AlertRecipient[]
|
||||
action: string
|
||||
}
|
||||
|
||||
export interface RenewalAlertConfig {
|
||||
enabled: boolean
|
||||
thresholds: AlertThresholdEntry[]
|
||||
}
|
||||
|
||||
export interface CancellationAlertConfig {
|
||||
enabled: boolean
|
||||
recipients: AlertRecipient[]
|
||||
}
|
||||
|
||||
export interface LatePaymentAlertConfig {
|
||||
enabled: boolean
|
||||
tiers: EscalationTier[]
|
||||
}
|
||||
|
||||
export interface CreditCardExpiryAlertConfig {
|
||||
enabled: boolean
|
||||
thresholds: AlertThresholdEntry[]
|
||||
autoDebitOnly: boolean
|
||||
}
|
||||
|
||||
export interface CustomAlertRule {
|
||||
id: string
|
||||
alertName: string
|
||||
field: string
|
||||
operator: 'gte' | 'lte' | 'eq' | 'gt' | 'lt' | 'contains'
|
||||
value: string | number | boolean
|
||||
recipients: AlertRecipient[]
|
||||
enabled: boolean
|
||||
}
|
||||
|
||||
export interface AlertConfig {
|
||||
emailSender: EmailSenderConfig
|
||||
renewals: RenewalAlertConfig
|
||||
cancellations: CancellationAlertConfig
|
||||
latePayments: LatePaymentAlertConfig
|
||||
creditCardExpiry: CreditCardExpiryAlertConfig
|
||||
customRules: CustomAlertRule[]
|
||||
}
|
||||
|
||||
/* ── Defaults ── */
|
||||
|
||||
function defaultConfig(): AlertConfig {
|
||||
return {
|
||||
emailSender: {
|
||||
senderEmail: 'alertas@miagencia.com',
|
||||
senderDisplayName: 'Segur-OS Alertas',
|
||||
replyToEmail: 'soporte@miagencia.com',
|
||||
},
|
||||
renewals: {
|
||||
enabled: true,
|
||||
thresholds: [
|
||||
{ id: 'r90', daysBefore: 90, enabled: true },
|
||||
{ id: 'r60', daysBefore: 60, enabled: true },
|
||||
{ id: 'r30', daysBefore: 30, enabled: true },
|
||||
{ id: 'r15', daysBefore: 15, enabled: true },
|
||||
],
|
||||
},
|
||||
cancellations: {
|
||||
enabled: true,
|
||||
recipients: ['handler', 'manager'],
|
||||
},
|
||||
latePayments: {
|
||||
enabled: true,
|
||||
tiers: [
|
||||
{ id: 'lp5', daysOverdue: 5, recipients: ['handler'], action: 'Notify assigned handler' },
|
||||
{ id: 'lp15', daysOverdue: 15, recipients: ['handler', 'manager'], action: 'Notify handler + manager' },
|
||||
{ id: 'lp30', daysOverdue: 30, recipients: ['handler', 'manager', 'customer'], action: 'Auto-escalate + notify customer' },
|
||||
],
|
||||
},
|
||||
creditCardExpiry: {
|
||||
enabled: true,
|
||||
thresholds: [
|
||||
{ id: 'cc60', daysBefore: 60, enabled: true },
|
||||
{ id: 'cc30', daysBefore: 30, enabled: true },
|
||||
{ id: 'cc15', daysBefore: 15, enabled: true },
|
||||
],
|
||||
autoDebitOnly: true,
|
||||
},
|
||||
customRules: [
|
||||
{
|
||||
id: 'cr1',
|
||||
alertName: 'High-value policy renewal',
|
||||
field: 'premium',
|
||||
operator: 'gte',
|
||||
value: 25000,
|
||||
recipients: ['handler', 'manager'],
|
||||
enabled: true,
|
||||
},
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Composable ── */
|
||||
|
||||
let _counter = 100
|
||||
|
||||
export function useAlertConfig() {
|
||||
const config = useLocalStorageRef<AlertConfig>('policy-ui-alert-config-v1', defaultConfig)
|
||||
|
||||
/* ── Threshold CRUD ── */
|
||||
|
||||
function addThreshold(section: 'renewals' | 'creditCardExpiry', daysBefore: number) {
|
||||
const id = `t${++_counter}`
|
||||
config.value[section].thresholds.push({ id, daysBefore, enabled: true })
|
||||
}
|
||||
|
||||
function removeThreshold(section: 'renewals' | 'creditCardExpiry', id: string) {
|
||||
config.value[section].thresholds = config.value[section].thresholds.filter(t => t.id !== id)
|
||||
}
|
||||
|
||||
/* ── Late payment tier CRUD ── */
|
||||
|
||||
function addPaymentTier(daysOverdue: number, action: string, recipients: AlertRecipient[]) {
|
||||
const id = `lp${++_counter}`
|
||||
config.value.latePayments.tiers.push({ id, daysOverdue, recipients, action })
|
||||
}
|
||||
|
||||
function removePaymentTier(id: string) {
|
||||
config.value.latePayments.tiers = config.value.latePayments.tiers.filter(t => t.id !== id)
|
||||
}
|
||||
|
||||
/* ── Custom rule CRUD ── */
|
||||
|
||||
function addCustomRule(rule: Omit<CustomAlertRule, 'id'>) {
|
||||
const id = `cr${++_counter}`
|
||||
config.value.customRules.push({ ...rule, id })
|
||||
}
|
||||
|
||||
function updateCustomRule(id: string, patch: Partial<CustomAlertRule>) {
|
||||
const idx = config.value.customRules.findIndex(r => r.id === id)
|
||||
if (idx !== -1) {
|
||||
config.value.customRules[idx] = { ...config.value.customRules[idx], ...patch }
|
||||
}
|
||||
}
|
||||
|
||||
function removeCustomRule(id: string) {
|
||||
config.value.customRules = config.value.customRules.filter(r => r.id !== id)
|
||||
}
|
||||
|
||||
return {
|
||||
config,
|
||||
addThreshold,
|
||||
removeThreshold,
|
||||
addPaymentTier,
|
||||
removePaymentTier,
|
||||
addCustomRule,
|
||||
updateCustomRule,
|
||||
removeCustomRule,
|
||||
}
|
||||
}
|
||||
@@ -1,147 +0,0 @@
|
||||
/**
|
||||
* Business Analytics — composable for chart state, SVG rendering, and domain filtering.
|
||||
* SVG chart math extracted from /app/pages/index.vue dashboard charts.
|
||||
*/
|
||||
import { useLocalStorageRef } from '~/utils/useLocalStorageRef'
|
||||
import {
|
||||
ANALYTICS_METRICS,
|
||||
ANALYTICS_KPI_SUMMARIES,
|
||||
type AnalyticsDomainId,
|
||||
type AnalyticsChartType,
|
||||
type AnalyticsTimePoint,
|
||||
} from '~/data/mock-analytics'
|
||||
|
||||
export interface ChartSvgModel {
|
||||
lineD: string
|
||||
areaD: string
|
||||
points: { x: number; y: number; v: number }[]
|
||||
bars: { x: number; y: number; w: number; h: number }[]
|
||||
gridYs: number[]
|
||||
viewW: number
|
||||
viewH: number
|
||||
padX: number
|
||||
innerW: number
|
||||
bottomY: number
|
||||
}
|
||||
|
||||
interface AnalyticsState {
|
||||
activeDomain: AnalyticsDomainId
|
||||
chartBuilderMetric: string
|
||||
chartBuilderType: AnalyticsChartType
|
||||
chartBuilderRange: '3m' | '6m' | '12m'
|
||||
}
|
||||
|
||||
function buildDefaults(): AnalyticsState {
|
||||
return {
|
||||
activeDomain: 'production',
|
||||
chartBuilderMetric: 'gwp',
|
||||
chartBuilderType: 'area',
|
||||
chartBuilderRange: '6m',
|
||||
}
|
||||
}
|
||||
|
||||
export function useAnalytics() {
|
||||
const state = useLocalStorageRef<AnalyticsState>('policy-ui-analytics-v1', buildDefaults)
|
||||
|
||||
const allMetrics = ANALYTICS_METRICS
|
||||
const kpiSummaries = ANALYTICS_KPI_SUMMARIES
|
||||
|
||||
const domainMetrics = computed(() =>
|
||||
allMetrics.filter(m => m.domain === state.value.activeDomain)
|
||||
)
|
||||
|
||||
const chartBuilderMetricObj = computed(() =>
|
||||
allMetrics.find(m => m.id === state.value.chartBuilderMetric) ?? allMetrics[0]!
|
||||
)
|
||||
|
||||
const chartBuilderData = computed(() => {
|
||||
const data = chartBuilderMetricObj.value.data12m.filter(d => d.m)
|
||||
const range = state.value.chartBuilderRange
|
||||
if (range === '3m') return data.slice(-3)
|
||||
if (range === '6m') return data.slice(-6)
|
||||
return data
|
||||
})
|
||||
|
||||
// ── SVG chart model builder (extracted from dashboard index.vue) ──
|
||||
function buildSvgModel(data: AnalyticsTimePoint[], viewW = 400, viewH = 120): ChartSvgModel {
|
||||
const pts = data.map(d => d.v)
|
||||
const padX = 8; const padY = 14
|
||||
const innerW = viewW - padX * 2; const innerH = viewH - padY * 2
|
||||
const maxV = Math.max(...pts, 1); const minV = Math.min(...pts, 0)
|
||||
const span = maxV - minV || 1
|
||||
const points = pts.map((p, i) => ({
|
||||
x: padX + (i / Math.max(1, pts.length - 1)) * innerW,
|
||||
y: padY + (1 - (p - minV) / span) * innerH,
|
||||
v: p,
|
||||
}))
|
||||
const bottomY = padY + innerH
|
||||
const first = points[0]!; const last = points[points.length - 1]!
|
||||
|
||||
// Line + area paths (smooth Bézier curves)
|
||||
let lineD = `M ${first.x},${first.y}`
|
||||
let areaD = `M ${first.x},${bottomY} L ${first.x},${first.y}`
|
||||
for (let i = 1; i < points.length; i++) {
|
||||
const p0 = points[i - 1]!; const p1 = points[i]!
|
||||
const cx = (p0.x + p1.x) / 2
|
||||
const seg = ` C ${cx},${p0.y} ${cx},${p1.y} ${p1.x},${p1.y}`
|
||||
lineD += seg; areaD += seg
|
||||
}
|
||||
areaD += ` L ${last.x},${bottomY} Z`
|
||||
|
||||
// Bar geometry
|
||||
const barW = Math.min(innerW / pts.length * 0.6, 40)
|
||||
const bars = pts.map((p, i) => ({
|
||||
x: padX + (i / Math.max(1, pts.length - 1)) * innerW - barW / 2,
|
||||
y: padY + (1 - (p - minV) / span) * innerH,
|
||||
w: barW,
|
||||
h: ((p - minV) / span) * innerH,
|
||||
}))
|
||||
|
||||
const gridYs = [0, 0.5, 1].map(t => padY + t * innerH)
|
||||
return { lineD, areaD, points, bars, gridYs, viewW, viewH, padX, innerW, bottomY }
|
||||
}
|
||||
|
||||
// ── Sparkline helpers ──
|
||||
function sparklinePath(pts: number[], w = 112, h = 32, pad = 2): string {
|
||||
const max = Math.max(...pts); const min = Math.min(...pts); const r = max - min || 1
|
||||
const mapped = pts.map((p, i, arr) => ({
|
||||
x: pad + (i / Math.max(1, arr.length - 1)) * (w - pad * 2),
|
||||
y: pad + (1 - (p - min) / r) * (h - pad * 2),
|
||||
}))
|
||||
if (mapped.length < 2) return ''
|
||||
let d = `M ${mapped[0]!.x},${mapped[0]!.y}`
|
||||
for (let i = 0; i < mapped.length - 1; i++) {
|
||||
const p0 = mapped[i]!; const p1 = mapped[i + 1]!; const cx = (p0.x + p1.x) / 2
|
||||
d += ` C ${cx},${p0.y} ${cx},${p1.y} ${p1.x},${p1.y}`
|
||||
}
|
||||
return d
|
||||
}
|
||||
|
||||
function sparklineArea(pts: number[], w = 112, h = 32, pad = 2): string {
|
||||
const path = sparklinePath(pts, w, h, pad)
|
||||
if (!path) return ''
|
||||
const max = Math.max(...pts); const min = Math.min(...pts); const r = max - min || 1
|
||||
const mapped = pts.map((p, i, arr) => ({
|
||||
x: pad + (i / Math.max(1, arr.length - 1)) * (w - pad * 2),
|
||||
y: pad + (1 - (p - min) / r) * (h - pad * 2),
|
||||
}))
|
||||
return `${path} L ${mapped[mapped.length - 1]!.x},${h} L ${mapped[0]!.x},${h} Z`
|
||||
}
|
||||
|
||||
const chartBuilderSvgModel = computed(() =>
|
||||
buildSvgModel(chartBuilderData.value, 400, 180)
|
||||
)
|
||||
|
||||
return {
|
||||
state,
|
||||
allMetrics,
|
||||
kpiSummaries,
|
||||
domainMetrics,
|
||||
chartBuilderMetricObj,
|
||||
chartBuilderData,
|
||||
chartBuilderSvgModel,
|
||||
buildSvgModel,
|
||||
sparklinePath,
|
||||
sparklineArea,
|
||||
}
|
||||
}
|
||||
@@ -1,32 +0,0 @@
|
||||
const STORAGE_KEY = 'policy-ui.sidebar.collapsed.v1'
|
||||
|
||||
export function useAppShellLayout() {
|
||||
const sidebarCollapsed = ref(false)
|
||||
|
||||
onMounted(() => {
|
||||
if (!import.meta.client) return
|
||||
try {
|
||||
sidebarCollapsed.value = localStorage.getItem(STORAGE_KEY) === '1'
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
})
|
||||
|
||||
watch(sidebarCollapsed, (c) => {
|
||||
if (!import.meta.client) return
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, c ? '1' : '0')
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
})
|
||||
|
||||
function toggleSidebar() {
|
||||
sidebarCollapsed.value = !sidebarCollapsed.value
|
||||
}
|
||||
|
||||
return {
|
||||
sidebarCollapsed,
|
||||
toggleSidebar
|
||||
}
|
||||
}
|
||||
@@ -1,46 +0,0 @@
|
||||
import type { AppThemeId } from '~/types/app-theme'
|
||||
import { APP_THEME_OPTIONS } from '~/types/app-theme'
|
||||
|
||||
const STORAGE_KEY = 'policy-ui.theme.v1'
|
||||
|
||||
const VALID: AppThemeId[] = ['light', 'purple', 'dark', 'dark-purple']
|
||||
|
||||
function isThemeId(x: string): x is AppThemeId {
|
||||
return (VALID as string[]).includes(x)
|
||||
}
|
||||
|
||||
export function useAppTheme() {
|
||||
const themeId = ref<AppThemeId>('light')
|
||||
|
||||
function applyTheme(id: AppThemeId) {
|
||||
themeId.value = id
|
||||
if (import.meta.client) {
|
||||
document.documentElement.setAttribute('data-theme', id)
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, id)
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
if (!import.meta.client) return
|
||||
try {
|
||||
const raw = localStorage.getItem(STORAGE_KEY)
|
||||
if (raw && isThemeId(raw)) {
|
||||
applyTheme(raw)
|
||||
return
|
||||
}
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
applyTheme('light')
|
||||
})
|
||||
|
||||
return {
|
||||
themeId,
|
||||
themeOptions: APP_THEME_OPTIONS,
|
||||
applyTheme
|
||||
}
|
||||
}
|
||||
@@ -1,29 +0,0 @@
|
||||
import type { AutoQuoteDraft } from '~/types/auto-quote-intake'
|
||||
|
||||
export function emptyAutoQuoteDraft(): AutoQuoteDraft {
|
||||
return {
|
||||
quoteMode: null,
|
||||
segment: null,
|
||||
insured: null,
|
||||
buyer: null,
|
||||
vehicle: {
|
||||
subRamo: '',
|
||||
clase: '',
|
||||
uso: '',
|
||||
marca: '',
|
||||
modelo: '',
|
||||
placa: '',
|
||||
year: '',
|
||||
capacidadPasajeros: '',
|
||||
rc_limits: '',
|
||||
market_value: 0,
|
||||
requested_value: 0,
|
||||
chassis_number: '',
|
||||
engine_number: ''
|
||||
},
|
||||
solicit: {
|
||||
carrierIds: [],
|
||||
planIds: []
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,31 +0,0 @@
|
||||
import { BRANDING_STORAGE_KEY, type BrokerageBrandingState } from '~/types/branding'
|
||||
import { useLocalStorageRef } from '~/utils/useLocalStorageRef'
|
||||
|
||||
export function defaultBrokerageBranding(): BrokerageBrandingState {
|
||||
return {
|
||||
companyName: '',
|
||||
logoDataUrl: null,
|
||||
logoFileName: '',
|
||||
reportPageHeader: '',
|
||||
reportPageFooter: ''
|
||||
}
|
||||
}
|
||||
|
||||
export function useBrokerageBranding() {
|
||||
const saved = useLocalStorageRef(BRANDING_STORAGE_KEY, defaultBrokerageBranding)
|
||||
|
||||
const productDisplayName = computed(() => {
|
||||
const n = saved.value.companyName?.trim()
|
||||
if (n) return n
|
||||
return null
|
||||
})
|
||||
|
||||
const sidebarTitle = computed(() => productDisplayName.value ?? 'Segur-OS')
|
||||
|
||||
return {
|
||||
saved,
|
||||
productDisplayName,
|
||||
sidebarTitle,
|
||||
defaultBrokerageBranding
|
||||
}
|
||||
}
|
||||
@@ -1,35 +0,0 @@
|
||||
/**
|
||||
* Client favorites — star customers for quick dashboard access.
|
||||
* Persisted in localStorage. Stores customer IDs.
|
||||
*/
|
||||
import { useLocalStorageRef } from '~/utils/useLocalStorageRef'
|
||||
|
||||
const KEY = 'policy-ui-client-favorites-v1'
|
||||
|
||||
export function useClientFavorites() {
|
||||
const favoriteIds = useLocalStorageRef<string[]>(KEY, () => [])
|
||||
|
||||
function isFavorite(customerId: string) {
|
||||
return favoriteIds.value.includes(customerId)
|
||||
}
|
||||
|
||||
function toggleFavorite(customerId: string) {
|
||||
if (isFavorite(customerId)) {
|
||||
favoriteIds.value = favoriteIds.value.filter(id => id !== customerId)
|
||||
} else {
|
||||
favoriteIds.value = [customerId, ...favoriteIds.value]
|
||||
}
|
||||
}
|
||||
|
||||
function addFavorite(customerId: string) {
|
||||
if (!isFavorite(customerId)) {
|
||||
favoriteIds.value = [customerId, ...favoriteIds.value]
|
||||
}
|
||||
}
|
||||
|
||||
function removeFavorite(customerId: string) {
|
||||
favoriteIds.value = favoriteIds.value.filter(id => id !== customerId)
|
||||
}
|
||||
|
||||
return { favoriteIds, isFavorite, toggleFavorite, addFavorite, removeFavorite }
|
||||
}
|
||||
@@ -1,47 +0,0 @@
|
||||
import type { ClientCaptureMeta, ClientRegistrationNatural } from '~/types/brokerage-registration'
|
||||
|
||||
export function createEmptyClientRegistration(): ClientRegistrationNatural {
|
||||
return {
|
||||
id: '',
|
||||
economicGroupId: '',
|
||||
conglomerateId: '',
|
||||
personType: 'natural',
|
||||
apellidoPaterno: '',
|
||||
apellidoMaterno: '',
|
||||
primerNombre: '',
|
||||
segundoNombre: '',
|
||||
fechaNacimiento: '',
|
||||
tipoIdentificacion: '',
|
||||
cedulaOPasaporte: '',
|
||||
telefonoCelular: '',
|
||||
correoElectronicoPersonal: '',
|
||||
ocupacion: '',
|
||||
procedencia: '',
|
||||
detalle: '',
|
||||
descripcion: ''
|
||||
}
|
||||
}
|
||||
|
||||
export function useClientCaptureMeta(): ClientCaptureMeta {
|
||||
const now = new Date()
|
||||
return {
|
||||
operadorId: '32',
|
||||
operadorNombre: 'Jordan',
|
||||
fechaCaptura: now.toISOString(),
|
||||
progresoCapturaPct: 6,
|
||||
estado: ''
|
||||
}
|
||||
}
|
||||
|
||||
export function toIndividualCustomerBody(r: ClientRegistrationNatural) {
|
||||
const last = [r.apellidoPaterno, r.apellidoMaterno].filter(Boolean).join(' ').trim()
|
||||
return {
|
||||
first_name: [r.primerNombre, r.segundoNombre].filter(Boolean).join(' ').trim() || r.primerNombre,
|
||||
last_name: last || '-',
|
||||
email: r.correoElectronicoPersonal.trim(),
|
||||
phone: r.telefonoCelular.trim(),
|
||||
birth_date: r.fechaNacimiento,
|
||||
gender: '',
|
||||
document_id: r.cedulaOPasaporte.trim()
|
||||
}
|
||||
}
|
||||
@@ -1,641 +0,0 @@
|
||||
/**
|
||||
* Colectivos (group accounts) — data backbone for the entire module.
|
||||
* Manages group accounts, members, documents, billing, and service requests.
|
||||
* Persisted in localStorage via useLocalStorageRef.
|
||||
*/
|
||||
import { useLocalStorageRef } from '~/utils/useLocalStorageRef'
|
||||
|
||||
/* ── Core Types ── */
|
||||
|
||||
export type ColectivoStatus = 'quoting' | 'onboarding' | 'active' | 'renewal_due' | 'suspended' | 'cancelled'
|
||||
export type MemberStatus = 'active' | 'pending_enrollment' | 'pending_docs' | 'excluded' | 'on_leave'
|
||||
export type ServiceRequestType = 'inclusion' | 'exclusion' | 'claim' | 'billing' | 'certificate' | 'amendment'
|
||||
export type ServiceRequestStatus = 'open' | 'in_progress' | 'pending_carrier' | 'pending_client' | 'resolved' | 'cancelled'
|
||||
export type DocumentCategory = 'policy' | 'contract' | 'endorsement' | 'certificate' | 'amendment' | 'census' | 'siniestralidad' | 'enrollment' | 'correspondence' | 'other'
|
||||
export type BillingStatus = 'upcoming' | 'invoiced' | 'paid' | 'overdue' | 'disputed' | 'reconciled'
|
||||
|
||||
export interface ColectivoMember {
|
||||
id: string
|
||||
name: string
|
||||
documentId: string
|
||||
email: string
|
||||
phone: string
|
||||
role: string
|
||||
department: string
|
||||
enrollmentDate: string
|
||||
status: MemberStatus
|
||||
tier: string
|
||||
dependents: number
|
||||
pendingDocs: string[]
|
||||
formsCompleted: number
|
||||
formsTotal: number
|
||||
}
|
||||
|
||||
export interface ColectivoDocument {
|
||||
id: string
|
||||
name: string
|
||||
category: DocumentCategory
|
||||
uploadedBy: string
|
||||
uploadedAt: string
|
||||
fileSize: string
|
||||
fileType: string
|
||||
version: number
|
||||
notes: string
|
||||
}
|
||||
|
||||
export interface BillingCycle {
|
||||
id: string
|
||||
period: string
|
||||
dueDate: string
|
||||
status: BillingStatus
|
||||
invoiceAmount: number
|
||||
paidAmount: number
|
||||
carrierRef: string
|
||||
membersBilled: number
|
||||
membersExpected: number
|
||||
discrepancy: number
|
||||
notes: string
|
||||
}
|
||||
|
||||
export interface ServiceRequest {
|
||||
id: string
|
||||
type: ServiceRequestType
|
||||
subject: string
|
||||
status: ServiceRequestStatus
|
||||
priority: 'low' | 'medium' | 'high' | 'urgent'
|
||||
assignee: string
|
||||
created: string
|
||||
updated: string
|
||||
memberName?: string
|
||||
notes: string
|
||||
}
|
||||
|
||||
export interface ColectivoAccount {
|
||||
id: string
|
||||
name: string
|
||||
ruc: string
|
||||
lob: string
|
||||
product: string
|
||||
carrier: string
|
||||
status: ColectivoStatus
|
||||
|
||||
contactName: string
|
||||
contactEmail: string
|
||||
contactPhone: string
|
||||
hrContactName: string
|
||||
hrContactEmail: string
|
||||
|
||||
effectiveDate: string
|
||||
renewalDate: string
|
||||
onboardingDate: string
|
||||
|
||||
totalMembers: number
|
||||
activeMembersCount: number
|
||||
dependentsCount: number
|
||||
pendingEnrollment: number
|
||||
|
||||
monthlyPremium: number
|
||||
annualPremium: number
|
||||
commissionPct: number
|
||||
|
||||
agent: string
|
||||
|
||||
members: ColectivoMember[]
|
||||
documents: ColectivoDocument[]
|
||||
billingCycles: BillingCycle[]
|
||||
serviceRequests: ServiceRequest[]
|
||||
|
||||
recentActivity: { date: string; text: string; type: string; actor: string }[]
|
||||
|
||||
hasUrgentIssues: boolean
|
||||
outstandingClaims: number
|
||||
pendingTasks: number
|
||||
}
|
||||
|
||||
/* ── Mock Data ── */
|
||||
|
||||
function buildDefaultAccounts(): ColectivoAccount[] {
|
||||
return [
|
||||
// ── 1. Banco Regional ──
|
||||
{
|
||||
id: 'col-001',
|
||||
name: 'Banco Regional S.A.',
|
||||
ruc: '80012345-6',
|
||||
lob: 'Health',
|
||||
product: 'Salud Corporativa Elite',
|
||||
carrier: 'Vida Plena',
|
||||
status: 'active',
|
||||
|
||||
contactName: 'Roberto Méndez',
|
||||
contactEmail: 'rmendez@bancoregional.com.py',
|
||||
contactPhone: '+595 21 410-2200',
|
||||
hrContactName: 'Silvia Acosta',
|
||||
hrContactEmail: 'sacosta@bancoregional.com.py',
|
||||
|
||||
effectiveDate: '2025-07-01',
|
||||
renewalDate: '2026-05-23',
|
||||
onboardingDate: '2025-06-15',
|
||||
|
||||
totalMembers: 412,
|
||||
activeMembersCount: 398,
|
||||
dependentsCount: 687,
|
||||
pendingEnrollment: 4,
|
||||
|
||||
monthlyPremium: 10000,
|
||||
annualPremium: 120000,
|
||||
commissionPct: 12,
|
||||
|
||||
agent: 'Carlos Villalba',
|
||||
|
||||
members: [
|
||||
{ id: 'mbr-001-01', name: 'Roberto Méndez', documentId: '3.456.789', email: 'rmendez@bancoregional.com.py', phone: '+595 981 222-001', role: 'Director General', department: 'Directorio', enrollmentDate: '2025-07-01', status: 'active', tier: 'Executive', dependents: 3, pendingDocs: [], formsCompleted: 4, formsTotal: 4 },
|
||||
{ id: 'mbr-001-02', name: 'Silvia Acosta', documentId: '4.123.456', email: 'sacosta@bancoregional.com.py', phone: '+595 981 222-002', role: 'Gerente RRHH', department: 'Recursos Humanos', enrollmentDate: '2025-07-01', status: 'active', tier: 'Plus', dependents: 2, pendingDocs: [], formsCompleted: 4, formsTotal: 4 },
|
||||
{ id: 'mbr-001-03', name: 'Jorge Ramírez', documentId: '2.987.654', email: 'jramirez@bancoregional.com.py', phone: '+595 981 222-003', role: 'Analista de Créditos', department: 'Banca Comercial', enrollmentDate: '2025-07-15', status: 'active', tier: 'Basic', dependents: 1, pendingDocs: [], formsCompleted: 4, formsTotal: 4 },
|
||||
{ id: 'mbr-001-04', name: 'María Elena Torres', documentId: '5.321.098', email: 'metorres@bancoregional.com.py', phone: '+595 981 222-004', role: 'Cajera Principal', department: 'Operaciones', enrollmentDate: '2025-07-01', status: 'active', tier: 'Basic', dependents: 2, pendingDocs: [], formsCompleted: 4, formsTotal: 4 },
|
||||
{ id: 'mbr-001-05', name: 'Fernando López', documentId: '1.654.321', email: 'flopez@bancoregional.com.py', phone: '+595 981 222-005', role: 'Gerente de Sucursal', department: 'Sucursales', enrollmentDate: '2025-08-01', status: 'active', tier: 'Plus', dependents: 4, pendingDocs: [], formsCompleted: 4, formsTotal: 4 },
|
||||
{ id: 'mbr-001-06', name: 'Patricia Benítez', documentId: '6.789.012', email: 'pbenitez@bancoregional.com.py', phone: '+595 981 222-006', role: 'Oficial de Cumplimiento', department: 'Legal', enrollmentDate: '2025-09-01', status: 'pending_docs', tier: 'Plus', dependents: 1, pendingDocs: ['Certificado médico', 'Formulario de dependientes'], formsCompleted: 2, formsTotal: 4 },
|
||||
{ id: 'mbr-001-07', name: 'Luis Giménez', documentId: '3.210.987', email: 'lgimenez@bancoregional.com.py', phone: '+595 981 222-007', role: 'Desarrollador Senior', department: 'Tecnología', enrollmentDate: '2026-01-15', status: 'active', tier: 'Basic', dependents: 0, pendingDocs: [], formsCompleted: 4, formsTotal: 4 },
|
||||
{ id: 'mbr-001-08', name: 'Ana Cristina Duarte', documentId: '7.654.321', email: 'acduarte@bancoregional.com.py', phone: '+595 981 222-008', role: 'Asistente Ejecutiva', department: 'Directorio', enrollmentDate: '2025-07-01', status: 'on_leave', tier: 'Executive', dependents: 2, pendingDocs: [], formsCompleted: 4, formsTotal: 4 },
|
||||
],
|
||||
|
||||
documents: [
|
||||
{ id: 'doc-001-01', name: 'Póliza Colectiva 2025-2026', category: 'policy', uploadedBy: 'Carlos Villalba', uploadedAt: '2025-06-28', fileSize: '4.2 MB', fileType: 'PDF', version: 2, notes: 'Versión final firmada' },
|
||||
{ id: 'doc-001-02', name: 'Contrato de Intermediación', category: 'contract', uploadedBy: 'Carlos Villalba', uploadedAt: '2025-06-20', fileSize: '1.8 MB', fileType: 'PDF', version: 1, notes: '' },
|
||||
{ id: 'doc-001-03', name: 'Censo Marzo 2026', category: 'census', uploadedBy: 'Silvia Acosta', uploadedAt: '2026-03-05', fileSize: '856 KB', fileType: 'XLSX', version: 1, notes: 'Incluye 3 nuevas altas' },
|
||||
{ id: 'doc-001-04', name: 'Endoso #3 - Inclusiones Feb 2026', category: 'endorsement', uploadedBy: 'Carlos Villalba', uploadedAt: '2026-02-18', fileSize: '320 KB', fileType: 'PDF', version: 1, notes: '8 inclusiones procesadas' },
|
||||
{ id: 'doc-001-05', name: 'Reporte Siniestralidad Q1 2026', category: 'siniestralidad', uploadedBy: 'Vida Plena', uploadedAt: '2026-04-02', fileSize: '2.1 MB', fileType: 'PDF', version: 1, notes: 'Siniestralidad al 68%' },
|
||||
{ id: 'doc-001-06', name: 'Certificado Individual - R. Méndez', category: 'certificate', uploadedBy: 'Carlos Villalba', uploadedAt: '2025-07-10', fileSize: '145 KB', fileType: 'PDF', version: 1, notes: '' },
|
||||
],
|
||||
|
||||
billingCycles: [
|
||||
{ id: 'bill-001-01', period: 'January 2026', dueDate: '2026-01-15', status: 'paid', invoiceAmount: 10000, paidAmount: 10000, carrierRef: 'VP-2026-0412-01', membersBilled: 410, membersExpected: 410, discrepancy: 0, notes: '' },
|
||||
{ id: 'bill-001-02', period: 'February 2026', dueDate: '2026-02-15', status: 'paid', invoiceAmount: 10200, paidAmount: 10200, carrierRef: 'VP-2026-0412-02', membersBilled: 412, membersExpected: 412, discrepancy: 0, notes: 'Incluyó 2 altas' },
|
||||
{ id: 'bill-001-03', period: 'March 2026', dueDate: '2026-03-15', status: 'paid', invoiceAmount: 10200, paidAmount: 10200, carrierRef: 'VP-2026-0412-03', membersBilled: 412, membersExpected: 412, discrepancy: 0, notes: '' },
|
||||
{ id: 'bill-001-04', period: 'April 2026', dueDate: '2026-04-15', status: 'invoiced', invoiceAmount: 10200, paidAmount: 0, carrierRef: 'VP-2026-0412-04', membersBilled: 412, membersExpected: 412, discrepancy: 0, notes: 'Factura enviada al cliente' },
|
||||
{ id: 'bill-001-05', period: 'May 2026', dueDate: '2026-05-15', status: 'upcoming', invoiceAmount: 10200, paidAmount: 0, carrierRef: '', membersBilled: 0, membersExpected: 412, discrepancy: 0, notes: '' },
|
||||
],
|
||||
|
||||
serviceRequests: [
|
||||
{ id: 'sr-001-01', type: 'claim', subject: 'Reclamo cobertura cirugía - Expediente #4521', status: 'pending_carrier', priority: 'urgent', assignee: 'Carlos Villalba', created: '2026-03-28', updated: '2026-04-05', memberName: 'Fernando López', notes: 'Carrier solicitó documentación adicional del hospital. Plazo vence 04/12.' },
|
||||
{ id: 'sr-001-02', type: 'inclusion', subject: 'Alta de 3 nuevos empleados - Sucursal Este', status: 'in_progress', priority: 'high', assignee: 'Carlos Villalba', created: '2026-04-01', updated: '2026-04-06', notes: 'Faltan formularios de 1 empleado.' },
|
||||
{ id: 'sr-001-03', type: 'certificate', subject: 'Certificados individuales para viaje corporativo', status: 'open', priority: 'urgent', assignee: 'Carlos Villalba', created: '2026-04-07', updated: '2026-04-07', notes: 'Necesitan 12 certificados para viaje el 04/18.' },
|
||||
{ id: 'sr-001-04', type: 'billing', subject: 'Consulta sobre diferencia en factura Enero', status: 'resolved', priority: 'low', assignee: 'Carlos Villalba', created: '2026-01-22', updated: '2026-02-03', notes: 'Diferencia por ajuste de prima. Aclarado con RRHH.' },
|
||||
],
|
||||
|
||||
recentActivity: [
|
||||
{ date: '2026-04-07', text: 'Solicitud urgente de certificados para viaje corporativo', type: 'service_request', actor: 'Silvia Acosta' },
|
||||
{ date: '2026-04-06', text: 'Actualización en reclamo #4521 - carrier solicita más docs', type: 'claim_update', actor: 'Vida Plena' },
|
||||
{ date: '2026-04-05', text: 'Factura de Abril enviada al cliente', type: 'billing', actor: 'Sistema' },
|
||||
{ date: '2026-04-01', text: 'Nueva solicitud de inclusión: 3 empleados Sucursal Este', type: 'inclusion', actor: 'Silvia Acosta' },
|
||||
{ date: '2026-03-28', text: 'Reclamo de cirugía elevado a urgente', type: 'claim_update', actor: 'Carlos Villalba' },
|
||||
{ date: '2026-03-05', text: 'Censo de Marzo recibido y cargado', type: 'document', actor: 'Silvia Acosta' },
|
||||
{ date: '2026-02-18', text: 'Endoso #3 procesado - 8 inclusiones', type: 'endorsement', actor: 'Carlos Villalba' },
|
||||
],
|
||||
|
||||
hasUrgentIssues: true,
|
||||
outstandingClaims: 2,
|
||||
pendingTasks: 5,
|
||||
},
|
||||
|
||||
// ── 2. Clínica San José ──
|
||||
{
|
||||
id: 'col-002',
|
||||
name: 'Clínica San José',
|
||||
ruc: '80034567-1',
|
||||
lob: 'Health',
|
||||
product: 'Salud Integral Empresarial',
|
||||
carrier: 'Salud Global',
|
||||
status: 'active',
|
||||
|
||||
contactName: 'Dr. Marcelo Insfrán',
|
||||
contactEmail: 'minsfran@clinicasanjose.com.py',
|
||||
contactPhone: '+595 21 550-3300',
|
||||
hrContactName: 'Laura Paredes',
|
||||
hrContactEmail: 'lparedes@clinicasanjose.com.py',
|
||||
|
||||
effectiveDate: '2025-09-01',
|
||||
renewalDate: '2026-09-01',
|
||||
onboardingDate: '2025-08-15',
|
||||
|
||||
totalMembers: 88,
|
||||
activeMembersCount: 88,
|
||||
dependentsCount: 142,
|
||||
pendingEnrollment: 0,
|
||||
|
||||
monthlyPremium: 3500,
|
||||
annualPremium: 42000,
|
||||
commissionPct: 10,
|
||||
|
||||
agent: 'María Fernanda Ortiz',
|
||||
|
||||
members: [
|
||||
{ id: 'mbr-002-01', name: 'Dr. Marcelo Insfrán', documentId: '1.234.567', email: 'minsfran@clinicasanjose.com.py', phone: '+595 982 333-001', role: 'Director Médico', department: 'Dirección', enrollmentDate: '2025-09-01', status: 'active', tier: 'Executive', dependents: 3, pendingDocs: [], formsCompleted: 3, formsTotal: 3 },
|
||||
{ id: 'mbr-002-02', name: 'Laura Paredes', documentId: '2.345.678', email: 'lparedes@clinicasanjose.com.py', phone: '+595 982 333-002', role: 'Jefa de RRHH', department: 'Administración', enrollmentDate: '2025-09-01', status: 'active', tier: 'Plus', dependents: 2, pendingDocs: [], formsCompleted: 3, formsTotal: 3 },
|
||||
{ id: 'mbr-002-03', name: 'Dra. Carolina Fleitas', documentId: '3.456.012', email: 'cfleitas@clinicasanjose.com.py', phone: '+595 982 333-003', role: 'Pediatra', department: 'Pediatría', enrollmentDate: '2025-09-01', status: 'active', tier: 'Plus', dependents: 1, pendingDocs: [], formsCompleted: 3, formsTotal: 3 },
|
||||
{ id: 'mbr-002-04', name: 'Enf. Rosa Martínez', documentId: '4.567.890', email: 'rmartinez@clinicasanjose.com.py', phone: '+595 982 333-004', role: 'Enfermera Jefa', department: 'Enfermería', enrollmentDate: '2025-09-15', status: 'active', tier: 'Basic', dependents: 2, pendingDocs: [], formsCompleted: 3, formsTotal: 3 },
|
||||
{ id: 'mbr-002-05', name: 'Carlos Ruiz', documentId: '5.678.901', email: 'cruiz@clinicasanjose.com.py', phone: '+595 982 333-005', role: 'Técnico de Laboratorio', department: 'Laboratorio', enrollmentDate: '2025-10-01', status: 'active', tier: 'Basic', dependents: 0, pendingDocs: [], formsCompleted: 3, formsTotal: 3 },
|
||||
{ id: 'mbr-002-06', name: 'Gabriela Sánchez', documentId: '6.789.012', email: 'gsanchez@clinicasanjose.com.py', phone: '+595 982 333-006', role: 'Recepcionista', department: 'Atención al Paciente', enrollmentDate: '2025-09-01', status: 'active', tier: 'Basic', dependents: 1, pendingDocs: [], formsCompleted: 3, formsTotal: 3 },
|
||||
],
|
||||
|
||||
documents: [
|
||||
{ id: 'doc-002-01', name: 'Póliza Colectiva Clínica SJ 2025-2026', category: 'policy', uploadedBy: 'María Fernanda Ortiz', uploadedAt: '2025-08-28', fileSize: '3.1 MB', fileType: 'PDF', version: 1, notes: '' },
|
||||
{ id: 'doc-002-02', name: 'Contrato de Servicios', category: 'contract', uploadedBy: 'María Fernanda Ortiz', uploadedAt: '2025-08-20', fileSize: '1.4 MB', fileType: 'PDF', version: 1, notes: '' },
|
||||
{ id: 'doc-002-03', name: 'Censo Actualizado Q1 2026', category: 'census', uploadedBy: 'Laura Paredes', uploadedAt: '2026-03-28', fileSize: '420 KB', fileType: 'XLSX', version: 1, notes: 'Sin cambios respecto al período anterior' },
|
||||
],
|
||||
|
||||
billingCycles: [
|
||||
{ id: 'bill-002-01', period: 'February 2026', dueDate: '2026-02-01', status: 'paid', invoiceAmount: 3500, paidAmount: 3500, carrierRef: 'SG-2026-088-02', membersBilled: 88, membersExpected: 88, discrepancy: 0, notes: '' },
|
||||
{ id: 'bill-002-02', period: 'March 2026', dueDate: '2026-03-01', status: 'paid', invoiceAmount: 3500, paidAmount: 3500, carrierRef: 'SG-2026-088-03', membersBilled: 88, membersExpected: 88, discrepancy: 0, notes: '' },
|
||||
{ id: 'bill-002-03', period: 'April 2026', dueDate: '2026-04-01', status: 'paid', invoiceAmount: 3500, paidAmount: 3500, carrierRef: 'SG-2026-088-04', membersBilled: 88, membersExpected: 88, discrepancy: 0, notes: '' },
|
||||
],
|
||||
|
||||
serviceRequests: [
|
||||
{ id: 'sr-002-01', type: 'certificate', subject: 'Renovación de certificados anuales', status: 'resolved', priority: 'medium', assignee: 'María Fernanda Ortiz', created: '2026-02-10', updated: '2026-02-20', notes: 'Todos los certificados emitidos y entregados.' },
|
||||
{ id: 'sr-002-02', type: 'amendment', subject: 'Actualización de coberturas de maternidad', status: 'resolved', priority: 'low', assignee: 'María Fernanda Ortiz', created: '2026-01-15', updated: '2026-02-01', notes: 'Endoso emitido por carrier.' },
|
||||
],
|
||||
|
||||
recentActivity: [
|
||||
{ date: '2026-04-01', text: 'Factura de Abril pagada a tiempo', type: 'billing', actor: 'Laura Paredes' },
|
||||
{ date: '2026-03-28', text: 'Censo Q1 2026 cargado - sin cambios', type: 'document', actor: 'Laura Paredes' },
|
||||
{ date: '2026-02-20', text: 'Certificados anuales entregados', type: 'service_request', actor: 'María Fernanda Ortiz' },
|
||||
{ date: '2026-02-01', text: 'Endoso de maternidad procesado', type: 'endorsement', actor: 'Salud Global' },
|
||||
{ date: '2026-01-15', text: 'Solicitud de actualización de coberturas', type: 'service_request', actor: 'Dr. Marcelo Insfrán' },
|
||||
],
|
||||
|
||||
hasUrgentIssues: false,
|
||||
outstandingClaims: 0,
|
||||
pendingTasks: 0,
|
||||
},
|
||||
|
||||
// ── 3. ITSA Corp ──
|
||||
{
|
||||
id: 'col-003',
|
||||
name: 'ITSA Corp S.A.',
|
||||
ruc: '80056789-3',
|
||||
lob: 'Disability',
|
||||
product: 'Protección Laboral Integral',
|
||||
carrier: 'Continental Life',
|
||||
status: 'onboarding',
|
||||
|
||||
contactName: 'Ing. Andrés Caballero',
|
||||
contactEmail: 'acaballero@itsacorp.com.py',
|
||||
contactPhone: '+595 21 620-1100',
|
||||
hrContactName: 'Verónica Meza',
|
||||
hrContactEmail: 'vmeza@itsacorp.com.py',
|
||||
|
||||
effectiveDate: '2026-05-01',
|
||||
renewalDate: '2027-05-01',
|
||||
onboardingDate: '2026-03-15',
|
||||
|
||||
totalMembers: 230,
|
||||
activeMembersCount: 210,
|
||||
dependentsCount: 0,
|
||||
pendingEnrollment: 15,
|
||||
|
||||
monthlyPremium: 2625,
|
||||
annualPremium: 31500,
|
||||
commissionPct: 8,
|
||||
|
||||
agent: 'Carlos Villalba',
|
||||
|
||||
members: [
|
||||
{ id: 'mbr-003-01', name: 'Ing. Andrés Caballero', documentId: '1.111.222', email: 'acaballero@itsacorp.com.py', phone: '+595 983 444-001', role: 'CEO', department: 'Dirección', enrollmentDate: '2026-03-20', status: 'active', tier: 'Executive', dependents: 0, pendingDocs: [], formsCompleted: 5, formsTotal: 5 },
|
||||
{ id: 'mbr-003-02', name: 'Verónica Meza', documentId: '2.222.333', email: 'vmeza@itsacorp.com.py', phone: '+595 983 444-002', role: 'Gerente RRHH', department: 'RRHH', enrollmentDate: '2026-03-20', status: 'active', tier: 'Plus', dependents: 0, pendingDocs: [], formsCompleted: 5, formsTotal: 5 },
|
||||
{ id: 'mbr-003-03', name: 'Diego Portillo', documentId: '3.333.444', email: 'dportillo@itsacorp.com.py', phone: '+595 983 444-003', role: 'Operario Línea A', department: 'Producción', enrollmentDate: '2026-03-25', status: 'pending_enrollment', tier: 'Basic', dependents: 0, pendingDocs: ['Declaración de salud', 'Copia de CI', 'Formulario de inscripción'], formsCompleted: 1, formsTotal: 5 },
|
||||
{ id: 'mbr-003-04', name: 'Sandra Lezcano', documentId: '4.444.555', email: 'slezcano@itsacorp.com.py', phone: '+595 983 444-004', role: 'Supervisora de Calidad', department: 'Calidad', enrollmentDate: '2026-03-22', status: 'active', tier: 'Plus', dependents: 0, pendingDocs: [], formsCompleted: 5, formsTotal: 5 },
|
||||
{ id: 'mbr-003-05', name: 'Ramón Villasboa', documentId: '5.555.666', email: 'rvillasboa@itsacorp.com.py', phone: '+595 983 444-005', role: 'Técnico de Mantenimiento', department: 'Mantenimiento', enrollmentDate: '2026-04-01', status: 'pending_enrollment', tier: 'Basic', dependents: 0, pendingDocs: ['Declaración de salud', 'Formulario de inscripción'], formsCompleted: 2, formsTotal: 5 },
|
||||
{ id: 'mbr-003-06', name: 'Claudia Estigarribia', documentId: '6.666.777', email: 'cestigarribia@itsacorp.com.py', phone: '+595 983 444-006', role: 'Contadora', department: 'Finanzas', enrollmentDate: '2026-03-20', status: 'active', tier: 'Plus', dependents: 0, pendingDocs: [], formsCompleted: 5, formsTotal: 5 },
|
||||
{ id: 'mbr-003-07', name: 'Miguel Ayala', documentId: '7.777.888', email: 'mayala@itsacorp.com.py', phone: '+595 983 444-007', role: 'Jefe de Planta', department: 'Producción', enrollmentDate: '2026-03-20', status: 'active', tier: 'Plus', dependents: 0, pendingDocs: [], formsCompleted: 5, formsTotal: 5 },
|
||||
{ id: 'mbr-003-08', name: 'Lorena Cáceres', documentId: '8.888.999', email: 'lcaceres@itsacorp.com.py', phone: '+595 983 444-008', role: 'Asistente Administrativa', department: 'Administración', enrollmentDate: '2026-04-03', status: 'pending_docs', tier: 'Basic', dependents: 0, pendingDocs: ['Certificado de antecedentes'], formsCompleted: 4, formsTotal: 5 },
|
||||
],
|
||||
|
||||
documents: [
|
||||
{ id: 'doc-003-01', name: 'Propuesta Continental Life - Disability', category: 'contract', uploadedBy: 'Carlos Villalba', uploadedAt: '2026-03-10', fileSize: '2.8 MB', fileType: 'PDF', version: 1, notes: 'Propuesta aceptada por cliente' },
|
||||
{ id: 'doc-003-02', name: 'Censo Inicial ITSA Corp', category: 'census', uploadedBy: 'Verónica Meza', uploadedAt: '2026-03-18', fileSize: '1.2 MB', fileType: 'XLSX', version: 2, notes: 'V2 - corregidos datos de 12 empleados' },
|
||||
{ id: 'doc-003-03', name: 'Formularios de Inscripción (Lote 1)', category: 'enrollment', uploadedBy: 'Verónica Meza', uploadedAt: '2026-03-25', fileSize: '15.4 MB', fileType: 'PDF', version: 1, notes: '180 formularios escaneados' },
|
||||
{ id: 'doc-003-04', name: 'Declaraciones de Salud (Lote 1)', category: 'enrollment', uploadedBy: 'Verónica Meza', uploadedAt: '2026-03-28', fileSize: '22.1 MB', fileType: 'PDF', version: 1, notes: '175 declaraciones recibidas' },
|
||||
],
|
||||
|
||||
billingCycles: [
|
||||
{ id: 'bill-003-01', period: 'May 2026', dueDate: '2026-05-01', status: 'upcoming', invoiceAmount: 2625, paidAmount: 0, carrierRef: '', membersBilled: 0, membersExpected: 230, discrepancy: 0, notes: 'Primer ciclo de facturación' },
|
||||
{ id: 'bill-003-02', period: 'June 2026', dueDate: '2026-06-01', status: 'upcoming', invoiceAmount: 2625, paidAmount: 0, carrierRef: '', membersBilled: 0, membersExpected: 230, discrepancy: 0, notes: '' },
|
||||
{ id: 'bill-003-03', period: 'July 2026', dueDate: '2026-07-01', status: 'upcoming', invoiceAmount: 2625, paidAmount: 0, carrierRef: '', membersBilled: 0, membersExpected: 230, discrepancy: 0, notes: '' },
|
||||
],
|
||||
|
||||
serviceRequests: [
|
||||
{ id: 'sr-003-01', type: 'inclusion', subject: 'Completar inscripción de 15 empleados pendientes', status: 'in_progress', priority: 'high', assignee: 'Carlos Villalba', created: '2026-04-01', updated: '2026-04-07', notes: 'RRHH está recopilando formularios faltantes. Fecha límite: 04/15.' },
|
||||
{ id: 'sr-003-02', type: 'amendment', subject: 'Solicitud de inclusión de cobertura dental', status: 'open', priority: 'medium', assignee: 'Carlos Villalba', created: '2026-04-05', updated: '2026-04-05', notes: 'Cliente consulta costo adicional para rider dental.' },
|
||||
{ id: 'sr-003-03', type: 'certificate', subject: 'Emisión de certificados individuales - Lote inicial', status: 'pending_carrier', priority: 'medium', assignee: 'Carlos Villalba', created: '2026-03-28', updated: '2026-04-03', notes: 'Carrier procesando 210 certificados.' },
|
||||
],
|
||||
|
||||
recentActivity: [
|
||||
{ date: '2026-04-07', text: 'Seguimiento de formularios pendientes con RRHH', type: 'onboarding', actor: 'Carlos Villalba' },
|
||||
{ date: '2026-04-05', text: 'Nueva solicitud: consulta sobre rider dental', type: 'service_request', actor: 'Ing. Andrés Caballero' },
|
||||
{ date: '2026-04-03', text: 'Certificados enviados a Continental Life para emisión', type: 'service_request', actor: 'Carlos Villalba' },
|
||||
{ date: '2026-03-28', text: 'Lote 1 de declaraciones de salud cargado (175)', type: 'document', actor: 'Verónica Meza' },
|
||||
{ date: '2026-03-25', text: 'Lote 1 de formularios de inscripción cargado (180)', type: 'document', actor: 'Verónica Meza' },
|
||||
{ date: '2026-03-20', text: 'Onboarding iniciado - primeros empleados registrados', type: 'onboarding', actor: 'Carlos Villalba' },
|
||||
],
|
||||
|
||||
hasUrgentIssues: false,
|
||||
outstandingClaims: 0,
|
||||
pendingTasks: 18,
|
||||
},
|
||||
|
||||
// ── 4. Municipalidad Central ──
|
||||
{
|
||||
id: 'col-004',
|
||||
name: 'Municipalidad Central',
|
||||
ruc: '80078901-5',
|
||||
lob: 'Health',
|
||||
product: 'Salud Pública Municipal',
|
||||
carrier: 'Vida Plena',
|
||||
status: 'active',
|
||||
|
||||
contactName: 'Lic. Gustavo Ferreira',
|
||||
contactEmail: 'gferreira@muniasuncion.gov.py',
|
||||
contactPhone: '+595 21 440-5500',
|
||||
hrContactName: 'Norma Jiménez',
|
||||
hrContactEmail: 'njimenez@muniasuncion.gov.py',
|
||||
|
||||
effectiveDate: '2025-01-01',
|
||||
renewalDate: '2026-01-01',
|
||||
onboardingDate: '2024-11-15',
|
||||
|
||||
totalMembers: 640,
|
||||
activeMembersCount: 625,
|
||||
dependentsCount: 1120,
|
||||
pendingEnrollment: 8,
|
||||
|
||||
monthlyPremium: 16500,
|
||||
annualPremium: 198000,
|
||||
commissionPct: 9,
|
||||
|
||||
agent: 'Carlos Villalba',
|
||||
|
||||
members: [
|
||||
{ id: 'mbr-004-01', name: 'Lic. Gustavo Ferreira', documentId: '1.010.101', email: 'gferreira@muniasuncion.gov.py', phone: '+595 984 555-001', role: 'Secretario General', department: 'Secretaría General', enrollmentDate: '2025-01-01', status: 'active', tier: 'Executive', dependents: 4, pendingDocs: [], formsCompleted: 4, formsTotal: 4 },
|
||||
{ id: 'mbr-004-02', name: 'Norma Jiménez', documentId: '2.020.202', email: 'njimenez@muniasuncion.gov.py', phone: '+595 984 555-002', role: 'Directora de RRHH', department: 'RRHH', enrollmentDate: '2025-01-01', status: 'active', tier: 'Plus', dependents: 3, pendingDocs: [], formsCompleted: 4, formsTotal: 4 },
|
||||
{ id: 'mbr-004-03', name: 'Pedro Gauto', documentId: '3.030.303', email: 'pgauto@muniasuncion.gov.py', phone: '+595 984 555-003', role: 'Inspector de Obras', department: 'Obras Públicas', enrollmentDate: '2025-01-01', status: 'active', tier: 'Basic', dependents: 2, pendingDocs: [], formsCompleted: 4, formsTotal: 4 },
|
||||
{ id: 'mbr-004-04', name: 'María Bogado', documentId: '4.040.404', email: 'mbogado@muniasuncion.gov.py', phone: '+595 984 555-004', role: 'Asistente Social', department: 'Acción Social', enrollmentDate: '2025-02-01', status: 'active', tier: 'Basic', dependents: 1, pendingDocs: [], formsCompleted: 4, formsTotal: 4 },
|
||||
{ id: 'mbr-004-05', name: 'Juan Arce', documentId: '5.050.505', email: 'jarce@muniasuncion.gov.py', phone: '+595 984 555-005', role: 'Conductor', department: 'Transporte', enrollmentDate: '2025-01-15', status: 'active', tier: 'Basic', dependents: 3, pendingDocs: [], formsCompleted: 4, formsTotal: 4 },
|
||||
{ id: 'mbr-004-06', name: 'Blanca Ovelar', documentId: '6.060.606', email: 'bovelar@muniasuncion.gov.py', phone: '+595 984 555-006', role: 'Contadora Municipal', department: 'Finanzas', enrollmentDate: '2025-01-01', status: 'active', tier: 'Plus', dependents: 2, pendingDocs: [], formsCompleted: 4, formsTotal: 4 },
|
||||
{ id: 'mbr-004-07', name: 'Raúl Cabrera', documentId: '7.070.707', email: 'rcabrera@muniasuncion.gov.py', phone: '+595 984 555-007', role: 'Jardinero Municipal', department: 'Espacios Verdes', enrollmentDate: '2025-03-01', status: 'pending_docs', tier: 'Basic', dependents: 1, pendingDocs: ['Formulario de dependientes actualizado'], formsCompleted: 3, formsTotal: 4 },
|
||||
],
|
||||
|
||||
documents: [
|
||||
{ id: 'doc-004-01', name: 'Póliza Colectiva Municipal 2025', category: 'policy', uploadedBy: 'Carlos Villalba', uploadedAt: '2024-12-20', fileSize: '5.8 MB', fileType: 'PDF', version: 1, notes: '' },
|
||||
{ id: 'doc-004-02', name: 'Convenio Marco Municipalidad-Brokerage', category: 'contract', uploadedBy: 'Carlos Villalba', uploadedAt: '2024-11-25', fileSize: '3.2 MB', fileType: 'PDF', version: 1, notes: 'Aprobado por resolución municipal #2024-1820' },
|
||||
{ id: 'doc-004-03', name: 'Censo Febrero 2026', category: 'census', uploadedBy: 'Norma Jiménez', uploadedAt: '2026-02-28', fileSize: '2.1 MB', fileType: 'XLSX', version: 1, notes: '8 altas, 3 bajas' },
|
||||
{ id: 'doc-004-04', name: 'Endoso #5 - Ajuste Feb 2026', category: 'endorsement', uploadedBy: 'Carlos Villalba', uploadedAt: '2026-03-10', fileSize: '280 KB', fileType: 'PDF', version: 1, notes: '' },
|
||||
{ id: 'doc-004-05', name: 'Reporte Siniestralidad 2025 Anual', category: 'siniestralidad', uploadedBy: 'Vida Plena', uploadedAt: '2026-02-15', fileSize: '4.5 MB', fileType: 'PDF', version: 1, notes: 'Siniestralidad 82% - bandera amarilla' },
|
||||
{ id: 'doc-004-06', name: 'Carta de Reclamo - Diferencia Facturación', category: 'correspondence', uploadedBy: 'Blanca Ovelar', uploadedAt: '2026-03-22', fileSize: '95 KB', fileType: 'PDF', version: 1, notes: 'Reclamo formal por discrepancia en marzo' },
|
||||
{ id: 'doc-004-07', name: 'Endoso #6 - Inclusiones Mar 2026', category: 'endorsement', uploadedBy: 'Carlos Villalba', uploadedAt: '2026-04-01', fileSize: '310 KB', fileType: 'PDF', version: 1, notes: '' },
|
||||
{ id: 'doc-004-08', name: 'Censo Marzo 2026', category: 'census', uploadedBy: 'Norma Jiménez', uploadedAt: '2026-03-31', fileSize: '2.2 MB', fileType: 'XLSX', version: 1, notes: '5 altas nuevas' },
|
||||
],
|
||||
|
||||
billingCycles: [
|
||||
{ id: 'bill-004-01', period: 'January 2026', dueDate: '2026-01-10', status: 'paid', invoiceAmount: 16200, paidAmount: 16200, carrierRef: 'VP-2026-0640-01', membersBilled: 635, membersExpected: 635, discrepancy: 0, notes: '' },
|
||||
{ id: 'bill-004-02', period: 'February 2026', dueDate: '2026-02-10', status: 'paid', invoiceAmount: 16350, paidAmount: 16350, carrierRef: 'VP-2026-0640-02', membersBilled: 638, membersExpected: 638, discrepancy: 0, notes: '' },
|
||||
{ id: 'bill-004-03', period: 'March 2026', dueDate: '2026-03-10', status: 'disputed', invoiceAmount: 16700, paidAmount: 0, carrierRef: 'VP-2026-0640-03', membersBilled: 648, membersExpected: 640, discrepancy: 8, notes: 'Carrier facturó 8 miembros de más. Cliente reclama diferencia de $208.' },
|
||||
{ id: 'bill-004-04', period: 'April 2026', dueDate: '2026-04-10', status: 'overdue', invoiceAmount: 16500, paidAmount: 0, carrierRef: 'VP-2026-0640-04', membersBilled: 640, membersExpected: 640, discrepancy: 0, notes: 'Cliente retiene pago hasta resolución de disputa de Marzo.' },
|
||||
{ id: 'bill-004-05', period: 'May 2026', dueDate: '2026-05-10', status: 'upcoming', invoiceAmount: 16500, paidAmount: 0, carrierRef: '', membersBilled: 0, membersExpected: 640, discrepancy: 0, notes: '' },
|
||||
{ id: 'bill-004-06', period: 'June 2026', dueDate: '2026-06-10', status: 'upcoming', invoiceAmount: 16500, paidAmount: 0, carrierRef: '', membersBilled: 0, membersExpected: 640, discrepancy: 0, notes: '' },
|
||||
],
|
||||
|
||||
serviceRequests: [
|
||||
{ id: 'sr-004-01', type: 'billing', subject: 'Discrepancia facturación Marzo - 8 miembros de más', status: 'in_progress', priority: 'high', assignee: 'Carlos Villalba', created: '2026-03-15', updated: '2026-04-06', notes: 'Carrier reconoce error. Nota de crédito en proceso. Cliente retiene pago de Abril hasta resolución.' },
|
||||
{ id: 'sr-004-02', type: 'inclusion', subject: 'Alta de 5 nuevos empleados - Marzo 2026', status: 'resolved', priority: 'medium', assignee: 'Carlos Villalba', created: '2026-03-05', updated: '2026-03-18', notes: 'Endoso procesado. Todos los certificados emitidos.' },
|
||||
{ id: 'sr-004-03', type: 'exclusion', subject: 'Baja de 3 empleados retirados', status: 'resolved', priority: 'medium', assignee: 'Carlos Villalba', created: '2026-02-20', updated: '2026-03-01', notes: 'Bajas procesadas en endoso #5.' },
|
||||
{ id: 'sr-004-04', type: 'claim', subject: 'Reclamo hospitalización - Expediente #7810', status: 'pending_carrier', priority: 'medium', assignee: 'Carlos Villalba', created: '2026-03-20', updated: '2026-04-02', memberName: 'Pedro Gauto', notes: 'Documentación completa enviada a carrier.' },
|
||||
{ id: 'sr-004-05', type: 'amendment', subject: 'Solicitud de mejora de cobertura oftalmológica', status: 'open', priority: 'low', assignee: 'Carlos Villalba', created: '2026-04-03', updated: '2026-04-03', notes: 'Sindicato solicitó mejoras. Pendiente cotización de carrier.' },
|
||||
],
|
||||
|
||||
recentActivity: [
|
||||
{ date: '2026-04-06', text: 'Carrier confirmó nota de crédito en proceso por discrepancia Marzo', type: 'billing', actor: 'Vida Plena' },
|
||||
{ date: '2026-04-03', text: 'Sindicato solicita mejora en cobertura oftalmológica', type: 'service_request', actor: 'Norma Jiménez' },
|
||||
{ date: '2026-04-01', text: 'Endoso #6 procesado - inclusiones de Marzo', type: 'endorsement', actor: 'Carlos Villalba' },
|
||||
{ date: '2026-03-31', text: 'Censo Marzo 2026 recibido', type: 'document', actor: 'Norma Jiménez' },
|
||||
{ date: '2026-03-22', text: 'Carta formal de reclamo por diferencia en facturación', type: 'correspondence', actor: 'Blanca Ovelar' },
|
||||
{ date: '2026-03-15', text: 'Discrepancia detectada en factura de Marzo: 8 miembros de más', type: 'billing', actor: 'Carlos Villalba' },
|
||||
{ date: '2026-03-10', text: 'Endoso #5 procesado', type: 'endorsement', actor: 'Carlos Villalba' },
|
||||
{ date: '2026-02-15', text: 'Reporte de siniestralidad anual 2025 recibido - 82%', type: 'document', actor: 'Vida Plena' },
|
||||
],
|
||||
|
||||
hasUrgentIssues: false,
|
||||
outstandingClaims: 1,
|
||||
pendingTasks: 4,
|
||||
},
|
||||
|
||||
// ── 5. Grupo Agrícola del Sur ──
|
||||
{
|
||||
id: 'col-005',
|
||||
name: 'Grupo Agrícola del Sur S.A.',
|
||||
ruc: '80090123-8',
|
||||
lob: 'Life',
|
||||
product: 'Vida Grupal Protección Familiar',
|
||||
carrier: 'Seguros del Pacífico',
|
||||
status: 'renewal_due',
|
||||
|
||||
contactName: 'Ing. Agr. Héctor Bogado',
|
||||
contactEmail: 'hbogado@gagricsur.com.py',
|
||||
contactPhone: '+595 71 205-600',
|
||||
hrContactName: 'Celeste Riveros',
|
||||
hrContactEmail: 'criveros@gagricsur.com.py',
|
||||
|
||||
effectiveDate: '2025-05-01',
|
||||
renewalDate: '2026-05-01',
|
||||
onboardingDate: '2025-04-10',
|
||||
|
||||
totalMembers: 175,
|
||||
activeMembersCount: 170,
|
||||
dependentsCount: 310,
|
||||
pendingEnrollment: 0,
|
||||
|
||||
monthlyPremium: 4833,
|
||||
annualPremium: 58000,
|
||||
commissionPct: 11,
|
||||
|
||||
agent: 'María Fernanda Ortiz',
|
||||
|
||||
members: [
|
||||
{ id: 'mbr-005-01', name: 'Ing. Agr. Héctor Bogado', documentId: '1.515.151', email: 'hbogado@gagricsur.com.py', phone: '+595 985 666-001', role: 'Director General', department: 'Dirección', enrollmentDate: '2025-05-01', status: 'active', tier: 'Executive', dependents: 4, pendingDocs: [], formsCompleted: 3, formsTotal: 3 },
|
||||
{ id: 'mbr-005-02', name: 'Celeste Riveros', documentId: '2.525.252', email: 'criveros@gagricsur.com.py', phone: '+595 985 666-002', role: 'Gerente de RRHH', department: 'RRHH', enrollmentDate: '2025-05-01', status: 'active', tier: 'Plus', dependents: 2, pendingDocs: [], formsCompleted: 3, formsTotal: 3 },
|
||||
{ id: 'mbr-005-03', name: 'Tomás Aquino', documentId: '3.535.353', email: 'taquino@gagricsur.com.py', phone: '+595 985 666-003', role: 'Capataz de Campo', department: 'Operaciones de Campo', enrollmentDate: '2025-05-01', status: 'active', tier: 'Basic', dependents: 3, pendingDocs: [], formsCompleted: 3, formsTotal: 3 },
|
||||
{ id: 'mbr-005-04', name: 'Rosa Benítez', documentId: '4.545.454', email: 'rbenitez@gagricsur.com.py', phone: '+595 985 666-004', role: 'Ingeniera Agrónoma', department: 'Técnica', enrollmentDate: '2025-05-15', status: 'active', tier: 'Plus', dependents: 1, pendingDocs: [], formsCompleted: 3, formsTotal: 3 },
|
||||
{ id: 'mbr-005-05', name: 'Óscar Domínguez', documentId: '5.555.565', email: 'odominguez@gagricsur.com.py', phone: '+595 985 666-005', role: 'Chofer de Camión', department: 'Logística', enrollmentDate: '2025-06-01', status: 'active', tier: 'Basic', dependents: 2, pendingDocs: [], formsCompleted: 3, formsTotal: 3 },
|
||||
{ id: 'mbr-005-06', name: 'Luz Marina Espínola', documentId: '6.565.656', email: 'lespinola@gagricsur.com.py', phone: '+595 985 666-006', role: 'Contadora', department: 'Administración', enrollmentDate: '2025-05-01', status: 'active', tier: 'Plus', dependents: 1, pendingDocs: [], formsCompleted: 3, formsTotal: 3 },
|
||||
{ id: 'mbr-005-07', name: 'Esteban Villalba', documentId: '7.575.757', email: 'evillalba@gagricsur.com.py', phone: '+595 985 666-007', role: 'Peón Rural', department: 'Operaciones de Campo', enrollmentDate: '2025-07-01', status: 'excluded', tier: 'Basic', dependents: 0, pendingDocs: [], formsCompleted: 3, formsTotal: 3 },
|
||||
],
|
||||
|
||||
documents: [
|
||||
{ id: 'doc-005-01', name: 'Póliza Vida Grupal 2025-2026', category: 'policy', uploadedBy: 'María Fernanda Ortiz', uploadedAt: '2025-04-28', fileSize: '3.5 MB', fileType: 'PDF', version: 1, notes: '' },
|
||||
{ id: 'doc-005-02', name: 'Contrato de Intermediación', category: 'contract', uploadedBy: 'María Fernanda Ortiz', uploadedAt: '2025-04-15', fileSize: '1.6 MB', fileType: 'PDF', version: 1, notes: '' },
|
||||
{ id: 'doc-005-03', name: 'Censo Actualizado Marzo 2026', category: 'census', uploadedBy: 'Celeste Riveros', uploadedAt: '2026-03-20', fileSize: '680 KB', fileType: 'XLSX', version: 1, notes: '1 exclusión (Villalba, E.) por renuncia' },
|
||||
{ id: 'doc-005-04', name: 'Propuesta de Renovación 2026-2027', category: 'other', uploadedBy: 'María Fernanda Ortiz', uploadedAt: '2026-03-25', fileSize: '2.2 MB', fileType: 'PDF', version: 1, notes: 'Incluye 3 opciones de carrier' },
|
||||
{ id: 'doc-005-05', name: 'Siniestralidad Acumulada 2025-2026', category: 'siniestralidad', uploadedBy: 'Seguros del Pacífico', uploadedAt: '2026-03-15', fileSize: '1.8 MB', fileType: 'PDF', version: 1, notes: 'Siniestralidad al 45% - muy favorable' },
|
||||
],
|
||||
|
||||
billingCycles: [
|
||||
{ id: 'bill-005-01', period: 'February 2026', dueDate: '2026-02-15', status: 'paid', invoiceAmount: 4833, paidAmount: 4833, carrierRef: 'SP-2026-0175-02', membersBilled: 175, membersExpected: 175, discrepancy: 0, notes: '' },
|
||||
{ id: 'bill-005-02', period: 'March 2026', dueDate: '2026-03-15', status: 'paid', invoiceAmount: 4810, paidAmount: 4810, carrierRef: 'SP-2026-0175-03', membersBilled: 174, membersExpected: 174, discrepancy: 0, notes: '1 baja procesada' },
|
||||
{ id: 'bill-005-03', period: 'April 2026', dueDate: '2026-04-15', status: 'invoiced', invoiceAmount: 4810, paidAmount: 0, carrierRef: 'SP-2026-0175-04', membersBilled: 174, membersExpected: 174, discrepancy: 0, notes: '' },
|
||||
],
|
||||
|
||||
serviceRequests: [
|
||||
{ id: 'sr-005-01', type: 'claim', subject: 'Reclamo fallecimiento - Beneficiario Flia. Aquino', status: 'in_progress', priority: 'high', assignee: 'María Fernanda Ortiz', created: '2026-02-10', updated: '2026-04-01', memberName: 'Tomás Aquino', notes: 'Documentación de siniestro completa. Carrier en revisión. Monto: Gs. 350.000.000.' },
|
||||
{ id: 'sr-005-02', type: 'exclusion', subject: 'Baja por renuncia - Esteban Villalba', status: 'resolved', priority: 'low', assignee: 'María Fernanda Ortiz', created: '2026-03-01', updated: '2026-03-15', memberName: 'Esteban Villalba', notes: 'Procesado en endoso.' },
|
||||
{ id: 'sr-005-03', type: 'amendment', subject: 'Propuesta de renovación 2026-2027 - Negociación', status: 'in_progress', priority: 'high', assignee: 'María Fernanda Ortiz', created: '2026-03-25', updated: '2026-04-05', notes: 'Presentadas 3 opciones. Cliente evaluando. Reunión programada para 04/12.' },
|
||||
],
|
||||
|
||||
recentActivity: [
|
||||
{ date: '2026-04-05', text: 'Seguimiento de propuesta de renovación con cliente', type: 'renewal', actor: 'María Fernanda Ortiz' },
|
||||
{ date: '2026-04-01', text: 'Carrier actualiza estado de reclamo Flia. Aquino', type: 'claim_update', actor: 'Seguros del Pacífico' },
|
||||
{ date: '2026-03-25', text: 'Propuesta de renovación enviada al cliente (3 opciones)', type: 'renewal', actor: 'María Fernanda Ortiz' },
|
||||
{ date: '2026-03-20', text: 'Censo actualizado recibido', type: 'document', actor: 'Celeste Riveros' },
|
||||
{ date: '2026-03-15', text: 'Reporte de siniestralidad recibido - 45%', type: 'document', actor: 'Seguros del Pacífico' },
|
||||
{ date: '2026-03-01', text: 'Solicitud de baja: Esteban Villalba por renuncia', type: 'exclusion', actor: 'Celeste Riveros' },
|
||||
],
|
||||
|
||||
hasUrgentIssues: false,
|
||||
outstandingClaims: 1,
|
||||
pendingTasks: 3,
|
||||
},
|
||||
|
||||
// ── 6. Tech Solutions S.A. ──
|
||||
{
|
||||
id: 'col-006',
|
||||
name: 'Tech Solutions S.A.',
|
||||
ruc: '80101234-2',
|
||||
lob: 'Health',
|
||||
product: 'Salud Digital Premium',
|
||||
carrier: 'Integral Medical',
|
||||
status: 'active',
|
||||
|
||||
contactName: 'Lic. Pamela Giménez',
|
||||
contactEmail: 'pgimenez@techsolutions.com.py',
|
||||
contactPhone: '+595 21 730-8800',
|
||||
hrContactName: 'Rodrigo Sanabria',
|
||||
hrContactEmail: 'rsanabria@techsolutions.com.py',
|
||||
|
||||
effectiveDate: '2025-11-01',
|
||||
renewalDate: '2026-11-01',
|
||||
onboardingDate: '2025-10-15',
|
||||
|
||||
totalMembers: 62,
|
||||
activeMembersCount: 60,
|
||||
dependentsCount: 85,
|
||||
pendingEnrollment: 2,
|
||||
|
||||
monthlyPremium: 3125,
|
||||
annualPremium: 37500,
|
||||
commissionPct: 10,
|
||||
|
||||
agent: 'María Fernanda Ortiz',
|
||||
|
||||
members: [
|
||||
{ id: 'mbr-006-01', name: 'Lic. Pamela Giménez', documentId: '1.616.161', email: 'pgimenez@techsolutions.com.py', phone: '+595 986 777-001', role: 'CEO', department: 'Dirección', enrollmentDate: '2025-11-01', status: 'active', tier: 'Executive', dependents: 2, pendingDocs: [], formsCompleted: 3, formsTotal: 3 },
|
||||
{ id: 'mbr-006-02', name: 'Rodrigo Sanabria', documentId: '2.626.262', email: 'rsanabria@techsolutions.com.py', phone: '+595 986 777-002', role: 'People & Culture Lead', department: 'People', enrollmentDate: '2025-11-01', status: 'active', tier: 'Plus', dependents: 1, pendingDocs: [], formsCompleted: 3, formsTotal: 3 },
|
||||
{ id: 'mbr-006-03', name: 'Matías Recalde', documentId: '3.636.363', email: 'mrecalde@techsolutions.com.py', phone: '+595 986 777-003', role: 'CTO', department: 'Engineering', enrollmentDate: '2025-11-01', status: 'active', tier: 'Executive', dependents: 3, pendingDocs: [], formsCompleted: 3, formsTotal: 3 },
|
||||
{ id: 'mbr-006-04', name: 'Sofía Cardozo', documentId: '4.646.464', email: 'scardozo@techsolutions.com.py', phone: '+595 986 777-004', role: 'UX Designer', department: 'Design', enrollmentDate: '2025-11-15', status: 'active', tier: 'Plus', dependents: 0, pendingDocs: [], formsCompleted: 3, formsTotal: 3 },
|
||||
{ id: 'mbr-006-05', name: 'Alejandro Núñez', documentId: '5.656.565', email: 'anunez@techsolutions.com.py', phone: '+595 986 777-005', role: 'Full Stack Developer', department: 'Engineering', enrollmentDate: '2025-12-01', status: 'active', tier: 'Basic', dependents: 1, pendingDocs: [], formsCompleted: 3, formsTotal: 3 },
|
||||
{ id: 'mbr-006-06', name: 'Valeria Ocampos', documentId: '6.666.676', email: 'vocampos@techsolutions.com.py', phone: '+595 986 777-006', role: 'QA Engineer', department: 'Engineering', enrollmentDate: '2026-01-15', status: 'active', tier: 'Basic', dependents: 0, pendingDocs: [], formsCompleted: 3, formsTotal: 3 },
|
||||
{ id: 'mbr-006-07', name: 'Nicolás Franco', documentId: '7.676.767', email: 'nfranco@techsolutions.com.py', phone: '+595 986 777-007', role: 'DevOps Engineer', department: 'Engineering', enrollmentDate: '2026-03-01', status: 'pending_enrollment', tier: 'Basic', dependents: 0, pendingDocs: ['Formulario de inscripción'], formsCompleted: 2, formsTotal: 3 },
|
||||
{ id: 'mbr-006-08', name: 'Carolina Espínola', documentId: '8.686.868', email: 'cespinola@techsolutions.com.py', phone: '+595 986 777-008', role: 'Product Manager', department: 'Product', enrollmentDate: '2026-03-15', status: 'pending_enrollment', tier: 'Plus', dependents: 1, pendingDocs: ['Formulario de inscripción', 'Declaración de salud'], formsCompleted: 1, formsTotal: 3 },
|
||||
],
|
||||
|
||||
documents: [
|
||||
{ id: 'doc-006-01', name: 'Póliza Salud Digital Premium 2025-2026', category: 'policy', uploadedBy: 'María Fernanda Ortiz', uploadedAt: '2025-10-28', fileSize: '2.9 MB', fileType: 'PDF', version: 1, notes: '' },
|
||||
{ id: 'doc-006-02', name: 'Contrato de Intermediación', category: 'contract', uploadedBy: 'María Fernanda Ortiz', uploadedAt: '2025-10-20', fileSize: '1.3 MB', fileType: 'PDF', version: 1, notes: '' },
|
||||
{ id: 'doc-006-03', name: 'Censo Q1 2026', category: 'census', uploadedBy: 'Rodrigo Sanabria', uploadedAt: '2026-03-30', fileSize: '310 KB', fileType: 'XLSX', version: 1, notes: '2 nuevas altas pendientes' },
|
||||
{ id: 'doc-006-04', name: 'Endoso #2 - Inclusiones Ene 2026', category: 'endorsement', uploadedBy: 'María Fernanda Ortiz', uploadedAt: '2026-01-25', fileSize: '185 KB', fileType: 'PDF', version: 1, notes: '' },
|
||||
],
|
||||
|
||||
billingCycles: [
|
||||
{ id: 'bill-006-01', period: 'February 2026', dueDate: '2026-02-05', status: 'paid', invoiceAmount: 3050, paidAmount: 3050, carrierRef: 'IM-2026-062-02', membersBilled: 60, membersExpected: 60, discrepancy: 0, notes: '' },
|
||||
{ id: 'bill-006-02', period: 'March 2026', dueDate: '2026-03-05', status: 'paid', invoiceAmount: 3050, paidAmount: 3050, carrierRef: 'IM-2026-062-03', membersBilled: 60, membersExpected: 60, discrepancy: 0, notes: '' },
|
||||
{ id: 'bill-006-03', period: 'April 2026', dueDate: '2026-04-05', status: 'paid', invoiceAmount: 3125, paidAmount: 3125, carrierRef: 'IM-2026-062-04', membersBilled: 62, membersExpected: 62, discrepancy: 0, notes: 'Incluye 2 nuevos miembros pendientes de inscripción formal' },
|
||||
],
|
||||
|
||||
serviceRequests: [
|
||||
{ id: 'sr-006-01', type: 'inclusion', subject: 'Alta de 2 nuevos empleados - Marzo 2026', status: 'in_progress', priority: 'medium', assignee: 'María Fernanda Ortiz', created: '2026-03-10', updated: '2026-04-02', notes: 'Faltan formularios de inscripción. People & Culture dará seguimiento.' },
|
||||
{ id: 'sr-006-02', type: 'certificate', subject: 'Certificado para trámite de visa - S. Cardozo', status: 'resolved', priority: 'medium', assignee: 'María Fernanda Ortiz', created: '2026-03-18', updated: '2026-03-22', memberName: 'Sofía Cardozo', notes: 'Certificado emitido y enviado.' },
|
||||
],
|
||||
|
||||
recentActivity: [
|
||||
{ date: '2026-04-05', text: 'Factura Abril pagada a tiempo', type: 'billing', actor: 'Rodrigo Sanabria' },
|
||||
{ date: '2026-04-02', text: 'Seguimiento de formularios pendientes para nuevas altas', type: 'service_request', actor: 'María Fernanda Ortiz' },
|
||||
{ date: '2026-03-30', text: 'Censo Q1 2026 cargado', type: 'document', actor: 'Rodrigo Sanabria' },
|
||||
{ date: '2026-03-22', text: 'Certificado de visa emitido para Sofía Cardozo', type: 'service_request', actor: 'María Fernanda Ortiz' },
|
||||
{ date: '2026-03-10', text: 'Solicitud de alta para 2 nuevos empleados', type: 'inclusion', actor: 'Rodrigo Sanabria' },
|
||||
],
|
||||
|
||||
hasUrgentIssues: false,
|
||||
outstandingClaims: 0,
|
||||
pendingTasks: 2,
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
/* ── Composable ── */
|
||||
|
||||
const KEY = 'policy-ui-colectivos-v1'
|
||||
|
||||
export function useColectivos() {
|
||||
const accounts = useLocalStorageRef<ColectivoAccount[]>(KEY, buildDefaultAccounts)
|
||||
|
||||
/* ── Lookups ── */
|
||||
|
||||
function getAccount(id: string): ColectivoAccount | undefined {
|
||||
return accounts.value.find(a => a.id === id)
|
||||
}
|
||||
|
||||
function getAccountMembers(accountId: string): ColectivoMember[] {
|
||||
return getAccount(accountId)?.members ?? []
|
||||
}
|
||||
|
||||
function getAccountServiceRequests(accountId: string): ServiceRequest[] {
|
||||
return getAccount(accountId)?.serviceRequests ?? []
|
||||
}
|
||||
|
||||
/* ── Filtered lists ── */
|
||||
|
||||
const activeAccounts = computed(() =>
|
||||
accounts.value.filter(a => a.status === 'active'),
|
||||
)
|
||||
|
||||
const onboardingAccounts = computed(() =>
|
||||
accounts.value.filter(a => a.status === 'onboarding'),
|
||||
)
|
||||
|
||||
/* ── Aggregate stats ── */
|
||||
|
||||
const totalMembers = computed(() =>
|
||||
accounts.value.reduce((sum, a) => sum + a.totalMembers, 0),
|
||||
)
|
||||
|
||||
const totalDependents = computed(() =>
|
||||
accounts.value.reduce((sum, a) => sum + a.dependentsCount, 0),
|
||||
)
|
||||
|
||||
const totalPremium = computed(() =>
|
||||
accounts.value.reduce((sum, a) => sum + a.annualPremium, 0),
|
||||
)
|
||||
|
||||
const urgentIssuesCount = computed(() =>
|
||||
accounts.value.filter(a => a.hasUrgentIssues).length,
|
||||
)
|
||||
|
||||
return {
|
||||
accounts,
|
||||
getAccount,
|
||||
getAccountMembers,
|
||||
getAccountServiceRequests,
|
||||
activeAccounts,
|
||||
onboardingAccounts,
|
||||
totalMembers,
|
||||
totalDependents,
|
||||
totalPremium,
|
||||
urgentIssuesCount,
|
||||
}
|
||||
}
|
||||
@@ -1,228 +0,0 @@
|
||||
import { computed } from 'vue'
|
||||
import { useLocalStorageRef } from '~/utils/useLocalStorageRef'
|
||||
|
||||
/* ── Types ── */
|
||||
|
||||
export type ServiceTierId = string
|
||||
|
||||
export interface ServiceTier {
|
||||
id: ServiceTierId
|
||||
name: string
|
||||
color: string
|
||||
icon: string
|
||||
description: string
|
||||
minScore: number
|
||||
benefits: string[]
|
||||
}
|
||||
|
||||
export interface AttentionRule {
|
||||
id: string
|
||||
field: 'premium' | 'policy_count' | 'commission' | 'collectivo_member' | 'multi_line' | 'tenure_years' | 'has_private_policies'
|
||||
operator: 'gte' | 'lte' | 'eq' | 'gt' | 'lt'
|
||||
value: number | boolean
|
||||
points: number
|
||||
label: string
|
||||
}
|
||||
|
||||
export interface CustomerAttentionConfig {
|
||||
tiers: ServiceTier[]
|
||||
rules: AttentionRule[]
|
||||
autoClassify: boolean
|
||||
}
|
||||
|
||||
/* ── Defaults ── */
|
||||
|
||||
function defaultTiers(): ServiceTier[] {
|
||||
return [
|
||||
{
|
||||
id: 'platinum',
|
||||
name: 'Platinum',
|
||||
color: '#7c3aed',
|
||||
icon: 'i-heroicons-star',
|
||||
description: 'VIP multi-line clients with highest lifetime value',
|
||||
minScore: 80,
|
||||
benefits: ['Priority claims handling', 'Dedicated account manager', 'Annual review', 'Renewal negotiation priority'],
|
||||
},
|
||||
{
|
||||
id: 'gold',
|
||||
name: 'Gold',
|
||||
color: '#d4a017',
|
||||
icon: 'i-heroicons-trophy',
|
||||
description: 'Established clients with strong portfolio',
|
||||
minScore: 55,
|
||||
benefits: ['Priority support', 'Proactive renewal outreach', 'Cross-sell consultation'],
|
||||
},
|
||||
{
|
||||
id: 'silver',
|
||||
name: 'Silver',
|
||||
color: '#6b7280',
|
||||
icon: 'i-heroicons-shield-check',
|
||||
description: 'Active clients with growth potential',
|
||||
minScore: 30,
|
||||
benefits: ['Standard support', 'Regular check-ins'],
|
||||
},
|
||||
{
|
||||
id: 'standard',
|
||||
name: 'Standard',
|
||||
color: '#01696f',
|
||||
icon: 'i-heroicons-user',
|
||||
description: 'All active customers',
|
||||
minScore: 0,
|
||||
benefits: ['Standard service'],
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
function defaultRules(): AttentionRule[] {
|
||||
return [
|
||||
{ id: 'r1', field: 'premium', operator: 'gte', value: 5000, points: 25, label: 'Annual premium >= $5,000' },
|
||||
{ id: 'r2', field: 'premium', operator: 'gte', value: 10000, points: 40, label: 'Annual premium >= $10,000' },
|
||||
{ id: 'r3', field: 'policy_count', operator: 'gte', value: 3, points: 15, label: '3+ active policies' },
|
||||
{ id: 'r4', field: 'policy_count', operator: 'gte', value: 5, points: 25, label: '5+ active policies' },
|
||||
{ id: 'r5', field: 'multi_line', operator: 'gte', value: 3, points: 20, label: 'Multi-line (3+ different lines)' },
|
||||
{ id: 'r6', field: 'tenure_years', operator: 'gte', value: 3, points: 10, label: 'Client for 3+ years' },
|
||||
{ id: 'r7', field: 'tenure_years', operator: 'gte', value: 5, points: 20, label: 'Client for 5+ years' },
|
||||
{ id: 'r8', field: 'collectivo_member', operator: 'eq', value: true, points: 15, label: 'Collectivo member with private policies' },
|
||||
{ id: 'r9', field: 'commission', operator: 'gte', value: 1000, points: 10, label: 'Annual commission >= $1,000' },
|
||||
]
|
||||
}
|
||||
|
||||
function defaultConfig(): CustomerAttentionConfig {
|
||||
return {
|
||||
tiers: defaultTiers(),
|
||||
rules: defaultRules(),
|
||||
autoClassify: true,
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Customer input shape ── */
|
||||
|
||||
export interface CustomerAttentionInput {
|
||||
totalPremium: number
|
||||
policyCount: number
|
||||
lineCount: number
|
||||
tenureYears: number
|
||||
isCollectivoMember: boolean
|
||||
hasPrivatePolicies: boolean
|
||||
estimatedCommission: number
|
||||
}
|
||||
|
||||
/* ── Rule evaluation ── */
|
||||
|
||||
function evaluateRule(rule: AttentionRule, customer: CustomerAttentionInput): boolean {
|
||||
let fieldValue: number | boolean
|
||||
|
||||
switch (rule.field) {
|
||||
case 'premium':
|
||||
fieldValue = customer.totalPremium
|
||||
break
|
||||
case 'policy_count':
|
||||
fieldValue = customer.policyCount
|
||||
break
|
||||
case 'commission':
|
||||
fieldValue = customer.estimatedCommission
|
||||
break
|
||||
case 'multi_line':
|
||||
fieldValue = customer.lineCount
|
||||
break
|
||||
case 'tenure_years':
|
||||
fieldValue = customer.tenureYears
|
||||
break
|
||||
case 'collectivo_member':
|
||||
// Special: collectivo member rule only matches if also has private policies
|
||||
return rule.value === true && customer.isCollectivoMember && customer.hasPrivatePolicies
|
||||
case 'has_private_policies':
|
||||
return typeof rule.value === 'boolean' ? customer.hasPrivatePolicies === rule.value : false
|
||||
default:
|
||||
return false
|
||||
}
|
||||
|
||||
if (typeof fieldValue === 'boolean' || typeof rule.value === 'boolean') return false
|
||||
|
||||
switch (rule.operator) {
|
||||
case 'gte': return (fieldValue as number) >= (rule.value as number)
|
||||
case 'gt': return (fieldValue as number) > (rule.value as number)
|
||||
case 'lte': return (fieldValue as number) <= (rule.value as number)
|
||||
case 'lt': return (fieldValue as number) < (rule.value as number)
|
||||
case 'eq': return (fieldValue as number) === (rule.value as number)
|
||||
default: return false
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Composable ── */
|
||||
|
||||
export function useCustomerAttention() {
|
||||
const config = useLocalStorageRef<CustomerAttentionConfig>('policy-ui-customer-attention-v1', defaultConfig)
|
||||
|
||||
const tiers = computed(() => [...config.value.tiers].sort((a, b) => b.minScore - a.minScore))
|
||||
|
||||
const rules = computed(() => config.value.rules)
|
||||
|
||||
function getScoreForCustomer(customer: CustomerAttentionInput): number {
|
||||
let score = 0
|
||||
for (const rule of config.value.rules) {
|
||||
if (evaluateRule(rule, customer)) {
|
||||
score += rule.points
|
||||
}
|
||||
}
|
||||
return score
|
||||
}
|
||||
|
||||
function getTierForCustomer(customer: CustomerAttentionInput): ServiceTier {
|
||||
const score = getScoreForCustomer(customer)
|
||||
const sorted = [...config.value.tiers].sort((a, b) => b.minScore - a.minScore)
|
||||
for (const tier of sorted) {
|
||||
if (score >= tier.minScore) return tier
|
||||
}
|
||||
// Fallback to lowest tier
|
||||
return sorted[sorted.length - 1] ?? config.value.tiers[0]
|
||||
}
|
||||
|
||||
/* ── Tier CRUD ── */
|
||||
|
||||
function addTier(tier: ServiceTier) {
|
||||
config.value.tiers.push(tier)
|
||||
}
|
||||
|
||||
function updateTier(id: string, patch: Partial<ServiceTier>) {
|
||||
const idx = config.value.tiers.findIndex(t => t.id === id)
|
||||
if (idx !== -1) {
|
||||
config.value.tiers[idx] = { ...config.value.tiers[idx], ...patch }
|
||||
}
|
||||
}
|
||||
|
||||
function removeTier(id: string) {
|
||||
config.value.tiers = config.value.tiers.filter(t => t.id !== id)
|
||||
}
|
||||
|
||||
/* ── Rule CRUD ── */
|
||||
|
||||
function addRule(rule: AttentionRule) {
|
||||
config.value.rules.push(rule)
|
||||
}
|
||||
|
||||
function updateRule(id: string, patch: Partial<AttentionRule>) {
|
||||
const idx = config.value.rules.findIndex(r => r.id === id)
|
||||
if (idx !== -1) {
|
||||
config.value.rules[idx] = { ...config.value.rules[idx], ...patch }
|
||||
}
|
||||
}
|
||||
|
||||
function removeRule(id: string) {
|
||||
config.value.rules = config.value.rules.filter(r => r.id !== id)
|
||||
}
|
||||
|
||||
return {
|
||||
config,
|
||||
tiers,
|
||||
rules,
|
||||
getScoreForCustomer,
|
||||
getTierForCustomer,
|
||||
addTier,
|
||||
updateTier,
|
||||
removeTier,
|
||||
addRule,
|
||||
updateRule,
|
||||
removeRule,
|
||||
}
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
import { emptyCustomerProfile, type CustomerProfileVault } from '~/types/customer-profile'
|
||||
import { useLocalStorageRef } from '~/utils/useLocalStorageRef'
|
||||
|
||||
const KEY = 'policy-ui-customer-profile-vault-v1'
|
||||
|
||||
export function useCustomerProfileVault() {
|
||||
const profile = useLocalStorageRef(KEY, () => emptyCustomerProfile())
|
||||
|
||||
function touch() {
|
||||
profile.value.updatedAt = new Date().toISOString()
|
||||
}
|
||||
|
||||
function reset() {
|
||||
profile.value = emptyCustomerProfile()
|
||||
}
|
||||
|
||||
return { profile, touch, reset }
|
||||
}
|
||||
@@ -1,137 +0,0 @@
|
||||
/**
|
||||
* Composable for managing customer selection in quote flows
|
||||
* Handles insured and buyer selection with validation
|
||||
*/
|
||||
export function useCustomerSelection() {
|
||||
const selectedCustomer = ref<any>(null) // Auto-generated type from useCustomer
|
||||
const useSameForBuyer = ref(true)
|
||||
const selectedBuyer = ref<any>(null)
|
||||
|
||||
/**
|
||||
* Convert customer-service customer to policy-service insured/buyer
|
||||
* Maps customer fields to policy-service structure
|
||||
*/
|
||||
const toPolicyPerson = (customer: any) => {
|
||||
if (customer.customer_type === 'corporate') {
|
||||
return {
|
||||
type: 'corporate',
|
||||
company_name: customer.legal_name,
|
||||
ruc: customer.ruc,
|
||||
legal_rep_name: customer.legal_rep_name,
|
||||
legal_rep_document: customer.legal_rep_document_id,
|
||||
email: customer.email,
|
||||
phone: customer.phone,
|
||||
address: customer.address
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
type: 'individual',
|
||||
name: `${customer.first_name} ${customer.last_name}`.trim(),
|
||||
date_of_birth: customer.birth_date,
|
||||
document_id: customer.document_id,
|
||||
email: customer.email,
|
||||
phone: customer.phone,
|
||||
address: customer.address
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get insured person from selected customer
|
||||
*/
|
||||
const insured = computed(() => {
|
||||
if (!selectedCustomer.value) return null
|
||||
return toPolicyPerson(selectedCustomer.value)
|
||||
})
|
||||
|
||||
/**
|
||||
* Get buyer person (either same as insured or different)
|
||||
*/
|
||||
const buyer = computed(() => {
|
||||
if (useSameForBuyer.value) {
|
||||
return insured.value
|
||||
}
|
||||
if (!selectedBuyer.value) return null
|
||||
return toPolicyPerson(selectedBuyer.value)
|
||||
})
|
||||
|
||||
/**
|
||||
* Validate customer has required fields for policy submission
|
||||
*/
|
||||
const validateCustomer = (customer: any) => {
|
||||
const missing: string[] = []
|
||||
|
||||
if (customer.customer_type === 'corporate') {
|
||||
if (!customer.legal_name) missing.push('legal_name')
|
||||
if (!customer.ruc) missing.push('ruc')
|
||||
if (!customer.legal_rep_name) missing.push('legal_rep_name')
|
||||
if (!customer.legal_rep_document_id) missing.push('legal_rep_document_id')
|
||||
} else {
|
||||
if (!customer.first_name) missing.push('first_name')
|
||||
if (!customer.last_name) missing.push('last_name')
|
||||
if (!customer.birth_date) missing.push('birth_date')
|
||||
if (!customer.document_id) missing.push('document_id')
|
||||
}
|
||||
|
||||
return { valid: missing.length === 0, missing }
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if insured is valid
|
||||
*/
|
||||
const isInsuredValid = computed(() => {
|
||||
if (!selectedCustomer.value) return false
|
||||
return validateCustomer(selectedCustomer.value).valid
|
||||
})
|
||||
|
||||
/**
|
||||
* Check if buyer is valid
|
||||
*/
|
||||
const isBuyerValid = computed(() => {
|
||||
if (useSameForBuyer.value) {
|
||||
return isInsuredValid.value
|
||||
}
|
||||
if (!selectedBuyer.value) return false
|
||||
return validateCustomer(selectedBuyer.value).valid
|
||||
})
|
||||
|
||||
/**
|
||||
* Get validation errors
|
||||
*/
|
||||
const validationErrors = computed(() => {
|
||||
const errors: { insured: string[]; buyer: string[] } = { insured: [], buyer: [] }
|
||||
|
||||
if (selectedCustomer.value) {
|
||||
const validation = validateCustomer(selectedCustomer.value)
|
||||
errors.insured = validation.missing
|
||||
}
|
||||
|
||||
if (!useSameForBuyer.value && selectedBuyer.value) {
|
||||
const validation = validateCustomer(selectedBuyer.value)
|
||||
errors.buyer = validation.missing
|
||||
}
|
||||
|
||||
return errors
|
||||
})
|
||||
|
||||
/**
|
||||
* Reset selection
|
||||
*/
|
||||
function reset() {
|
||||
selectedCustomer.value = null
|
||||
selectedBuyer.value = null
|
||||
useSameForBuyer.value = true
|
||||
}
|
||||
|
||||
return {
|
||||
selectedCustomer,
|
||||
selectedBuyer,
|
||||
useSameForBuyer,
|
||||
insured,
|
||||
buyer,
|
||||
isInsuredValid,
|
||||
isBuyerValid,
|
||||
validationErrors,
|
||||
reset
|
||||
}
|
||||
}
|
||||
@@ -1,273 +0,0 @@
|
||||
/**
|
||||
* Home dashboard widget visibility — role presets + per-widget toggles.
|
||||
* Persisted locally until per-user API exists.
|
||||
*/
|
||||
|
||||
export type DashboardWidgetId =
|
||||
| 'hero'
|
||||
| 'milestone'
|
||||
| 'performance'
|
||||
| 'tasks_alerts'
|
||||
| 'charts'
|
||||
| 'brokerage_health'
|
||||
| 'quotes_line'
|
||||
| 'notes'
|
||||
| 'calendar'
|
||||
| 'quick_leads'
|
||||
| 'sales_leads'
|
||||
| 'client_favorites'
|
||||
| 'drafts'
|
||||
|
||||
export type DashboardRolePresetId =
|
||||
| 'sales_manager'
|
||||
| 'executive_manager'
|
||||
| 'director'
|
||||
| 'financial'
|
||||
| 'admin_manager'
|
||||
| 'customer_service_manager'
|
||||
|
||||
export type DashboardWidgetMeta = {
|
||||
id: DashboardWidgetId
|
||||
label: string
|
||||
description: string
|
||||
}
|
||||
|
||||
export const DASHBOARD_WIDGETS: DashboardWidgetMeta[] = [
|
||||
{ id: 'hero', label: 'Welcome banner', description: 'Greeting, CTAs, workspace strip' },
|
||||
{ id: 'milestone', label: 'MTD milestone', description: 'Plan vs actual snapshot' },
|
||||
{ id: 'tasks_alerts', label: 'Tasks & alerts', description: 'Daily work + exceptions' },
|
||||
{ id: 'performance', label: 'Today at a glance', description: 'Headline KPIs + sparklines' },
|
||||
{ id: 'charts', label: 'Charts', description: 'GWP trend & quoted pipeline' },
|
||||
{ id: 'brokerage_health', label: 'Brokerage health', description: 'YTD / trailing book metrics' },
|
||||
{ id: 'quotes_line', label: 'Sent quotes', description: 'Sortable list of quotes sent to clients' },
|
||||
{ id: 'notes', label: 'Notes', description: 'Personal scratchpad and reminders' },
|
||||
{ id: 'calendar', label: 'Calendar', description: 'Agenda, renewals, alerts & reminders' },
|
||||
{ id: 'quick_leads', label: 'Quick leads', description: 'Recent quick leads from the last 10 days' },
|
||||
{ id: 'sales_leads', label: 'Sales leads', description: 'All leads by source — filter by channel, campaign, or API' },
|
||||
{ id: 'client_favorites', label: 'Favorite clients', description: 'Starred clients for quick access' },
|
||||
{ id: 'drafts', label: 'Drafts', description: 'Resume in-progress quotes, solicitudes & registrations' }
|
||||
]
|
||||
|
||||
const STORAGE_KEY = 'policy-ui.dashboard.widgets.v4'
|
||||
|
||||
export const DEFAULT_WIDGET_ORDER: DashboardWidgetId[] = DASHBOARD_WIDGETS.map((w) => w.id)
|
||||
|
||||
function normalizeWidgetOrder(raw: unknown): DashboardWidgetId[] {
|
||||
const base = [...DEFAULT_WIDGET_ORDER]
|
||||
if (!Array.isArray(raw)) return base
|
||||
const seen = new Set<DashboardWidgetId>()
|
||||
const out: DashboardWidgetId[] = []
|
||||
for (const x of raw) {
|
||||
if (typeof x === 'string' && base.includes(x as DashboardWidgetId) && !seen.has(x as DashboardWidgetId)) {
|
||||
const id = x as DashboardWidgetId
|
||||
seen.add(id)
|
||||
out.push(id)
|
||||
}
|
||||
}
|
||||
for (const id of base) {
|
||||
if (!seen.has(id)) out.push(id)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
const ALL_ON: Record<DashboardWidgetId, boolean> = {
|
||||
hero: true,
|
||||
milestone: true,
|
||||
performance: false,
|
||||
tasks_alerts: true,
|
||||
charts: true,
|
||||
brokerage_health: true,
|
||||
quotes_line: true,
|
||||
notes: true,
|
||||
calendar: true,
|
||||
quick_leads: true,
|
||||
sales_leads: true,
|
||||
client_favorites: true,
|
||||
drafts: true
|
||||
}
|
||||
|
||||
export const DASHBOARD_ROLE_PRESETS: Record<
|
||||
DashboardRolePresetId,
|
||||
{ label: string; hint: string; widgets: Record<DashboardWidgetId, boolean> }
|
||||
> = {
|
||||
sales_manager: {
|
||||
label: 'Sales manager',
|
||||
hint: 'Pipeline, tasks, quotes — lighter book-of-business tile.',
|
||||
widgets: { ...ALL_ON, brokerage_health: false }
|
||||
},
|
||||
executive_manager: {
|
||||
label: 'Executive manager',
|
||||
hint: 'Balanced operational + book view.',
|
||||
widgets: { ...ALL_ON }
|
||||
},
|
||||
director: {
|
||||
label: 'Director',
|
||||
hint: 'Strategic KPIs & health; fewer operational tiles.',
|
||||
widgets: {
|
||||
...ALL_ON,
|
||||
tasks_alerts: false,
|
||||
quotes_line: false
|
||||
}
|
||||
},
|
||||
financial: {
|
||||
label: 'Financial',
|
||||
hint: 'Premium, AR, health metrics; fewer sales shortcuts.',
|
||||
widgets: {
|
||||
...ALL_ON,
|
||||
quotes_line: false,
|
||||
tasks_alerts: true,
|
||||
performance: false,
|
||||
brokerage_health: true,
|
||||
charts: true,
|
||||
sales_leads: false
|
||||
}
|
||||
},
|
||||
admin_manager: {
|
||||
label: 'Admin / operations',
|
||||
hint: 'Permissions, forms, and carrier setup — fewer quote shortcuts.',
|
||||
widgets: {
|
||||
...ALL_ON,
|
||||
quotes_line: false,
|
||||
charts: false,
|
||||
brokerage_health: true,
|
||||
sales_leads: false
|
||||
}
|
||||
},
|
||||
customer_service_manager: {
|
||||
label: 'Customer service manager',
|
||||
hint: 'Queues, tasks, and exceptions — lighter GWP / book tiles.',
|
||||
widgets: {
|
||||
...ALL_ON,
|
||||
charts: false,
|
||||
brokerage_health: false,
|
||||
quotes_line: false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Stable order for selects. */
|
||||
export const DASHBOARD_PRESET_ORDER: DashboardRolePresetId[] = [
|
||||
'sales_manager',
|
||||
'executive_manager',
|
||||
'director',
|
||||
'financial',
|
||||
'admin_manager',
|
||||
'customer_service_manager'
|
||||
]
|
||||
|
||||
function cloneWidgets(w: Record<DashboardWidgetId, boolean>): Record<DashboardWidgetId, boolean> {
|
||||
return { ...w }
|
||||
}
|
||||
|
||||
export function useDashboardHomeWidgets() {
|
||||
const activePreset = ref<DashboardRolePresetId>('executive_manager')
|
||||
const widgets = ref<Record<DashboardWidgetId, boolean>>(
|
||||
cloneWidgets(DASHBOARD_ROLE_PRESETS.executive_manager.widgets)
|
||||
)
|
||||
const widgetOrder = ref<DashboardWidgetId[]>([...DEFAULT_WIDGET_ORDER])
|
||||
const layoutUnlocked = ref(false)
|
||||
const hydrated = ref(false)
|
||||
|
||||
function persist() {
|
||||
if (typeof localStorage === 'undefined') return
|
||||
try {
|
||||
localStorage.setItem(
|
||||
STORAGE_KEY,
|
||||
JSON.stringify({
|
||||
preset: activePreset.value,
|
||||
widgets: widgets.value,
|
||||
widgetOrder: widgetOrder.value,
|
||||
layoutUnlocked: layoutUnlocked.value
|
||||
})
|
||||
)
|
||||
} catch {
|
||||
/* quota */
|
||||
}
|
||||
}
|
||||
|
||||
function load() {
|
||||
if (typeof localStorage === 'undefined') return
|
||||
try {
|
||||
const raw = localStorage.getItem(STORAGE_KEY)
|
||||
if (!raw) return
|
||||
const data = JSON.parse(raw) as {
|
||||
preset?: DashboardRolePresetId
|
||||
widgets?: Partial<Record<DashboardWidgetId, boolean>>
|
||||
widgetOrder?: DashboardWidgetId[]
|
||||
layoutUnlocked?: boolean
|
||||
}
|
||||
if (data.preset && DASHBOARD_ROLE_PRESETS[data.preset]) {
|
||||
activePreset.value = data.preset
|
||||
}
|
||||
if (data.widgets) {
|
||||
const merged = { ...widgets.value }
|
||||
for (const k of Object.keys(merged) as DashboardWidgetId[]) {
|
||||
if (data.widgets[k] !== undefined) merged[k] = data.widgets[k]!
|
||||
}
|
||||
widgets.value = merged
|
||||
}
|
||||
widgetOrder.value = normalizeWidgetOrder(data.widgetOrder)
|
||||
if (typeof data.layoutUnlocked === 'boolean') {
|
||||
layoutUnlocked.value = data.layoutUnlocked
|
||||
}
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
load()
|
||||
hydrated.value = true
|
||||
})
|
||||
|
||||
watch(
|
||||
[activePreset, widgets, widgetOrder, layoutUnlocked],
|
||||
() => {
|
||||
if (hydrated.value) persist()
|
||||
},
|
||||
{ deep: true }
|
||||
)
|
||||
|
||||
const isPresetDirty = computed(() => {
|
||||
const preset = DASHBOARD_ROLE_PRESETS[activePreset.value]
|
||||
if (!preset) return false
|
||||
return DASHBOARD_WIDGETS.some((w) => widgets.value[w.id] !== preset.widgets[w.id])
|
||||
})
|
||||
|
||||
function applyPreset(id: DashboardRolePresetId) {
|
||||
activePreset.value = id
|
||||
const p = DASHBOARD_ROLE_PRESETS[id]
|
||||
if (p) widgets.value = cloneWidgets(p.widgets)
|
||||
}
|
||||
|
||||
function setWidget(id: DashboardWidgetId, on: boolean) {
|
||||
widgets.value = { ...widgets.value, [id]: on }
|
||||
}
|
||||
|
||||
function reapplySelectedPreset() {
|
||||
applyPreset(activePreset.value)
|
||||
}
|
||||
|
||||
function reorderWidgets(fromId: DashboardWidgetId, toId: DashboardWidgetId) {
|
||||
if (fromId === toId) return
|
||||
const arr = [...widgetOrder.value]
|
||||
const fromI = arr.indexOf(fromId)
|
||||
const toI = arr.indexOf(toId)
|
||||
if (fromI === -1 || toI === -1) return
|
||||
arr.splice(fromI, 1)
|
||||
arr.splice(toI, 0, fromId)
|
||||
widgetOrder.value = arr
|
||||
}
|
||||
|
||||
return {
|
||||
widgets,
|
||||
widgetOrder,
|
||||
layoutUnlocked,
|
||||
activePreset,
|
||||
isPresetDirty,
|
||||
applyPreset,
|
||||
setWidget,
|
||||
reapplySelectedPreset,
|
||||
reorderWidgets
|
||||
}
|
||||
}
|
||||
@@ -1,52 +0,0 @@
|
||||
import { useLocalStorageRef } from '~/utils/useLocalStorageRef'
|
||||
|
||||
export type EmissionItem = {
|
||||
id: string
|
||||
createdAt: string
|
||||
customerLabel: string
|
||||
insurerSlug: string
|
||||
subRamoKey: string
|
||||
productLine: string
|
||||
status: 'pending_review' | 'approved' | 'sent_to_insurer' | 'in_force'
|
||||
bindToken?: string
|
||||
/** 'auto' = generated from quote acceptance, 'manual' = created from solicitud form */
|
||||
source?: 'auto' | 'manual'
|
||||
/** Carrier product name when auto-generated from comparative */
|
||||
carrierProduct?: string
|
||||
}
|
||||
|
||||
const KEY = 'policy-ui-emissions-queue-v1'
|
||||
|
||||
export function useEmissionsQueue() {
|
||||
const items = useLocalStorageRef<EmissionItem[]>(KEY, () => [])
|
||||
|
||||
function enqueue(
|
||||
entry: Omit<EmissionItem, 'id' | 'createdAt' | 'status'> & { status?: EmissionItem['status'] }
|
||||
) {
|
||||
const row: EmissionItem = {
|
||||
id: crypto.randomUUID?.() ?? String(Date.now()),
|
||||
createdAt: new Date().toISOString(),
|
||||
status: entry.status ?? 'pending_review',
|
||||
...entry
|
||||
}
|
||||
items.value = [row, ...items.value]
|
||||
return row
|
||||
}
|
||||
|
||||
function approve(id: string) {
|
||||
const i = items.value.find((x) => x.id === id)
|
||||
if (i) i.status = 'approved'
|
||||
}
|
||||
|
||||
function sendToInsurer(id: string) {
|
||||
const i = items.value.find((x) => x.id === id)
|
||||
if (i) i.status = 'sent_to_insurer'
|
||||
}
|
||||
|
||||
function markInForce(id: string) {
|
||||
const i = items.value.find((x) => x.id === id)
|
||||
if (i) i.status = 'in_force'
|
||||
}
|
||||
|
||||
return { items, enqueue, approve, sendToInsurer, markInForce }
|
||||
}
|
||||
@@ -1,174 +0,0 @@
|
||||
import catalogJson from '~/data/forms-catalog.json'
|
||||
import fieldGroupsJson from '~/data/form-field-groups.json'
|
||||
import type {
|
||||
FormCatalogFile,
|
||||
FormCatalogProductLine,
|
||||
FormCatalogRow,
|
||||
FormCatalogSelection
|
||||
} from '~/types/form-catalog'
|
||||
import type { FormFieldGroupDef, FormFieldGroupsFile } from '~/types/form-field-groups'
|
||||
|
||||
const catalog = catalogJson as FormCatalogFile
|
||||
const fieldGroupsFile = fieldGroupsJson as FormFieldGroupsFile
|
||||
|
||||
const PRODUCT_LINE_LABELS: Record<FormCatalogProductLine, string> = {
|
||||
life: 'Life',
|
||||
health_local: 'Health · local',
|
||||
health_international: 'Health · international',
|
||||
auto_full_coverage: 'Auto · full coverage',
|
||||
auto_dat_liability: 'Auto · DAT (liability)',
|
||||
home: 'Home',
|
||||
general_liability: 'General liability',
|
||||
any: 'Any / not specified'
|
||||
}
|
||||
|
||||
const ALL_PRODUCT_LINES: FormCatalogProductLine[] = [
|
||||
'life',
|
||||
'health_local',
|
||||
'health_international',
|
||||
'auto_full_coverage',
|
||||
'auto_dat_liability',
|
||||
'home',
|
||||
'general_liability',
|
||||
'any'
|
||||
]
|
||||
|
||||
export function productLineLabel(line: FormCatalogProductLine | null | undefined): string {
|
||||
if (line == null || line === 'any') return '—'
|
||||
return PRODUCT_LINE_LABELS[line] ?? String(line)
|
||||
}
|
||||
|
||||
function personMatches(row: FormCatalogRow, person: 'natural' | 'juridica'): boolean {
|
||||
if (row.personKinds === 'both') return true
|
||||
return row.personKinds === person
|
||||
}
|
||||
|
||||
function productLineMatches(row: FormCatalogRow, sel: FormCatalogSelection): boolean {
|
||||
const rowPl = row.productLine
|
||||
const selPl = sel.productLine
|
||||
|
||||
if (selPl === null || selPl === 'any') {
|
||||
return rowPl == null
|
||||
}
|
||||
if (rowPl == null) return true
|
||||
return rowPl === selPl
|
||||
}
|
||||
|
||||
function subRamoMatches(row: FormCatalogRow, subRamoKey: string | null): boolean {
|
||||
if (!subRamoKey) return false
|
||||
if (row.subRamoKey === 'any') return true
|
||||
return row.subRamoKey === subRamoKey
|
||||
}
|
||||
|
||||
export function filterRows(all: FormCatalogRow[], sel: FormCatalogSelection): FormCatalogRow[] {
|
||||
if (!sel.insurerSlug || !sel.subRamoKey) return []
|
||||
return all.filter((row) => {
|
||||
if (!row.insurerSlugs.includes(sel.insurerSlug!)) return false
|
||||
if (!subRamoMatches(row, sel.subRamoKey)) return false
|
||||
if (!personMatches(row, sel.personKind)) return false
|
||||
if (!productLineMatches(row, sel)) return false
|
||||
return true
|
||||
})
|
||||
}
|
||||
|
||||
export function resolveFieldGroupsForRows(
|
||||
matched: FormCatalogRow[],
|
||||
groupMap: Map<string, FormFieldGroupDef>
|
||||
): FormFieldGroupDef[] {
|
||||
const ids = new Set<string>()
|
||||
for (const r of matched) {
|
||||
for (const id of r.fieldGroupIds ?? []) ids.add(id)
|
||||
}
|
||||
return [...ids]
|
||||
.map((id) => groupMap.get(id))
|
||||
.filter((g): g is FormFieldGroupDef => g != null)
|
||||
}
|
||||
|
||||
export function buildFormMapIndex(rows: FormCatalogRow[]): Map<string, number[]> {
|
||||
const m = new Map<string, number[]>()
|
||||
for (const r of rows) {
|
||||
for (const ins of r.insurerSlugs) {
|
||||
const pl = r.productLine ?? ''
|
||||
const key = `${ins}|${r.subRamoKey}|${pl}`
|
||||
const list = m.get(key) ?? []
|
||||
list.push(r.id)
|
||||
m.set(key, list)
|
||||
}
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
function insurerSlugToLabel(slug: string): string {
|
||||
return slug
|
||||
.split('_')
|
||||
.map((w) => w.slice(0, 1).toUpperCase() + w.slice(1))
|
||||
.join(' ')
|
||||
}
|
||||
|
||||
function buildInsurerItems(rows: FormCatalogRow[]) {
|
||||
const set = new Set<string>()
|
||||
for (const r of rows) {
|
||||
for (const s of r.insurerSlugs) set.add(s)
|
||||
}
|
||||
return [...set]
|
||||
.sort()
|
||||
.map((value) => ({ label: insurerSlugToLabel(value), value }))
|
||||
}
|
||||
|
||||
function buildSubRamoItems(rows: FormCatalogRow[], insurerSlug: string | null) {
|
||||
if (!insurerSlug) return []
|
||||
const map = new Map<string, string>()
|
||||
for (const r of rows) {
|
||||
if (!r.insurerSlugs.includes(insurerSlug)) continue
|
||||
if (r.subRamoKey === 'any') continue
|
||||
map.set(r.subRamoKey, r.subRamoLabel)
|
||||
}
|
||||
return [...map.entries()]
|
||||
.sort((a, b) => a[1].localeCompare(b[1]))
|
||||
.map(([value, label]) => ({ label, value }))
|
||||
}
|
||||
|
||||
function productLineSelectOptions() {
|
||||
return ALL_PRODUCT_LINES.map((value) => ({
|
||||
label: PRODUCT_LINE_LABELS[value],
|
||||
value
|
||||
}))
|
||||
}
|
||||
|
||||
export function useFormsCatalog() {
|
||||
const rows = computed(() => catalog.rows)
|
||||
const version = computed(() => catalog.version)
|
||||
|
||||
const groupById = computed(() => {
|
||||
const m = new Map<string, FormFieldGroupDef>()
|
||||
for (const g of fieldGroupsFile.groups) m.set(g.id, g)
|
||||
return m
|
||||
})
|
||||
|
||||
const insurerItems = computed(() => buildInsurerItems(catalog.rows))
|
||||
|
||||
function subRamoItems(insurerSlug: string | null) {
|
||||
return buildSubRamoItems(catalog.rows, insurerSlug)
|
||||
}
|
||||
|
||||
const productLineItems = productLineSelectOptions()
|
||||
|
||||
function fieldGroupsForMatched(matched: FormCatalogRow[]) {
|
||||
return resolveFieldGroupsForRows(matched, groupById.value)
|
||||
}
|
||||
|
||||
return {
|
||||
catalog,
|
||||
rows,
|
||||
version,
|
||||
fieldGroupsVersion: computed(() => fieldGroupsFile.version),
|
||||
filterRows: (sel: FormCatalogSelection) => filterRows(catalog.rows, sel),
|
||||
fieldGroupsForMatched,
|
||||
buildFormMapIndex: () => buildFormMapIndex(catalog.rows),
|
||||
insurerItems,
|
||||
subRamoItems,
|
||||
productLineItems,
|
||||
productLineLabel,
|
||||
insurerSlugToLabel
|
||||
}
|
||||
}
|
||||
@@ -1,33 +0,0 @@
|
||||
import type { HealthQuoteDraft } from '~/types/health-quote-intake'
|
||||
|
||||
export function emptyHealthQuoteDraft(): HealthQuoteDraft {
|
||||
return {
|
||||
quoteMode: null,
|
||||
segment: null,
|
||||
client: {
|
||||
fullName: '',
|
||||
email: '',
|
||||
phone: '',
|
||||
documentId: '',
|
||||
organizationName: ''
|
||||
},
|
||||
health: {
|
||||
coverageArea: '',
|
||||
networkTier: '',
|
||||
deductible: '',
|
||||
dateOfBirth: '',
|
||||
age: '',
|
||||
preexistingConditions: false,
|
||||
preexistingDetails: ''
|
||||
},
|
||||
forms: {
|
||||
medicalQuestionnaire: false,
|
||||
beneficiaryDesignation: false,
|
||||
groupCensus: false
|
||||
},
|
||||
solicit: {
|
||||
carrierIds: [],
|
||||
planIds: []
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,29 +0,0 @@
|
||||
import type { LifeQuoteDraft } from '~/types/life-quote-intake'
|
||||
|
||||
export function emptyLifeQuoteDraft(): LifeQuoteDraft {
|
||||
return {
|
||||
quoteMode: null,
|
||||
segment: null,
|
||||
insured: null,
|
||||
buyer: null,
|
||||
life: {
|
||||
coverage_type: 'banking',
|
||||
coverage_amount: 0,
|
||||
coverage_years: 10,
|
||||
smoker: false,
|
||||
medications: '',
|
||||
surgeries: '',
|
||||
weight: 0,
|
||||
height: 0
|
||||
},
|
||||
forms: {
|
||||
medicalQuestionnaire: false,
|
||||
beneficiaryDesignation: false,
|
||||
groupCensus: false
|
||||
},
|
||||
solicit: {
|
||||
carrierIds: [],
|
||||
planIds: []
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
export function usePageTitle(title: string) {
|
||||
export function usePageTitle(title: string | (() => string)) {
|
||||
const computedTitle = typeof title === 'function' ? computed(title) : ref(title)
|
||||
|
||||
useHead({
|
||||
title,
|
||||
titleTemplate: (t) => (t ? `${t} · Policy UI` : 'Policy UI')
|
||||
title: computed(() => `${computedTitle.value} · Segur-OS`)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,12 +0,0 @@
|
||||
import type { PdfFieldMappingFile } from '~/types/pdf-field-mapping'
|
||||
import mappingsJson from '~/data/pdf-field-mappings.json'
|
||||
|
||||
const file = mappingsJson as PdfFieldMappingFile
|
||||
|
||||
export function usePdfFieldMappings() {
|
||||
function mappingForCatalogFormId(catalogFormId: number) {
|
||||
return file.mappings.find((m) => m.catalogFormId === catalogFormId) ?? null
|
||||
}
|
||||
|
||||
return { version: file.version, mappingForCatalogFormId, mappings: file.mappings }
|
||||
}
|
||||
@@ -1,67 +0,0 @@
|
||||
/**
|
||||
* Composable for policy API operations
|
||||
* Handles quote submission and acceptance
|
||||
*/
|
||||
export function usePolicyApi() {
|
||||
const { $policy } = useNuxtApp()
|
||||
const toast = useToast()
|
||||
const router = useRouter()
|
||||
|
||||
/**
|
||||
* Submit a policy quote request
|
||||
*/
|
||||
async function submitPolicyQuote(payload: {
|
||||
policy_type: 'car' | 'life' | 'fire_structure' | 'fire_contents'
|
||||
insured: any
|
||||
buyer: any
|
||||
policy_details: any
|
||||
selected_providers: Array<{ provider_id: string; email: string }>
|
||||
}) {
|
||||
try {
|
||||
const data = await $policy('/policies', {
|
||||
method: 'POST',
|
||||
body: payload
|
||||
}) as any
|
||||
|
||||
toast.add({ title: 'Quote submitted successfully', color: 'green' })
|
||||
return data
|
||||
} catch (e: any) {
|
||||
toast.add({
|
||||
title: 'Failed to submit quote',
|
||||
description: e?.data?.error ?? e.message,
|
||||
color: 'red'
|
||||
})
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Accept a quote plan and trigger solicitation
|
||||
*/
|
||||
async function acceptQuote(applicationId: string, acceptedPlanId: string, acceptedBy: string) {
|
||||
try {
|
||||
const data = await $policy(`/policies/${applicationId}/accept`, {
|
||||
method: 'POST',
|
||||
body: {
|
||||
accepted_plan_id: acceptedPlanId,
|
||||
accepted_by: acceptedBy
|
||||
}
|
||||
}) as any
|
||||
|
||||
toast.add({ title: 'Plan accepted successfully', color: 'green' })
|
||||
return data
|
||||
} catch (e: any) {
|
||||
toast.add({
|
||||
title: 'Failed to accept plan',
|
||||
description: e?.data?.error ?? e.message,
|
||||
color: 'red'
|
||||
})
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
submitPolicyQuote,
|
||||
acceptQuote
|
||||
}
|
||||
}
|
||||
@@ -1,88 +0,0 @@
|
||||
import type { Ref } from 'vue'
|
||||
import type { PolicyInstallmentRow, PolicyRegistration } from '~/types/brokerage-registration'
|
||||
import { POLICY_DRAFT_STORAGE_KEY } from '~/types/brokerage-registration'
|
||||
|
||||
export function createEmptyPolicyRegistration(): PolicyRegistration {
|
||||
return {
|
||||
mintPolicyNumber: '',
|
||||
contratanteId: '',
|
||||
ramo: '',
|
||||
subRamo: '',
|
||||
aseguradora: '',
|
||||
producto: '',
|
||||
agencia: '',
|
||||
numeroPolizaProveedor: '',
|
||||
acreedor: '',
|
||||
fechaEmision: '',
|
||||
inicioVigencia: '',
|
||||
finVigencia: '',
|
||||
comisiones: [
|
||||
{ idx: 1, agenteId: '', porcentaje: '' },
|
||||
{ idx: 2, agenteId: '', porcentaje: '' },
|
||||
{ idx: 3, agenteId: '', porcentaje: '' }
|
||||
],
|
||||
formaPago: '',
|
||||
valorAsegurado: '',
|
||||
primaBruta: '',
|
||||
impuestoPct: '6',
|
||||
primaNeta: '',
|
||||
numCuotas: 10,
|
||||
cuotas: [],
|
||||
cotizacionMintId: '',
|
||||
pdfCotizacionNombre: '',
|
||||
pdfPolizaNombre: '',
|
||||
notas: ''
|
||||
}
|
||||
}
|
||||
|
||||
export function rebuildInstallmentSchedule(p: PolicyRegistration): PolicyInstallmentRow[] {
|
||||
const n = Math.max(1, Math.min(60, Math.floor(p.numCuotas) || 1))
|
||||
const start = p.inicioVigencia ? new Date(p.inicioVigencia) : new Date()
|
||||
const base = Number.isNaN(start.getTime()) ? new Date() : start
|
||||
const per = p.primaBruta
|
||||
? (Number.parseFloat(String(p.primaBruta).replace(/[^0-9.-]/g, '')) || 0) / n
|
||||
: 0
|
||||
const rows: PolicyInstallmentRow[] = []
|
||||
for (let i = 0; i < n; i++) {
|
||||
const d = new Date(base)
|
||||
d.setMonth(d.getMonth() + i)
|
||||
rows.push({
|
||||
n: i + 1,
|
||||
fechaVencimiento: d.toISOString().slice(0, 16),
|
||||
prima: per > 0 ? per.toFixed(2) : ''
|
||||
})
|
||||
}
|
||||
return rows
|
||||
}
|
||||
|
||||
export function setFinOneYearAfterInicio(p: PolicyRegistration) {
|
||||
if (!p.inicioVigencia) return
|
||||
const d = new Date(p.inicioVigencia)
|
||||
if (Number.isNaN(d.getTime())) return
|
||||
d.setFullYear(d.getFullYear() + 1)
|
||||
p.finVigencia = d.toISOString().slice(0, 16)
|
||||
}
|
||||
|
||||
export function usePolicyDraftPersistence(form: Ref<PolicyRegistration>) {
|
||||
if (import.meta.server) return
|
||||
try {
|
||||
const raw = localStorage.getItem(POLICY_DRAFT_STORAGE_KEY)
|
||||
if (raw) {
|
||||
const parsed = JSON.parse(raw) as PolicyRegistration
|
||||
form.value = { ...createEmptyPolicyRegistration(), ...parsed, cuotas: parsed.cuotas ?? [] }
|
||||
}
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
watch(
|
||||
form,
|
||||
(v) => {
|
||||
try {
|
||||
localStorage.setItem(POLICY_DRAFT_STORAGE_KEY, JSON.stringify(v))
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
},
|
||||
{ deep: true }
|
||||
)
|
||||
}
|
||||
@@ -1,205 +0,0 @@
|
||||
import { computed } from 'vue'
|
||||
import { useLocalStorageRef } from '~/utils/useLocalStorageRef'
|
||||
|
||||
/* ── Types ── */
|
||||
|
||||
export type ProfileRole = 'sales' | 'claims' | 'renewals' | 'general_service' | 'management' | 'superadmin'
|
||||
|
||||
export interface ProfileSection {
|
||||
id: string
|
||||
label: string
|
||||
visible: boolean
|
||||
order: number
|
||||
}
|
||||
|
||||
export interface ProfileLayout {
|
||||
id: string
|
||||
role: ProfileRole | string
|
||||
name: string
|
||||
description: string
|
||||
icon: string
|
||||
sections: ProfileSection[]
|
||||
defaultTab: 'policies' | 'claims' | 'payments' | 'activity' | 'history' | 'relationships' | 'notes'
|
||||
isCustom: boolean
|
||||
}
|
||||
|
||||
/* ── Section catalog ── */
|
||||
|
||||
const ALL_SECTION_IDS = [
|
||||
'orientation',
|
||||
'kpi_strip',
|
||||
'quick_policies',
|
||||
'service_actions',
|
||||
'personal_details',
|
||||
'tabbed_content',
|
||||
'documents',
|
||||
] as const
|
||||
|
||||
const SECTION_LABELS: Record<string, string> = {
|
||||
orientation: 'Account Orientation',
|
||||
kpi_strip: 'KPI Strip',
|
||||
quick_policies: 'Quick Policies',
|
||||
service_actions: 'Service Actions',
|
||||
personal_details: 'Personal Details',
|
||||
tabbed_content: 'Tabbed Content',
|
||||
documents: 'Documents',
|
||||
}
|
||||
|
||||
function makeSections(order: string[], hidden: string[] = []): ProfileSection[] {
|
||||
return order.map((id, i) => ({
|
||||
id,
|
||||
label: SECTION_LABELS[id] ?? id,
|
||||
visible: !hidden.includes(id),
|
||||
order: i,
|
||||
}))
|
||||
}
|
||||
|
||||
/* ── Built-in layouts ── */
|
||||
|
||||
function defaultLayouts(): ProfileLayout[] {
|
||||
return [
|
||||
{
|
||||
id: 'sales',
|
||||
role: 'sales',
|
||||
name: 'Sales',
|
||||
description: 'Focus on policies, quotes, and pipeline.',
|
||||
icon: 'i-heroicons-currency-dollar',
|
||||
sections: makeSections([
|
||||
'orientation', 'quick_policies', 'kpi_strip', 'tabbed_content',
|
||||
'service_actions', 'personal_details', 'documents',
|
||||
]),
|
||||
defaultTab: 'policies',
|
||||
isCustom: false,
|
||||
},
|
||||
{
|
||||
id: 'claims',
|
||||
role: 'claims',
|
||||
name: 'Claims',
|
||||
description: 'Focus on claims and service actions.',
|
||||
icon: 'i-heroicons-shield-exclamation',
|
||||
sections: makeSections([
|
||||
'service_actions', 'orientation', 'kpi_strip', 'tabbed_content',
|
||||
'quick_policies', 'personal_details', 'documents',
|
||||
]),
|
||||
defaultTab: 'claims',
|
||||
isCustom: false,
|
||||
},
|
||||
{
|
||||
id: 'renewals',
|
||||
role: 'renewals',
|
||||
name: 'Renewals',
|
||||
description: 'Focus on upcoming events and policies.',
|
||||
icon: 'i-heroicons-arrow-path',
|
||||
sections: makeSections([
|
||||
'orientation', 'quick_policies', 'kpi_strip', 'tabbed_content',
|
||||
'service_actions', 'personal_details', 'documents',
|
||||
]),
|
||||
defaultTab: 'policies',
|
||||
isCustom: false,
|
||||
},
|
||||
{
|
||||
id: 'general_service',
|
||||
role: 'general_service',
|
||||
name: 'General Service',
|
||||
description: 'Balanced default for service representatives.',
|
||||
icon: 'i-heroicons-lifebuoy',
|
||||
sections: makeSections([
|
||||
'orientation', 'kpi_strip', 'quick_policies', 'service_actions',
|
||||
'personal_details', 'tabbed_content', 'documents',
|
||||
]),
|
||||
defaultTab: 'policies',
|
||||
isCustom: false,
|
||||
},
|
||||
{
|
||||
id: 'management',
|
||||
role: 'management',
|
||||
name: 'Management',
|
||||
description: 'KPIs first, everything visible.',
|
||||
icon: 'i-heroicons-chart-bar',
|
||||
sections: makeSections([
|
||||
'kpi_strip', 'orientation', 'service_actions', 'quick_policies',
|
||||
'tabbed_content', 'personal_details', 'documents',
|
||||
]),
|
||||
defaultTab: 'history',
|
||||
isCustom: false,
|
||||
},
|
||||
{
|
||||
id: 'superadmin',
|
||||
role: 'superadmin',
|
||||
name: 'Superadmin',
|
||||
description: 'Everything visible, history focus.',
|
||||
icon: 'i-heroicons-cog-8-tooth',
|
||||
sections: makeSections([
|
||||
'kpi_strip', 'orientation', 'service_actions', 'quick_policies',
|
||||
'tabbed_content', 'personal_details', 'documents',
|
||||
]),
|
||||
defaultTab: 'history',
|
||||
isCustom: false,
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
const LAYOUTS_KEY = 'policy-ui-profile-layouts-v1'
|
||||
const ACTIVE_KEY = 'policy-ui-active-profile-layout-v1'
|
||||
|
||||
/* ── Composable ── */
|
||||
|
||||
export function useProfileLayouts() {
|
||||
const layouts = useLocalStorageRef<ProfileLayout[]>(LAYOUTS_KEY, defaultLayouts)
|
||||
|
||||
const activeLayoutId = useLocalStorageRef<{ id: string }>(ACTIVE_KEY, () => ({ id: 'general_service' }))
|
||||
|
||||
const activeLayout = computed<ProfileLayout>(() => {
|
||||
const found = layouts.value.find(l => l.id === activeLayoutId.value.id)
|
||||
return found ?? layouts.value[0] ?? defaultLayouts()[3] // fallback to general_service
|
||||
})
|
||||
|
||||
const sortedSections = computed<ProfileSection[]>(() =>
|
||||
[...activeLayout.value.sections]
|
||||
.filter(s => s.visible)
|
||||
.sort((a, b) => a.order - b.order)
|
||||
)
|
||||
|
||||
function setActiveLayout(id: string) {
|
||||
activeLayoutId.value = { id }
|
||||
}
|
||||
|
||||
function addCustomLayout(layout: Omit<ProfileLayout, 'isCustom'>) {
|
||||
layouts.value = [
|
||||
...layouts.value,
|
||||
{ ...layout, isCustom: true },
|
||||
]
|
||||
}
|
||||
|
||||
function updateLayout(id: string, partial: Partial<ProfileLayout>) {
|
||||
layouts.value = layouts.value.map(l =>
|
||||
l.id === id ? { ...l, ...partial } : l
|
||||
)
|
||||
}
|
||||
|
||||
function removeCustomLayout(id: string) {
|
||||
const target = layouts.value.find(l => l.id === id)
|
||||
if (!target || !target.isCustom) return
|
||||
layouts.value = layouts.value.filter(l => l.id !== id)
|
||||
if (activeLayoutId.value.id === id) {
|
||||
activeLayoutId.value = { id: 'general_service' }
|
||||
}
|
||||
}
|
||||
|
||||
function resetToDefaults() {
|
||||
layouts.value = defaultLayouts()
|
||||
activeLayoutId.value = { id: 'general_service' }
|
||||
}
|
||||
|
||||
return {
|
||||
layouts,
|
||||
activeLayoutId: computed(() => activeLayoutId.value.id),
|
||||
activeLayout,
|
||||
sortedSections,
|
||||
setActiveLayout,
|
||||
addCustomLayout,
|
||||
updateLayout,
|
||||
removeCustomLayout,
|
||||
resetToDefaults,
|
||||
}
|
||||
}
|
||||
@@ -1,26 +0,0 @@
|
||||
import {
|
||||
emptyProviderContacts,
|
||||
PROVIDER_EMAIL_ROLE_LABEL,
|
||||
PROVIDER_EMAIL_ROLE_ORDER,
|
||||
type ProviderContactEmails,
|
||||
type ProviderEmailRole
|
||||
} from '~/types/provider-contacts'
|
||||
import { useLocalStorageRef } from '~/utils/useLocalStorageRef'
|
||||
|
||||
function storageKey(providerId: string) {
|
||||
return `policy-ui-provider-contacts-v1-${providerId}`
|
||||
}
|
||||
|
||||
export function useProviderContactEmails(providerId: string) {
|
||||
const emails = useLocalStorageRef(storageKey(providerId), emptyProviderContacts)
|
||||
|
||||
function label(r: ProviderEmailRole) {
|
||||
return PROVIDER_EMAIL_ROLE_LABEL[r]
|
||||
}
|
||||
|
||||
return {
|
||||
emails,
|
||||
roles: PROVIDER_EMAIL_ROLE_ORDER,
|
||||
label
|
||||
}
|
||||
}
|
||||
@@ -1,46 +0,0 @@
|
||||
/**
|
||||
* Quick lead capture list — persisted in localStorage.
|
||||
* Used by the Quick Lead form and dashboard widget.
|
||||
*/
|
||||
import { useLocalStorageRef } from '~/utils/useLocalStorageRef'
|
||||
|
||||
export interface QuickLead {
|
||||
id: string
|
||||
name: string
|
||||
phone: string
|
||||
email: string
|
||||
product: string
|
||||
source: string
|
||||
priority: 'normal' | 'high' | 'urgent'
|
||||
note: string
|
||||
agent: string
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
const KEY = 'policy-ui-quick-leads-v1'
|
||||
|
||||
export function useQuickLeads() {
|
||||
const leads = useLocalStorageRef<QuickLead[]>(KEY, () => [])
|
||||
|
||||
function addLead(entry: Omit<QuickLead, 'id' | 'createdAt'>) {
|
||||
const lead: QuickLead = {
|
||||
id: crypto.randomUUID?.() ?? String(Date.now()),
|
||||
createdAt: new Date().toISOString(),
|
||||
...entry,
|
||||
}
|
||||
leads.value = [lead, ...leads.value]
|
||||
return lead
|
||||
}
|
||||
|
||||
function removeLead(id: string) {
|
||||
leads.value = leads.value.filter((l) => l.id !== id)
|
||||
}
|
||||
|
||||
/** Leads from the last N days */
|
||||
function recentLeads(days: number) {
|
||||
const cutoff = Date.now() - days * 86_400_000
|
||||
return leads.value.filter((l) => new Date(l.createdAt).getTime() >= cutoff)
|
||||
}
|
||||
|
||||
return { leads, addLead, removeLead, recentLeads }
|
||||
}
|
||||
@@ -1,37 +0,0 @@
|
||||
const STORAGE_KEY = 'policy-ui.quote-request-email.v1'
|
||||
|
||||
/**
|
||||
* When false, quote flows record the intent locally but do not describe outbound provider emails
|
||||
* (use when rates come from in-app tables, AI, or carrier APIs instead).
|
||||
*/
|
||||
export function useQuoteRequestEmailEnabled() {
|
||||
const quoteRequestEmailEnabled = ref(true)
|
||||
|
||||
function read() {
|
||||
if (!import.meta.client) return
|
||||
try {
|
||||
const v = localStorage.getItem(STORAGE_KEY)
|
||||
if (v === '0') quoteRequestEmailEnabled.value = false
|
||||
else if (v === '1') quoteRequestEmailEnabled.value = true
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
|
||||
function setQuoteRequestEmailEnabled(v: boolean) {
|
||||
quoteRequestEmailEnabled.value = v
|
||||
if (!import.meta.client) return
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, v ? '1' : '0')
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => read())
|
||||
|
||||
return {
|
||||
quoteRequestEmailEnabled,
|
||||
setQuoteRequestEmailEnabled
|
||||
}
|
||||
}
|
||||
@@ -1,77 +0,0 @@
|
||||
import type { QuoteComparativeView } from '~/types/quote-view-model'
|
||||
import { useLocalStorageRef } from '~/utils/useLocalStorageRef'
|
||||
|
||||
const KEY = 'policy-ui-quote-session-v1'
|
||||
|
||||
function defaultView(): QuoteComparativeView {
|
||||
return {
|
||||
title: 'ANÁLISIS COMPARATIVO · VIDA UNIVERSAL',
|
||||
subtitle: 'Protección & Ahorro',
|
||||
tagline:
|
||||
'Comparativa de aseguradoras — valores garantizados y proyectados. Prima mensual fija de referencia.',
|
||||
quoteDateIso: new Date().toISOString().slice(0, 10),
|
||||
validDays: 30,
|
||||
client: {
|
||||
name: 'María Claudia Piña Ríos',
|
||||
ageYears: 30,
|
||||
gender: 'Femenino',
|
||||
smoker: false,
|
||||
riskClass: 'Estándar',
|
||||
occupation: 'Administrativo'
|
||||
},
|
||||
request: {
|
||||
sumAssuredUsd: 100_000,
|
||||
monthlyPremiumUsd: 75,
|
||||
annualPremiumUsd: 900,
|
||||
benefitTypeLabel: 'Opción B — Creciente',
|
||||
additionalCoverageLabel: 'No contratadas',
|
||||
initialDepositLabel: 'No aplica'
|
||||
},
|
||||
carriers: [
|
||||
{
|
||||
carrierName: 'ASSA COMPAÑÍA DE SEGUROS, S.A.',
|
||||
productName: 'ASSA Universal II',
|
||||
ratesLine: 'T. garantizada 3.5% · T. corriente 4.0%',
|
||||
sumAssuredUsd: 100_000,
|
||||
footnote: 'Vigente hasta edad 91',
|
||||
cells: [
|
||||
{ yearLabel: 'Año 10', ageLabel: '40', guaranteed: 8200, projected: 12400 },
|
||||
{ yearLabel: 'Año 20', ageLabel: '50', guaranteed: 15200, projected: 24100 },
|
||||
{ yearLabel: 'Año 30', ageLabel: '60', guaranteed: 21000, projected: 38900 },
|
||||
{ yearLabel: 'Edad 65', ageLabel: '65', guaranteed: 24500, projected: 36859 }
|
||||
],
|
||||
highlightProjectedUsd: 36859,
|
||||
highlightNote: 'Valor proyectado a la edad de referencia'
|
||||
},
|
||||
{
|
||||
carrierName: 'ASSA COMPAÑÍA DE SEGUROS, S.A.',
|
||||
productName: 'ASSA Vida Segura',
|
||||
ratesLine: 'T. garantizada 4.0% · T. corriente 4.0%',
|
||||
sumAssuredUsd: 100_000,
|
||||
footnote: 'Vence a los 70 años',
|
||||
cells: [
|
||||
{ yearLabel: 'Año 10', ageLabel: '40', guaranteed: 7800, projected: 11800 },
|
||||
{ yearLabel: 'Año 20', ageLabel: '50', guaranteed: 14100, projected: 22800 },
|
||||
{ yearLabel: 'Año 30', ageLabel: '60', guaranteed: 19800, projected: 35200 },
|
||||
{ yearLabel: 'Edad 65', ageLabel: '65', guaranteed: 22100, projected: 33100 }
|
||||
],
|
||||
highlightProjectedUsd: 33100,
|
||||
highlightNote: 'Revisar vigencia del producto'
|
||||
}
|
||||
],
|
||||
accumulatedPremiumsUsd: [9000, 18_000, 27_000, 31_500],
|
||||
advisorColumns: [
|
||||
'Retorno garantizado a los 65: comparar valores acumulados vs primas pagadas.',
|
||||
'Protección de largo plazo: priorizar vigencia del seguro hasta edad avanzada.',
|
||||
'Coberturas opcionales (cáncer, cardiovascular, etc.) no incluidas en esta cotización.'
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
export function useQuoteSession() {
|
||||
const view = useLocalStorageRef(KEY, defaultView)
|
||||
function reset() {
|
||||
view.value = defaultView()
|
||||
}
|
||||
return { view, reset, defaultView }
|
||||
}
|
||||
@@ -1,67 +0,0 @@
|
||||
/**
|
||||
* Referral channel registry — persisted in localStorage.
|
||||
* Used across the app: quick leads, customer registration, reporting.
|
||||
*/
|
||||
import { useLocalStorageRef } from '~/utils/useLocalStorageRef'
|
||||
|
||||
export interface ReferralChannel {
|
||||
id: string
|
||||
name: string
|
||||
type: 'person' | 'company' | 'digital' | 'event' | 'other'
|
||||
contactName: string
|
||||
contactPhone: string
|
||||
contactEmail: string
|
||||
note: string
|
||||
active: boolean
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
const KEY = 'policy-ui-referral-channels-v1'
|
||||
|
||||
const SEED_CHANNELS: ReferralChannel[] = [
|
||||
{ id: 'ref-001', name: 'Roberto Jiménez', type: 'person', contactName: 'Roberto Jiménez', contactPhone: '+506 8834-2291', contactEmail: 'rjimenez@email.com', note: 'Long-time VIP client — strong auto & life referrals', active: true, createdAt: '2024-06-10T10:00:00Z' },
|
||||
{ id: 'ref-002', name: 'Constructora Delta', type: 'company', contactName: 'Ing. Carlos Mora', contactPhone: '+506 2245-8800', contactEmail: 'cmora@delta.cr', note: 'Construction company — fleet and liability leads', active: true, createdAt: '2024-08-15T10:00:00Z' },
|
||||
{ id: 'ref-003', name: 'Instagram Ads', type: 'digital', contactName: '', contactPhone: '', contactEmail: '', note: 'Paid social campaigns — auto & health focus', active: true, createdAt: '2025-01-10T10:00:00Z' },
|
||||
{ id: 'ref-004', name: 'Google Ads', type: 'digital', contactName: '', contactPhone: '', contactEmail: '', note: 'Search campaigns — high intent leads', active: true, createdAt: '2025-01-10T10:00:00Z' },
|
||||
{ id: 'ref-005', name: 'Expo Comercio 2025', type: 'event', contactName: 'Comité Organizador', contactPhone: '+506 2222-0000', contactEmail: 'info@expocomercio.cr', note: 'Annual trade expo — collected 40+ contacts', active: false, createdAt: '2025-03-20T10:00:00Z' },
|
||||
{ id: 'ref-006', name: 'Cámara de Comercio', type: 'company', contactName: 'Patricia Arias', contactPhone: '+506 2233-5500', contactEmail: 'parias@camara.cr', note: 'Chamber of commerce partnership — corporate referrals', active: true, createdAt: '2024-11-05T10:00:00Z' },
|
||||
{ id: 'ref-007', name: 'Walk-in / Oficina', type: 'other', contactName: '', contactPhone: '', contactEmail: '', note: 'Foot traffic to main office', active: true, createdAt: '2024-01-01T10:00:00Z' },
|
||||
]
|
||||
|
||||
export function useReferralChannels() {
|
||||
const channels = useLocalStorageRef<ReferralChannel[]>(KEY, () => [])
|
||||
|
||||
// Seed on first use
|
||||
if (import.meta.client && channels.value.length === 0) {
|
||||
channels.value = [...SEED_CHANNELS]
|
||||
}
|
||||
|
||||
function addChannel(entry: Omit<ReferralChannel, 'id' | 'createdAt'>) {
|
||||
const channel: ReferralChannel = {
|
||||
id: 'ref-' + (crypto.randomUUID?.() ?? String(Date.now())).slice(0, 8),
|
||||
createdAt: new Date().toISOString(),
|
||||
...entry,
|
||||
}
|
||||
channels.value = [channel, ...channels.value]
|
||||
return channel
|
||||
}
|
||||
|
||||
function updateChannel(id: string, updates: Partial<Omit<ReferralChannel, 'id' | 'createdAt'>>) {
|
||||
channels.value = channels.value.map(c =>
|
||||
c.id === id ? { ...c, ...updates } : c
|
||||
)
|
||||
}
|
||||
|
||||
function removeChannel(id: string) {
|
||||
channels.value = channels.value.filter(c => c.id !== id)
|
||||
}
|
||||
|
||||
const activeChannels = computed(() => channels.value.filter(c => c.active))
|
||||
|
||||
/** Flat list for use in dropdowns */
|
||||
const channelOptions = computed(() =>
|
||||
activeChannels.value.map(c => ({ label: c.name, value: c.id }))
|
||||
)
|
||||
|
||||
return { channels, activeChannels, channelOptions, addChannel, updateChannel, removeChannel }
|
||||
}
|
||||
@@ -1,316 +0,0 @@
|
||||
/**
|
||||
* Sales pipeline tracker — per-deal stage + form completion tracking.
|
||||
* Persisted in localStorage. Each deal flows:
|
||||
* Customer → Get Quotes → [waiting] → Present Quotes → [waiting] → Solicitud → Emission
|
||||
*/
|
||||
import { useLocalStorageRef } from '~/utils/useLocalStorageRef'
|
||||
|
||||
export type PipelineStage =
|
||||
| 'customer'
|
||||
| 'get_quotes'
|
||||
| 'waiting_carriers'
|
||||
| 'present_quotes'
|
||||
| 'waiting_client'
|
||||
| 'solicitud'
|
||||
| 'emission'
|
||||
|
||||
export type FormStatus = 'not_started' | 'in_progress' | 'complete'
|
||||
|
||||
export interface DealForm {
|
||||
id: string
|
||||
label: string
|
||||
/** 0–100 */
|
||||
completionPct: number
|
||||
status: FormStatus
|
||||
requiredFields: number
|
||||
completedFields: number
|
||||
}
|
||||
|
||||
export interface SalesDeal {
|
||||
id: string
|
||||
customerId: string
|
||||
customerName: string
|
||||
productLine: string
|
||||
currentStage: PipelineStage
|
||||
/** Stages that have been fully completed */
|
||||
completedStages: PipelineStage[]
|
||||
/** ISO timestamps for when each stage was entered */
|
||||
stageTimestamps: Partial<Record<PipelineStage, string>>
|
||||
/** Forms assigned to this deal, keyed by stage */
|
||||
forms: Partial<Record<PipelineStage, DealForm[]>>
|
||||
/** Optional carrier info */
|
||||
carrier?: string
|
||||
carrierProduct?: string
|
||||
/** Bind token linking compare → solicitud */
|
||||
bindToken?: string
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
const KEY = 'policy-ui-sales-pipeline-v1'
|
||||
|
||||
/** Ordered stages for rendering */
|
||||
export const PIPELINE_STAGES: { id: PipelineStage; label: string; isWaiting: boolean }[] = [
|
||||
{ id: 'customer', label: 'Customer', isWaiting: false },
|
||||
{ id: 'get_quotes', label: 'Get Quotes', isWaiting: false },
|
||||
{ id: 'waiting_carriers', label: 'Awaiting Carriers', isWaiting: true },
|
||||
{ id: 'present_quotes', label: 'Present Quotes', isWaiting: false },
|
||||
{ id: 'waiting_client', label: 'Awaiting Client', isWaiting: true },
|
||||
{ id: 'solicitud', label: 'Solicitud', isWaiting: false },
|
||||
{ id: 'emission', label: 'Emission', isWaiting: false },
|
||||
]
|
||||
|
||||
function stageIndex(stage: PipelineStage): number {
|
||||
return PIPELINE_STAGES.findIndex(s => s.id === stage)
|
||||
}
|
||||
|
||||
/** Default forms per stage (seeded when deal enters a stage) */
|
||||
function defaultFormsForStage(stage: PipelineStage, productLine: string): DealForm[] {
|
||||
switch (stage) {
|
||||
case 'customer':
|
||||
return [
|
||||
{ id: 'client-info', label: 'Client information', completionPct: 0, status: 'not_started', requiredFields: 8, completedFields: 0 },
|
||||
{ id: 'kyc-docs', label: 'KYC / ID documents', completionPct: 0, status: 'not_started', requiredFields: 3, completedFields: 0 },
|
||||
]
|
||||
case 'get_quotes':
|
||||
return [
|
||||
{ id: 'quote-request', label: 'Quote request form', completionPct: 0, status: 'not_started', requiredFields: 12, completedFields: 0 },
|
||||
{ id: 'risk-details', label: `${productLine} risk details`, completionPct: 0, status: 'not_started', requiredFields: 10, completedFields: 0 },
|
||||
]
|
||||
case 'solicitud':
|
||||
return [
|
||||
{ id: 'solicitud-form', label: 'Solicitud de seguro', completionPct: 0, status: 'not_started', requiredFields: 18, completedFields: 0 },
|
||||
{ id: 'payment-auth', label: 'Payment authorization', completionPct: 0, status: 'not_started', requiredFields: 5, completedFields: 0 },
|
||||
{ id: 'beneficiaries', label: 'Beneficiary designation', completionPct: 0, status: 'not_started', requiredFields: 4, completedFields: 0 },
|
||||
]
|
||||
case 'emission':
|
||||
return [
|
||||
{ id: 'policy-review', label: 'Policy review checklist', completionPct: 0, status: 'not_started', requiredFields: 6, completedFields: 0 },
|
||||
]
|
||||
default:
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
/** Seed demo deals */
|
||||
const SEED_DEALS: SalesDeal[] = [
|
||||
{
|
||||
id: 'deal-001',
|
||||
customerId: 'cust-001',
|
||||
customerName: 'María Elena Pérez Solano',
|
||||
productLine: 'Auto',
|
||||
currentStage: 'waiting_carriers',
|
||||
completedStages: ['customer', 'get_quotes'],
|
||||
stageTimestamps: {
|
||||
customer: '2026-04-02T09:00:00Z',
|
||||
get_quotes: '2026-04-02T09:30:00Z',
|
||||
waiting_carriers: '2026-04-02T10:00:00Z',
|
||||
},
|
||||
forms: {
|
||||
customer: [
|
||||
{ id: 'client-info', label: 'Client information', completionPct: 100, status: 'complete', requiredFields: 8, completedFields: 8 },
|
||||
{ id: 'kyc-docs', label: 'KYC / ID documents', completionPct: 100, status: 'complete', requiredFields: 3, completedFields: 3 },
|
||||
],
|
||||
get_quotes: [
|
||||
{ id: 'quote-request', label: 'Quote request form', completionPct: 100, status: 'complete', requiredFields: 12, completedFields: 12 },
|
||||
{ id: 'risk-details', label: 'Auto risk details', completionPct: 100, status: 'complete', requiredFields: 10, completedFields: 10 },
|
||||
],
|
||||
},
|
||||
createdAt: '2026-04-02T09:00:00Z',
|
||||
updatedAt: '2026-04-02T10:00:00Z',
|
||||
},
|
||||
{
|
||||
id: 'deal-002',
|
||||
customerId: 'cust-002',
|
||||
customerName: 'Roberto Jiménez Mora',
|
||||
productLine: 'Life',
|
||||
currentStage: 'solicitud',
|
||||
completedStages: ['customer', 'get_quotes', 'waiting_carriers', 'present_quotes', 'waiting_client'],
|
||||
stageTimestamps: {
|
||||
customer: '2026-03-28T11:00:00Z',
|
||||
get_quotes: '2026-03-28T11:30:00Z',
|
||||
waiting_carriers: '2026-03-28T12:00:00Z',
|
||||
present_quotes: '2026-04-01T14:00:00Z',
|
||||
waiting_client: '2026-04-01T15:00:00Z',
|
||||
solicitud: '2026-04-03T09:00:00Z',
|
||||
},
|
||||
forms: {
|
||||
customer: [
|
||||
{ id: 'client-info', label: 'Client information', completionPct: 100, status: 'complete', requiredFields: 8, completedFields: 8 },
|
||||
{ id: 'kyc-docs', label: 'KYC / ID documents', completionPct: 100, status: 'complete', requiredFields: 3, completedFields: 3 },
|
||||
],
|
||||
get_quotes: [
|
||||
{ id: 'quote-request', label: 'Quote request form', completionPct: 100, status: 'complete', requiredFields: 12, completedFields: 12 },
|
||||
{ id: 'risk-details', label: 'Life risk details', completionPct: 100, status: 'complete', requiredFields: 10, completedFields: 10 },
|
||||
],
|
||||
solicitud: [
|
||||
{ id: 'solicitud-form', label: 'Solicitud de seguro', completionPct: 72, status: 'in_progress', requiredFields: 18, completedFields: 13 },
|
||||
{ id: 'payment-auth', label: 'Payment authorization', completionPct: 40, status: 'in_progress', requiredFields: 5, completedFields: 2 },
|
||||
{ id: 'beneficiaries', label: 'Beneficiary designation', completionPct: 0, status: 'not_started', requiredFields: 4, completedFields: 0 },
|
||||
],
|
||||
},
|
||||
carrier: 'ASSA',
|
||||
carrierProduct: 'Universal II',
|
||||
bindToken: 'bind-abc123',
|
||||
createdAt: '2026-03-28T11:00:00Z',
|
||||
updatedAt: '2026-04-03T09:00:00Z',
|
||||
},
|
||||
{
|
||||
id: 'deal-003',
|
||||
customerId: 'cust-005',
|
||||
customerName: 'Sofía Rojas Delgado',
|
||||
productLine: 'Auto',
|
||||
currentStage: 'get_quotes',
|
||||
completedStages: ['customer'],
|
||||
stageTimestamps: {
|
||||
customer: '2026-04-05T08:00:00Z',
|
||||
get_quotes: '2026-04-05T08:15:00Z',
|
||||
},
|
||||
forms: {
|
||||
customer: [
|
||||
{ id: 'client-info', label: 'Client information', completionPct: 100, status: 'complete', requiredFields: 8, completedFields: 8 },
|
||||
{ id: 'kyc-docs', label: 'KYC / ID documents', completionPct: 67, status: 'in_progress', requiredFields: 3, completedFields: 2 },
|
||||
],
|
||||
get_quotes: [
|
||||
{ id: 'quote-request', label: 'Quote request form', completionPct: 50, status: 'in_progress', requiredFields: 12, completedFields: 6 },
|
||||
{ id: 'risk-details', label: 'Auto risk details', completionPct: 0, status: 'not_started', requiredFields: 10, completedFields: 0 },
|
||||
],
|
||||
},
|
||||
createdAt: '2026-04-05T08:00:00Z',
|
||||
updatedAt: '2026-04-05T08:15:00Z',
|
||||
},
|
||||
]
|
||||
|
||||
export function useSalesPipeline() {
|
||||
const deals = useLocalStorageRef<SalesDeal[]>(KEY, () => [])
|
||||
|
||||
// Seed on first use
|
||||
if (import.meta.client && deals.value.length === 0) {
|
||||
deals.value = [...SEED_DEALS]
|
||||
}
|
||||
|
||||
/** Get a deal by ID */
|
||||
function getDeal(dealId: string) {
|
||||
return deals.value.find(d => d.id === dealId)
|
||||
}
|
||||
|
||||
/** Get deals for a specific customer */
|
||||
function getDealsForCustomer(customerId: string) {
|
||||
return deals.value.filter(d => d.customerId === customerId)
|
||||
}
|
||||
|
||||
/** Get the active (most recent non-emission) deal for a customer */
|
||||
function getActiveDeal(customerId: string) {
|
||||
return deals.value
|
||||
.filter(d => d.customerId === customerId && d.currentStage !== 'emission')
|
||||
.sort((a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime())[0]
|
||||
}
|
||||
|
||||
/** Create a new deal */
|
||||
function createDeal(customerId: string, customerName: string, productLine: string): SalesDeal {
|
||||
const now = new Date().toISOString()
|
||||
const deal: SalesDeal = {
|
||||
id: 'deal-' + (crypto.randomUUID?.() ?? String(Date.now())).slice(0, 8),
|
||||
customerId,
|
||||
customerName,
|
||||
productLine,
|
||||
currentStage: 'customer',
|
||||
completedStages: [],
|
||||
stageTimestamps: { customer: now },
|
||||
forms: { customer: defaultFormsForStage('customer', productLine) },
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
}
|
||||
deals.value = [deal, ...deals.value]
|
||||
return deal
|
||||
}
|
||||
|
||||
/** Advance deal to the next stage */
|
||||
function advanceStage(dealId: string) {
|
||||
const deal = deals.value.find(d => d.id === dealId)
|
||||
if (!deal) return
|
||||
|
||||
const currentIdx = stageIndex(deal.currentStage)
|
||||
const nextStage = PIPELINE_STAGES[currentIdx + 1]
|
||||
if (!nextStage) return
|
||||
|
||||
const now = new Date().toISOString()
|
||||
deal.completedStages = [...new Set([...deal.completedStages, deal.currentStage])]
|
||||
deal.currentStage = nextStage.id
|
||||
deal.stageTimestamps = { ...deal.stageTimestamps, [nextStage.id]: now }
|
||||
deal.updatedAt = now
|
||||
|
||||
// Seed forms for the new stage if not already present
|
||||
if (!deal.forms[nextStage.id]) {
|
||||
deal.forms = { ...deal.forms, [nextStage.id]: defaultFormsForStage(nextStage.id, deal.productLine) }
|
||||
}
|
||||
|
||||
// Trigger reactivity
|
||||
deals.value = [...deals.value]
|
||||
}
|
||||
|
||||
/** Set deal to a specific stage (e.g., when quotes arrive) */
|
||||
function setStage(dealId: string, stage: PipelineStage) {
|
||||
const deal = deals.value.find(d => d.id === dealId)
|
||||
if (!deal) return
|
||||
|
||||
const now = new Date().toISOString()
|
||||
// Mark all stages before the target as completed
|
||||
const targetIdx = stageIndex(stage)
|
||||
const completed = PIPELINE_STAGES.slice(0, targetIdx).map(s => s.id)
|
||||
deal.completedStages = [...new Set([...deal.completedStages, ...completed])]
|
||||
deal.currentStage = stage
|
||||
deal.stageTimestamps = { ...deal.stageTimestamps, [stage]: now }
|
||||
deal.updatedAt = now
|
||||
|
||||
if (!deal.forms[stage]) {
|
||||
deal.forms = { ...deal.forms, [stage]: defaultFormsForStage(stage, deal.productLine) }
|
||||
}
|
||||
|
||||
deals.value = [...deals.value]
|
||||
}
|
||||
|
||||
/** Update form completion within a deal */
|
||||
function updateFormProgress(dealId: string, stage: PipelineStage, formId: string, completedFields: number) {
|
||||
const deal = deals.value.find(d => d.id === dealId)
|
||||
if (!deal) return
|
||||
const stageForms = deal.forms[stage]
|
||||
if (!stageForms) return
|
||||
const form = stageForms.find(f => f.id === formId)
|
||||
if (!form) return
|
||||
|
||||
form.completedFields = Math.min(completedFields, form.requiredFields)
|
||||
form.completionPct = form.requiredFields > 0 ? Math.round((form.completedFields / form.requiredFields) * 100) : 100
|
||||
form.status = form.completionPct === 0 ? 'not_started' : form.completionPct === 100 ? 'complete' : 'in_progress'
|
||||
deal.updatedAt = new Date().toISOString()
|
||||
|
||||
deals.value = [...deals.value]
|
||||
}
|
||||
|
||||
/** Remove a deal */
|
||||
function removeDeal(dealId: string) {
|
||||
deals.value = deals.value.filter(d => d.id !== dealId)
|
||||
}
|
||||
|
||||
/** Computed: stage completion percentage for a deal's current stage forms */
|
||||
function stageFormProgress(deal: SalesDeal, stage: PipelineStage): number {
|
||||
const forms = deal.forms[stage]
|
||||
if (!forms || forms.length === 0) return 0
|
||||
const totalRequired = forms.reduce((s, f) => s + f.requiredFields, 0)
|
||||
const totalCompleted = forms.reduce((s, f) => s + f.completedFields, 0)
|
||||
return totalRequired > 0 ? Math.round((totalCompleted / totalRequired) * 100) : 100
|
||||
}
|
||||
|
||||
return {
|
||||
deals,
|
||||
getDeal,
|
||||
getDealsForCustomer,
|
||||
getActiveDeal,
|
||||
createDeal,
|
||||
advanceStage,
|
||||
setStage,
|
||||
updateFormProgress,
|
||||
removeDeal,
|
||||
stageFormProgress,
|
||||
}
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
import { useLocalStorageRef } from '~/utils/useLocalStorageRef'
|
||||
import type { Ref } from 'vue'
|
||||
|
||||
interface SidebarFeatures {
|
||||
showWorkstations: boolean
|
||||
showAiTools: boolean
|
||||
showLeadsHub: boolean
|
||||
}
|
||||
|
||||
const KEY = 'policy-ui-sidebar-features-v1'
|
||||
|
||||
let _shared: Ref<SidebarFeatures> | null = null
|
||||
|
||||
export function useSidebarFeatures() {
|
||||
if (!_shared) {
|
||||
_shared = useLocalStorageRef<SidebarFeatures>(KEY, () => ({
|
||||
showWorkstations: false,
|
||||
showAiTools: false,
|
||||
showLeadsHub: true,
|
||||
}))
|
||||
}
|
||||
return _shared
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
/**
|
||||
* Tenant-level admin (company logo, legal name, etc.).
|
||||
* Wire to real roles/claims when auth exists. Until then:
|
||||
* - localStorage `policy-ui.superadmin` = `1` | `0`
|
||||
* - in dev, defaults to true when the key is unset so the org screen is reachable
|
||||
*/
|
||||
export function useSuperAdmin() {
|
||||
const isSuperAdmin = computed(() => {
|
||||
if (!import.meta.client) return false
|
||||
try {
|
||||
const v = localStorage.getItem('policy-ui.superadmin')
|
||||
if (v === '1') return true
|
||||
if (v === '0') return false
|
||||
return import.meta.dev
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
})
|
||||
|
||||
return { isSuperAdmin }
|
||||
}
|
||||
@@ -1,184 +0,0 @@
|
||||
/**
|
||||
* Support Tickets — composable for queue state, filtering, CRUD, and mock routing.
|
||||
* Persisted in localStorage via useLocalStorageRef.
|
||||
*/
|
||||
import { useLocalStorageRef } from '~/utils/useLocalStorageRef'
|
||||
import {
|
||||
type SupportTicket,
|
||||
type SupportTicketDetail,
|
||||
type TicketMessage,
|
||||
type TicketStatus,
|
||||
type SupportChannel,
|
||||
type RoutingTier,
|
||||
type RoutedQueue,
|
||||
type RoutingRule,
|
||||
MOCK_SUPPORT_TICKETS,
|
||||
MOCK_TICKET_DETAILS,
|
||||
MOCK_ROUTING_RULES,
|
||||
} from '~/data/mock-support'
|
||||
|
||||
interface SupportState {
|
||||
tickets: SupportTicket[]
|
||||
details: Record<string, SupportTicketDetail>
|
||||
routingRules: RoutingRule[]
|
||||
}
|
||||
|
||||
function buildDefaults(): SupportState {
|
||||
return {
|
||||
tickets: [...MOCK_SUPPORT_TICKETS],
|
||||
details: { ...MOCK_TICKET_DETAILS },
|
||||
routingRules: [...MOCK_ROUTING_RULES],
|
||||
}
|
||||
}
|
||||
|
||||
export function useSupportTickets() {
|
||||
const state = useLocalStorageRef<SupportState>('policy-ui-support-tickets-v1', buildDefaults)
|
||||
|
||||
// ── Computed: filtered lists ──
|
||||
const openTickets = computed(() => state.value.tickets.filter(t => t.status === 'open'))
|
||||
const unresolvedCount = computed(() => state.value.tickets.filter(t => t.status !== 'resolved').length)
|
||||
const breachedCount = computed(() => state.value.tickets.filter(t => t.slaPercent >= 100).length)
|
||||
const unassignedCount = computed(() => state.value.tickets.filter(t => !t.assignedTo).length)
|
||||
const openPoolCount = computed(() => state.value.tickets.filter(t => t.routedQueue === 'open_pool').length)
|
||||
const inProgressCount = computed(() => state.value.tickets.filter(t => t.status === 'in_progress').length)
|
||||
|
||||
const kpis = computed(() => {
|
||||
const all = state.value.tickets
|
||||
const unresolved = all.filter(t => t.status !== 'resolved')
|
||||
const avgDaysOpen = unresolved.length
|
||||
? Math.round(unresolved.reduce((sum, t) => sum + t.daysOpen, 0) / unresolved.length)
|
||||
: 0
|
||||
return {
|
||||
total: all.length,
|
||||
open: openTickets.value.length,
|
||||
inProgress: inProgressCount.value,
|
||||
breached: breachedCount.value,
|
||||
avgDaysOpen,
|
||||
unassigned: unassignedCount.value,
|
||||
openPool: openPoolCount.value,
|
||||
}
|
||||
})
|
||||
|
||||
// ── CRUD ──
|
||||
function updateStatus(ticketId: string, status: TicketStatus) {
|
||||
const ticket = state.value.tickets.find(t => t.id === ticketId)
|
||||
if (ticket) {
|
||||
ticket.status = status
|
||||
ticket.updatedAt = new Date().toISOString().slice(0, 10)
|
||||
}
|
||||
const detail = state.value.details[ticketId]
|
||||
if (detail) {
|
||||
detail.status = status
|
||||
detail.updatedAt = new Date().toISOString().slice(0, 10)
|
||||
}
|
||||
}
|
||||
|
||||
function assignTicket(ticketId: string, agent: string) {
|
||||
const ticket = state.value.tickets.find(t => t.id === ticketId)
|
||||
if (ticket) {
|
||||
ticket.assignedTo = agent
|
||||
ticket.updatedAt = new Date().toISOString().slice(0, 10)
|
||||
}
|
||||
const detail = state.value.details[ticketId]
|
||||
if (detail) {
|
||||
detail.assignedTo = agent
|
||||
detail.updatedAt = new Date().toISOString().slice(0, 10)
|
||||
detail.messages.push({
|
||||
id: `msg-${Date.now()}`,
|
||||
type: 'system',
|
||||
direction: 'internal',
|
||||
from: 'Sistema',
|
||||
to: null,
|
||||
subject: null,
|
||||
body: `Ticket asignado a ${agent}`,
|
||||
timestamp: new Date().toISOString(),
|
||||
aiDigest: null,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
function addMessage(ticketId: string, message: Omit<TicketMessage, 'id' | 'timestamp'>) {
|
||||
const detail = state.value.details[ticketId]
|
||||
if (!detail) return
|
||||
const msg: TicketMessage = {
|
||||
...message,
|
||||
id: `msg-${Date.now()}`,
|
||||
timestamp: new Date().toISOString(),
|
||||
}
|
||||
detail.messages.push(msg)
|
||||
detail.messageCount = detail.messages.length
|
||||
detail.updatedAt = new Date().toISOString().slice(0, 10)
|
||||
detail.lastMessagePreview = msg.body.slice(0, 80)
|
||||
|
||||
// sync summary ticket
|
||||
const ticket = state.value.tickets.find(t => t.id === ticketId)
|
||||
if (ticket) {
|
||||
ticket.messageCount = detail.messageCount
|
||||
ticket.updatedAt = detail.updatedAt
|
||||
ticket.lastMessagePreview = detail.lastMessagePreview
|
||||
}
|
||||
}
|
||||
|
||||
// ── Mock Routing ──
|
||||
const routingKeywords: Record<RoutedQueue, string[]> = {
|
||||
collections: ['pago', 'factura', 'cobro', 'recibo', 'transferencia', 'mora'],
|
||||
claims: ['siniestro', 'accidente', 'robo', 'daño', 'choque', 'grúa', 'colisión'],
|
||||
sales: ['cotización', 'seguro nuevo', 'precio', 'cuánto sale', 'cobertura'],
|
||||
renewals: ['renovación', 'vencimiento', 'prórroga', 'vigencia'],
|
||||
operations: ['endoso', 'certificado', 'modificar', 'cambio de beneficiario'],
|
||||
open_pool: [],
|
||||
}
|
||||
|
||||
function simulateRouting(channel: SupportChannel, body: string): { tier: RoutingTier; queue: RoutedQueue; confidence: number } {
|
||||
const lower = body.toLowerCase()
|
||||
|
||||
// Tier 2: keyword matching
|
||||
for (const [queue, keywords] of Object.entries(routingKeywords) as [RoutedQueue, string[]][]) {
|
||||
if (!keywords.length) continue
|
||||
const matched = keywords.filter(kw => lower.includes(kw))
|
||||
if (matched.length > 0) {
|
||||
const confidence = Math.min(0.95, 0.6 + matched.length * 0.1)
|
||||
return { tier: 'tier2_rule', queue, confidence }
|
||||
}
|
||||
}
|
||||
|
||||
// Tier 3: no match → open pool
|
||||
return { tier: 'tier3_open', queue: 'open_pool', confidence: 0.3 }
|
||||
}
|
||||
|
||||
// ── Routing Rules CRUD ──
|
||||
function toggleRule(ruleId: string) {
|
||||
const rule = state.value.routingRules.find(r => r.id === ruleId)
|
||||
if (rule) rule.enabled = !rule.enabled
|
||||
}
|
||||
|
||||
function updateRule(ruleId: string, updates: Partial<RoutingRule>) {
|
||||
const rule = state.value.routingRules.find(r => r.id === ruleId)
|
||||
if (rule) Object.assign(rule, updates)
|
||||
}
|
||||
|
||||
function getDetail(ticketId: string): SupportTicketDetail | undefined {
|
||||
return state.value.details[ticketId]
|
||||
}
|
||||
|
||||
return {
|
||||
state,
|
||||
// computed
|
||||
openTickets,
|
||||
unresolvedCount,
|
||||
breachedCount,
|
||||
unassignedCount,
|
||||
openPoolCount,
|
||||
inProgressCount,
|
||||
kpis,
|
||||
// CRUD
|
||||
updateStatus,
|
||||
assignTicket,
|
||||
addMessage,
|
||||
getDetail,
|
||||
// routing
|
||||
simulateRouting,
|
||||
toggleRule,
|
||||
updateRule,
|
||||
}
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
import type { WelcomeDashboardConfig } from '~/types/welcome-dashboard'
|
||||
|
||||
/**
|
||||
* Home / welcome dashboard content. Reads `app.config` first; later merge runtime config or APIs.
|
||||
* Brokerage company name from Settings → Personalization overrides `productName` when set.
|
||||
*/
|
||||
export function useWelcomeDashboard(): ComputedRef<WelcomeDashboardConfig> {
|
||||
const app = useAppConfig()
|
||||
const { saved: branding } = useBrokerageBranding()
|
||||
|
||||
return computed((): WelcomeDashboardConfig => {
|
||||
const base = (app.welcomeDashboard ?? {}) as Partial<WelcomeDashboardConfig>
|
||||
const fromBranding = branding.value.companyName?.trim()
|
||||
return {
|
||||
greetingName: base.greetingName ?? 'User',
|
||||
productName: fromBranding || base.productName || 'Segur-OS Beta',
|
||||
subtitle: base.subtitle ?? '',
|
||||
dailyTasks: base.dailyTasks ?? [],
|
||||
alerts: base.alerts ?? [],
|
||||
performanceKpis: base.performanceKpis ?? [],
|
||||
ceoKpis: base.ceoKpis ?? [],
|
||||
quickLinks: base.quickLinks ?? []
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -1,70 +0,0 @@
|
||||
/** Auto quoting — option lists aligned with grid filters / production LOB vocabulary (ES labels). */
|
||||
|
||||
export const AUTO_RAMO_LABEL = 'Vehículos'
|
||||
|
||||
/** First option is empty — pair with placeholder “Select one” in UI */
|
||||
export const AUTO_SUB_RAMO_OPTIONS: { label: string; value: string }[] = [
|
||||
{ label: 'Manual', value: 'manual' },
|
||||
{ label: 'Cobertura completa', value: 'cobertura_completa' },
|
||||
{ label: 'Daños a terceros', value: 'danos_terceros' }
|
||||
]
|
||||
|
||||
export const AUTO_CLASE_OPTIONS: { label: string; value: string }[] = [
|
||||
{ label: 'Pickup', value: 'pickup' },
|
||||
{ label: 'Mula', value: 'mula' },
|
||||
{ label: 'Van', value: 'van' },
|
||||
{ label: 'MiniVan', value: 'minivan' },
|
||||
{ label: 'Panel', value: 'panel' },
|
||||
{ label: 'Camión', value: 'camion' },
|
||||
{ label: 'Camioneta', value: 'camioneta' },
|
||||
{ label: 'Sedan', value: 'sedan' },
|
||||
{ label: 'Cabezal', value: 'cabezal' },
|
||||
{ label: 'Bus (0-15)', value: 'bus_0_15' },
|
||||
{ label: 'Bus (16-30)', value: 'bus_16_30' },
|
||||
{ label: 'Bus (+30)', value: 'bus_30_plus' },
|
||||
{ label: 'Liviano', value: 'liviano' },
|
||||
{ label: 'Mediano', value: 'mediano' },
|
||||
{ label: 'Pesado', value: 'pesado' },
|
||||
{ label: 'Moto', value: 'moto' }
|
||||
]
|
||||
|
||||
export const AUTO_USO_OPTIONS: { label: string; value: string }[] = [
|
||||
{ label: 'Particular', value: 'particular' },
|
||||
{ label: 'Comercial', value: 'comercial' }
|
||||
]
|
||||
|
||||
export const AUTO_MARCA_OPTIONS: { label: string; value: string }[] = [
|
||||
{ label: 'Toyota', value: 'toyota' }
|
||||
]
|
||||
|
||||
export const AUTO_MODELO_OPTIONS: { label: string; value: string }[] = [
|
||||
{ label: 'Cellica', value: 'cellica' },
|
||||
{ label: 'Corolla', value: 'corolla' }
|
||||
]
|
||||
|
||||
const currentYear = new Date().getFullYear()
|
||||
|
||||
export const AUTO_YEAR_OPTIONS: { label: string; value: string }[] = [
|
||||
...Array.from({ length: 22 }, (_, i) => {
|
||||
const y = String(currentYear - i)
|
||||
return { label: y, value: y }
|
||||
})
|
||||
]
|
||||
|
||||
/**
|
||||
* Carriers available for solicit — in production, matches providers configured under Settings / Providers
|
||||
* (each has a quoting email on file).
|
||||
*/
|
||||
export const AUTO_QUOTE_CARRIERS: { id: string; name: string; detail: string }[] = [
|
||||
{ id: 'mapfre', name: 'Mapfre', detail: 'Quoting email on file in provider profile' },
|
||||
{ id: 'seguros_aurora', name: 'Seguros Aurora', detail: 'Quoting email on file in provider profile' },
|
||||
{ id: 'continental', name: 'Continental', detail: 'Quoting email on file in provider profile' },
|
||||
{ id: 'internacional', name: 'Internacional', detail: 'Quoting email on file in provider profile' }
|
||||
]
|
||||
|
||||
/** Predetermined plan packages — comparative mode builds side-by-side rows from these */
|
||||
export const AUTO_COVERAGE_PLANS: { id: string; label: string; hint: string }[] = [
|
||||
{ id: 'cc_full', label: 'Cobertura completa', hint: 'Collision, comprehensive, liability' },
|
||||
{ id: 'dat', label: 'Daños a terceros', hint: 'Third-party liability' },
|
||||
{ id: 'plan_corporate', label: 'Paquete corporativo', hint: 'Fleet-friendly endorsements' }
|
||||
]
|
||||
@@ -1,41 +0,0 @@
|
||||
{
|
||||
"version": 1,
|
||||
"groups": [
|
||||
{
|
||||
"id": "kyc_identity",
|
||||
"title": "Identificación",
|
||||
"description": "Cédula, pasaporte, PEP según SSRP",
|
||||
"fieldKeys": ["full_name", "document_id", "document_expiry", "nationality"]
|
||||
},
|
||||
{
|
||||
"id": "life_risk_health",
|
||||
"title": "Salud y hábitos (vida)",
|
||||
"description": "Declaración de salud, tabaco, deportes de riesgo",
|
||||
"fieldKeys": ["smoking", "height_cm", "weight_kg", "medical_conditions"]
|
||||
},
|
||||
{
|
||||
"id": "auto_vehicle",
|
||||
"title": "Vehículo",
|
||||
"description": "Placa, uso, valor, accesorios",
|
||||
"fieldKeys": ["plate", "vin", "year", "use_type", "declared_value"]
|
||||
},
|
||||
{
|
||||
"id": "health_local_cover",
|
||||
"title": "Salud local · cobertura",
|
||||
"description": "Red, deducible, preexistencias",
|
||||
"fieldKeys": ["network", "deductible_usd", "preexisting_disclosure"]
|
||||
},
|
||||
{
|
||||
"id": "health_intl_cover",
|
||||
"title": "Salud internacional",
|
||||
"description": "Zona de cobertura, repatriación, USA cover",
|
||||
"fieldKeys": ["coverage_zone", "usa_cover", "repatriation"]
|
||||
},
|
||||
{
|
||||
"id": "home_property",
|
||||
"title": "Propiedad (hogar)",
|
||||
"description": "Ubicación, construcción, suma contenido",
|
||||
"fieldKeys": ["address", "construction_type", "contents_sum"]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,116 +0,0 @@
|
||||
{
|
||||
"version": 1,
|
||||
"rows": [
|
||||
{
|
||||
"id": 39,
|
||||
"description": "1031083 GE_ADB_GEN_OPTIMA_DIG 250507",
|
||||
"insurerSlugs": ["optima"],
|
||||
"subRamoKey": "danos_terceros_completa",
|
||||
"subRamoLabel": "Daños a terceros · Cobertura completa",
|
||||
"personKinds": "both",
|
||||
"productLine": "auto_full_coverage",
|
||||
"fileUrl": "/forms/README.txt",
|
||||
"fileLabel": "17569365751031083-GE_ADB_GEN_OPTIMA.pdf",
|
||||
"badge": 39,
|
||||
"kind": "carrier_pdf",
|
||||
"fieldGroupIds": ["kyc_identity", "auto_vehicle"]
|
||||
},
|
||||
{
|
||||
"id": 38,
|
||||
"description": "1031082 GE_ADB_GEN_OPTIMA_DAT",
|
||||
"insurerSlugs": ["optima"],
|
||||
"subRamoKey": "vehiculos_dat",
|
||||
"subRamoLabel": "Vehículos · Daños a terceros (DAT)",
|
||||
"personKinds": "both",
|
||||
"productLine": "auto_dat_liability",
|
||||
"fileUrl": "/forms/README.txt",
|
||||
"fileLabel": "OPTIMA_DAT_solicitud.pdf",
|
||||
"badge": 38,
|
||||
"kind": "carrier_pdf",
|
||||
"fieldGroupIds": ["kyc_identity", "auto_vehicle"]
|
||||
},
|
||||
{
|
||||
"id": 37,
|
||||
"description": "MAPFRE_MUNDIAL_AUTO_FULL",
|
||||
"insurerSlugs": ["mapfre"],
|
||||
"subRamoKey": "danos_terceros_completa",
|
||||
"subRamoLabel": "Daños a terceros · Cobertura completa",
|
||||
"personKinds": "both",
|
||||
"productLine": "auto_full_coverage",
|
||||
"fileUrl": "/forms/README.txt",
|
||||
"fileLabel": "MAPFRE_MUNDIAL_FOREVER_PLUS.pdf",
|
||||
"badge": 37,
|
||||
"kind": "carrier_pdf",
|
||||
"fieldGroupIds": ["kyc_identity", "auto_vehicle"]
|
||||
},
|
||||
{
|
||||
"id": 36,
|
||||
"description": "ASSA_VIDA_UNIVERSAL_PACK",
|
||||
"insurerSlugs": ["assa"],
|
||||
"subRamoKey": "vida_universal",
|
||||
"subRamoLabel": "Vida universal · Protección y ahorro",
|
||||
"personKinds": "natural",
|
||||
"productLine": "life",
|
||||
"fileUrl": "/forms/README.txt",
|
||||
"fileLabel": "ASSA_Universal_II_illustration.pdf",
|
||||
"badge": 36,
|
||||
"kind": "carrier_pdf",
|
||||
"fieldGroupIds": ["kyc_identity", "life_risk_health"]
|
||||
},
|
||||
{
|
||||
"id": 35,
|
||||
"description": "SALUD_LOCAL_KYC",
|
||||
"insurerSlugs": ["mapfre", "assa"],
|
||||
"subRamoKey": "salud_local",
|
||||
"subRamoLabel": "Salud local",
|
||||
"personKinds": "both",
|
||||
"productLine": "health_local",
|
||||
"fileUrl": "/forms/README.txt",
|
||||
"fileLabel": "KYC_salud_local.pdf",
|
||||
"badge": 35,
|
||||
"kind": "carrier_pdf",
|
||||
"fieldGroupIds": ["kyc_identity", "health_local_cover"]
|
||||
},
|
||||
{
|
||||
"id": 34,
|
||||
"description": "SALUD_INTERNACIONAL_KYC",
|
||||
"insurerSlugs": ["palig", "mapfre"],
|
||||
"subRamoKey": "salud_internacional",
|
||||
"subRamoLabel": "Salud internacional / IPMI",
|
||||
"personKinds": "both",
|
||||
"productLine": "health_international",
|
||||
"fileUrl": "/forms/README.txt",
|
||||
"fileLabel": "IPMI_solicitud.pdf",
|
||||
"badge": 34,
|
||||
"kind": "carrier_pdf",
|
||||
"fieldGroupIds": ["kyc_identity", "health_intl_cover"]
|
||||
},
|
||||
{
|
||||
"id": 33,
|
||||
"description": "CEDULA_NATURAL",
|
||||
"insurerSlugs": ["optima", "mapfre", "assa", "acerta", "fedpa", "ancon"],
|
||||
"subRamoKey": "any",
|
||||
"subRamoLabel": "Cualquier ramo (identificación)",
|
||||
"personKinds": "natural",
|
||||
"productLine": null,
|
||||
"fileUrl": "/forms/README.txt",
|
||||
"fileLabel": "cedula_ejemplo.png",
|
||||
"kind": "identity",
|
||||
"fieldGroupIds": ["kyc_identity"]
|
||||
},
|
||||
{
|
||||
"id": 32,
|
||||
"description": "ACERTA_VEH_FULL_1012013",
|
||||
"insurerSlugs": ["acerta"],
|
||||
"subRamoKey": "danos_terceros_completa",
|
||||
"subRamoLabel": "Daños a terceros · Cobertura completa",
|
||||
"personKinds": "both",
|
||||
"productLine": "auto_full_coverage",
|
||||
"fileUrl": "/forms/README.txt",
|
||||
"fileLabel": "GE_ATC_GEN_ACERTA_DIG.pdf",
|
||||
"badge": 32,
|
||||
"kind": "carrier_pdf",
|
||||
"fieldGroupIds": ["kyc_identity", "auto_vehicle"]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,53 +0,0 @@
|
||||
/** Health quoting — mock carriers, plans, and reference rate table (age bands). */
|
||||
|
||||
export const HEALTH_QUOTE_CARRIERS: { id: string; name: string; detail: string; hasPublishedRateTable?: boolean }[] = [
|
||||
{
|
||||
id: 'vida_plena',
|
||||
name: 'Vida Plena Salud',
|
||||
detail: 'Quoting email on file · published age-banded table',
|
||||
hasPublishedRateTable: true
|
||||
},
|
||||
{
|
||||
id: 'salud_global',
|
||||
name: 'Salud Global',
|
||||
detail: 'Quoting email on file',
|
||||
hasPublishedRateTable: false
|
||||
},
|
||||
{
|
||||
id: 'integral_med',
|
||||
name: 'Integral Medical',
|
||||
detail: 'Quoting email on file',
|
||||
hasPublishedRateTable: true
|
||||
}
|
||||
]
|
||||
|
||||
export const HEALTH_COVERAGE_PLANS: { id: string; label: string; hint: string }[] = [
|
||||
{ id: 'local_base', label: 'Local · Base', hint: 'In-country network, standard deductible' },
|
||||
{ id: 'local_plus', label: 'Local · Plus', hint: 'Broader network, lower copay' },
|
||||
{ id: 'intl_major', label: 'International · Major medical', hint: 'Evacuation + US/EU coverage tier' }
|
||||
]
|
||||
|
||||
export const HEALTH_COVERAGE_AREA: { label: string; value: string }[] = [
|
||||
{ label: 'Local', value: 'local' },
|
||||
{ label: 'International', value: 'international' }
|
||||
]
|
||||
|
||||
export const HEALTH_NETWORK_TIER: { label: string; value: string }[] = [
|
||||
{ label: 'Preferred / cerrado', value: 'preferred' },
|
||||
{ label: 'Open / amplio', value: 'open' }
|
||||
]
|
||||
|
||||
export const HEALTH_DEDUCTIBLE: { label: string; value: string }[] = [
|
||||
{ label: '$500', value: '500' },
|
||||
{ label: '$1,000', value: '1000' },
|
||||
{ label: '$2,500', value: '2500' }
|
||||
]
|
||||
|
||||
/** Mock age-band premium table (USD/mo) — some carriers publish this instead of email-only quotes */
|
||||
export const HEALTH_AGE_BAND_REFERENCE: { ageBand: string; employee: number; spouse: number; children: number }[] = [
|
||||
{ ageBand: '0–17', employee: 0, spouse: 0, children: 118 },
|
||||
{ ageBand: '18–29', employee: 142, spouse: 198, children: 0 },
|
||||
{ ageBand: '30–44', employee: 186, spouse: 251, children: 0 },
|
||||
{ ageBand: '45–54', employee: 264, spouse: 318, children: 0 },
|
||||
{ ageBand: '55–64', employee: 352, spouse: 401, children: 0 }
|
||||
]
|
||||
@@ -1,45 +0,0 @@
|
||||
/** Life quoting — mock carriers, plans, and option lists. */
|
||||
|
||||
export const LIFE_QUOTE_CARRIERS: { id: string; name: string; detail: string }[] = [
|
||||
{ id: 'vida_plena', name: 'Vida Plena', detail: 'Quoting email on file in provider profile' },
|
||||
{ id: 'seguros_del_pacifico', name: 'Seguros del Pacífico', detail: 'Quoting email on file in provider profile' },
|
||||
{ id: 'continental_life', name: 'Continental Life', detail: 'Quoting email on file in provider profile' }
|
||||
]
|
||||
|
||||
export const LIFE_COVERAGE_PLANS: { id: string; label: string; hint: string }[] = [
|
||||
{ id: 'term_basic', label: 'Term life · Basic', hint: 'Level-premium term, standard coverage' },
|
||||
{ id: 'term_plus', label: 'Term life · Plus', hint: 'Term with accidental-death rider' },
|
||||
{ id: 'whole_life', label: 'Whole life', hint: 'Permanent coverage with cash-value component' },
|
||||
{ id: 'keyman', label: 'Key person', hint: 'Business-owned policy on key employee' }
|
||||
]
|
||||
|
||||
export const LIFE_GENDER_OPTIONS: { label: string; value: string }[] = [
|
||||
{ label: 'Male', value: 'male' },
|
||||
{ label: 'Female', value: 'female' }
|
||||
]
|
||||
|
||||
export const LIFE_COVERAGE_TERM_OPTIONS: { label: string; value: string }[] = [
|
||||
{ label: '10 years', value: '10' },
|
||||
{ label: '15 years', value: '15' },
|
||||
{ label: '20 years', value: '20' },
|
||||
{ label: '30 years', value: '30' },
|
||||
{ label: 'Whole life', value: 'whole' }
|
||||
]
|
||||
|
||||
export const LIFE_COVERAGE_AMOUNT_OPTIONS: { label: string; value: string }[] = [
|
||||
{ label: '$25,000', value: '25000' },
|
||||
{ label: '$50,000', value: '50000' },
|
||||
{ label: '$100,000', value: '100000' },
|
||||
{ label: '$250,000', value: '250000' },
|
||||
{ label: '$500,000', value: '500000' },
|
||||
{ label: '$1,000,000', value: '1000000' }
|
||||
]
|
||||
|
||||
export const LIFE_BENEFICIARY_RELATIONSHIP_OPTIONS: { label: string; value: string }[] = [
|
||||
{ label: 'Spouse', value: 'spouse' },
|
||||
{ label: 'Child', value: 'child' },
|
||||
{ label: 'Parent', value: 'parent' },
|
||||
{ label: 'Sibling', value: 'sibling' },
|
||||
{ label: 'Business entity', value: 'business' },
|
||||
{ label: 'Other', value: 'other' }
|
||||
]
|
||||
@@ -1,363 +0,0 @@
|
||||
// ─── Business Analytics — Types, Labels, Mock Data ───────────────────────────
|
||||
|
||||
export type AnalyticsTimePoint = { m: string; v: number; display: string }
|
||||
export type AnalyticsDomainId = 'production' | 'claims' | 'pipeline' | 'service'
|
||||
export type AnalyticsChartType = 'line' | 'bar' | 'area'
|
||||
|
||||
export interface AnalyticsMetric {
|
||||
id: string
|
||||
domain: AnalyticsDomainId
|
||||
label: string
|
||||
unit: string
|
||||
data12m: AnalyticsTimePoint[]
|
||||
change: string
|
||||
changeTone: 'positive' | 'negative' | 'neutral'
|
||||
defaultChartType: AnalyticsChartType
|
||||
}
|
||||
|
||||
export interface AnalyticsKpiSummary {
|
||||
id: string
|
||||
label: string
|
||||
value: string
|
||||
change: string
|
||||
changeTone: 'positive' | 'negative' | 'neutral'
|
||||
hint: string
|
||||
sparkline: number[]
|
||||
}
|
||||
|
||||
// ─── Domain Labels ───────────────────────────────────────────────────────────
|
||||
|
||||
export const ANALYTICS_DOMAIN_LABELS: Record<AnalyticsDomainId, string> = {
|
||||
production: 'Producción',
|
||||
claims: 'Siniestros',
|
||||
pipeline: 'Pipeline',
|
||||
service: 'Servicio',
|
||||
}
|
||||
|
||||
// ─── Headline KPI Summaries ──────────────────────────────────────────────────
|
||||
|
||||
export const ANALYTICS_KPI_SUMMARIES: AnalyticsKpiSummary[] = [
|
||||
{ id: 'gwp', label: 'GWP Written', value: '$5.41M', change: '+6.2%', changeTone: 'positive', hint: 'Gross written premium YTD', sparkline: [72, 68, 76, 74, 81, 88, 85, 90, 87, 92, 95, 98] },
|
||||
{ id: 'policies', label: 'Active Policies', value: '342', change: '+12', changeTone: 'positive', hint: 'Currently active policies', sparkline: [290, 295, 298, 305, 310, 315, 318, 322, 328, 332, 338, 342] },
|
||||
{ id: 'loss-ratio', label: 'Loss Ratio', value: '58%', change: '-3.1%', changeTone: 'positive', hint: 'Claims paid / earned premium', sparkline: [68, 72, 65, 63, 60, 58, 61, 59, 57, 60, 58, 58] },
|
||||
{ id: 'retention', label: 'Retention Rate', value: '91%', change: '+1.2%', changeTone: 'positive', hint: 'Client renewal rate', sparkline: [86, 87, 88, 87, 89, 90, 89, 90, 91, 90, 91, 91] },
|
||||
{ id: 'open-claims', label: 'Open Claims', value: '7', change: '-2', changeTone: 'positive', hint: 'Currently unresolved claims', sparkline: [12, 11, 10, 9, 11, 10, 8, 9, 8, 7, 8, 7] },
|
||||
{ id: 'pipeline-value', label: 'Pipeline Value', value: '$6.2M', change: '+$820K', changeTone: 'positive', hint: 'Open quoted premium', sparkline: [42, 45, 48, 50, 52, 55, 53, 56, 58, 60, 62, 65] },
|
||||
]
|
||||
|
||||
// ─── Metrics: Production & Revenue ───────────────────────────────────────────
|
||||
|
||||
const productionMetrics: AnalyticsMetric[] = [
|
||||
{
|
||||
id: 'gwp', domain: 'production', label: 'GWP Written', unit: '$',
|
||||
change: '+6.2%', changeTone: 'positive', defaultChartType: 'area',
|
||||
data12m: [
|
||||
{ m: 'May', v: 62, display: '$3.82M' }, { m: 'Jun', v: 65, display: '$4.01M' },
|
||||
{ m: 'Jul', v: 60, display: '$3.70M' }, { m: 'Aug', v: 68, display: '$4.19M' },
|
||||
{ m: 'Sep', v: 71, display: '$4.38M' }, { m: 'Oct', v: 72, display: '$4.52M' },
|
||||
{ m: 'Nov', v: 68, display: '$4.28M' }, { m: 'Dec', v: 76, display: '$4.71M' },
|
||||
{ m: 'Jan', v: 74, display: '$4.61M' }, { m: 'Feb', v: 81, display: '$4.98M' },
|
||||
{ m: 'Mar', v: 88, display: '$5.41M' }, { m: 'Apr', v: 91, display: '$5.58M' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'premium-by-lob', domain: 'production', label: 'Premium by LOB', unit: '$',
|
||||
change: '+4.8%', changeTone: 'positive', defaultChartType: 'bar',
|
||||
data12m: [
|
||||
{ m: 'Auto', v: 85, display: '$1.82M' }, { m: 'Health', v: 72, display: '$1.54M' },
|
||||
{ m: 'Life', v: 48, display: '$1.03M' }, { m: 'Property', v: 35, display: '$750K' },
|
||||
{ m: 'Marine', v: 18, display: '$386K' }, { m: 'Liability', v: 12, display: '$257K' },
|
||||
{ m: 'Surety', v: 8, display: '$171K' }, { m: 'Travel', v: 5, display: '$107K' },
|
||||
{ m: 'Other', v: 3, display: '$64K' }, { m: '', v: 0, display: '' },
|
||||
{ m: '', v: 0, display: '' }, { m: '', v: 0, display: '' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'new-biz-vs-renewal', domain: 'production', label: 'New Biz vs Renewal', unit: '$',
|
||||
change: '+$820K', changeTone: 'positive', defaultChartType: 'bar',
|
||||
data12m: [
|
||||
{ m: 'May', v: 45, display: '$1.8M' }, { m: 'Jun', v: 48, display: '$1.9M' },
|
||||
{ m: 'Jul', v: 42, display: '$1.7M' }, { m: 'Aug', v: 52, display: '$2.1M' },
|
||||
{ m: 'Sep', v: 55, display: '$2.2M' }, { m: 'Oct', v: 52, display: '$2.4M' },
|
||||
{ m: 'Nov', v: 48, display: '$2.2M' }, { m: 'Dec', v: 62, display: '$2.9M' },
|
||||
{ m: 'Jan', v: 58, display: '$2.7M' }, { m: 'Feb', v: 64, display: '$3.0M' },
|
||||
{ m: 'Mar', v: 70, display: '$3.2M' }, { m: 'Apr', v: 73, display: '$3.4M' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'commission-revenue', domain: 'production', label: 'Commission Revenue', unit: '$',
|
||||
change: '+8.4%', changeTone: 'positive', defaultChartType: 'area',
|
||||
data12m: [
|
||||
{ m: 'May', v: 48, display: '$590K' }, { m: 'Jun', v: 50, display: '$615K' },
|
||||
{ m: 'Jul', v: 46, display: '$565K' }, { m: 'Aug', v: 53, display: '$652K' },
|
||||
{ m: 'Sep', v: 55, display: '$680K' }, { m: 'Oct', v: 55, display: '$680K' },
|
||||
{ m: 'Nov', v: 52, display: '$640K' }, { m: 'Dec', v: 60, display: '$740K' },
|
||||
{ m: 'Jan', v: 58, display: '$715K' }, { m: 'Feb', v: 65, display: '$800K' },
|
||||
{ m: 'Mar', v: 72, display: '$886K' }, { m: 'Apr', v: 75, display: '$923K' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'policies-bound', domain: 'production', label: 'Policies Bound', unit: '#',
|
||||
change: '+12', changeTone: 'positive', defaultChartType: 'bar',
|
||||
data12m: [
|
||||
{ m: 'May', v: 28, display: '28' }, { m: 'Jun', v: 30, display: '30' },
|
||||
{ m: 'Jul', v: 25, display: '25' }, { m: 'Aug', v: 33, display: '33' },
|
||||
{ m: 'Sep', v: 35, display: '35' }, { m: 'Oct', v: 32, display: '32' },
|
||||
{ m: 'Nov', v: 28, display: '28' }, { m: 'Dec', v: 35, display: '35' },
|
||||
{ m: 'Jan', v: 38, display: '38' }, { m: 'Feb', v: 36, display: '36' },
|
||||
{ m: 'Mar', v: 42, display: '42' }, { m: 'Apr', v: 44, display: '44' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'avg-premium', domain: 'production', label: 'Avg Premium', unit: '$',
|
||||
change: '+2.1%', changeTone: 'positive', defaultChartType: 'line',
|
||||
data12m: [
|
||||
{ m: 'May', v: 52, display: '$12.4K' }, { m: 'Jun', v: 53, display: '$12.6K' },
|
||||
{ m: 'Jul', v: 51, display: '$12.1K' }, { m: 'Aug', v: 54, display: '$12.8K' },
|
||||
{ m: 'Sep', v: 55, display: '$13.1K' }, { m: 'Oct', v: 56, display: '$13.3K' },
|
||||
{ m: 'Nov', v: 55, display: '$13.1K' }, { m: 'Dec', v: 57, display: '$13.6K' },
|
||||
{ m: 'Jan', v: 56, display: '$13.3K' }, { m: 'Feb', v: 58, display: '$13.8K' },
|
||||
{ m: 'Mar', v: 59, display: '$14.0K' }, { m: 'Apr', v: 60, display: '$14.3K' },
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
// ─── Metrics: Claims & Loss ──────────────────────────────────────────────────
|
||||
|
||||
const claimsMetrics: AnalyticsMetric[] = [
|
||||
{
|
||||
id: 'claims-count', domain: 'claims', label: 'Claims Opened', unit: '#',
|
||||
change: '-2', changeTone: 'positive', defaultChartType: 'bar',
|
||||
data12m: [
|
||||
{ m: 'May', v: 8, display: '8' }, { m: 'Jun', v: 6, display: '6' },
|
||||
{ m: 'Jul', v: 10, display: '10' }, { m: 'Aug', v: 7, display: '7' },
|
||||
{ m: 'Sep', v: 9, display: '9' }, { m: 'Oct', v: 8, display: '8' },
|
||||
{ m: 'Nov', v: 6, display: '6' }, { m: 'Dec', v: 5, display: '5' },
|
||||
{ m: 'Jan', v: 7, display: '7' }, { m: 'Feb', v: 8, display: '8' },
|
||||
{ m: 'Mar', v: 6, display: '6' }, { m: 'Apr', v: 5, display: '5' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'loss-ratio', domain: 'claims', label: 'Loss Ratio', unit: '%',
|
||||
change: '-3.1%', changeTone: 'positive', defaultChartType: 'line',
|
||||
data12m: [
|
||||
{ m: 'May', v: 66, display: '66%' }, { m: 'Jun', v: 64, display: '64%' },
|
||||
{ m: 'Jul', v: 70, display: '70%' }, { m: 'Aug', v: 67, display: '67%' },
|
||||
{ m: 'Sep', v: 65, display: '65%' }, { m: 'Oct', v: 68, display: '68%' },
|
||||
{ m: 'Nov', v: 72, display: '72%' }, { m: 'Dec', v: 65, display: '65%' },
|
||||
{ m: 'Jan', v: 63, display: '63%' }, { m: 'Feb', v: 60, display: '60%' },
|
||||
{ m: 'Mar', v: 58, display: '58%' }, { m: 'Apr', v: 57, display: '57%' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'avg-resolution-days', domain: 'claims', label: 'Avg Resolution', unit: 'days',
|
||||
change: '-4d', changeTone: 'positive', defaultChartType: 'line',
|
||||
data12m: [
|
||||
{ m: 'May', v: 38, display: '38d' }, { m: 'Jun', v: 35, display: '35d' },
|
||||
{ m: 'Jul', v: 40, display: '40d' }, { m: 'Aug', v: 36, display: '36d' },
|
||||
{ m: 'Sep', v: 34, display: '34d' }, { m: 'Oct', v: 32, display: '32d' },
|
||||
{ m: 'Nov', v: 30, display: '30d' }, { m: 'Dec', v: 28, display: '28d' },
|
||||
{ m: 'Jan', v: 31, display: '31d' }, { m: 'Feb', v: 29, display: '29d' },
|
||||
{ m: 'Mar', v: 27, display: '27d' }, { m: 'Apr', v: 26, display: '26d' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'reserve-trend', domain: 'claims', label: 'Reserve Trend', unit: '$',
|
||||
change: '-$45K', changeTone: 'positive', defaultChartType: 'area',
|
||||
data12m: [
|
||||
{ m: 'May', v: 75, display: '$380K' }, { m: 'Jun', v: 70, display: '$355K' },
|
||||
{ m: 'Jul', v: 80, display: '$405K' }, { m: 'Aug', v: 72, display: '$365K' },
|
||||
{ m: 'Sep', v: 68, display: '$345K' }, { m: 'Oct', v: 65, display: '$330K' },
|
||||
{ m: 'Nov', v: 60, display: '$304K' }, { m: 'Dec', v: 58, display: '$294K' },
|
||||
{ m: 'Jan', v: 62, display: '$314K' }, { m: 'Feb', v: 57, display: '$289K' },
|
||||
{ m: 'Mar', v: 55, display: '$279K' }, { m: 'Apr', v: 52, display: '$264K' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'claims-by-status', domain: 'claims', label: 'Claims by Status', unit: '#',
|
||||
change: '', changeTone: 'neutral', defaultChartType: 'bar',
|
||||
data12m: [
|
||||
{ m: 'Open', v: 3, display: '3' }, { m: 'Review', v: 3, display: '3' },
|
||||
{ m: 'Docs', v: 1, display: '1' }, { m: 'Approved', v: 2, display: '2' },
|
||||
{ m: 'Denied', v: 0, display: '0' }, { m: 'Closed', v: 1, display: '1' },
|
||||
{ m: '', v: 0, display: '' }, { m: '', v: 0, display: '' },
|
||||
{ m: '', v: 0, display: '' }, { m: '', v: 0, display: '' },
|
||||
{ m: '', v: 0, display: '' }, { m: '', v: 0, display: '' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'severity-trend', domain: 'claims', label: 'Avg Severity', unit: '$',
|
||||
change: '-$2.1K', changeTone: 'positive', defaultChartType: 'line',
|
||||
data12m: [
|
||||
{ m: 'May', v: 65, display: '$18.5K' }, { m: 'Jun', v: 60, display: '$17.1K' },
|
||||
{ m: 'Jul', v: 72, display: '$20.5K' }, { m: 'Aug', v: 63, display: '$17.9K' },
|
||||
{ m: 'Sep', v: 58, display: '$16.5K' }, { m: 'Oct', v: 55, display: '$15.7K' },
|
||||
{ m: 'Nov', v: 52, display: '$14.8K' }, { m: 'Dec', v: 50, display: '$14.2K' },
|
||||
{ m: 'Jan', v: 54, display: '$15.4K' }, { m: 'Feb', v: 48, display: '$13.7K' },
|
||||
{ m: 'Mar', v: 46, display: '$13.1K' }, { m: 'Apr', v: 44, display: '$12.5K' },
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
// ─── Metrics: Sales Pipeline ─────────────────────────────────────────────────
|
||||
|
||||
const pipelineMetrics: AnalyticsMetric[] = [
|
||||
{
|
||||
id: 'conversion-rate', domain: 'pipeline', label: 'Conversion Rate', unit: '%',
|
||||
change: '+2.8%', changeTone: 'positive', defaultChartType: 'line',
|
||||
data12m: [
|
||||
{ m: 'May', v: 28, display: '28%' }, { m: 'Jun', v: 30, display: '30%' },
|
||||
{ m: 'Jul', v: 27, display: '27%' }, { m: 'Aug', v: 32, display: '32%' },
|
||||
{ m: 'Sep', v: 31, display: '31%' }, { m: 'Oct', v: 33, display: '33%' },
|
||||
{ m: 'Nov', v: 30, display: '30%' }, { m: 'Dec', v: 35, display: '35%' },
|
||||
{ m: 'Jan', v: 34, display: '34%' }, { m: 'Feb', v: 36, display: '36%' },
|
||||
{ m: 'Mar', v: 38, display: '38%' }, { m: 'Apr', v: 39, display: '39%' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'pipeline-value', domain: 'pipeline', label: 'Pipeline Value', unit: '$',
|
||||
change: '+$820K', changeTone: 'positive', defaultChartType: 'area',
|
||||
data12m: [
|
||||
{ m: 'May', v: 42, display: '$3.8M' }, { m: 'Jun', v: 45, display: '$4.1M' },
|
||||
{ m: 'Jul', v: 40, display: '$3.6M' }, { m: 'Aug', v: 48, display: '$4.4M' },
|
||||
{ m: 'Sep', v: 52, display: '$4.7M' }, { m: 'Oct', v: 55, display: '$5.0M' },
|
||||
{ m: 'Nov', v: 50, display: '$4.5M' }, { m: 'Dec', v: 58, display: '$5.3M' },
|
||||
{ m: 'Jan', v: 56, display: '$5.1M' }, { m: 'Feb', v: 60, display: '$5.5M' },
|
||||
{ m: 'Mar', v: 65, display: '$5.9M' }, { m: 'Apr', v: 68, display: '$6.2M' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'lead-sources', domain: 'pipeline', label: 'Lead Sources', unit: '#',
|
||||
change: '+18%', changeTone: 'positive', defaultChartType: 'bar',
|
||||
data12m: [
|
||||
{ m: 'Referral', v: 85, display: '85' }, { m: 'Walk-in', v: 42, display: '42' },
|
||||
{ m: 'WhatsApp', v: 38, display: '38' }, { m: 'Google', v: 28, display: '28' },
|
||||
{ m: 'Instagram', v: 22, display: '22' }, { m: 'Facebook', v: 15, display: '15' },
|
||||
{ m: 'Partner', v: 12, display: '12' }, { m: 'Event', v: 8, display: '8' },
|
||||
{ m: 'Other', v: 6, display: '6' }, { m: '', v: 0, display: '' },
|
||||
{ m: '', v: 0, display: '' }, { m: '', v: 0, display: '' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'quote-to-bind-days', domain: 'pipeline', label: 'Quote-to-Bind', unit: 'days',
|
||||
change: '-2d', changeTone: 'positive', defaultChartType: 'line',
|
||||
data12m: [
|
||||
{ m: 'May', v: 18, display: '18d' }, { m: 'Jun', v: 16, display: '16d' },
|
||||
{ m: 'Jul', v: 20, display: '20d' }, { m: 'Aug', v: 15, display: '15d' },
|
||||
{ m: 'Sep', v: 14, display: '14d' }, { m: 'Oct', v: 13, display: '13d' },
|
||||
{ m: 'Nov', v: 15, display: '15d' }, { m: 'Dec', v: 12, display: '12d' },
|
||||
{ m: 'Jan', v: 14, display: '14d' }, { m: 'Feb', v: 11, display: '11d' },
|
||||
{ m: 'Mar', v: 10, display: '10d' }, { m: 'Apr', v: 9, display: '9d' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'agent-performance', domain: 'pipeline', label: 'Agent Performance', unit: '#',
|
||||
change: '', changeTone: 'neutral', defaultChartType: 'bar',
|
||||
data12m: [
|
||||
{ m: 'Ana R.', v: 88, display: '44 pólizas' }, { m: 'Marco V.', v: 72, display: '36 pólizas' },
|
||||
{ m: 'Carlos V.', v: 64, display: '32 pólizas' }, { m: 'María F.', v: 56, display: '28 pólizas' },
|
||||
{ m: 'Luis G.', v: 40, display: '20 pólizas' }, { m: '', v: 0, display: '' },
|
||||
{ m: '', v: 0, display: '' }, { m: '', v: 0, display: '' },
|
||||
{ m: '', v: 0, display: '' }, { m: '', v: 0, display: '' },
|
||||
{ m: '', v: 0, display: '' }, { m: '', v: 0, display: '' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'win-rate', domain: 'pipeline', label: 'Win Rate', unit: '%',
|
||||
change: '+3.5%', changeTone: 'positive', defaultChartType: 'line',
|
||||
data12m: [
|
||||
{ m: 'May', v: 35, display: '35%' }, { m: 'Jun', v: 38, display: '38%' },
|
||||
{ m: 'Jul', v: 33, display: '33%' }, { m: 'Aug', v: 40, display: '40%' },
|
||||
{ m: 'Sep', v: 42, display: '42%' }, { m: 'Oct', v: 41, display: '41%' },
|
||||
{ m: 'Nov', v: 39, display: '39%' }, { m: 'Dec', v: 44, display: '44%' },
|
||||
{ m: 'Jan', v: 43, display: '43%' }, { m: 'Feb', v: 46, display: '46%' },
|
||||
{ m: 'Mar', v: 48, display: '48%' }, { m: 'Apr', v: 50, display: '50%' },
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
// ─── Metrics: Customer & Service ─────────────────────────────────────────────
|
||||
|
||||
const serviceMetrics: AnalyticsMetric[] = [
|
||||
{
|
||||
id: 'support-volume', domain: 'service', label: 'Tickets Opened', unit: '#',
|
||||
change: '+15%', changeTone: 'negative', defaultChartType: 'bar',
|
||||
data12m: [
|
||||
{ m: 'May', v: 22, display: '22' }, { m: 'Jun', v: 25, display: '25' },
|
||||
{ m: 'Jul', v: 28, display: '28' }, { m: 'Aug', v: 24, display: '24' },
|
||||
{ m: 'Sep', v: 30, display: '30' }, { m: 'Oct', v: 27, display: '27' },
|
||||
{ m: 'Nov', v: 32, display: '32' }, { m: 'Dec', v: 20, display: '20' },
|
||||
{ m: 'Jan', v: 26, display: '26' }, { m: 'Feb', v: 29, display: '29' },
|
||||
{ m: 'Mar', v: 34, display: '34' }, { m: 'Apr', v: 36, display: '36' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'sla-compliance', domain: 'service', label: 'SLA Compliance', unit: '%',
|
||||
change: '+4%', changeTone: 'positive', defaultChartType: 'line',
|
||||
data12m: [
|
||||
{ m: 'May', v: 78, display: '78%' }, { m: 'Jun', v: 80, display: '80%' },
|
||||
{ m: 'Jul', v: 76, display: '76%' }, { m: 'Aug', v: 82, display: '82%' },
|
||||
{ m: 'Sep', v: 84, display: '84%' }, { m: 'Oct', v: 83, display: '83%' },
|
||||
{ m: 'Nov', v: 85, display: '85%' }, { m: 'Dec', v: 88, display: '88%' },
|
||||
{ m: 'Jan', v: 86, display: '86%' }, { m: 'Feb', v: 89, display: '89%' },
|
||||
{ m: 'Mar', v: 90, display: '90%' }, { m: 'Apr', v: 92, display: '92%' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'avg-response-time', domain: 'service', label: 'Avg Response Time', unit: 'hrs',
|
||||
change: '-1.2h', changeTone: 'positive', defaultChartType: 'line',
|
||||
data12m: [
|
||||
{ m: 'May', v: 72, display: '5.8h' }, { m: 'Jun', v: 68, display: '5.4h' },
|
||||
{ m: 'Jul', v: 75, display: '6.0h' }, { m: 'Aug', v: 65, display: '5.2h' },
|
||||
{ m: 'Sep', v: 60, display: '4.8h' }, { m: 'Oct', v: 55, display: '4.4h' },
|
||||
{ m: 'Nov', v: 52, display: '4.2h' }, { m: 'Dec', v: 48, display: '3.8h' },
|
||||
{ m: 'Jan', v: 50, display: '4.0h' }, { m: 'Feb', v: 45, display: '3.6h' },
|
||||
{ m: 'Mar', v: 42, display: '3.4h' }, { m: 'Apr', v: 40, display: '3.2h' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'retention-rate', domain: 'service', label: 'Retention Rate', unit: '%',
|
||||
change: '+1.2%', changeTone: 'positive', defaultChartType: 'area',
|
||||
data12m: [
|
||||
{ m: 'May', v: 84, display: '84%' }, { m: 'Jun', v: 85, display: '85%' },
|
||||
{ m: 'Jul', v: 86, display: '86%' }, { m: 'Aug', v: 87, display: '87%' },
|
||||
{ m: 'Sep', v: 88, display: '88%' }, { m: 'Oct', v: 88, display: '88%' },
|
||||
{ m: 'Nov', v: 87, display: '87%' }, { m: 'Dec', v: 89, display: '89%' },
|
||||
{ m: 'Jan', v: 90, display: '90%' }, { m: 'Feb', v: 90, display: '90%' },
|
||||
{ m: 'Mar', v: 91, display: '91%' }, { m: 'Apr', v: 91, display: '91%' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'nps-score', domain: 'service', label: 'NPS Score', unit: '#',
|
||||
change: '+5', changeTone: 'positive', defaultChartType: 'line',
|
||||
data12m: [
|
||||
{ m: 'May', v: 42, display: '42' }, { m: 'Jun', v: 44, display: '44' },
|
||||
{ m: 'Jul', v: 40, display: '40' }, { m: 'Aug', v: 46, display: '46' },
|
||||
{ m: 'Sep', v: 48, display: '48' }, { m: 'Oct', v: 50, display: '50' },
|
||||
{ m: 'Nov', v: 48, display: '48' }, { m: 'Dec', v: 52, display: '52' },
|
||||
{ m: 'Jan', v: 54, display: '54' }, { m: 'Feb', v: 55, display: '55' },
|
||||
{ m: 'Mar', v: 58, display: '58' }, { m: 'Apr', v: 60, display: '60' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'ticket-resolution-days', domain: 'service', label: 'Ticket Resolution', unit: 'days',
|
||||
change: '-0.8d', changeTone: 'positive', defaultChartType: 'line',
|
||||
data12m: [
|
||||
{ m: 'May', v: 55, display: '4.4d' }, { m: 'Jun', v: 52, display: '4.2d' },
|
||||
{ m: 'Jul', v: 58, display: '4.6d' }, { m: 'Aug', v: 50, display: '4.0d' },
|
||||
{ m: 'Sep', v: 48, display: '3.8d' }, { m: 'Oct', v: 45, display: '3.6d' },
|
||||
{ m: 'Nov', v: 42, display: '3.4d' }, { m: 'Dec', v: 40, display: '3.2d' },
|
||||
{ m: 'Jan', v: 43, display: '3.4d' }, { m: 'Feb', v: 38, display: '3.0d' },
|
||||
{ m: 'Mar', v: 36, display: '2.9d' }, { m: 'Apr', v: 34, display: '2.7d' },
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
// ─── Combined Export ─────────────────────────────────────────────────────────
|
||||
|
||||
export const ANALYTICS_METRICS: AnalyticsMetric[] = [
|
||||
...productionMetrics,
|
||||
...claimsMetrics,
|
||||
...pipelineMetrics,
|
||||
...serviceMetrics,
|
||||
]
|
||||
@@ -1,550 +0,0 @@
|
||||
// ── Claims Management — Types & Mock Data ───────────────────────────────────
|
||||
|
||||
export type CarrierStatus =
|
||||
| 'fnol_submitted'
|
||||
| 'acknowledged'
|
||||
| 'investigation'
|
||||
| 'documentation_pending'
|
||||
| 'reserved'
|
||||
| 'negotiation'
|
||||
| 'settlement_offered'
|
||||
| 'closed'
|
||||
|
||||
export type BrokerWorkflowStatus =
|
||||
| 'waiting_carrier'
|
||||
| 'waiting_insured_docs'
|
||||
| 'needs_escalation'
|
||||
| 'client_update_overdue'
|
||||
| 'ready_to_close'
|
||||
|
||||
export type ClaimPriority = 'critical' | 'high' | 'medium' | 'low'
|
||||
|
||||
export type PartyRole = 'insured' | 'adjuster' | 'carrier_contact' | 'handler' | 'attorney'
|
||||
|
||||
export interface ClaimParty {
|
||||
id: string
|
||||
role: PartyRole
|
||||
name: string
|
||||
initials: string
|
||||
email?: string
|
||||
phone?: string
|
||||
company?: string
|
||||
unreadComms: number
|
||||
}
|
||||
|
||||
export type TaskType = 'document' | 'communication' | 'escalation' | 'general'
|
||||
export type TaskStatus = 'open' | 'in_progress' | 'overdue' | 'done'
|
||||
|
||||
export interface ClaimTask {
|
||||
id: string
|
||||
title: string
|
||||
status: TaskStatus
|
||||
assignee: string
|
||||
dueDate: string
|
||||
slaPercent: number
|
||||
type: TaskType
|
||||
isSystemSuggested?: boolean
|
||||
dismissedUntil?: string | null
|
||||
}
|
||||
|
||||
export type CommType = 'email' | 'call' | 'note' | 'system'
|
||||
|
||||
export interface ClaimCommEntry {
|
||||
id: string
|
||||
type: CommType
|
||||
partyId: string
|
||||
from: string
|
||||
to?: string
|
||||
subject?: string
|
||||
body: string
|
||||
timestamp: string
|
||||
threadId?: string
|
||||
aiDigest?: string
|
||||
}
|
||||
|
||||
export type DocCategory = 'fnol' | 'evidence' | 'estimates' | 'correspondence' | 'settlement'
|
||||
|
||||
export interface ClaimDocument {
|
||||
id: string
|
||||
name: string
|
||||
category: DocCategory
|
||||
uploadedBy: string
|
||||
uploadedAt: string
|
||||
size: string
|
||||
required: boolean
|
||||
received: boolean
|
||||
}
|
||||
|
||||
export type IntakeStatus = 'not_sent' | 'sent' | 'in_progress' | 'completed'
|
||||
export type GeneratedFormStatus = 'draft' | 'ready_for_signature' | 'signed' | 'submitted'
|
||||
|
||||
export interface GeneratedForm {
|
||||
id: string
|
||||
carrierFormName: string
|
||||
carrier: string
|
||||
lob: string
|
||||
status: GeneratedFormStatus
|
||||
generatedAt: string
|
||||
signedAt: string | null
|
||||
}
|
||||
|
||||
export type FinancialType = 'reserve_change' | 'payment' | 'subrogation' | 'expense'
|
||||
|
||||
export interface ClaimFinancialEntry {
|
||||
id: string
|
||||
type: FinancialType
|
||||
date: string
|
||||
amount: number
|
||||
description: string
|
||||
annotation?: string
|
||||
}
|
||||
|
||||
export interface ClaimDetail {
|
||||
id: string
|
||||
customerId: string
|
||||
customerName: string
|
||||
policyId: string
|
||||
policyNumber: string
|
||||
carrier: string
|
||||
lob: string
|
||||
type: string
|
||||
carrierStatus: CarrierStatus
|
||||
workflowStatus: BrokerWorkflowStatus
|
||||
priority: ClaimPriority
|
||||
dateFiled: string
|
||||
daysOpen: number
|
||||
handler: string
|
||||
reservedAmount: number
|
||||
paidAmount: number
|
||||
parties: ClaimParty[]
|
||||
tasks: ClaimTask[]
|
||||
communications: ClaimCommEntry[]
|
||||
documents: ClaimDocument[]
|
||||
financials: ClaimFinancialEntry[]
|
||||
aiRecap: string
|
||||
aiRecapSourceCount: number
|
||||
keyDates: { label: string; date: string; done: boolean }[]
|
||||
reserveHistory: { date: string; amount: number; annotation: string }[]
|
||||
intakeToken: string | null
|
||||
intakeStatus: IntakeStatus
|
||||
intakeSentAt: string | null
|
||||
intakeCompletedAt: string | null
|
||||
generatedForms: GeneratedForm[]
|
||||
}
|
||||
|
||||
// ── Label Maps ──────────────────────────────────────────────────────────────
|
||||
|
||||
export const CARRIER_STATUS_LABELS: Record<CarrierStatus, string> = {
|
||||
fnol_submitted: 'FNOL Submitted',
|
||||
acknowledged: 'Acknowledged',
|
||||
investigation: 'Investigation',
|
||||
documentation_pending: 'Documentation Pending',
|
||||
reserved: 'Reserved',
|
||||
negotiation: 'Negotiation',
|
||||
settlement_offered: 'Settlement Offered',
|
||||
closed: 'Closed',
|
||||
}
|
||||
|
||||
export const WORKFLOW_STATUS_LABELS: Record<BrokerWorkflowStatus, string> = {
|
||||
waiting_carrier: 'Waiting on Carrier',
|
||||
waiting_insured_docs: 'Waiting on Insured Docs',
|
||||
needs_escalation: 'Needs Escalation',
|
||||
client_update_overdue: 'Client Update Overdue',
|
||||
ready_to_close: 'Ready to Close',
|
||||
}
|
||||
|
||||
export const PRIORITY_LABELS: Record<ClaimPriority, string> = {
|
||||
critical: 'Critical',
|
||||
high: 'High',
|
||||
medium: 'Medium',
|
||||
low: 'Low',
|
||||
}
|
||||
|
||||
export const TASK_STATUS_LABELS: Record<TaskStatus, string> = {
|
||||
open: 'Open',
|
||||
in_progress: 'In Progress',
|
||||
overdue: 'Overdue',
|
||||
done: 'Done',
|
||||
}
|
||||
|
||||
export const DOC_CATEGORY_LABELS: Record<DocCategory, string> = {
|
||||
fnol: 'FNOL & Notice',
|
||||
evidence: 'Evidence & Photos',
|
||||
estimates: 'Estimates & Appraisals',
|
||||
correspondence: 'Correspondence',
|
||||
settlement: 'Settlement',
|
||||
}
|
||||
|
||||
// ── Helpers ─────────────────────────────────────────────────────────────────
|
||||
|
||||
export function slaColor(percent: number): 'green' | 'amber' | 'red' {
|
||||
if (percent >= 100) return 'red'
|
||||
if (percent >= 75) return 'amber'
|
||||
return 'green'
|
||||
}
|
||||
|
||||
export function fmtClaimMoney(n: number): string {
|
||||
if (n >= 1_000_000) return '$' + (n / 1_000_000).toFixed(1) + 'M'
|
||||
if (n >= 1_000) return '$' + (n / 1_000).toFixed(1) + 'K'
|
||||
return '$' + n.toLocaleString()
|
||||
}
|
||||
|
||||
// ── Mock Data ───────────────────────────────────────────────────────────────
|
||||
|
||||
const clm0048: ClaimDetail = {
|
||||
id: 'CLM-0048',
|
||||
customerId: 'corp-hotel-pacifico',
|
||||
customerName: 'Hotel Pacífico S.A.',
|
||||
policyId: 'POL-2024-HP-001',
|
||||
policyNumber: 'PROP-2024-HP-001',
|
||||
carrier: 'ASSA',
|
||||
lob: 'General Risk',
|
||||
type: 'Fire damage — kitchen wing',
|
||||
carrierStatus: 'investigation',
|
||||
workflowStatus: 'waiting_carrier',
|
||||
priority: 'critical',
|
||||
dateFiled: '2026-04-05',
|
||||
daysOpen: 3,
|
||||
handler: 'Ana R.',
|
||||
reservedAmount: 128_000,
|
||||
paidAmount: 0,
|
||||
|
||||
parties: [
|
||||
{ id: 'p1', role: 'insured', name: 'Carlos Montero', initials: 'CM', email: 'cmontero@hotelpacifico.cr', phone: '+506 2643-1200', company: 'Hotel Pacífico S.A.', unreadComms: 2 },
|
||||
{ id: 'p2', role: 'adjuster', name: 'Roberto Méndez', initials: 'RM', email: 'rmendez@peritajes.cr', phone: '+506 8844-2200', company: 'Peritajes CR', unreadComms: 0 },
|
||||
{ id: 'p3', role: 'carrier_contact', name: 'Lucía Vargas', initials: 'LV', email: 'lvargas@assa.cr', phone: '+506 2222-5000', company: 'ASSA', unreadComms: 1 },
|
||||
{ id: 'p4', role: 'handler', name: 'Ana Ramírez', initials: 'AR', email: 'ana.r@seguros.cr', phone: '+506 8855-3300', unreadComms: 0 },
|
||||
],
|
||||
|
||||
tasks: [
|
||||
{ id: 't1', title: 'Upload police/fire report', status: 'overdue', assignee: 'Ana R.', dueDate: '2026-04-07', slaPercent: 110, type: 'document' },
|
||||
{ id: 't2', title: 'Follow up with adjuster — no site visit scheduled', status: 'open', assignee: 'Ana R.', dueDate: '2026-04-09', slaPercent: 60, type: 'communication' },
|
||||
{ id: 't3', title: 'Send initial status update to insured', status: 'open', assignee: 'Ana R.', dueDate: '2026-04-08', slaPercent: 85, type: 'communication' },
|
||||
{ id: 't4', title: 'Carrier non-response 3 days — escalate?', status: 'open', assignee: 'Ana R.', dueDate: '2026-04-08', slaPercent: 90, type: 'escalation', isSystemSuggested: true },
|
||||
{ id: 't5', title: 'Request preliminary damage estimate from adjuster', status: 'open', assignee: 'Ana R.', dueDate: '2026-04-12', slaPercent: 30, type: 'general' },
|
||||
{ id: 't6', title: 'Confirm policy coverage for fire peril', status: 'done', assignee: 'Ana R.', dueDate: '2026-04-06', slaPercent: 100, type: 'general' },
|
||||
],
|
||||
|
||||
communications: [
|
||||
{ id: 'c1', type: 'system', partyId: 'p4', from: 'System', body: 'Claim CLM-0048 created. FNOL submitted to ASSA.', timestamp: '2026-04-05T09:15:00' },
|
||||
{ id: 'c2', type: 'email', partyId: 'p1', from: 'Carlos Montero', to: 'Ana Ramírez', subject: 'Fire Incident — Hotel Pacífico Kitchen Wing', body: 'Ana, the fire started around 2am in the kitchen exhaust system. The fire department responded within 20 minutes. The kitchen wing sustained significant structural damage, and the adjacent dining area has smoke and water damage. We have temporarily closed the restaurant. Attached are initial photos from the scene. We need to process this claim urgently as we are losing revenue daily.', timestamp: '2026-04-05T10:30:00', threadId: 'th1' },
|
||||
{ id: 'c3', type: 'email', partyId: 'p4', from: 'Ana Ramírez', to: 'Lucía Vargas', subject: 'FNOL — Hotel Pacífico Fire Claim CLM-0048', body: 'Lucía, please find attached the FNOL for Hotel Pacífico. Fire damage to kitchen wing on April 5. Policy PROP-2024-HP-001 covers fire peril with $500K limit. Requesting immediate adjuster assignment. This is a high-value commercial client with business interruption exposure.', timestamp: '2026-04-05T11:45:00', threadId: 'th2' },
|
||||
{ id: 'c4', type: 'call', partyId: 'p1', from: 'Ana Ramírez', body: 'Called Carlos Montero to confirm FNOL was submitted. Discussed initial documentation needed: fire report, photos, inventory of damaged equipment. Carlos will send equipment list by end of day. Advised to keep all receipts for temporary repairs.', timestamp: '2026-04-05T14:00:00' },
|
||||
{ id: 'c5', type: 'email', partyId: 'p3', from: 'Lucía Vargas', to: 'Ana Ramírez', subject: 'RE: FNOL — Hotel Pacífico Fire Claim CLM-0048', body: 'Ana, claim received and logged under ASSA reference FI-2026-04412. We are assigning adjuster Roberto Méndez from Peritajes CR. He will contact you to schedule site inspection.', timestamp: '2026-04-06T09:00:00', threadId: 'th2', aiDigest: 'ASSA acknowledged claim as FI-2026-04412. Adjuster Roberto Méndez (Peritajes CR) assigned. Site inspection pending scheduling.' },
|
||||
{ id: 'c6', type: 'system', partyId: 'p4', from: 'System', body: 'Carrier status updated: FNOL Submitted → Acknowledged', timestamp: '2026-04-06T09:05:00' },
|
||||
{ id: 'c7', type: 'note', partyId: 'p4', from: 'Ana Ramírez', body: 'Reviewed policy. Fire peril covered. Business interruption sublimit $200K with 48h waiting period. Need to flag BI exposure to carrier early — hotel restaurant closure = significant daily revenue loss.', timestamp: '2026-04-06T10:30:00' },
|
||||
{ id: 'c8', type: 'email', partyId: 'p2', from: 'Roberto Méndez', to: 'Ana Ramírez', subject: 'Site Inspection — Hotel Pacífico', body: 'Good morning Ana. I have been assigned to inspect the fire damage at Hotel Pacífico. Could you coordinate with the insured for access? I am available Thursday or Friday this week.', timestamp: '2026-04-07T08:00:00', threadId: 'th3' },
|
||||
{ id: 'c9', type: 'email', partyId: 'p4', from: 'Ana Ramírez', to: 'Roberto Méndez', subject: 'RE: Site Inspection — Hotel Pacífico', body: 'Roberto, Thursday works. I will confirm with the hotel and send you the contact details. Please plan for approximately 3 hours — the damage area covers the kitchen wing and adjacent dining area.', timestamp: '2026-04-07T09:15:00', threadId: 'th3' },
|
||||
{ id: 'c10', type: 'system', partyId: 'p4', from: 'System', body: 'Carrier status updated: Acknowledged → Investigation', timestamp: '2026-04-07T10:00:00' },
|
||||
{ id: 'c11', type: 'email', partyId: 'p1', from: 'Carlos Montero', to: 'Ana Ramírez', subject: 'RE: Fire Incident — Equipment Inventory', body: 'Ana, attached is the damaged equipment inventory as requested. Total estimated replacement value approximately $85,000. Also including photos of the structural damage to the exhaust hood and ceiling. The fire inspector\'s preliminary report should be ready by Friday.', timestamp: '2026-04-07T16:00:00', threadId: 'th1', aiDigest: 'Insured provided equipment inventory ($85K estimated). Structural damage photos attached. Fire inspector report expected Friday.' },
|
||||
{ id: 'c12', type: 'note', partyId: 'p4', from: 'Ana Ramírez', body: 'Equipment inventory received — $85K. Combined with structural estimates, total exposure could exceed $150K. May need to flag for reserve increase once adjuster report comes in. BI claim will be separate — need to start documenting daily revenue loss.', timestamp: '2026-04-08T08:30:00' },
|
||||
],
|
||||
|
||||
documents: [
|
||||
{ id: 'd1', name: 'FNOL-CLM0048.pdf', category: 'fnol', uploadedBy: 'Ana R.', uploadedAt: '2026-04-05', size: '245 KB', required: true, received: true },
|
||||
{ id: 'd2', name: 'Policy-Declarations-HP.pdf', category: 'fnol', uploadedBy: 'Ana R.', uploadedAt: '2026-04-05', size: '1.2 MB', required: true, received: true },
|
||||
{ id: 'd3', name: 'Initial-Photos-Kitchen.zip', category: 'evidence', uploadedBy: 'Carlos Montero', uploadedAt: '2026-04-05', size: '18.4 MB', required: true, received: true },
|
||||
{ id: 'd4', name: 'Equipment-Inventory.xlsx', category: 'estimates', uploadedBy: 'Carlos Montero', uploadedAt: '2026-04-07', size: '89 KB', required: true, received: true },
|
||||
{ id: 'd5', name: 'Structural-Damage-Photos.zip', category: 'evidence', uploadedBy: 'Carlos Montero', uploadedAt: '2026-04-07', size: '24.1 MB', required: false, received: true },
|
||||
{ id: 'd6', name: 'ASSA-Acknowledgment-FI202604412.pdf', category: 'correspondence', uploadedBy: 'Ana R.', uploadedAt: '2026-04-06', size: '156 KB', required: false, received: true },
|
||||
{ id: 'd7', name: 'Fire-Inspector-Report.pdf', category: 'evidence', uploadedBy: '', uploadedAt: '', size: '', required: true, received: false },
|
||||
{ id: 'd8', name: 'Adjuster-Preliminary-Estimate.pdf', category: 'estimates', uploadedBy: '', uploadedAt: '', size: '', required: true, received: false },
|
||||
{ id: 'd9', name: 'Business-Interruption-Docs.pdf', category: 'estimates', uploadedBy: '', uploadedAt: '', size: '', required: true, received: false },
|
||||
{ id: 'd10', name: 'Police-Fire-Report.pdf', category: 'fnol', uploadedBy: '', uploadedAt: '', size: '', required: true, received: false },
|
||||
],
|
||||
|
||||
financials: [
|
||||
{ id: 'f1', type: 'reserve_change', date: '2026-04-05', amount: 128_000, description: 'Initial reserve set', annotation: 'Based on preliminary damage assessment and policy limits' },
|
||||
{ id: 'f2', type: 'expense', date: '2026-04-06', amount: 1_500, description: 'Adjuster assignment fee — Peritajes CR', annotation: 'Standard commercial property rate' },
|
||||
{ id: 'f3', type: 'expense', date: '2026-04-07', amount: 450, description: 'Emergency structural assessment', annotation: 'Required for safety clearance' },
|
||||
],
|
||||
|
||||
aiRecap: 'Siniestro por incendio en el ala de cocina del Hotel Pacífico, reportado el 5 de abril de 2026. El fuego se originó en el sistema de extracción de la cocina alrededor de las 2am. Los bomberos respondieron en 20 minutos. Daños significativos a la estructura de la cocina y daños por humo y agua en el comedor adyacente. El restaurante está cerrado temporalmente.\n\nASSA acusó recibo el 6 de abril (ref. FI-2026-04412) y asignó al ajustador Roberto Méndez de Peritajes CR. La inspección del sitio está pendiente de programar para esta semana.\n\nEl asegurado proporcionó un inventario de equipos dañados valorado en ~$85K. La exposición total podría superar $150K una vez que se complete la evaluación estructural. Hay exposición adicional por interrupción de negocio (sublímite de $200K con período de espera de 48h).\n\nPendiente: reporte del inspector de bomberos (esperado viernes), estimación preliminar del ajustador, documentación de pérdida de ingresos diarios para reclamo de BI.',
|
||||
aiRecapSourceCount: 14,
|
||||
|
||||
keyDates: [
|
||||
{ label: 'FNOL Filed', date: '2026-04-05', done: true },
|
||||
{ label: 'Carrier Acknowledged', date: '2026-04-06', done: true },
|
||||
{ label: 'Adjuster Assigned', date: '2026-04-06', done: true },
|
||||
{ label: 'Investigation Started', date: '2026-04-07', done: true },
|
||||
{ label: 'Site Inspection', date: '2026-04-10', done: false },
|
||||
{ label: 'Preliminary Estimate', date: '', done: false },
|
||||
{ label: 'Reserve Review', date: '', done: false },
|
||||
{ label: 'Settlement', date: '', done: false },
|
||||
],
|
||||
|
||||
reserveHistory: [
|
||||
{ date: '2026-04-05', amount: 128_000, annotation: 'Initial reserve — fire damage assessment pending' },
|
||||
],
|
||||
intakeToken: 'tk_hp_048_a3f1',
|
||||
intakeStatus: 'completed',
|
||||
intakeSentAt: '2026-04-05T14:30:00Z',
|
||||
intakeCompletedAt: '2026-04-06T09:12:00Z',
|
||||
generatedForms: [
|
||||
{ id: 'gf-048-1', carrierFormName: 'Aviso de Pérdida — ASSA', carrier: 'ASSA', lob: 'General Risk', status: 'ready_for_signature', generatedAt: '2026-04-06T10:00:00Z', signedAt: null },
|
||||
],
|
||||
}
|
||||
|
||||
const clm0047: ClaimDetail = {
|
||||
id: 'CLM-0047',
|
||||
customerId: 'corp-empresa-abc',
|
||||
customerName: 'Empresa ABC S.A.',
|
||||
policyId: 'POL-2024-ABC-FLEET',
|
||||
policyNumber: 'AUTO-2024-FLEET-007',
|
||||
carrier: 'Qualitas',
|
||||
lob: 'Auto',
|
||||
type: 'Auto collision — fleet vehicle',
|
||||
carrierStatus: 'documentation_pending',
|
||||
workflowStatus: 'waiting_insured_docs',
|
||||
priority: 'high',
|
||||
dateFiled: '2026-04-03',
|
||||
daysOpen: 5,
|
||||
handler: 'Marco V.',
|
||||
reservedAmount: 14_200,
|
||||
paidAmount: 0,
|
||||
|
||||
parties: [
|
||||
{ id: 'p1', role: 'insured', name: 'Fernando Solano', initials: 'FS', email: 'fsolano@empresaabc.cr', phone: '+506 2255-8800', company: 'Empresa ABC S.A.', unreadComms: 0 },
|
||||
{ id: 'p2', role: 'adjuster', name: 'Patricia Ulate', initials: 'PU', email: 'pulate@qualitas.cr', phone: '+506 2233-4400', company: 'Qualitas', unreadComms: 1 },
|
||||
{ id: 'p3', role: 'carrier_contact', name: 'Diego Mora', initials: 'DM', email: 'dmora@qualitas.cr', phone: '+506 2233-4401', company: 'Qualitas', unreadComms: 0 },
|
||||
{ id: 'p4', role: 'handler', name: 'Marco Vargas', initials: 'MV', email: 'marco.v@seguros.cr', phone: '+506 8866-4400', unreadComms: 0 },
|
||||
],
|
||||
|
||||
tasks: [
|
||||
{ id: 't1', title: 'Obtain police report from insured', status: 'overdue', assignee: 'Marco V.', dueDate: '2026-04-06', slaPercent: 120, type: 'document' },
|
||||
{ id: 't2', title: 'Submit repair estimates to carrier', status: 'open', assignee: 'Marco V.', dueDate: '2026-04-10', slaPercent: 50, type: 'document' },
|
||||
{ id: 't3', title: 'Confirm driver was authorized fleet operator', status: 'open', assignee: 'Marco V.', dueDate: '2026-04-09', slaPercent: 65, type: 'general' },
|
||||
{ id: 't4', title: 'Send client status update — 5 days no communication', status: 'open', assignee: 'Marco V.', dueDate: '2026-04-08', slaPercent: 92, type: 'communication', isSystemSuggested: true },
|
||||
{ id: 't5', title: 'Request adjuster photos from body shop', status: 'done', assignee: 'Marco V.', dueDate: '2026-04-05', slaPercent: 100, type: 'general' },
|
||||
],
|
||||
|
||||
communications: [
|
||||
{ id: 'c1', type: 'system', partyId: 'p4', from: 'System', body: 'Claim CLM-0047 created. FNOL submitted to Qualitas.', timestamp: '2026-04-03T08:30:00' },
|
||||
{ id: 'c2', type: 'email', partyId: 'p1', from: 'Fernando Solano', to: 'Marco Vargas', subject: 'Fleet Vehicle Accident — Unit 07', body: 'Marco, one of our delivery trucks (Unit 07, plates SJO-7744) was involved in a collision yesterday on Ruta 27 near Escazú. The driver (José Mora) is fine but the front end is heavily damaged. The vehicle was towed to Taller Central in La Uruca. Police were called and a report was filed.', timestamp: '2026-04-03T09:00:00', threadId: 'th1' },
|
||||
{ id: 'c3', type: 'email', partyId: 'p4', from: 'Marco Vargas', to: 'Diego Mora', subject: 'FNOL — Empresa ABC Fleet Collision CLM-0047', body: 'Diego, submitting FNOL for fleet collision. Policy AUTO-2024-FLEET-007, Unit 07 (SJO-7744). Collision on Ruta 27, 2 April. Vehicle at Taller Central, La Uruca. Police report filed. Requesting adjuster assignment.', timestamp: '2026-04-03T10:15:00', threadId: 'th2' },
|
||||
{ id: 'c4', type: 'email', partyId: 'p3', from: 'Diego Mora', to: 'Marco Vargas', subject: 'RE: FNOL — Empresa ABC Fleet Collision', body: 'Marco, claim received under Qualitas ref QAC-2026-1182. Adjuster Patricia Ulate will handle. She will coordinate directly with the body shop for inspection. Please provide the police report and driver authorization docs at your earliest convenience.', timestamp: '2026-04-04T08:00:00', threadId: 'th2', aiDigest: 'Qualitas acknowledged as QAC-2026-1182. Adjuster Patricia Ulate assigned. Requesting police report and driver authorization docs.' },
|
||||
{ id: 'c5', type: 'call', partyId: 'p2', from: 'Marco Vargas', body: 'Called Patricia Ulate. She confirmed she will visit Taller Central on Monday for inspection. Needs police report before she can proceed with estimate. Estimated repair range $12K–$16K based on initial description.', timestamp: '2026-04-04T14:30:00' },
|
||||
{ id: 'c6', type: 'system', partyId: 'p4', from: 'System', body: 'Carrier status updated: Acknowledged → Documentation Pending', timestamp: '2026-04-05T09:00:00' },
|
||||
{ id: 'c7', type: 'note', partyId: 'p4', from: 'Marco Vargas', body: 'Insured has not yet sent police report. Called Fernando twice, went to voicemail. Will try again tomorrow. Fleet policy requires authorized driver confirmation — need to get signed driver roster from HR department.', timestamp: '2026-04-06T16:00:00' },
|
||||
{ id: 'c8', type: 'email', partyId: 'p2', from: 'Patricia Ulate', to: 'Marco Vargas', subject: 'Body Shop Photos — Unit 07', body: 'Marco, I visited Taller Central this morning. Photos attached. Front bumper, hood, radiator, and right fender all need replacement. Frame appears straight — no structural damage. Preliminary estimate pending receipt of police report to confirm fault assignment.', timestamp: '2026-04-07T11:00:00', threadId: 'th3', aiDigest: 'Adjuster inspected vehicle. Damage: bumper, hood, radiator, right fender. No structural damage. Estimate pending police report for fault assignment.' },
|
||||
],
|
||||
|
||||
documents: [
|
||||
{ id: 'd1', name: 'FNOL-CLM0047.pdf', category: 'fnol', uploadedBy: 'Marco V.', uploadedAt: '2026-04-03', size: '198 KB', required: true, received: true },
|
||||
{ id: 'd2', name: 'Fleet-Policy-Declarations.pdf', category: 'fnol', uploadedBy: 'Marco V.', uploadedAt: '2026-04-03', size: '890 KB', required: true, received: true },
|
||||
{ id: 'd3', name: 'Accident-Scene-Photos.zip', category: 'evidence', uploadedBy: 'Fernando Solano', uploadedAt: '2026-04-03', size: '12.6 MB', required: true, received: true },
|
||||
{ id: 'd4', name: 'Body-Shop-Inspection-Photos.zip', category: 'evidence', uploadedBy: 'Patricia Ulate', uploadedAt: '2026-04-07', size: '8.9 MB', required: false, received: true },
|
||||
{ id: 'd5', name: 'Police-Report.pdf', category: 'fnol', uploadedBy: '', uploadedAt: '', size: '', required: true, received: false },
|
||||
{ id: 'd6', name: 'Driver-Authorization-Roster.pdf', category: 'fnol', uploadedBy: '', uploadedAt: '', size: '', required: true, received: false },
|
||||
{ id: 'd7', name: 'Repair-Estimate-TallerCentral.pdf', category: 'estimates', uploadedBy: '', uploadedAt: '', size: '', required: true, received: false },
|
||||
],
|
||||
|
||||
financials: [
|
||||
{ id: 'f1', type: 'reserve_change', date: '2026-04-03', amount: 14_200, description: 'Initial reserve set', annotation: 'Based on typical fleet collision range' },
|
||||
{ id: 'f2', type: 'expense', date: '2026-04-03', amount: 85, description: 'Towing — Ruta 27 to Taller Central' },
|
||||
],
|
||||
|
||||
aiRecap: 'Colisión de vehículo de flota (Unidad 07, SJO-7744) de Empresa ABC en Ruta 27 cerca de Escazú el 2 de abril. El conductor José Mora no resultó herido. El vehículo fue remolcado a Taller Central en La Uruca.\n\nQualitas acusó recibo (ref QAC-2026-1182) y asignó a la ajustadora Patricia Ulate. Ella inspeccionó el vehículo el 7 de abril — daños en bumper, capó, radiador y guardafango derecho. Sin daño estructural. Estimación pendiente del reporte policial para determinar culpa.\n\nBloqueadores: el asegurado no ha proporcionado el reporte policial (vencido) ni la documentación de autorización del conductor. Se han hecho múltiples intentos de contacto sin respuesta.',
|
||||
aiRecapSourceCount: 9,
|
||||
|
||||
keyDates: [
|
||||
{ label: 'FNOL Filed', date: '2026-04-03', done: true },
|
||||
{ label: 'Carrier Acknowledged', date: '2026-04-04', done: true },
|
||||
{ label: 'Adjuster Assigned', date: '2026-04-04', done: true },
|
||||
{ label: 'Vehicle Inspection', date: '2026-04-07', done: true },
|
||||
{ label: 'Police Report Due', date: '2026-04-06', done: false },
|
||||
{ label: 'Repair Estimate', date: '', done: false },
|
||||
{ label: 'Settlement', date: '', done: false },
|
||||
],
|
||||
|
||||
reserveHistory: [
|
||||
{ date: '2026-04-03', amount: 14_200, annotation: 'Initial reserve — typical fleet collision' },
|
||||
],
|
||||
intakeToken: 'tk_abc_047_b7e2',
|
||||
intakeStatus: 'in_progress',
|
||||
intakeSentAt: '2026-04-04T11:00:00Z',
|
||||
intakeCompletedAt: null,
|
||||
generatedForms: [],
|
||||
}
|
||||
|
||||
const clm0043: ClaimDetail = {
|
||||
id: 'CLM-0043',
|
||||
customerId: 'corp-supermercado-tico',
|
||||
customerName: 'Supermercado Tico S.A.',
|
||||
policyId: 'POL-2023-ST-GL',
|
||||
policyNumber: 'GL-2023-ST-001',
|
||||
carrier: 'INS',
|
||||
lob: 'General Risk',
|
||||
type: 'Liability — customer injury in store',
|
||||
carrierStatus: 'negotiation',
|
||||
workflowStatus: 'client_update_overdue',
|
||||
priority: 'high',
|
||||
dateFiled: '2026-03-17',
|
||||
daysOpen: 22,
|
||||
handler: 'Ana R.',
|
||||
reservedAmount: 45_000,
|
||||
paidAmount: 0,
|
||||
|
||||
parties: [
|
||||
{ id: 'p1', role: 'insured', name: 'Jorge Calvo', initials: 'JC', email: 'jcalvo@supertico.cr', phone: '+506 2244-9900', company: 'Supermercado Tico S.A.', unreadComms: 3 },
|
||||
{ id: 'p2', role: 'adjuster', name: 'Sandra Pérez', initials: 'SP', email: 'sperez@ins.go.cr', phone: '+506 2287-6600', company: 'INS', unreadComms: 0 },
|
||||
{ id: 'p3', role: 'carrier_contact', name: 'Miguel Hernández', initials: 'MH', email: 'mhernandez@ins.go.cr', phone: '+506 2287-6601', company: 'INS', unreadComms: 0 },
|
||||
{ id: 'p4', role: 'handler', name: 'Ana Ramírez', initials: 'AR', email: 'ana.r@seguros.cr', phone: '+506 8855-3300', unreadComms: 0 },
|
||||
{ id: 'p5', role: 'attorney', name: 'Lic. Gabriela Rojas', initials: 'GR', email: 'grojas@bufeterojas.cr', phone: '+506 2255-1100', company: 'Bufete Rojas & Asociados', unreadComms: 1 },
|
||||
],
|
||||
|
||||
tasks: [
|
||||
{ id: 't1', title: 'Send client status update — 8 days overdue', status: 'overdue', assignee: 'Ana R.', dueDate: '2026-03-31', slaPercent: 140, type: 'communication' },
|
||||
{ id: 't2', title: 'Review settlement offer from INS — $38K proposed', status: 'open', assignee: 'Ana R.', dueDate: '2026-04-10', slaPercent: 55, type: 'general' },
|
||||
{ id: 't3', title: 'Coordinate with attorney on counter-offer strategy', status: 'in_progress', assignee: 'Ana R.', dueDate: '2026-04-09', slaPercent: 70, type: 'communication' },
|
||||
{ id: 't4', title: 'Upload updated medical records from claimant', status: 'open', assignee: 'Ana R.', dueDate: '2026-04-11', slaPercent: 40, type: 'document' },
|
||||
{ id: 't5', title: 'Client update overdue — escalate?', status: 'open', assignee: 'Ana R.', dueDate: '2026-04-08', slaPercent: 100, type: 'escalation', isSystemSuggested: true },
|
||||
{ id: 't6', title: 'File FNOL with carrier', status: 'done', assignee: 'Ana R.', dueDate: '2026-03-17', slaPercent: 100, type: 'general' },
|
||||
{ id: 't7', title: 'Obtain incident report from store manager', status: 'done', assignee: 'Ana R.', dueDate: '2026-03-19', slaPercent: 100, type: 'document' },
|
||||
{ id: 't8', title: 'Upload CCTV footage', status: 'done', assignee: 'Ana R.', dueDate: '2026-03-22', slaPercent: 100, type: 'document' },
|
||||
],
|
||||
|
||||
communications: [
|
||||
{ id: 'c1', type: 'system', partyId: 'p4', from: 'System', body: 'Claim CLM-0043 created. FNOL submitted to INS.', timestamp: '2026-03-17T10:00:00' },
|
||||
{ id: 'c2', type: 'email', partyId: 'p1', from: 'Jorge Calvo', to: 'Ana Ramírez', subject: 'Customer Slip and Fall — Produce Section', body: 'Ana, we had an incident on March 16. A customer (Marta Solís) slipped on a wet floor in the produce section and sustained a hip injury. She was taken to Hospital CIMA by ambulance. She has retained an attorney. We have CCTV footage and the incident report from our store manager.', timestamp: '2026-03-17T10:30:00', threadId: 'th1' },
|
||||
{ id: 'c3', type: 'email', partyId: 'p4', from: 'Ana Ramírez', to: 'Miguel Hernández', subject: 'FNOL — Supermercado Tico Liability Claim CLM-0043', body: 'Miguel, submitting FNOL for a general liability claim. Customer slip and fall injury in the produce section. Incident March 16. Claimant retained attorney. We have CCTV footage and incident report. Policy GL-2023-ST-001 with $1M per-occurrence limit.', timestamp: '2026-03-17T11:30:00', threadId: 'th2' },
|
||||
{ id: 'c4', type: 'email', partyId: 'p3', from: 'Miguel Hernández', to: 'Ana Ramírez', subject: 'RE: FNOL — Supermercado Tico Liability', body: 'Ana, claim acknowledged under INS ref LB-2026-0388. Sandra Pérez assigned as adjuster. Please forward CCTV footage and incident report. Given attorney involvement, we are fast-tracking investigation.', timestamp: '2026-03-18T09:00:00', threadId: 'th2', aiDigest: 'INS acknowledged claim as LB-2026-0388. Adjuster Sandra Pérez assigned. Fast-tracking due to attorney involvement. Requesting CCTV and incident report.' },
|
||||
{ id: 'c5', type: 'call', partyId: 'p5', from: 'Lic. Gabriela Rojas', body: 'Incoming call from claimant\'s attorney. Informed that Marta Solís underwent hip surgery. Medical expenses to date approximately $28K. Attorney indicated client is seeking $50K total including pain and suffering. Requested we expedite the claim process.', timestamp: '2026-03-20T14:00:00' },
|
||||
{ id: 'c6', type: 'note', partyId: 'p4', from: 'Ana Ramírez', body: 'Attorney demanding $50K. Medical expenses $28K. Need to review CCTV carefully — if the wet floor sign was properly placed, liability may be disputed. Sent CCTV to INS adjuster for review.', timestamp: '2026-03-20T15:30:00' },
|
||||
{ id: 'c7', type: 'system', partyId: 'p4', from: 'System', body: 'Carrier status updated: Investigation → Reserved. Reserve set at $45,000.', timestamp: '2026-03-25T09:00:00' },
|
||||
{ id: 'c8', type: 'email', partyId: 'p2', from: 'Sandra Pérez', to: 'Ana Ramírez', subject: 'Investigation Update — CLM-0043', body: 'Ana, we reviewed the CCTV footage. The wet floor sign was visible but positioned slightly away from the actual wet area. This creates partial liability exposure. We are setting reserve at $45K. Our recommendation is to negotiate settlement in the $35K–$40K range to avoid litigation costs.', timestamp: '2026-03-25T10:00:00', threadId: 'th4', aiDigest: 'INS reviewed CCTV. Wet floor sign was present but mispositioned — partial liability. Reserve set $45K. Recommends settling $35K–$40K to avoid litigation costs.' },
|
||||
{ id: 'c9', type: 'email', partyId: 'p4', from: 'Ana Ramírez', to: 'Jorge Calvo', subject: 'Claim Update — Liability Assessment', body: 'Jorge, INS has completed their initial investigation. The CCTV review indicates the wet floor sign, while present, was not optimally positioned. This creates some liability exposure. INS is recommending a negotiated settlement. I will discuss strategy with you once we have a formal offer from the carrier. Please call me at your convenience to discuss.', timestamp: '2026-03-25T14:00:00', threadId: 'th5' },
|
||||
{ id: 'c10', type: 'email', partyId: 'p3', from: 'Miguel Hernández', to: 'Ana Ramírez', subject: 'Settlement Offer — CLM-0043', body: 'Ana, INS is prepared to offer $38,000 to settle this claim. This includes medical expenses ($28K) plus $10K for pain and suffering. Please communicate this to the insured and the claimant\'s attorney. We believe this is a fair offer given the shared liability circumstances.', timestamp: '2026-04-01T09:00:00', threadId: 'th6', aiDigest: 'INS offering $38K settlement ($28K medical + $10K pain/suffering). Considers shared liability. Awaiting broker/attorney response.' },
|
||||
{ id: 'c11', type: 'system', partyId: 'p4', from: 'System', body: 'Carrier status updated: Reserved → Negotiation', timestamp: '2026-04-01T09:05:00' },
|
||||
{ id: 'c12', type: 'call', partyId: 'p5', from: 'Ana Ramírez', body: 'Called attorney Gabriela Rojas to discuss $38K offer. Attorney says client won\'t accept less than $45K. Agreed to prepare counter-proposal for $42K based on additional medical documentation showing ongoing rehabilitation needs.', timestamp: '2026-04-02T11:00:00' },
|
||||
],
|
||||
|
||||
documents: [
|
||||
{ id: 'd1', name: 'FNOL-CLM0043.pdf', category: 'fnol', uploadedBy: 'Ana R.', uploadedAt: '2026-03-17', size: '212 KB', required: true, received: true },
|
||||
{ id: 'd2', name: 'GL-Policy-Declarations.pdf', category: 'fnol', uploadedBy: 'Ana R.', uploadedAt: '2026-03-17', size: '1.1 MB', required: true, received: true },
|
||||
{ id: 'd3', name: 'Incident-Report-StoreManager.pdf', category: 'evidence', uploadedBy: 'Jorge Calvo', uploadedAt: '2026-03-18', size: '340 KB', required: true, received: true },
|
||||
{ id: 'd4', name: 'CCTV-Footage-ProduceSection.mp4', category: 'evidence', uploadedBy: 'Jorge Calvo', uploadedAt: '2026-03-19', size: '245 MB', required: true, received: true },
|
||||
{ id: 'd5', name: 'Medical-Records-MartaSolis.pdf', category: 'evidence', uploadedBy: 'Lic. Gabriela Rojas', uploadedAt: '2026-03-21', size: '4.2 MB', required: true, received: true },
|
||||
{ id: 'd6', name: 'INS-Investigation-Report.pdf', category: 'correspondence', uploadedBy: 'Sandra Pérez', uploadedAt: '2026-03-25', size: '890 KB', required: false, received: true },
|
||||
{ id: 'd7', name: 'Settlement-Offer-38K.pdf', category: 'settlement', uploadedBy: 'Ana R.', uploadedAt: '2026-04-01', size: '156 KB', required: false, received: true },
|
||||
{ id: 'd8', name: 'Updated-Medical-Records.pdf', category: 'evidence', uploadedBy: '', uploadedAt: '', size: '', required: true, received: false },
|
||||
{ id: 'd9', name: 'Counter-Offer-Response.pdf', category: 'settlement', uploadedBy: '', uploadedAt: '', size: '', required: true, received: false },
|
||||
{ id: 'd10', name: 'Signed-Release-Form.pdf', category: 'settlement', uploadedBy: '', uploadedAt: '', size: '', required: true, received: false },
|
||||
],
|
||||
|
||||
financials: [
|
||||
{ id: 'f1', type: 'reserve_change', date: '2026-03-17', amount: 30_000, description: 'Initial reserve set', annotation: 'Preliminary — slip and fall with attorney involvement' },
|
||||
{ id: 'f2', type: 'reserve_change', date: '2026-03-25', amount: 45_000, description: 'Reserve increased', annotation: 'CCTV review shows partial liability — increased from $30K' },
|
||||
{ id: 'f3', type: 'expense', date: '2026-03-18', amount: 200, description: 'Incident scene documentation' },
|
||||
{ id: 'f4', type: 'expense', date: '2026-03-25', amount: 750, description: 'Legal consultation — liability assessment' },
|
||||
],
|
||||
|
||||
aiRecap: 'Reclamo de responsabilidad civil por caída de cliente (Marta Solís) en la sección de productos del Supermercado Tico el 16 de marzo. La clienta sufrió una lesión de cadera que requirió cirugía. Ha contratado abogada (Lic. Gabriela Rojas).\n\nINS completó la investigación el 25 de marzo. La revisión del CCTV muestra que el letrero de piso mojado estaba presente pero mal posicionado — esto crea responsabilidad parcial. La reserva se incrementó de $30K a $45K.\n\nINS ofreció $38K para liquidar ($28K gastos médicos + $10K dolor y sufrimiento). La abogada de la reclamante rechazó — exige mínimo $45K. Se está preparando contraoferta de $42K basada en documentación médica adicional que muestra necesidades de rehabilitación continua.\n\nACCIÓN REQUERIDA: Actualización al cliente pendiente hace 8 días. El asegurado (Jorge Calvo) no ha sido informado del estado de la negociación.',
|
||||
aiRecapSourceCount: 18,
|
||||
|
||||
keyDates: [
|
||||
{ label: 'Incident Date', date: '2026-03-16', done: true },
|
||||
{ label: 'FNOL Filed', date: '2026-03-17', done: true },
|
||||
{ label: 'Carrier Acknowledged', date: '2026-03-18', done: true },
|
||||
{ label: 'Investigation Complete', date: '2026-03-25', done: true },
|
||||
{ label: 'Reserve Set ($45K)', date: '2026-03-25', done: true },
|
||||
{ label: 'Settlement Offered ($38K)', date: '2026-04-01', done: true },
|
||||
{ label: 'Counter-Offer Deadline', date: '2026-04-10', done: false },
|
||||
{ label: 'Settlement or Litigation', date: '', done: false },
|
||||
],
|
||||
|
||||
reserveHistory: [
|
||||
{ date: '2026-03-17', amount: 30_000, annotation: 'Initial reserve — slip and fall with legal representation' },
|
||||
{ date: '2026-03-25', amount: 45_000, annotation: 'Increased after CCTV review — partial liability exposure confirmed' },
|
||||
],
|
||||
intakeToken: 'tk_st_043_c9d4',
|
||||
intakeStatus: 'completed',
|
||||
intakeSentAt: '2026-03-17T16:00:00Z',
|
||||
intakeCompletedAt: '2026-03-18T08:45:00Z',
|
||||
generatedForms: [
|
||||
{ id: 'gf-043-1', carrierFormName: 'Aviso de Pérdida — Mapfre', carrier: 'Mapfre', lob: 'General Risk', status: 'submitted', generatedAt: '2026-03-18T10:00:00Z', signedAt: '2026-03-19T14:30:00Z' },
|
||||
],
|
||||
}
|
||||
|
||||
const clm0045: ClaimDetail = {
|
||||
id: 'CLM-0045',
|
||||
customerId: 'corp-clinica-sanjose',
|
||||
customerName: 'Clínica San José',
|
||||
policyId: 'POL-2024-CSJ-LIFE',
|
||||
policyNumber: 'LIFE-2024-CSJ-001',
|
||||
carrier: 'Pan-American Life',
|
||||
lob: 'Life',
|
||||
type: 'Surgery pre-authorization',
|
||||
carrierStatus: 'reserved',
|
||||
workflowStatus: 'waiting_carrier',
|
||||
priority: 'high',
|
||||
dateFiled: '2026-03-27',
|
||||
daysOpen: 12,
|
||||
handler: 'Ana R.',
|
||||
reservedAmount: 23_500,
|
||||
paidAmount: 0,
|
||||
|
||||
parties: [
|
||||
{ id: 'p1', role: 'insured', name: 'Dr. Ricardo Blanco', initials: 'RB', email: 'rblanco@clinicasj.cr', phone: '+506 2290-1500', company: 'Clínica San José', unreadComms: 0 },
|
||||
{ id: 'p2', role: 'carrier_contact', name: 'Elena Cordero', initials: 'EC', email: 'ecordero@palig.com', phone: '+506 2201-3300', company: 'Pan-American Life', unreadComms: 0 },
|
||||
{ id: 'p3', role: 'handler', name: 'Ana Ramírez', initials: 'AR', email: 'ana.r@seguros.cr', phone: '+506 8855-3300', unreadComms: 0 },
|
||||
],
|
||||
|
||||
tasks: [
|
||||
{ id: 't1', title: 'Follow up on pre-authorization decision — 5 days pending', status: 'open', assignee: 'Ana R.', dueDate: '2026-04-09', slaPercent: 78, type: 'communication' },
|
||||
{ id: 't2', title: 'Confirm surgical facility is in-network', status: 'done', assignee: 'Ana R.', dueDate: '2026-03-29', slaPercent: 100, type: 'general' },
|
||||
{ id: 't3', title: 'Upload specialist referral letter', status: 'done', assignee: 'Ana R.', dueDate: '2026-03-30', slaPercent: 100, type: 'document' },
|
||||
],
|
||||
|
||||
communications: [
|
||||
{ id: 'c1', type: 'system', partyId: 'p3', from: 'System', body: 'Claim CLM-0045 created. Pre-authorization request submitted to Pan-American Life.', timestamp: '2026-03-27T09:00:00' },
|
||||
{ id: 'c2', type: 'email', partyId: 'p1', from: 'Dr. Ricardo Blanco', to: 'Ana Ramírez', subject: 'Surgery Pre-Auth Request — Group Policy', body: 'Ana, we need pre-authorization for knee replacement surgery for one of our covered employees (María del Carmen Vega, DOB 15/07/1968). The procedure is scheduled for April 15 at Hospital Metropolitano. Attached are the specialist referral, diagnostic imaging, and treatment plan.', timestamp: '2026-03-27T09:30:00', threadId: 'th1' },
|
||||
{ id: 'c3', type: 'email', partyId: 'p3', from: 'Ana Ramírez', to: 'Elena Cordero', subject: 'Pre-Auth Request — Clínica San José Group CLM-0045', body: 'Elena, submitting pre-authorization for knee replacement surgery under group policy LIFE-2024-CSJ-001. Patient María del Carmen Vega. Surgery scheduled April 15 at Hospital Metropolitano (in-network confirmed). All supporting documentation attached.', timestamp: '2026-03-27T11:00:00', threadId: 'th2' },
|
||||
{ id: 'c4', type: 'email', partyId: 'p2', from: 'Elena Cordero', to: 'Ana Ramírez', subject: 'RE: Pre-Auth Request — Clínica San José Group', body: 'Ana, request received and under medical review. Reference PA-2026-0455. Standard review period is 5-7 business days. We may request additional documentation if needed.', timestamp: '2026-03-28T10:00:00', threadId: 'th2', aiDigest: 'Pan-American Life acknowledged pre-auth request as PA-2026-0455. Under medical review. 5-7 business day standard review period.' },
|
||||
{ id: 'c5', type: 'call', partyId: 'p1', from: 'Ana Ramírez', body: 'Called Dr. Blanco to confirm submission. Advised that standard review is 5-7 business days. He is concerned about timing — surgery scheduled April 15 and patient has been waiting 3 months. Will escalate if no response by April 4.', timestamp: '2026-03-28T14:00:00' },
|
||||
{ id: 'c6', type: 'system', partyId: 'p3', from: 'System', body: 'Carrier status updated: Documentation Pending → Reserved. Reserve set at $23,500.', timestamp: '2026-04-02T09:00:00' },
|
||||
{ id: 'c7', type: 'note', partyId: 'p3', from: 'Ana Ramírez', body: 'Reserve set at $23,500 — this is the estimated surgery cost. No decision yet on pre-authorization. 5 business days have passed. Will follow up with Elena tomorrow if no response.', timestamp: '2026-04-03T16:00:00' },
|
||||
],
|
||||
|
||||
documents: [
|
||||
{ id: 'd1', name: 'PreAuth-Request-CLM0045.pdf', category: 'fnol', uploadedBy: 'Ana R.', uploadedAt: '2026-03-27', size: '178 KB', required: true, received: true },
|
||||
{ id: 'd2', name: 'Specialist-Referral-Letter.pdf', category: 'fnol', uploadedBy: 'Dr. Ricardo Blanco', uploadedAt: '2026-03-27', size: '95 KB', required: true, received: true },
|
||||
{ id: 'd3', name: 'Diagnostic-Imaging-KneeMRI.pdf', category: 'evidence', uploadedBy: 'Dr. Ricardo Blanco', uploadedAt: '2026-03-27', size: '15.2 MB', required: true, received: true },
|
||||
{ id: 'd4', name: 'Treatment-Plan.pdf', category: 'evidence', uploadedBy: 'Dr. Ricardo Blanco', uploadedAt: '2026-03-27', size: '240 KB', required: true, received: true },
|
||||
{ id: 'd5', name: 'PALIG-Acknowledgment.pdf', category: 'correspondence', uploadedBy: 'Ana R.', uploadedAt: '2026-03-28', size: '112 KB', required: false, received: true },
|
||||
{ id: 'd6', name: 'PreAuth-Decision-Letter.pdf', category: 'correspondence', uploadedBy: '', uploadedAt: '', size: '', required: true, received: false },
|
||||
],
|
||||
|
||||
financials: [
|
||||
{ id: 'f1', type: 'reserve_change', date: '2026-04-02', amount: 23_500, description: 'Reserve set — estimated surgery cost', annotation: 'Knee replacement at Hospital Metropolitano' },
|
||||
],
|
||||
|
||||
aiRecap: 'Solicitud de preautorización para cirugía de reemplazo de rodilla para empleada María del Carmen Vega bajo la póliza grupal de Clínica San José. Cirugía programada para el 15 de abril en Hospital Metropolitano (red confirmada).\n\nPan-American Life acusó recibo el 28 de marzo (ref PA-2026-0455). Período de revisión estándar 5-7 días hábiles. La reserva se estableció en $23,500 el 2 de abril.\n\nHan pasado 7 días hábiles sin decisión. El Dr. Blanco está preocupado por el cronograma — la paciente ha esperado 3 meses. Se necesita seguimiento urgente con la aseguradora para obtener decisión antes de la fecha de cirugía.',
|
||||
aiRecapSourceCount: 8,
|
||||
|
||||
keyDates: [
|
||||
{ label: 'Pre-Auth Submitted', date: '2026-03-27', done: true },
|
||||
{ label: 'Carrier Acknowledged', date: '2026-03-28', done: true },
|
||||
{ label: 'Review Period Ends', date: '2026-04-07', done: false },
|
||||
{ label: 'Decision Expected', date: '2026-04-09', done: false },
|
||||
{ label: 'Surgery Scheduled', date: '2026-04-15', done: false },
|
||||
],
|
||||
|
||||
reserveHistory: [
|
||||
{ date: '2026-04-02', amount: 23_500, annotation: 'Reserve set — estimated surgery cost at Hospital Metropolitano' },
|
||||
],
|
||||
intakeToken: null,
|
||||
intakeStatus: 'not_sent',
|
||||
intakeSentAt: null,
|
||||
intakeCompletedAt: null,
|
||||
generatedForms: [],
|
||||
}
|
||||
|
||||
// ── Export ───────────────────────────────────────────────────────────────────
|
||||
|
||||
export const MOCK_CLAIM_DETAILS: Record<string, ClaimDetail> = {
|
||||
'CLM-0048': clm0048,
|
||||
'CLM-0047': clm0047,
|
||||
'CLM-0043': clm0043,
|
||||
'CLM-0045': clm0045,
|
||||
}
|
||||
@@ -1,461 +0,0 @@
|
||||
/**
|
||||
* Mock customer data for visual design & demo.
|
||||
* Each client has realistic policies, claims, payments, and activity.
|
||||
*/
|
||||
|
||||
export type MockPolicy = {
|
||||
id: string
|
||||
line: string
|
||||
carrier: string
|
||||
product: string
|
||||
premium: number
|
||||
status: 'Active' | 'Pending' | 'Lapsed' | 'Cancelled'
|
||||
renewal: string
|
||||
icon: string
|
||||
details?: string
|
||||
referralChannel?: string
|
||||
}
|
||||
|
||||
export type MockClaim = {
|
||||
id: string
|
||||
policy: string
|
||||
type: string
|
||||
date: string
|
||||
amount: number
|
||||
status: 'In progress' | 'Resolved' | 'Denied' | 'Under review'
|
||||
}
|
||||
|
||||
export type MockPayment = {
|
||||
date: string
|
||||
amount: number
|
||||
policy: string
|
||||
method: string
|
||||
status: 'Paid' | 'Pending' | 'Overdue' | 'Failed'
|
||||
}
|
||||
|
||||
export type MockActivityEvent = {
|
||||
date: string
|
||||
text: string
|
||||
type: 'claim' | 'payment' | 'renewal' | 'quote' | 'note' | 'policy' | 'onboarding'
|
||||
}
|
||||
|
||||
/**
|
||||
* Customer tier — derived from data completeness & policy status:
|
||||
* quick_lead — minimal capture (name + phone/email only)
|
||||
* lead — profile info but no policies yet
|
||||
* customer — has at least one active policy
|
||||
* cancelled — had policies but all cancelled / lapsed
|
||||
*/
|
||||
export type CustomerTier = 'quick_lead' | 'lead' | 'customer' | 'cancelled'
|
||||
|
||||
export type MockCustomer = {
|
||||
id: string
|
||||
name: string
|
||||
initials: string
|
||||
type: 'Individual' | 'Corporate'
|
||||
documentId: string
|
||||
email: string
|
||||
phone: string
|
||||
birthDate: string
|
||||
gender: string
|
||||
address: string
|
||||
since: string
|
||||
agent: string
|
||||
preferredLang: string
|
||||
tags: string[]
|
||||
policies: MockPolicy[]
|
||||
claims: MockClaim[]
|
||||
payments: MockPayment[]
|
||||
activity: MockActivityEvent[]
|
||||
paymentStatus: 'Current' | 'Overdue' | 'Grace period' | 'N/A'
|
||||
}
|
||||
|
||||
/** Derive tier from customer data */
|
||||
export function customerTier(c: MockCustomer): CustomerTier {
|
||||
if (c.policies.length === 0 && (!c.documentId || c.documentId === '—') && !c.address) return 'quick_lead'
|
||||
if (c.policies.length === 0) return 'lead'
|
||||
const hasActive = c.policies.some(p => p.status === 'Active' || p.status === 'Pending')
|
||||
if (!hasActive) return 'cancelled'
|
||||
return 'customer'
|
||||
}
|
||||
|
||||
/* ────────────────────────────────────────────── */
|
||||
/* 1 · María Elena Pérez Solano */
|
||||
/* ────────────────────────────────────────────── */
|
||||
const maria: MockCustomer = {
|
||||
id: 'cust-001',
|
||||
name: 'María Elena Pérez Solano',
|
||||
initials: 'MP',
|
||||
type: 'Individual',
|
||||
documentId: '1-0456-0812',
|
||||
email: 'maria.perez@email.com',
|
||||
phone: '+506 8834-2291',
|
||||
birthDate: '1988-03-14',
|
||||
gender: 'Female',
|
||||
address: 'San José, Escazú, Trejos Montealegre',
|
||||
since: '2021-06-10',
|
||||
agent: 'Ana R.',
|
||||
preferredLang: 'Spanish',
|
||||
tags: ['VIP', 'Referral source'],
|
||||
paymentStatus: 'Current',
|
||||
policies: [
|
||||
{ id: 'POL-2024-4412', line: 'Auto', carrier: 'ASSA', product: '2023 Toyota RAV4 — SJO-4412', premium: 1840, status: 'Active', renewal: '2025-06-15', icon: 'i-heroicons-truck', details: 'Comprehensive • $500 deductible', referralChannel: 'Direct walk-in' },
|
||||
{ id: 'POL-2024-7788', line: 'Life', carrier: 'INS', product: 'Individual health plan', premium: 3200, status: 'Active', renewal: '2025-09-01', icon: 'i-heroicons-heart', details: 'Gold tier • Dental included', referralChannel: 'Direct walk-in' },
|
||||
{ id: 'POL-2023-1190', line: 'Life', carrier: 'Mapfre', product: 'Term life — 20yr, $250K', premium: 960, status: 'Active', renewal: '2026-01-10', icon: 'i-heroicons-shield-check', details: 'Beneficiary: Carlos Pérez (spouse)', referralChannel: 'Cross-sell' },
|
||||
],
|
||||
claims: [
|
||||
{ id: 'CL-2891', policy: 'POL-2024-4412', type: 'Collision', date: '2025-02-18', amount: 4200, status: 'In progress' },
|
||||
{ id: 'CL-2204', policy: 'POL-2024-7788', type: 'Medical reimbursement', date: '2024-08-05', amount: 1100, status: 'Resolved' },
|
||||
],
|
||||
payments: [
|
||||
{ date: '2025-04-01', amount: 540, policy: 'POL-2024-4412', method: 'Auto-debit', status: 'Paid' },
|
||||
{ date: '2025-04-01', amount: 267, policy: 'POL-2024-7788', method: 'Auto-debit', status: 'Paid' },
|
||||
{ date: '2025-03-01', amount: 540, policy: 'POL-2024-4412', method: 'Auto-debit', status: 'Paid' },
|
||||
{ date: '2025-03-01', amount: 267, policy: 'POL-2024-7788', method: 'Auto-debit', status: 'Paid' },
|
||||
{ date: '2025-03-01', amount: 80, policy: 'POL-2023-1190', method: 'Transfer', status: 'Paid' },
|
||||
],
|
||||
activity: [
|
||||
{ date: 'Today', text: 'Auto claim CL-2891 — adjuster report uploaded', type: 'claim' },
|
||||
{ date: 'Yesterday', text: 'Health premium payment confirmed ($267)', type: 'payment' },
|
||||
{ date: 'Mar 28', text: 'Renewal notice sent for Auto policy', type: 'renewal' },
|
||||
{ date: 'Mar 15', text: 'Quote requested: Home insurance', type: 'quote' },
|
||||
{ date: 'Feb 18', text: 'Collision claim filed — CL-2891', type: 'claim' },
|
||||
],
|
||||
}
|
||||
|
||||
/* ────────────────────────────────────────────── */
|
||||
/* 2 · Roberto Jiménez Mora */
|
||||
/* ────────────────────────────────────────────── */
|
||||
const roberto: MockCustomer = {
|
||||
id: 'cust-002',
|
||||
name: 'Roberto Jiménez Mora',
|
||||
initials: 'RJ',
|
||||
type: 'Individual',
|
||||
documentId: '3-0321-0654',
|
||||
email: 'roberto.jimenez@correo.cr',
|
||||
phone: '+506 7012-8845',
|
||||
birthDate: '1975-11-22',
|
||||
gender: 'Male',
|
||||
address: 'Heredia, Belén, La Asunción',
|
||||
since: '2019-02-15',
|
||||
agent: 'Ana R.',
|
||||
preferredLang: 'Spanish',
|
||||
tags: ['Long-term client'],
|
||||
paymentStatus: 'Current',
|
||||
policies: [
|
||||
{ id: 'POL-2023-3301', line: 'Auto', carrier: 'Qualitas', product: '2021 Hyundai Tucson — HER-9901', premium: 1520, status: 'Active', renewal: '2025-08-20', icon: 'i-heroicons-truck', details: 'Comprehensive • $750 deductible', referralChannel: 'Referral — client' },
|
||||
{ id: 'POL-2023-3302', line: 'Auto', carrier: 'Qualitas', product: '2019 Honda CRV — HER-6632', premium: 1280, status: 'Active', renewal: '2025-08-20', icon: 'i-heroicons-truck', details: 'Comprehensive • $750 deductible • Spouse vehicle', referralChannel: 'Referral — client' },
|
||||
{ id: 'POL-2022-1010', line: 'Home', carrier: 'ASSA', product: 'Homeowner — Belén residence', premium: 890, status: 'Active', renewal: '2025-11-01', icon: 'i-heroicons-home-modern', details: 'Dwelling $185K • Contents $40K • Earthquake included', referralChannel: 'Cross-sell' },
|
||||
{ id: 'POL-2024-5500', line: 'Life', carrier: 'Pan-American Life', product: 'Whole life — $150K', premium: 1440, status: 'Active', renewal: '2026-02-15', icon: 'i-heroicons-shield-check', details: 'Beneficiary: Lucía Jiménez (spouse)', referralChannel: 'Cross-sell' },
|
||||
{ id: 'POL-2024-5501', line: 'General Risk', carrier: 'INS', product: 'Personal umbrella — $1M', premium: 420, status: 'Active', renewal: '2025-12-01', icon: 'i-heroicons-shield-exclamation', details: 'Excess liability over auto + home', referralChannel: 'Cross-sell' },
|
||||
],
|
||||
claims: [
|
||||
{ id: 'CL-1840', policy: 'POL-2022-1010', type: 'Water damage', date: '2024-04-12', amount: 6800, status: 'Resolved' },
|
||||
{ id: 'CL-2105', policy: 'POL-2023-3301', type: 'Windshield', date: '2024-11-28', amount: 450, status: 'Resolved' },
|
||||
],
|
||||
payments: [
|
||||
{ date: '2025-04-01', amount: 233, policy: 'POL-2023-3301', method: 'Auto-debit', status: 'Paid' },
|
||||
{ date: '2025-04-01', amount: 197, policy: 'POL-2023-3302', method: 'Auto-debit', status: 'Paid' },
|
||||
{ date: '2025-04-01', amount: 74, policy: 'POL-2022-1010', method: 'Auto-debit', status: 'Paid' },
|
||||
{ date: '2025-04-01', amount: 120, policy: 'POL-2024-5500', method: 'Auto-debit', status: 'Paid' },
|
||||
{ date: '2025-03-01', amount: 233, policy: 'POL-2023-3301', method: 'Auto-debit', status: 'Paid' },
|
||||
{ date: '2025-03-01', amount: 197, policy: 'POL-2023-3302', method: 'Auto-debit', status: 'Paid' },
|
||||
],
|
||||
activity: [
|
||||
{ date: 'Yesterday', text: 'Monthly premium auto-debited ($624)', type: 'payment' },
|
||||
{ date: 'Mar 30', text: 'Umbrella policy annual review scheduled', type: 'renewal' },
|
||||
{ date: 'Mar 15', text: 'Quote requested: Teen driver add-on', type: 'quote' },
|
||||
{ date: 'Feb 20', text: 'Home policy endorsement — added jewelry rider', type: 'policy' },
|
||||
{ date: 'Jan 10', text: 'Windshield claim CL-2105 resolved ($450)', type: 'claim' },
|
||||
{ date: 'Dec 15', text: 'Year-end portfolio review completed', type: 'note' },
|
||||
],
|
||||
}
|
||||
|
||||
/* ────────────────────────────────────────────── */
|
||||
/* 3 · Carolina Fallas Vargas */
|
||||
/* ────────────────────────────────────────────── */
|
||||
const carolina: MockCustomer = {
|
||||
id: 'cust-003',
|
||||
name: 'Carolina Fallas Vargas',
|
||||
initials: 'CF',
|
||||
type: 'Individual',
|
||||
documentId: '2-0589-0177',
|
||||
email: 'carolina.fallas@gmail.com',
|
||||
phone: '+506 6198-3340',
|
||||
birthDate: '1992-07-30',
|
||||
gender: 'Female',
|
||||
address: 'Cartago, Paraíso, Orosí',
|
||||
since: '2023-09-05',
|
||||
agent: 'Marco V.',
|
||||
preferredLang: 'Spanish',
|
||||
tags: ['New client'],
|
||||
paymentStatus: 'Current',
|
||||
policies: [
|
||||
{ id: 'POL-2024-8810', line: 'Auto', carrier: 'INS', product: '2024 Kia Sportage — CAR-1177', premium: 1650, status: 'Active', renewal: '2025-09-05', icon: 'i-heroicons-truck', details: 'Comprehensive • $500 deductible', referralChannel: 'Facebook campaign' },
|
||||
{ id: 'POL-2024-8811', line: 'Home', carrier: 'ASSA', product: "Renter's insurance — Paraíso apt", premium: 320, status: 'Active', renewal: '2025-09-05', icon: 'i-heroicons-home-modern', details: 'Contents $25K • Liability $100K', referralChannel: 'Facebook campaign' },
|
||||
],
|
||||
claims: [],
|
||||
payments: [
|
||||
{ date: '2025-04-01', amount: 138, policy: 'POL-2024-8810', method: 'Credit card', status: 'Paid' },
|
||||
{ date: '2025-04-01', amount: 27, policy: 'POL-2024-8811', method: 'Credit card', status: 'Paid' },
|
||||
{ date: '2025-03-01', amount: 138, policy: 'POL-2024-8810', method: 'Credit card', status: 'Paid' },
|
||||
{ date: '2025-03-01', amount: 27, policy: 'POL-2024-8811', method: 'Credit card', status: 'Paid' },
|
||||
],
|
||||
activity: [
|
||||
{ date: 'Mar 25', text: 'Monthly payment processed ($165)', type: 'payment' },
|
||||
{ date: 'Mar 10', text: 'Renter policy — updated inventory list', type: 'policy' },
|
||||
{ date: 'Feb 05', text: 'Welcome call completed by Marco V.', type: 'onboarding' },
|
||||
{ date: 'Sep 05', text: 'Policies issued — Auto + Renter', type: 'policy' },
|
||||
],
|
||||
}
|
||||
|
||||
/* ────────────────────────────────────────────── */
|
||||
/* 4 · Luis Andrés Solís Calderón */
|
||||
/* ────────────────────────────────────────────── */
|
||||
const luis: MockCustomer = {
|
||||
id: 'cust-004',
|
||||
name: 'Luis Andrés Solís Calderón',
|
||||
initials: 'LS',
|
||||
type: 'Individual',
|
||||
documentId: '1-1102-0398',
|
||||
email: 'luis.solis@outlook.com',
|
||||
phone: '+506 8455-7721',
|
||||
birthDate: '1968-01-09',
|
||||
gender: 'Male',
|
||||
address: 'San José, Santa Ana, Pozos',
|
||||
since: '2017-11-20',
|
||||
agent: 'Ana R.',
|
||||
preferredLang: 'Spanish',
|
||||
tags: ['High-value', 'Referral source', 'Multi-line'],
|
||||
paymentStatus: 'Overdue',
|
||||
policies: [
|
||||
{ id: 'POL-2022-2200', line: 'Auto', carrier: 'Qualitas', product: '2022 BMW X5 — SJO-2200', premium: 3200, status: 'Active', renewal: '2025-05-01', icon: 'i-heroicons-truck', details: 'Comprehensive • $1,000 deductible', referralChannel: 'Referral — client' },
|
||||
{ id: 'POL-2022-2201', line: 'Auto', carrier: 'Qualitas', product: '2020 Mercedes GLC — SJO-7788', premium: 2800, status: 'Active', renewal: '2025-05-01', icon: 'i-heroicons-truck', details: 'Comprehensive • $1,000 deductible • Spouse vehicle', referralChannel: 'Referral — client' },
|
||||
{ id: 'POL-2020-0055', line: 'Home', carrier: 'ASSA', product: 'Homeowner — Santa Ana residence', premium: 2100, status: 'Active', renewal: '2025-07-15', icon: 'i-heroicons-home-modern', details: 'Dwelling $420K • Contents $95K • Flood + Earthquake', referralChannel: 'Cross-sell' },
|
||||
{ id: 'POL-2021-0750', line: 'Life', carrier: 'Pan-American Life', product: 'Whole life — $500K', premium: 4800, status: 'Active', renewal: '2025-11-20', icon: 'i-heroicons-shield-check', details: 'Beneficiaries: Patricia Calderón (60%), Children (40%)', referralChannel: 'Google Ads' },
|
||||
{ id: 'POL-2023-6600', line: 'Life', carrier: 'Blue Cross', product: 'Family health — Platinum', premium: 8400, status: 'Active', renewal: '2025-10-01', icon: 'i-heroicons-heart', details: '4 members • Dental + Vision • Intl coverage', referralChannel: 'Google Ads' },
|
||||
{ id: 'POL-2023-6601', line: 'General Risk', carrier: 'INS', product: 'Personal umbrella — $2M', premium: 680, status: 'Active', renewal: '2025-12-01', icon: 'i-heroicons-shield-exclamation', details: 'Excess liability over auto + home', referralChannel: 'Cross-sell' },
|
||||
],
|
||||
claims: [
|
||||
{ id: 'CL-3020', policy: 'POL-2022-2200', type: 'Collision — rear-end', date: '2025-03-08', amount: 9500, status: 'In progress' },
|
||||
{ id: 'CL-2650', policy: 'POL-2023-6600', type: 'Surgery reimbursement', date: '2024-10-15', amount: 12400, status: 'Resolved' },
|
||||
{ id: 'CL-2102', policy: 'POL-2020-0055', type: 'Storm damage — roof', date: '2024-06-22', amount: 8200, status: 'Resolved' },
|
||||
],
|
||||
payments: [
|
||||
{ date: '2025-04-01', amount: 1832, policy: 'Multiple', method: 'Auto-debit', status: 'Failed' },
|
||||
{ date: '2025-03-01', amount: 1832, policy: 'Multiple', method: 'Auto-debit', status: 'Paid' },
|
||||
{ date: '2025-02-01', amount: 1832, policy: 'Multiple', method: 'Auto-debit', status: 'Paid' },
|
||||
{ date: '2025-01-01', amount: 1832, policy: 'Multiple', method: 'Auto-debit', status: 'Paid' },
|
||||
],
|
||||
activity: [
|
||||
{ date: 'Today', text: 'ALERT: April payment failed — auto-debit declined', type: 'payment' },
|
||||
{ date: 'Yesterday', text: 'Collision claim CL-3020 — repair estimate received ($9,500)', type: 'claim' },
|
||||
{ date: 'Mar 20', text: 'Auto policies up for renewal May 1 — quote comparison started', type: 'renewal' },
|
||||
{ date: 'Mar 08', text: 'Collision claim filed — CL-3020 (BMW rear-end)', type: 'claim' },
|
||||
{ date: 'Feb 15', text: 'Annual portfolio review — recommended umbrella increase', type: 'note' },
|
||||
{ date: 'Jan 20', text: 'Health claim CL-2650 resolved — $12,400 reimbursed', type: 'claim' },
|
||||
],
|
||||
}
|
||||
|
||||
/* ────────────────────────────────────────────── */
|
||||
/* 5 · Sofía Campos Rojas */
|
||||
/* ────────────────────────────────────────────── */
|
||||
const sofia: MockCustomer = {
|
||||
id: 'cust-005',
|
||||
name: 'Sofía Campos Rojas',
|
||||
initials: 'SC',
|
||||
type: 'Individual',
|
||||
documentId: '4-0220-0561',
|
||||
email: 'sofia.campos@icloud.com',
|
||||
phone: '+506 7233-0098',
|
||||
birthDate: '1995-12-03',
|
||||
gender: 'Female',
|
||||
address: 'Guanacaste, Liberia, Centro',
|
||||
since: '2024-01-15',
|
||||
agent: 'Marco V.',
|
||||
preferredLang: 'Spanish',
|
||||
tags: ['Young professional'],
|
||||
paymentStatus: 'Grace period',
|
||||
policies: [
|
||||
{ id: 'POL-2024-9901', line: 'Auto', carrier: 'INS', product: '2024 Mazda CX-30 — GUA-0098', premium: 1380, status: 'Active', renewal: '2026-01-15', icon: 'i-heroicons-truck', details: 'Comprehensive • $500 deductible', referralChannel: 'Instagram campaign' },
|
||||
{ id: 'POL-2024-9902', line: 'Life', carrier: 'Mapfre', product: 'Term life — 10yr, $100K', premium: 360, status: 'Active', renewal: '2026-01-15', icon: 'i-heroicons-shield-check', details: 'Beneficiary: Elena Rojas (mother)', referralChannel: 'Cross-sell' },
|
||||
],
|
||||
claims: [
|
||||
{ id: 'CL-3101', policy: 'POL-2024-9901', type: 'Fender bender', date: '2025-03-20', amount: 1800, status: 'Under review' },
|
||||
],
|
||||
payments: [
|
||||
{ date: '2025-04-01', amount: 145, policy: 'POL-2024-9901', method: 'Transfer', status: 'Pending' },
|
||||
{ date: '2025-03-01', amount: 145, policy: 'POL-2024-9901', method: 'Transfer', status: 'Paid' },
|
||||
{ date: '2025-03-01', amount: 30, policy: 'POL-2024-9902', method: 'Transfer', status: 'Paid' },
|
||||
{ date: '2025-02-01', amount: 145, policy: 'POL-2024-9901', method: 'Transfer', status: 'Paid' },
|
||||
],
|
||||
activity: [
|
||||
{ date: 'Today', text: 'Grace period — April auto premium not yet received', type: 'payment' },
|
||||
{ date: 'Mar 22', text: 'Fender bender claim CL-3101 filed', type: 'claim' },
|
||||
{ date: 'Mar 01', text: 'Monthly payment received ($175)', type: 'payment' },
|
||||
{ date: 'Jan 15', text: 'Policies renewed — Auto + Life', type: 'renewal' },
|
||||
],
|
||||
}
|
||||
|
||||
/* ────────────────────────────────────────────── */
|
||||
/* 6 · Quick Lead — Diego Herrera */
|
||||
/* ────────────────────────────────────────────── */
|
||||
const diego: MockCustomer = {
|
||||
id: 'cust-006',
|
||||
name: 'Diego Herrera',
|
||||
initials: 'DH',
|
||||
type: 'Individual',
|
||||
documentId: '—',
|
||||
email: 'diego.h@gmail.com',
|
||||
phone: '+506 6100-4422',
|
||||
birthDate: '',
|
||||
gender: '',
|
||||
address: '',
|
||||
since: '2026-03-28',
|
||||
agent: 'Marco V.',
|
||||
preferredLang: 'Spanish',
|
||||
tags: ['Quick lead', 'Referral'],
|
||||
paymentStatus: 'N/A',
|
||||
policies: [],
|
||||
claims: [],
|
||||
payments: [],
|
||||
activity: [
|
||||
{ date: 'Mar 28', text: 'Quick lead captured — referred by Roberto Jiménez', type: 'onboarding' },
|
||||
],
|
||||
}
|
||||
|
||||
/* ────────────────────────────────────────────── */
|
||||
/* 7 · Quick Lead — Valeria Núñez */
|
||||
/* ────────────────────────────────────────────── */
|
||||
const valeria: MockCustomer = {
|
||||
id: 'cust-007',
|
||||
name: 'Valeria Núñez',
|
||||
initials: 'VN',
|
||||
type: 'Individual',
|
||||
documentId: '—',
|
||||
email: '',
|
||||
phone: '+506 8899-1100',
|
||||
birthDate: '',
|
||||
gender: '',
|
||||
address: '',
|
||||
since: '2026-04-01',
|
||||
agent: 'Ana R.',
|
||||
preferredLang: 'Spanish',
|
||||
tags: ['Quick lead', 'Walk-in'],
|
||||
paymentStatus: 'N/A',
|
||||
policies: [],
|
||||
claims: [],
|
||||
payments: [],
|
||||
activity: [
|
||||
{ date: 'Apr 01', text: 'Walk-in lead — interested in auto insurance', type: 'onboarding' },
|
||||
],
|
||||
}
|
||||
|
||||
/* ────────────────────────────────────────────── */
|
||||
/* 8 · Lead — Andrés Mora Villalobos */
|
||||
/* ────────────────────────────────────────────── */
|
||||
const andres: MockCustomer = {
|
||||
id: 'cust-008',
|
||||
name: 'Andrés Mora Villalobos',
|
||||
initials: 'AM',
|
||||
type: 'Individual',
|
||||
documentId: '1-1450-0221',
|
||||
email: 'andres.mora@empresa.cr',
|
||||
phone: '+506 7744-5566',
|
||||
birthDate: '1990-05-18',
|
||||
gender: 'Male',
|
||||
address: 'San José, Moravia, San Vicente',
|
||||
since: '2026-03-15',
|
||||
agent: 'Ana R.',
|
||||
preferredLang: 'Spanish',
|
||||
tags: ['Lead', 'Corporate referral'],
|
||||
paymentStatus: 'N/A',
|
||||
policies: [],
|
||||
claims: [],
|
||||
payments: [],
|
||||
activity: [
|
||||
{ date: 'Mar 15', text: 'Lead created — needs auto + health quotes', type: 'onboarding' },
|
||||
{ date: 'Mar 20', text: 'Discovery call completed — family of 3', type: 'note' },
|
||||
],
|
||||
}
|
||||
|
||||
/* ────────────────────────────────────────────── */
|
||||
/* 9 · Lead — Corporación Tecnológica del Valle */
|
||||
/* ────────────────────────────────────────────── */
|
||||
const corpTech: MockCustomer = {
|
||||
id: 'cust-009',
|
||||
name: 'Corporación Tecnológica del Valle',
|
||||
initials: 'CT',
|
||||
type: 'Corporate',
|
||||
documentId: '3-101-789456',
|
||||
email: 'rrhh@corptech.cr',
|
||||
phone: '+506 2234-8800',
|
||||
birthDate: '',
|
||||
gender: '',
|
||||
address: 'Heredia, Heredia, Zona Franca',
|
||||
since: '2026-02-10',
|
||||
agent: 'Marco V.',
|
||||
preferredLang: 'Spanish',
|
||||
tags: ['Lead', 'Corporate', 'Group health prospect'],
|
||||
paymentStatus: 'N/A',
|
||||
policies: [],
|
||||
claims: [],
|
||||
payments: [],
|
||||
activity: [
|
||||
{ date: 'Feb 10', text: 'Corporate lead — 45 employees, group health RFP', type: 'onboarding' },
|
||||
{ date: 'Mar 05', text: 'Census received — quoting in progress', type: 'quote' },
|
||||
],
|
||||
}
|
||||
|
||||
/* ────────────────────────────────────────────── */
|
||||
/* 10 · Cancelled — Fernando Arias Blanco */
|
||||
/* ────────────────────────────────────────────── */
|
||||
const fernando: MockCustomer = {
|
||||
id: 'cust-010',
|
||||
name: 'Fernando Arias Blanco',
|
||||
initials: 'FA',
|
||||
type: 'Individual',
|
||||
documentId: '1-0892-0344',
|
||||
email: 'fernando.arias@hotmail.com',
|
||||
phone: '+506 8322-0017',
|
||||
birthDate: '1982-09-14',
|
||||
gender: 'Male',
|
||||
address: 'Alajuela, San Carlos, Ciudad Quesada',
|
||||
since: '2020-04-01',
|
||||
agent: 'Ana R.',
|
||||
preferredLang: 'Spanish',
|
||||
tags: ['Cancelled', 'Win-back opportunity'],
|
||||
paymentStatus: 'N/A',
|
||||
policies: [
|
||||
{ id: 'POL-2020-1100', line: 'Auto', carrier: 'INS', product: '2018 Toyota Hilux — ALJ-4400', premium: 1200, status: 'Cancelled', renewal: '2024-04-01', icon: 'i-heroicons-truck', details: 'Cancelled — non-payment', referralChannel: 'Direct walk-in' },
|
||||
{ id: 'POL-2021-2200', line: 'Home', carrier: 'ASSA', product: 'Homeowner — Ciudad Quesada', premium: 650, status: 'Lapsed', renewal: '2024-06-15', icon: 'i-heroicons-home-modern', details: 'Lapsed — did not renew', referralChannel: 'Direct walk-in' },
|
||||
],
|
||||
claims: [
|
||||
{ id: 'CL-1200', policy: 'POL-2020-1100', type: 'Theft attempt', date: '2023-08-10', amount: 3200, status: 'Resolved' },
|
||||
],
|
||||
payments: [
|
||||
{ date: '2024-01-01', amount: 154, policy: 'POL-2020-1100', method: 'Transfer', status: 'Paid' },
|
||||
{ date: '2024-02-01', amount: 154, policy: 'POL-2020-1100', method: 'Transfer', status: 'Overdue' },
|
||||
{ date: '2024-03-01', amount: 154, policy: 'POL-2020-1100', method: 'Transfer', status: 'Overdue' },
|
||||
],
|
||||
activity: [
|
||||
{ date: 'Apr 01', text: 'Auto policy cancelled — 3 months unpaid', type: 'policy' },
|
||||
{ date: 'Jun 15', text: 'Home policy lapsed — did not renew', type: 'policy' },
|
||||
{ date: 'Aug 20', text: 'Win-back call attempted — no answer', type: 'note' },
|
||||
],
|
||||
}
|
||||
|
||||
/* ────────────────────────────────────────────── */
|
||||
/* Exports */
|
||||
/* ────────────────────────────────────────────── */
|
||||
|
||||
export const MOCK_CUSTOMERS: MockCustomer[] = [maria, roberto, carolina, luis, sofia, diego, valeria, andres, corpTech, fernando]
|
||||
|
||||
export const MOCK_CUSTOMERS_BY_ID: Record<string, MockCustomer> = Object.fromEntries(
|
||||
MOCK_CUSTOMERS.map((c) => [c.id, c])
|
||||
)
|
||||
|
||||
/** Helper to format currency */
|
||||
export function fmtMoney(n: number): string {
|
||||
return `$${n.toLocaleString()}`
|
||||
}
|
||||
@@ -1,660 +0,0 @@
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
// RENEWALS — Data Layer
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
// --- Carrier Status ---
|
||||
export type CarrierRenewalStatus =
|
||||
| 'pending'
|
||||
| 'terms_received'
|
||||
| 'remarketing'
|
||||
| 'bound'
|
||||
| 'declined'
|
||||
| 'lapsed'
|
||||
|
||||
// --- Broker Workflow Status ---
|
||||
export type BrokerRenewalStatus =
|
||||
| 'unreviewed'
|
||||
| 'under_review'
|
||||
| 'proposal_sent'
|
||||
| 'awaiting_client_response'
|
||||
| 'awaiting_payment'
|
||||
| 'closed_renewed'
|
||||
| 'closed_remarketed'
|
||||
| 'closed_cancelled'
|
||||
| 'not_renewing'
|
||||
|
||||
export type RenewalPriority = 'critical' | 'high' | 'medium' | 'low'
|
||||
export type RetentionRisk = 'high' | 'medium' | 'low'
|
||||
|
||||
export type CancellationReason =
|
||||
| 'price'
|
||||
| 'service'
|
||||
| 'competitor'
|
||||
| 'coverage_gap'
|
||||
| 'business_closed'
|
||||
| 'non_payment'
|
||||
| 'carrier_declined'
|
||||
| 'other'
|
||||
|
||||
export interface CancellationData {
|
||||
reason: CancellationReason
|
||||
reasonDetail: string | null
|
||||
competitor: string | null
|
||||
competitorPremium: number | null
|
||||
recoverable: boolean
|
||||
exitDate: string
|
||||
}
|
||||
|
||||
export interface RenewalTask {
|
||||
id: string
|
||||
title: string
|
||||
type: 'review' | 'send_proposal' | 'follow_up' | 'collect_payment' | 'escalation'
|
||||
status: 'open' | 'in_progress' | 'overdue' | 'done'
|
||||
assignee: string
|
||||
dueDate: string
|
||||
aiGenerated: boolean
|
||||
slaPercent: number
|
||||
}
|
||||
|
||||
export interface RenewalCommunication {
|
||||
id: string
|
||||
type: 'email' | 'call' | 'note' | 'system'
|
||||
direction: 'inbound' | 'outbound' | 'internal'
|
||||
from: string
|
||||
to: string | null
|
||||
subject: string | null
|
||||
body: string
|
||||
aiDigest: string | null
|
||||
templateUsed: string | null
|
||||
timestamp: string
|
||||
partyRole: string
|
||||
}
|
||||
|
||||
export interface RenewalDocument {
|
||||
id: string
|
||||
name: string
|
||||
category: 'current_policy' | 'renewal_terms' | 'proposal_sent' |
|
||||
'client_confirmation' | 'payment_receipt' | 'loss_runs' |
|
||||
'correspondence' | 'cancellation'
|
||||
uploadedBy: string
|
||||
uploadedAt: string
|
||||
required: boolean
|
||||
fulfilled: boolean
|
||||
}
|
||||
|
||||
export interface RenewalParty {
|
||||
id: string
|
||||
role: 'insured' | 'carrier_rep' | 'producer' | 'handler' | 'ai_agent'
|
||||
name: string
|
||||
company: string | null
|
||||
phone: string | null
|
||||
email: string | null
|
||||
hasUnread: boolean
|
||||
}
|
||||
|
||||
export interface RenewalQuote {
|
||||
id: string
|
||||
carrier: string
|
||||
premium: number
|
||||
currency: 'USD' | 'CRC'
|
||||
coverageAmount: number
|
||||
deductible: number
|
||||
receivedAt: string
|
||||
recommended: boolean
|
||||
notes: string | null
|
||||
}
|
||||
|
||||
export interface CoverageLine {
|
||||
name: string
|
||||
currentAmount: number | string
|
||||
renewalAmount: number | string | null
|
||||
delta: string | null
|
||||
flag: 'increase' | 'decrease' | 'same' | 'new' | 'removed' | null
|
||||
}
|
||||
|
||||
export interface PolicyComparison {
|
||||
currentPremium: number
|
||||
renewalPremium: number | null
|
||||
premiumDelta: number | null
|
||||
currentDeductible: number
|
||||
renewalDeductible: number | null
|
||||
deductibleDelta: number | null
|
||||
coverageLines: CoverageLine[]
|
||||
aiAnalysis: string | null
|
||||
}
|
||||
|
||||
export interface EmailTemplate {
|
||||
id: string
|
||||
name: string
|
||||
subject: string
|
||||
body: string
|
||||
stage: BrokerRenewalStatus
|
||||
lob: string | 'all'
|
||||
isDefault: boolean
|
||||
}
|
||||
|
||||
export interface RenewalHistoryEntry {
|
||||
year: number
|
||||
carrier: string
|
||||
premium: number
|
||||
outcome: 'renewed' | 'remarketed' | 'cancelled' | 'new'
|
||||
}
|
||||
|
||||
// --- List item ---
|
||||
export interface Renewal {
|
||||
id: string
|
||||
policyId: string
|
||||
policyNumber: string
|
||||
customerId: string
|
||||
customerName: string
|
||||
customerType: 'individual' | 'corporate'
|
||||
carrier: string
|
||||
lob: string
|
||||
currentPremium: number
|
||||
renewalPremium: number | null
|
||||
premiumDelta: number | null
|
||||
currency: 'USD' | 'CRC'
|
||||
expiryDate: string
|
||||
daysUntilExpiry: number
|
||||
carrierStatus: CarrierRenewalStatus
|
||||
brokerStatus: BrokerRenewalStatus
|
||||
priority: RenewalPriority
|
||||
retentionRisk: RetentionRisk
|
||||
lossRatio: number
|
||||
yearsAsClient: number
|
||||
openClaims: number
|
||||
assignedTo: string
|
||||
lastContactDate: string | null
|
||||
slaPercent: number
|
||||
outstandingBalance: number
|
||||
paymentStatus: 'current' | 'overdue' | 'grace_period'
|
||||
}
|
||||
|
||||
// --- Detail ---
|
||||
export interface RenewalDetail extends Renewal {
|
||||
claimIds: string[]
|
||||
parties: RenewalParty[]
|
||||
tasks: RenewalTask[]
|
||||
communications: RenewalCommunication[]
|
||||
documents: RenewalDocument[]
|
||||
comparison: PolicyComparison | null
|
||||
quotes: RenewalQuote[]
|
||||
renewalHistory: RenewalHistoryEntry[]
|
||||
cancellationData: CancellationData | null
|
||||
commissionRate: number
|
||||
commissionAmount: number
|
||||
aiRenewalBrief: string | null
|
||||
aiTalkTrack: string[] | null
|
||||
aiRetentionFactors: string[] | null
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
// Label Maps
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
export const carrierStatusLabels: Record<CarrierRenewalStatus, string> = {
|
||||
pending: 'Pendiente',
|
||||
terms_received: 'Términos Recibidos',
|
||||
remarketing: 'En Remarketing',
|
||||
bound: 'Vinculada',
|
||||
declined: 'Declinada',
|
||||
lapsed: 'Vencida',
|
||||
}
|
||||
|
||||
export const brokerStatusLabels: Record<BrokerRenewalStatus, string> = {
|
||||
unreviewed: 'Sin Revisar',
|
||||
under_review: 'En Revisión',
|
||||
proposal_sent: 'Propuesta Enviada',
|
||||
awaiting_client_response: 'Esperando Cliente',
|
||||
awaiting_payment: 'Esperando Pago',
|
||||
closed_renewed: 'Renovada',
|
||||
closed_remarketed: 'Remarketing Exitoso',
|
||||
closed_cancelled: 'Cancelada',
|
||||
not_renewing: 'No Renueva',
|
||||
}
|
||||
|
||||
export const priorityLabels: Record<RenewalPriority, string> = {
|
||||
critical: 'Crítica',
|
||||
high: 'Alta',
|
||||
medium: 'Media',
|
||||
low: 'Baja',
|
||||
}
|
||||
|
||||
export const retentionRiskLabels: Record<RetentionRisk, string> = {
|
||||
high: 'Alto',
|
||||
medium: 'Medio',
|
||||
low: 'Bajo',
|
||||
}
|
||||
|
||||
export const cancellationReasonLabels: Record<CancellationReason, string> = {
|
||||
price: 'Precio',
|
||||
service: 'Servicio',
|
||||
competitor: 'Competencia',
|
||||
coverage_gap: 'Cobertura insuficiente',
|
||||
business_closed: 'Cierre de negocio',
|
||||
non_payment: 'Falta de pago',
|
||||
carrier_declined: 'Aseguradora declinó',
|
||||
other: 'Otro',
|
||||
}
|
||||
|
||||
export const expiryBuckets = {
|
||||
expired: { label: 'Vencidas', min: -Infinity, max: -1 },
|
||||
this_week: { label: 'Esta semana', min: 0, max: 7 },
|
||||
thirty_days: { label: '30 días', min: 8, max: 30 },
|
||||
sixty_days: { label: '60 días', min: 31, max: 60 },
|
||||
ninety_days: { label: '90 días', min: 61, max: 90 },
|
||||
future: { label: '90+ días', min: 91, max: Infinity },
|
||||
}
|
||||
|
||||
export const templateMergeFields = [
|
||||
'{{customer_name}}', '{{policy_number}}', '{{lob}}', '{{carrier}}',
|
||||
'{{current_premium}}', '{{renewal_premium}}', '{{premium_delta}}',
|
||||
'{{expiry_date}}', '{{coverage_amount}}', '{{deductible}}',
|
||||
'{{producer_name}}', '{{producer_phone}}', '{{producer_email}}',
|
||||
'{{company_name}}', '{{loss_ratio}}', '{{years_as_client}}',
|
||||
]
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
// Helpers
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
export function slaColor(percent: number): 'green' | 'amber' | 'red' {
|
||||
if (percent >= 100) return 'red'
|
||||
if (percent >= 75) return 'amber'
|
||||
return 'green'
|
||||
}
|
||||
|
||||
export function expiryBucket(days: number): string {
|
||||
for (const [key, b] of Object.entries(expiryBuckets)) {
|
||||
if (days >= b.min && days <= b.max) return key
|
||||
}
|
||||
return 'future'
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
// Mock Email Templates
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
export const MOCK_EMAIL_TEMPLATES: EmailTemplate[] = [
|
||||
{
|
||||
id: 'tpl-1', name: 'Aviso de Renovación', stage: 'under_review', lob: 'all', isDefault: true,
|
||||
subject: 'Su póliza {{policy_number}} próxima a vencer',
|
||||
body: 'Estimado/a {{customer_name}},\n\nLe informamos que su póliza {{policy_number}} de {{lob}} con {{carrier}} vence el {{expiry_date}}.\n\nPrima actual: {{current_premium}}\n\nNos pondremos en contacto próximamente con los términos de renovación.\n\nSaludos cordiales,\n{{producer_name}}',
|
||||
},
|
||||
{
|
||||
id: 'tpl-2', name: 'Propuesta de Renovación', stage: 'proposal_sent', lob: 'all', isDefault: true,
|
||||
subject: 'Términos de renovación — {{policy_number}}',
|
||||
body: 'Estimado/a {{customer_name}},\n\nAdjunto los términos de renovación para su póliza {{policy_number}}:\n\n• Prima actual: {{current_premium}}\n• Prima renovación: {{renewal_premium}} ({{premium_delta}})\n• Cobertura: {{coverage_amount}}\n• Deducible: {{deductible}}\n\nPor favor confirme si desea proceder con la renovación.\n\nQuedamos atentos,\n{{producer_name}}\n{{producer_phone}}',
|
||||
},
|
||||
{
|
||||
id: 'tpl-3', name: 'Seguimiento #1', stage: 'awaiting_client_response', lob: 'all', isDefault: true,
|
||||
subject: 'Recordatorio: renovación de póliza {{policy_number}}',
|
||||
body: 'Estimado/a {{customer_name}},\n\nLe recordamos que estamos a la espera de su confirmación para renovar la póliza {{policy_number}} que vence el {{expiry_date}}.\n\nPor favor responda a este correo o comuníquese al {{producer_phone}} para evitar cualquier lapso en su cobertura.\n\nSaludos,\n{{producer_name}}',
|
||||
},
|
||||
{
|
||||
id: 'tpl-4', name: 'Seguimiento #2', stage: 'awaiting_client_response', lob: 'all', isDefault: true,
|
||||
subject: 'Segundo aviso: su póliza {{policy_number}} vence el {{expiry_date}}',
|
||||
body: 'Estimado/a {{customer_name}},\n\nEste es un segundo aviso sobre la renovación de su póliza {{policy_number}}. La fecha de vencimiento es {{expiry_date}} y necesitamos su confirmación a la brevedad posible.\n\nSin su confirmación, la póliza podría vencer sin renovación, dejándole sin cobertura.\n\nPor favor contáctenos de inmediato.\n\n{{producer_name}}\n{{producer_email}} | {{producer_phone}}',
|
||||
},
|
||||
{
|
||||
id: 'tpl-5', name: 'Confirmación de Pago', stage: 'awaiting_payment', lob: 'all', isDefault: true,
|
||||
subject: 'Pago pendiente — renovación {{policy_number}}',
|
||||
body: 'Estimado/a {{customer_name}},\n\nGracias por confirmar la renovación de su póliza {{policy_number}}.\n\nPara completar el proceso, le solicitamos realizar el pago de {{renewal_premium}} a la brevedad.\n\nUna vez recibido el pago, procederemos con la emisión de su nueva póliza.\n\nSaludos,\n{{producer_name}}',
|
||||
},
|
||||
{
|
||||
id: 'tpl-6', name: 'Renovación Completada', stage: 'closed_renewed', lob: 'all', isDefault: true,
|
||||
subject: 'Confirmación de renovación — {{policy_number}}',
|
||||
body: 'Estimado/a {{customer_name}},\n\nNos complace confirmar que su póliza {{policy_number}} ha sido renovada exitosamente con {{carrier}}.\n\nNueva vigencia: {{expiry_date}}\nPrima: {{renewal_premium}}\n\nAdjunto encontrará su nueva póliza. No dude en contactarnos para cualquier consulta.\n\nGracias por su confianza,\n{{producer_name}}\n{{company_name}}',
|
||||
},
|
||||
]
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
// Mock Renewals (Pipeline List)
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
export const MOCK_RENEWALS: Renewal[] = [
|
||||
{
|
||||
id: 'REN-001', policyId: 'POL-0955', policyNumber: 'PROP-2024-HP-001',
|
||||
customerId: 'CUS-001', customerName: 'Hotel Pacífico Resort', customerType: 'corporate',
|
||||
carrier: 'ASSA', lob: 'Auto', currentPremium: 18500, renewalPremium: 22570,
|
||||
premiumDelta: 22, currency: 'USD', expiryDate: '2026-04-11', daysUntilExpiry: 3,
|
||||
carrierStatus: 'terms_received', brokerStatus: 'awaiting_client_response',
|
||||
priority: 'critical', retentionRisk: 'high', lossRatio: 0.42, yearsAsClient: 5,
|
||||
openClaims: 1, assignedTo: 'Marco V.', lastContactDate: '2026-04-03',
|
||||
slaPercent: 95, outstandingBalance: 0, paymentStatus: 'current',
|
||||
},
|
||||
{
|
||||
id: 'REN-002', policyId: 'POL-1034', policyNumber: 'GR-2024-CM-001',
|
||||
customerId: 'CUS-002', customerName: 'Constructora Montes', customerType: 'corporate',
|
||||
carrier: 'Mapfre', lob: 'General Risk', currentPremium: 32000, renewalPremium: null,
|
||||
premiumDelta: null, currency: 'USD', expiryDate: '2026-04-22', daysUntilExpiry: 14,
|
||||
carrierStatus: 'pending', brokerStatus: 'under_review',
|
||||
priority: 'high', retentionRisk: 'medium', lossRatio: 0.15, yearsAsClient: 3,
|
||||
openClaims: 0, assignedTo: 'Ana R.', lastContactDate: '2026-04-01',
|
||||
slaPercent: 55, outstandingBalance: 0, paymentStatus: 'current',
|
||||
},
|
||||
{
|
||||
id: 'REN-003', policyId: 'POL-1077', policyNumber: 'LIFE-2024-CR-001',
|
||||
customerId: 'CUS-003', customerName: 'Carmen Ruiz', customerType: 'individual',
|
||||
carrier: 'Pan-American Life', lob: 'Life', currentPremium: 2800, renewalPremium: 2940,
|
||||
premiumDelta: 5, currency: 'USD', expiryDate: '2026-04-15', daysUntilExpiry: 7,
|
||||
carrierStatus: 'terms_received', brokerStatus: 'awaiting_payment',
|
||||
priority: 'medium', retentionRisk: 'low', lossRatio: 0, yearsAsClient: 8,
|
||||
openClaims: 0, assignedTo: 'Ana R.', lastContactDate: '2026-04-06',
|
||||
slaPercent: 30, outstandingBalance: 0, paymentStatus: 'current',
|
||||
},
|
||||
{
|
||||
id: 'REN-004', policyId: 'POL-1110', policyNumber: 'AUTO-2024-ABC-001',
|
||||
customerId: 'CUS-004', customerName: 'Empresa ABC S.A.', customerType: 'corporate',
|
||||
carrier: 'Qualitas', lob: 'Auto', currentPremium: 22400, renewalPremium: null,
|
||||
premiumDelta: null, currency: 'USD', expiryDate: '2026-05-08', daysUntilExpiry: 30,
|
||||
carrierStatus: 'pending', brokerStatus: 'unreviewed',
|
||||
priority: 'low', retentionRisk: 'low', lossRatio: 0.08, yearsAsClient: 2,
|
||||
openClaims: 0, assignedTo: 'Unassigned', lastContactDate: null,
|
||||
slaPercent: 10, outstandingBalance: 0, paymentStatus: 'current',
|
||||
},
|
||||
{
|
||||
id: 'REN-005', policyId: 'POL-0888', policyNumber: 'HEALTH-2024-CSJ-001',
|
||||
customerId: 'CUS-005', customerName: 'Clínica San José', customerType: 'corporate',
|
||||
carrier: 'ASSA', lob: 'Health', currentPremium: 45000, renewalPremium: null,
|
||||
premiumDelta: null, currency: 'USD', expiryDate: '2026-04-01', daysUntilExpiry: -7,
|
||||
carrierStatus: 'lapsed', brokerStatus: 'closed_cancelled',
|
||||
priority: 'high', retentionRisk: 'high', lossRatio: 0.65, yearsAsClient: 4,
|
||||
openClaims: 0, assignedTo: 'Marco V.', lastContactDate: '2026-03-28',
|
||||
slaPercent: 100, outstandingBalance: 8500, paymentStatus: 'overdue',
|
||||
},
|
||||
{
|
||||
id: 'REN-006', policyId: 'POL-1200', policyNumber: 'GR-2024-TN-001',
|
||||
customerId: 'CUS-006', customerName: 'Transportes del Norte', customerType: 'corporate',
|
||||
carrier: 'ASSA', lob: 'General Risk', currentPremium: 38000, renewalPremium: null,
|
||||
premiumDelta: null, currency: 'USD', expiryDate: '2026-06-07', daysUntilExpiry: 60,
|
||||
carrierStatus: 'pending', brokerStatus: 'unreviewed',
|
||||
priority: 'high', retentionRisk: 'high', lossRatio: 0.55, yearsAsClient: 6,
|
||||
openClaims: 1, assignedTo: 'Unassigned', lastContactDate: null,
|
||||
slaPercent: 15, outstandingBalance: 21000, paymentStatus: 'overdue',
|
||||
},
|
||||
]
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
// Mock Renewal Details
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
const ren001Detail: RenewalDetail = {
|
||||
...MOCK_RENEWALS[0],
|
||||
claimIds: ['CLM-0048'],
|
||||
parties: [
|
||||
{ id: 'rp-1', role: 'insured', name: 'Carlos Montero', company: 'Hotel Pacífico Resort', phone: '+507 6700-1234', email: 'cmontero@hotelpacífico.com', hasUnread: true },
|
||||
{ id: 'rp-2', role: 'carrier_rep', name: 'Lucía Vargas', company: 'ASSA', phone: '+507 6600-9876', email: 'lvargas@assa.com', hasUnread: false },
|
||||
{ id: 'rp-3', role: 'handler', name: 'Marco V.', company: null, phone: null, email: 'marco@segur-os.com', hasUnread: false },
|
||||
],
|
||||
tasks: [
|
||||
{ id: 'rt-1', title: 'Follow up with client — no response in 5 days', type: 'follow_up', status: 'overdue', assignee: 'Marco V.', dueDate: '2026-04-06', aiGenerated: true, slaPercent: 110 },
|
||||
{ id: 'rt-2', title: 'Prepare counter-proposal (premium increase justification)', type: 'send_proposal', status: 'open', assignee: 'Marco V.', dueDate: '2026-04-09', aiGenerated: false, slaPercent: 80 },
|
||||
{ id: 'rt-3', title: 'Review loss runs before presenting to client', type: 'review', status: 'done', assignee: 'Marco V.', dueDate: '2026-04-02', aiGenerated: false, slaPercent: 20 },
|
||||
],
|
||||
communications: [
|
||||
{ id: 'rc-1', type: 'email', direction: 'outbound', from: 'Marco V.', to: 'Carlos Montero', subject: 'Términos de renovación — PROP-2024-HP-001', body: 'Estimado Carlos, adjunto los términos de renovación de su póliza de auto. La prima pasa de $18,500 a $22,570 (+22%) debido al reclamo abierto y al ajuste de mercado. Quedo atento a su respuesta.', aiDigest: null, templateUsed: 'tpl-2', timestamp: '2026-04-03T10:30:00', partyRole: 'insured' },
|
||||
{ id: 'rc-2', type: 'email', direction: 'inbound', from: 'Lucía Vargas', to: 'Marco V.', subject: 'RE: Renewal terms HP-001', body: 'Marco, adjunto los términos definitivos para Hotel Pacífico. El aumento refleja el siniestro abierto CLM-0048 y el ajuste de tarifa general para flota comercial.', aiDigest: 'ASSA confirma aumento de 22% justificado por siniestro abierto y ajuste de tarifa.', templateUsed: null, timestamp: '2026-04-02T15:45:00', partyRole: 'carrier_rep' },
|
||||
{ id: 'rc-3', type: 'note', direction: 'internal', from: 'Marco V.', to: null, subject: null, body: 'Client not responding to emails. Called twice, went to voicemail. Need to escalate if no response by Apr 8.', aiDigest: null, templateUsed: null, timestamp: '2026-04-06T09:00:00', partyRole: 'handler' },
|
||||
{ id: 'rc-4', type: 'system', direction: 'internal', from: 'System', to: null, subject: null, body: 'AI escalation: Client non-response exceeds 5 days. Recommended action: send Seguimiento #1 template.', aiDigest: null, templateUsed: null, timestamp: '2026-04-08T08:00:00', partyRole: 'ai_agent' },
|
||||
],
|
||||
documents: [
|
||||
{ id: 'rd-1', name: 'Póliza actual — PROP-2024-HP-001.pdf', category: 'current_policy', uploadedBy: 'System', uploadedAt: '2026-03-01', required: true, fulfilled: true },
|
||||
{ id: 'rd-2', name: 'Términos renovación ASSA 2026.pdf', category: 'renewal_terms', uploadedBy: 'Lucía Vargas', uploadedAt: '2026-04-02', required: true, fulfilled: true },
|
||||
{ id: 'rd-3', name: 'Propuesta enviada a cliente.pdf', category: 'proposal_sent', uploadedBy: 'Marco V.', uploadedAt: '2026-04-03', required: true, fulfilled: true },
|
||||
{ id: 'rd-4', name: 'Loss runs 2023-2025.pdf', category: 'loss_runs', uploadedBy: 'ASSA', uploadedAt: '2026-04-01', required: true, fulfilled: true },
|
||||
{ id: 'rd-5', name: 'Client confirmation', category: 'client_confirmation', uploadedBy: '', uploadedAt: '', required: true, fulfilled: false },
|
||||
{ id: 'rd-6', name: 'Payment receipt', category: 'payment_receipt', uploadedBy: '', uploadedAt: '', required: true, fulfilled: false },
|
||||
],
|
||||
comparison: {
|
||||
currentPremium: 18500, renewalPremium: 22570, premiumDelta: 22,
|
||||
currentDeductible: 500, renewalDeductible: 750, deductibleDelta: 50,
|
||||
coverageLines: [
|
||||
{ name: 'Responsabilidad Civil', currentAmount: 100000, renewalAmount: 100000, delta: '=', flag: 'same' },
|
||||
{ name: 'Daños Propios', currentAmount: 45000, renewalAmount: 45000, delta: '=', flag: 'same' },
|
||||
{ name: 'Robo Total', currentAmount: 45000, renewalAmount: 40000, delta: '-11%', flag: 'decrease' },
|
||||
{ name: 'Asistencia Vial', currentAmount: 'Incluida', renewalAmount: 'Incluida', delta: '=', flag: 'same' },
|
||||
{ name: 'Vidrios', currentAmount: 2000, renewalAmount: 2500, delta: '+25%', flag: 'increase' },
|
||||
],
|
||||
aiAnalysis: 'El aumento de 22% se justifica por el siniestro abierto CLM-0048 ($128K reservado) y el ajuste de tarifa general de ASSA para flotas comerciales (+8% promedio). El deducible subió 50%. La cobertura de Robo Total baja 11% — recomendar al cliente mantener nivel actual. Se detecta oportunidad de negociar Vidrios si se acepta deducible mayor.',
|
||||
},
|
||||
quotes: [],
|
||||
renewalHistory: [
|
||||
{ year: 2025, carrier: 'ASSA', premium: 18500, outcome: 'renewed' },
|
||||
{ year: 2024, carrier: 'ASSA', premium: 16200, outcome: 'renewed' },
|
||||
{ year: 2023, carrier: 'Qualitas', premium: 15800, outcome: 'remarketed' },
|
||||
{ year: 2022, carrier: 'Qualitas', premium: 14500, outcome: 'renewed' },
|
||||
],
|
||||
cancellationData: null,
|
||||
commissionRate: 15, commissionAmount: 3385.50,
|
||||
aiRenewalBrief: 'Hotel Pacífico es un cliente corporativo de 5 años con prima actual de $18,500 en Auto. ASSA ofrece renovación a $22,570 (+22%), justificado por siniestro abierto CLM-0048 con $128K reservados y ajuste de tarifa de flota. El cliente no ha respondido en 5 días. El deducible sube de $500 a $750. Loss ratio de 42% está por encima del promedio para esta línea. Comisión proyectada: $3,385. Riesgo de retención ALTO.',
|
||||
aiTalkTrack: [
|
||||
'Abrir con empatía: reconocer que un aumento de 22% es significativo y que entendemos su preocupación.',
|
||||
'Explicar el contexto: el siniestro abierto impacta directamente la tarificación. ASSA subió tarifas de flota 8% en general.',
|
||||
'Destacar valor: 5 años como cliente, historial mayormente limpio. Hemos negociado mantener la cobertura de RC en $100K sin cambio.',
|
||||
'Proponer alternativa: si acepta deducible de $1,000 en vez de $750, podemos negociar bajar prima a ~$21,500.',
|
||||
'Urgencia: la póliza vence el 11 de abril. Sin confirmación antes del 9, hay riesgo de lapso.',
|
||||
],
|
||||
aiRetentionFactors: [
|
||||
'5 años como cliente — relación establecida',
|
||||
'Siniestro abierto CLM-0048 dificulta remarketing',
|
||||
'Flota comercial con cobertura especializada — pocas opciones en mercado local',
|
||||
'Balance al día, historial de pago puntual',
|
||||
'Riesgo: aumento de 22% puede motivar búsqueda de alternativas',
|
||||
],
|
||||
}
|
||||
|
||||
const ren005Detail: RenewalDetail = {
|
||||
...MOCK_RENEWALS[4],
|
||||
claimIds: [],
|
||||
parties: [
|
||||
{ id: 'rp-10', role: 'insured', name: 'Dr. Alejandro Solís', company: 'Clínica San José', phone: '+507 6500-3456', email: 'asolis@clinicasanjose.com', hasUnread: false },
|
||||
{ id: 'rp-11', role: 'handler', name: 'Marco V.', company: null, phone: null, email: 'marco@segur-os.com', hasUnread: false },
|
||||
],
|
||||
tasks: [
|
||||
{ id: 'rt-10', title: 'Document cancellation reason', type: 'review', status: 'done', assignee: 'Marco V.', dueDate: '2026-04-02', aiGenerated: false, slaPercent: 100 },
|
||||
{ id: 'rt-11', title: 'Win-back outreach — 30 day follow-up', type: 'follow_up', status: 'open', assignee: 'Marco V.', dueDate: '2026-05-01', aiGenerated: true, slaPercent: 20 },
|
||||
],
|
||||
communications: [
|
||||
{ id: 'rc-10', type: 'email', direction: 'inbound', from: 'Dr. Alejandro Solís', to: 'Marco V.', subject: 'Decisión sobre renovación', body: 'Marco, lamentablemente hemos decidido no renovar con ASSA. Recibimos una oferta más competitiva de Mapfre a $38,500 con mejores términos de copago. Agradecemos el servicio durante estos 4 años.', aiDigest: 'Cliente cancela por oferta competidora de Mapfre a $38,500 (vs $45,000 actual). Menciona mejores copagos.', templateUsed: null, timestamp: '2026-03-28T11:00:00', partyRole: 'insured' },
|
||||
{ id: 'rc-11', type: 'note', direction: 'internal', from: 'Marco V.', to: null, subject: null, body: 'Lost to Mapfre. Client says their copay terms are better. ASSA was unable to match. Client open to reconsidering in 12 months if terms change. Marked as recoverable.', aiDigest: null, templateUsed: null, timestamp: '2026-03-29T09:00:00', partyRole: 'handler' },
|
||||
],
|
||||
documents: [
|
||||
{ id: 'rd-10', name: 'Póliza actual — HEALTH-2024-CSJ-001.pdf', category: 'current_policy', uploadedBy: 'System', uploadedAt: '2026-02-15', required: true, fulfilled: true },
|
||||
{ id: 'rd-11', name: 'Cancellation email from client.pdf', category: 'cancellation', uploadedBy: 'Marco V.', uploadedAt: '2026-03-28', required: false, fulfilled: true },
|
||||
],
|
||||
comparison: null,
|
||||
quotes: [],
|
||||
renewalHistory: [
|
||||
{ year: 2025, carrier: 'ASSA', premium: 45000, outcome: 'cancelled' },
|
||||
{ year: 2024, carrier: 'ASSA', premium: 42000, outcome: 'renewed' },
|
||||
{ year: 2023, carrier: 'ASSA', premium: 38000, outcome: 'renewed' },
|
||||
{ year: 2022, carrier: 'ASSA', premium: 35000, outcome: 'new' },
|
||||
],
|
||||
cancellationData: {
|
||||
reason: 'competitor',
|
||||
reasonDetail: 'Mapfre offered better copay terms and lower premium',
|
||||
competitor: 'Mapfre',
|
||||
competitorPremium: 38500,
|
||||
recoverable: true,
|
||||
exitDate: '2026-04-01',
|
||||
},
|
||||
commissionRate: 12, commissionAmount: 0,
|
||||
aiRenewalBrief: 'Clínica San José — cliente corporativo de 4 años, póliza de Salud Grupal con ASSA por $45,000. Cancelada por competencia: Mapfre ofreció $38,500 con mejores copagos. Loss ratio 65% (alto). El cliente expresó apertura a reconsiderar en 12 meses. Marcado como recuperable para win-back.',
|
||||
aiTalkTrack: null,
|
||||
aiRetentionFactors: [
|
||||
'Loss ratio de 65% — difícil competir en precio',
|
||||
'Mapfre ofreció $6,500 menos con mejores copagos',
|
||||
'Cliente indica apertura a reconsiderar en 12 meses',
|
||||
'Balance pendiente de $8,500 complica relación',
|
||||
],
|
||||
}
|
||||
|
||||
const ren002Detail: RenewalDetail = {
|
||||
...MOCK_RENEWALS[1],
|
||||
claimIds: [],
|
||||
parties: [
|
||||
{ id: 'rp-20', role: 'insured', name: 'Ing. Ricardo Montes', company: 'Constructora Montes', phone: '+507 6800-2222', email: 'rmontes@constructoramontes.com', hasUnread: false },
|
||||
{ id: 'rp-21', role: 'carrier_rep', name: 'Fernando Gil', company: 'Mapfre', phone: '+507 6600-3333', email: 'fgil@mapfre.com', hasUnread: false },
|
||||
{ id: 'rp-22', role: 'handler', name: 'Ana R.', company: null, phone: null, email: 'ana@segur-os.com', hasUnread: false },
|
||||
],
|
||||
tasks: [
|
||||
{ id: 'rt-20', title: 'Request renewal terms from Mapfre', type: 'review', status: 'in_progress', assignee: 'Ana R.', dueDate: '2026-04-10', aiGenerated: false, slaPercent: 55 },
|
||||
{ id: 'rt-21', title: 'Review last year loss runs', type: 'review', status: 'open', assignee: 'Ana R.', dueDate: '2026-04-12', aiGenerated: false, slaPercent: 40 },
|
||||
],
|
||||
communications: [
|
||||
{ id: 'rc-20', type: 'email', direction: 'outbound', from: 'Ana R.', to: 'Fernando Gil', subject: 'Solicitud de términos — GR-2024-CM-001', body: 'Fernando, buen día. Solicitamos los términos de renovación para la póliza GR-2024-CM-001 de Constructora Montes, vigente hasta el 22 de abril. Agradecemos envío a la brevedad.', aiDigest: null, templateUsed: null, timestamp: '2026-04-01T14:00:00', partyRole: 'carrier_rep' },
|
||||
],
|
||||
documents: [
|
||||
{ id: 'rd-20', name: 'Póliza actual — GR-2024-CM-001.pdf', category: 'current_policy', uploadedBy: 'System', uploadedAt: '2026-03-15', required: true, fulfilled: true },
|
||||
{ id: 'rd-21', name: 'Loss runs 2024-2025.pdf', category: 'loss_runs', uploadedBy: 'Mapfre', uploadedAt: '2026-03-20', required: true, fulfilled: true },
|
||||
{ id: 'rd-22', name: 'Renewal terms', category: 'renewal_terms', uploadedBy: '', uploadedAt: '', required: true, fulfilled: false },
|
||||
],
|
||||
comparison: null,
|
||||
quotes: [],
|
||||
renewalHistory: [
|
||||
{ year: 2025, carrier: 'Mapfre', premium: 32000, outcome: 'renewed' },
|
||||
{ year: 2024, carrier: 'Mapfre', premium: 29000, outcome: 'renewed' },
|
||||
{ year: 2023, carrier: 'ASSA', premium: 27500, outcome: 'remarketed' },
|
||||
],
|
||||
cancellationData: null,
|
||||
commissionRate: 12, commissionAmount: 3840,
|
||||
aiRenewalBrief: 'Constructora Montes — cliente corporativo de 3 años, General Risk con Mapfre por $32,000. Términos de renovación aún no recibidos. Loss ratio bajo (15%). Sin reclamos abiertos. Buena relación con carrier. Se espera renovación sin complicaciones pero se necesitan los términos antes del 15 de abril para cumplir SLA.',
|
||||
aiTalkTrack: null,
|
||||
aiRetentionFactors: [
|
||||
'Loss ratio de 15% — excelente para la línea',
|
||||
'Sin reclamos abiertos',
|
||||
'3 años como cliente, relación estable',
|
||||
'Riesgo moderado: constructora puede buscar cotizaciones competitivas',
|
||||
],
|
||||
}
|
||||
|
||||
const ren003Detail: RenewalDetail = {
|
||||
...MOCK_RENEWALS[2],
|
||||
claimIds: [],
|
||||
parties: [
|
||||
{ id: 'rp-30', role: 'insured', name: 'Carmen Ruiz', company: null, phone: '+507 6400-5555', email: 'carmen.ruiz@gmail.com', hasUnread: false },
|
||||
{ id: 'rp-31', role: 'handler', name: 'Ana R.', company: null, phone: null, email: 'ana@segur-os.com', hasUnread: false },
|
||||
],
|
||||
tasks: [
|
||||
{ id: 'rt-30', title: 'Collect payment — client confirmed renewal', type: 'collect_payment', status: 'open', assignee: 'Ana R.', dueDate: '2026-04-12', aiGenerated: false, slaPercent: 30 },
|
||||
],
|
||||
communications: [
|
||||
{ id: 'rc-30', type: 'email', direction: 'outbound', from: 'Ana R.', to: 'Carmen Ruiz', subject: 'Confirmación de pago — LIFE-2024-CR-001', body: 'Estimada Carmen, gracias por confirmar la renovación de su póliza de vida. La prima para el nuevo período es de $2,940. Por favor realice la transferencia para completar el proceso.', aiDigest: null, templateUsed: 'tpl-5', timestamp: '2026-04-06T10:00:00', partyRole: 'insured' },
|
||||
{ id: 'rc-31', type: 'email', direction: 'inbound', from: 'Carmen Ruiz', to: 'Ana R.', subject: 'RE: Renovación vida', body: 'Ana, confirmo que deseo renovar. Haré la transferencia esta semana.', aiDigest: 'Cliente confirma renovación, promete pago esta semana.', templateUsed: null, timestamp: '2026-04-05T16:30:00', partyRole: 'insured' },
|
||||
],
|
||||
documents: [
|
||||
{ id: 'rd-30', name: 'Póliza actual — LIFE-2024-CR-001.pdf', category: 'current_policy', uploadedBy: 'System', uploadedAt: '2026-03-01', required: true, fulfilled: true },
|
||||
{ id: 'rd-31', name: 'Términos Pan-American Life 2026.pdf', category: 'renewal_terms', uploadedBy: 'Pan-American Life', uploadedAt: '2026-03-25', required: true, fulfilled: true },
|
||||
{ id: 'rd-32', name: 'Propuesta enviada.pdf', category: 'proposal_sent', uploadedBy: 'Ana R.', uploadedAt: '2026-03-28', required: true, fulfilled: true },
|
||||
{ id: 'rd-33', name: 'Client confirmation email.pdf', category: 'client_confirmation', uploadedBy: 'Ana R.', uploadedAt: '2026-04-05', required: true, fulfilled: true },
|
||||
{ id: 'rd-34', name: 'Payment receipt', category: 'payment_receipt', uploadedBy: '', uploadedAt: '', required: true, fulfilled: false },
|
||||
],
|
||||
comparison: {
|
||||
currentPremium: 2800, renewalPremium: 2940, premiumDelta: 5,
|
||||
currentDeductible: 0, renewalDeductible: 0, deductibleDelta: 0,
|
||||
coverageLines: [
|
||||
{ name: 'Vida Individual', currentAmount: 100000, renewalAmount: 100000, delta: '=', flag: 'same' },
|
||||
{ name: 'Muerte Accidental', currentAmount: 50000, renewalAmount: 50000, delta: '=', flag: 'same' },
|
||||
{ name: 'Incapacidad Total', currentAmount: 100000, renewalAmount: 100000, delta: '=', flag: 'same' },
|
||||
],
|
||||
aiAnalysis: 'Aumento de 5% es estándar para ajuste de edad en seguro de vida. Coberturas sin cambio. Renovación rutinaria sin complicaciones.',
|
||||
},
|
||||
quotes: [],
|
||||
renewalHistory: [
|
||||
{ year: 2025, carrier: 'Pan-American Life', premium: 2800, outcome: 'renewed' },
|
||||
{ year: 2024, carrier: 'Pan-American Life', premium: 2650, outcome: 'renewed' },
|
||||
{ year: 2023, carrier: 'Pan-American Life', premium: 2500, outcome: 'renewed' },
|
||||
{ year: 2022, carrier: 'Pan-American Life', premium: 2350, outcome: 'renewed' },
|
||||
{ year: 2021, carrier: 'Pan-American Life', premium: 2200, outcome: 'renewed' },
|
||||
{ year: 2020, carrier: 'Pan-American Life', premium: 2050, outcome: 'renewed' },
|
||||
{ year: 2019, carrier: 'Pan-American Life', premium: 1900, outcome: 'renewed' },
|
||||
{ year: 2018, carrier: 'Pan-American Life', premium: 1750, outcome: 'new' },
|
||||
],
|
||||
cancellationData: null,
|
||||
commissionRate: 20, commissionAmount: 588,
|
||||
aiRenewalBrief: 'Carmen Ruiz — cliente individual de 8 años, póliza de Vida con Pan-American Life. Aumento de 5% por ajuste de edad, estándar. Cliente confirmó renovación, pendiente cobro de $2,940. Excelente historial de retención. Sin reclamos.',
|
||||
aiTalkTrack: null,
|
||||
aiRetentionFactors: ['8 años como cliente — altamente leal', 'Sin reclamos, loss ratio 0%', 'Aumento de 5% es mínimo y esperado'],
|
||||
}
|
||||
|
||||
const ren004Detail: RenewalDetail = {
|
||||
...MOCK_RENEWALS[3],
|
||||
claimIds: [],
|
||||
parties: [
|
||||
{ id: 'rp-40', role: 'insured', name: 'Lic. Patricia Vega', company: 'Empresa ABC S.A.', phone: '+507 6300-7777', email: 'pvega@empresaabc.com', hasUnread: false },
|
||||
],
|
||||
tasks: [
|
||||
{ id: 'rt-40', title: 'Assign handler and begin review', type: 'review', status: 'open', assignee: 'Unassigned', dueDate: '2026-04-15', aiGenerated: true, slaPercent: 10 },
|
||||
],
|
||||
communications: [],
|
||||
documents: [
|
||||
{ id: 'rd-40', name: 'Póliza actual — AUTO-2024-ABC-001.pdf', category: 'current_policy', uploadedBy: 'System', uploadedAt: '2026-04-01', required: true, fulfilled: true },
|
||||
],
|
||||
comparison: null,
|
||||
quotes: [],
|
||||
renewalHistory: [
|
||||
{ year: 2025, carrier: 'Qualitas', premium: 22400, outcome: 'renewed' },
|
||||
{ year: 2024, carrier: 'Qualitas', premium: 21000, outcome: 'new' },
|
||||
],
|
||||
cancellationData: null,
|
||||
commissionRate: 15, commissionAmount: 3360,
|
||||
aiRenewalBrief: 'Empresa ABC S.A. — flota Auto con Qualitas por $22,400. Renovación sin asignar, 30 días hasta vencimiento. Sin reclamos, loss ratio 8%. Necesita asignación inmediata para cumplir SLA.',
|
||||
aiTalkTrack: null,
|
||||
aiRetentionFactors: ['Loss ratio bajo (8%)', '2 años como cliente', 'Sin complicaciones previas'],
|
||||
}
|
||||
|
||||
const ren006Detail: RenewalDetail = {
|
||||
...MOCK_RENEWALS[5],
|
||||
claimIds: ['CLM-0043'],
|
||||
parties: [
|
||||
{ id: 'rp-60', role: 'insured', name: 'Gerardo Núñez', company: 'Transportes del Norte', phone: '+507 6200-8888', email: 'gnunez@transportesnorte.com', hasUnread: true },
|
||||
],
|
||||
tasks: [
|
||||
{ id: 'rt-60', title: 'Assign handler — high-risk renewal', type: 'review', status: 'open', assignee: 'Unassigned', dueDate: '2026-04-20', aiGenerated: true, slaPercent: 15 },
|
||||
{ id: 'rt-61', title: 'Address $21K outstanding balance before renewal', type: 'escalation', status: 'open', assignee: 'Unassigned', dueDate: '2026-04-18', aiGenerated: true, slaPercent: 20 },
|
||||
],
|
||||
communications: [
|
||||
{ id: 'rc-60', type: 'system', direction: 'internal', from: 'System', to: null, subject: null, body: 'AI alert: Transportes del Norte has $21,000 outstanding balance and an open claim (CLM-0043). This renewal requires early attention due to high retention risk.', aiDigest: null, templateUsed: null, timestamp: '2026-04-08T08:00:00', partyRole: 'ai_agent' },
|
||||
],
|
||||
documents: [
|
||||
{ id: 'rd-60', name: 'Póliza actual — GR-2024-TN-001.pdf', category: 'current_policy', uploadedBy: 'System', uploadedAt: '2026-03-15', required: true, fulfilled: true },
|
||||
],
|
||||
comparison: null,
|
||||
quotes: [],
|
||||
renewalHistory: [
|
||||
{ year: 2025, carrier: 'ASSA', premium: 38000, outcome: 'renewed' },
|
||||
{ year: 2024, carrier: 'ASSA', premium: 35000, outcome: 'renewed' },
|
||||
{ year: 2023, carrier: 'ASSA', premium: 32000, outcome: 'renewed' },
|
||||
{ year: 2022, carrier: 'ASSA', premium: 28000, outcome: 'renewed' },
|
||||
{ year: 2021, carrier: 'ASSA', premium: 25000, outcome: 'renewed' },
|
||||
{ year: 2020, carrier: 'ASSA', premium: 22000, outcome: 'new' },
|
||||
],
|
||||
cancellationData: null,
|
||||
commissionRate: 12, commissionAmount: 4560,
|
||||
aiRenewalBrief: 'Transportes del Norte — cliente corporativo de 6 años, General Risk con ASSA por $38,000. ALERTA: balance pendiente de $21,000 y reclamo abierto CLM-0043 ($45K reservado). Loss ratio de 55% dificulta la negociación. Necesita atención temprana a pesar de tener 60 días hasta vencimiento. Comisión en riesgo: $4,560.',
|
||||
aiTalkTrack: [
|
||||
'Primero resolver el tema del balance pendiente de $21K antes de hablar de renovación.',
|
||||
'Reconocer que es un cliente de 6 años con historial generalmente positivo.',
|
||||
'El reclamo abierto CLM-0043 impactará los términos — preparar al cliente para posible aumento.',
|
||||
'Proponer plan de pago para el balance pendiente como condición para negociar mejores términos de renovación.',
|
||||
'Si ASSA sube prima significativamente, tener plan B de remarketing con Mapfre.',
|
||||
],
|
||||
aiRetentionFactors: [
|
||||
'6 años como cliente — relación de largo plazo',
|
||||
'Balance pendiente de $21K — señal de alerta',
|
||||
'Reclamo abierto CLM-0043 por $45K reservado',
|
||||
'Loss ratio 55% — por encima del promedio',
|
||||
'Comisión de $4,560 en riesgo',
|
||||
'Flota de transporte — pocas opciones en mercado local',
|
||||
],
|
||||
}
|
||||
|
||||
export const MOCK_RENEWAL_DETAILS: Record<string, RenewalDetail> = {
|
||||
'REN-001': ren001Detail,
|
||||
'REN-002': ren002Detail,
|
||||
'REN-003': ren003Detail,
|
||||
'REN-004': ren004Detail,
|
||||
'REN-005': ren005Detail,
|
||||
'REN-006': ren006Detail,
|
||||
}
|
||||
@@ -1,699 +0,0 @@
|
||||
// ─── Support Ticket System — Types, Labels, Helpers, Mock Data ───────────────
|
||||
|
||||
// ── Channel ──
|
||||
export type SupportChannel = 'whatsapp' | 'email' | 'phone' | 'walk_in' | 'web_form'
|
||||
|
||||
// ── Routing Tier ──
|
||||
export type RoutingTier = 'tier1_auto' | 'tier2_rule' | 'tier3_open'
|
||||
|
||||
// ── Ticket Status ──
|
||||
export type TicketStatus = 'open' | 'in_progress' | 'pending_customer' | 'resolved'
|
||||
|
||||
// ── Priority ──
|
||||
export type TicketPriority = 'urgent' | 'high' | 'medium' | 'low'
|
||||
|
||||
// ── Intent Category (LLM classification) ──
|
||||
export type IntentCategory =
|
||||
| 'payment_inquiry'
|
||||
| 'claim_report'
|
||||
| 'sales_interest'
|
||||
| 'doc_request'
|
||||
| 'policy_question'
|
||||
| 'complaint'
|
||||
| 'endorsement'
|
||||
| 'certificate_request'
|
||||
| 'general'
|
||||
|
||||
// ── Routed Queue ──
|
||||
export type RoutedQueue = 'collections' | 'claims' | 'sales' | 'renewals' | 'operations' | 'open_pool'
|
||||
|
||||
// ── Message Types ──
|
||||
export type MessageType = 'whatsapp' | 'email' | 'phone_note' | 'internal_note' | 'system'
|
||||
export type MessageDirection = 'inbound' | 'outbound' | 'internal'
|
||||
|
||||
// ── Routing Rule Type ──
|
||||
export type RoutingRuleType = 'customer_match' | 'lob_specialist' | 'keyword_intent' | 'doc_auto_fulfill'
|
||||
|
||||
// ─── Interfaces ──────────────────────────────────────────────────────────────
|
||||
|
||||
export interface TicketMessage {
|
||||
id: string
|
||||
type: MessageType
|
||||
direction: MessageDirection
|
||||
from: string
|
||||
to: string | null
|
||||
subject: string | null
|
||||
body: string
|
||||
timestamp: string
|
||||
aiDigest: string | null
|
||||
deliveryStatus?: 'sent' | 'delivered' | 'read'
|
||||
}
|
||||
|
||||
export interface SupportTicket {
|
||||
id: string
|
||||
subject: string
|
||||
channel: SupportChannel
|
||||
status: TicketStatus
|
||||
priority: TicketPriority
|
||||
intentCategory: IntentCategory
|
||||
routingTier: RoutingTier
|
||||
routedQueue: RoutedQueue
|
||||
customerId: string | null
|
||||
customerName: string
|
||||
policyId: string | null
|
||||
policyNumber: string | null
|
||||
assignedTo: string | null
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
daysOpen: number
|
||||
slaPercent: number
|
||||
messageCount: number
|
||||
lastMessagePreview: string
|
||||
}
|
||||
|
||||
export interface SupportTicketDetail extends SupportTicket {
|
||||
customerEmail: string | null
|
||||
customerPhone: string | null
|
||||
linkedPolicies: { id: string; number: string; carrier: string; lob: string; status: string; renewal: string }[]
|
||||
linkedClaims: { id: string; type: string; status: string }[]
|
||||
messages: TicketMessage[]
|
||||
aiSummary: string
|
||||
aiSuggestedIntent: IntentCategory
|
||||
aiConfidence: number
|
||||
routingTrace: { step: string; result: string; timestamp: string }[]
|
||||
}
|
||||
|
||||
export interface RoutingRule {
|
||||
id: string
|
||||
tier: RoutingTier
|
||||
type: RoutingRuleType
|
||||
name: string
|
||||
condition: string
|
||||
targetQueue: RoutedQueue
|
||||
targetAgent: string | null
|
||||
enabled: boolean
|
||||
priority: number
|
||||
}
|
||||
|
||||
// ─── Label Maps ──────────────────────────────────────────────────────────────
|
||||
|
||||
export const CHANNEL_LABELS: Record<SupportChannel, string> = {
|
||||
whatsapp: 'WhatsApp',
|
||||
email: 'Email',
|
||||
phone: 'Teléfono',
|
||||
walk_in: 'Presencial',
|
||||
web_form: 'Formulario Web',
|
||||
}
|
||||
|
||||
export const CHANNEL_ICONS: Record<SupportChannel, string> = {
|
||||
whatsapp: 'i-heroicons-chat-bubble-left-ellipsis',
|
||||
email: 'i-heroicons-envelope',
|
||||
phone: 'i-heroicons-phone',
|
||||
walk_in: 'i-heroicons-building-storefront',
|
||||
web_form: 'i-heroicons-globe-alt',
|
||||
}
|
||||
|
||||
export const STATUS_LABELS: Record<TicketStatus, string> = {
|
||||
open: 'Abierto',
|
||||
in_progress: 'En Proceso',
|
||||
pending_customer: 'Esperando Cliente',
|
||||
resolved: 'Resuelto',
|
||||
}
|
||||
|
||||
export const PRIORITY_LABELS: Record<TicketPriority, string> = {
|
||||
urgent: 'Urgente',
|
||||
high: 'Alta',
|
||||
medium: 'Media',
|
||||
low: 'Baja',
|
||||
}
|
||||
|
||||
export const INTENT_LABELS: Record<IntentCategory, string> = {
|
||||
payment_inquiry: 'Consulta de Pago',
|
||||
claim_report: 'Reporte de Siniestro',
|
||||
sales_interest: 'Interés en Seguros',
|
||||
doc_request: 'Solicitud de Documento',
|
||||
policy_question: 'Consulta de Póliza',
|
||||
complaint: 'Queja',
|
||||
endorsement: 'Endoso',
|
||||
certificate_request: 'Certificado',
|
||||
general: 'General',
|
||||
}
|
||||
|
||||
export const TIER_LABELS: Record<RoutingTier, string> = {
|
||||
tier1_auto: 'Tier 1 — Auto',
|
||||
tier2_rule: 'Tier 2 — Regla',
|
||||
tier3_open: 'Tier 3 — Pool',
|
||||
}
|
||||
|
||||
export const QUEUE_LABELS: Record<RoutedQueue, string> = {
|
||||
collections: 'Cobranza',
|
||||
claims: 'Siniestros',
|
||||
sales: 'Ventas',
|
||||
renewals: 'Renovaciones',
|
||||
operations: 'Operaciones',
|
||||
open_pool: 'Pool Abierto',
|
||||
}
|
||||
|
||||
export const MESSAGE_TYPE_LABELS: Record<MessageType, string> = {
|
||||
whatsapp: 'WhatsApp',
|
||||
email: 'Email',
|
||||
phone_note: 'Nota de Llamada',
|
||||
internal_note: 'Nota Interna',
|
||||
system: 'Sistema',
|
||||
}
|
||||
|
||||
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
||||
|
||||
export function slaColor(percent: number): 'green' | 'amber' | 'red' {
|
||||
if (percent >= 100) return 'red'
|
||||
if (percent >= 75) return 'amber'
|
||||
return 'green'
|
||||
}
|
||||
|
||||
export function channelIcon(ch: SupportChannel): string {
|
||||
return CHANNEL_ICONS[ch] ?? 'i-heroicons-question-mark-circle'
|
||||
}
|
||||
|
||||
export function tierBadgeClass(tier: RoutingTier): string {
|
||||
switch (tier) {
|
||||
case 'tier1_auto': return 'sp-tier-1'
|
||||
case 'tier2_rule': return 'sp-tier-2'
|
||||
case 'tier3_open': return 'sp-tier-3'
|
||||
default: return ''
|
||||
}
|
||||
}
|
||||
|
||||
export function fmtDate(d: string): string {
|
||||
return new Date(d).toLocaleDateString('es-PA', { month: 'short', day: 'numeric', year: 'numeric' })
|
||||
}
|
||||
|
||||
export function fmtTime(d: string): string {
|
||||
return new Date(d).toLocaleTimeString('es-PA', { hour: '2-digit', minute: '2-digit' })
|
||||
}
|
||||
|
||||
export function fmtDateTime(d: string): string {
|
||||
return `${fmtDate(d)} ${fmtTime(d)}`
|
||||
}
|
||||
|
||||
// ─── Mock Tickets (List) ─────────────────────────────────────────────────────
|
||||
|
||||
export const MOCK_SUPPORT_TICKETS: SupportTicket[] = [
|
||||
{
|
||||
id: 'SP-001',
|
||||
subject: 'Consulta sobre pago pendiente de póliza auto',
|
||||
channel: 'whatsapp',
|
||||
status: 'resolved',
|
||||
priority: 'medium',
|
||||
intentCategory: 'payment_inquiry',
|
||||
routingTier: 'tier2_rule',
|
||||
routedQueue: 'collections',
|
||||
customerId: 'cust-001',
|
||||
customerName: 'María Elena Pérez Solano',
|
||||
policyId: 'POL-2024-4412',
|
||||
policyNumber: 'AUTO-2024-4412',
|
||||
assignedTo: 'Carlos Villalba',
|
||||
createdAt: '2026-04-01T09:15:00',
|
||||
updatedAt: '2026-04-01T14:30:00',
|
||||
daysOpen: 0,
|
||||
slaPercent: 45,
|
||||
messageCount: 6,
|
||||
lastMessagePreview: 'Perfecto, ya quedó registrado el pago. Gracias María Elena.',
|
||||
},
|
||||
{
|
||||
id: 'SP-002',
|
||||
subject: 'Reporte de accidente vehicular — Toyota RAV4',
|
||||
channel: 'email',
|
||||
status: 'in_progress',
|
||||
priority: 'high',
|
||||
intentCategory: 'claim_report',
|
||||
routingTier: 'tier2_rule',
|
||||
routedQueue: 'claims',
|
||||
customerId: 'cust-004',
|
||||
customerName: 'Luis Andrés Solís Calderón',
|
||||
policyId: 'POL-2022-2200',
|
||||
policyNumber: 'AUTO-2022-2200',
|
||||
assignedTo: 'María Fernanda Ortiz',
|
||||
createdAt: '2026-04-03T11:22:00',
|
||||
updatedAt: '2026-04-07T16:45:00',
|
||||
daysOpen: 5,
|
||||
slaPercent: 62,
|
||||
messageCount: 5,
|
||||
lastMessagePreview: 'Adjunto las fotos del daño al vehículo tomadas esta mañana.',
|
||||
},
|
||||
{
|
||||
id: 'SP-003',
|
||||
subject: 'Solicitud de certificado de seguro para banco',
|
||||
channel: 'walk_in',
|
||||
status: 'resolved',
|
||||
priority: 'low',
|
||||
intentCategory: 'certificate_request',
|
||||
routingTier: 'tier1_auto',
|
||||
routedQueue: 'operations',
|
||||
customerId: 'cust-002',
|
||||
customerName: 'Roberto Jiménez Mora',
|
||||
policyId: 'POL-2023-3301',
|
||||
policyNumber: 'AUTO-2023-3301',
|
||||
assignedTo: 'Ana R.',
|
||||
createdAt: '2026-04-04T10:00:00',
|
||||
updatedAt: '2026-04-04T10:35:00',
|
||||
daysOpen: 0,
|
||||
slaPercent: 20,
|
||||
messageCount: 3,
|
||||
lastMessagePreview: 'Certificado generado y entregado en mano.',
|
||||
},
|
||||
{
|
||||
id: 'SP-004',
|
||||
subject: 'Cotización seguro auto para BMW X3 2025',
|
||||
channel: 'whatsapp',
|
||||
status: 'open',
|
||||
priority: 'medium',
|
||||
intentCategory: 'sales_interest',
|
||||
routingTier: 'tier2_rule',
|
||||
routedQueue: 'sales',
|
||||
customerId: null,
|
||||
customerName: 'Número desconocido (+507 6789-0123)',
|
||||
policyId: null,
|
||||
policyNumber: null,
|
||||
assignedTo: 'María Fernanda Ortiz',
|
||||
createdAt: '2026-04-07T08:30:00',
|
||||
updatedAt: '2026-04-07T08:30:00',
|
||||
daysOpen: 1,
|
||||
slaPercent: 35,
|
||||
messageCount: 3,
|
||||
lastMessagePreview: 'Buenas, cuánto sale un seguro todo riesgo para un BMW X3 2025?',
|
||||
},
|
||||
{
|
||||
id: 'SP-005',
|
||||
subject: 'Queja por demora en proceso de siniestro CLM-0048',
|
||||
channel: 'email',
|
||||
status: 'pending_customer',
|
||||
priority: 'high',
|
||||
intentCategory: 'complaint',
|
||||
routingTier: 'tier3_open',
|
||||
routedQueue: 'open_pool',
|
||||
customerId: 'cust-001',
|
||||
customerName: 'María Elena Pérez Solano',
|
||||
policyId: 'POL-2024-4412',
|
||||
policyNumber: 'AUTO-2024-4412',
|
||||
assignedTo: 'Carlos Villalba',
|
||||
createdAt: '2026-04-02T15:00:00',
|
||||
updatedAt: '2026-04-06T09:20:00',
|
||||
daysOpen: 6,
|
||||
slaPercent: 88,
|
||||
messageCount: 7,
|
||||
lastMessagePreview: 'Estamos pendientes de su respuesta con los documentos adicionales.',
|
||||
},
|
||||
{
|
||||
id: 'SP-006',
|
||||
subject: 'Consulta plan salud colectivo — Grupo Agrícola del Sur',
|
||||
channel: 'web_form',
|
||||
status: 'in_progress',
|
||||
priority: 'medium',
|
||||
intentCategory: 'policy_question',
|
||||
routingTier: 'tier1_auto',
|
||||
routedQueue: 'operations',
|
||||
customerId: 'cust-010',
|
||||
customerName: 'Fernando Arias Blanco',
|
||||
policyId: null,
|
||||
policyNumber: null,
|
||||
assignedTo: 'Ana R.',
|
||||
createdAt: '2026-04-06T14:10:00',
|
||||
updatedAt: '2026-04-07T11:00:00',
|
||||
daysOpen: 2,
|
||||
slaPercent: 50,
|
||||
messageCount: 4,
|
||||
lastMessagePreview: 'Le envío las opciones de cobertura para su grupo.',
|
||||
},
|
||||
{
|
||||
id: 'SP-007',
|
||||
subject: 'Mensaje ambiguo — "necesito ayuda con mi seguro"',
|
||||
channel: 'whatsapp',
|
||||
status: 'open',
|
||||
priority: 'medium',
|
||||
intentCategory: 'general',
|
||||
routingTier: 'tier3_open',
|
||||
routedQueue: 'open_pool',
|
||||
customerId: null,
|
||||
customerName: 'Número desconocido (+507 6234-5678)',
|
||||
policyId: null,
|
||||
policyNumber: null,
|
||||
assignedTo: null,
|
||||
createdAt: '2026-04-08T07:45:00',
|
||||
updatedAt: '2026-04-08T07:45:00',
|
||||
daysOpen: 0,
|
||||
slaPercent: 15,
|
||||
messageCount: 1,
|
||||
lastMessagePreview: 'Hola buenos días, necesito ayuda con mi seguro, me pueden atender?',
|
||||
},
|
||||
{
|
||||
id: 'SP-008',
|
||||
subject: 'Endoso — agregar conductor a póliza auto',
|
||||
channel: 'email',
|
||||
status: 'in_progress',
|
||||
priority: 'medium',
|
||||
intentCategory: 'endorsement',
|
||||
routingTier: 'tier2_rule',
|
||||
routedQueue: 'operations',
|
||||
customerId: 'cust-004',
|
||||
customerName: 'Luis Andrés Solís Calderón',
|
||||
policyId: 'POL-2022-2201',
|
||||
policyNumber: 'AUTO-2022-2201',
|
||||
assignedTo: 'Ana R.',
|
||||
createdAt: '2026-04-05T09:00:00',
|
||||
updatedAt: '2026-04-07T15:30:00',
|
||||
daysOpen: 3,
|
||||
slaPercent: 55,
|
||||
messageCount: 5,
|
||||
lastMessagePreview: 'Necesitamos la cédula y licencia del conductor adicional.',
|
||||
},
|
||||
{
|
||||
id: 'SP-009',
|
||||
subject: 'URGENTE — Choque frontal Toyota Hilux, requiere grúa',
|
||||
channel: 'whatsapp',
|
||||
status: 'open',
|
||||
priority: 'urgent',
|
||||
intentCategory: 'claim_report',
|
||||
routingTier: 'tier1_auto',
|
||||
routedQueue: 'claims',
|
||||
customerId: 'cust-010',
|
||||
customerName: 'Fernando Arias Blanco',
|
||||
policyId: 'POL-2020-1100',
|
||||
policyNumber: 'AUTO-2020-1100',
|
||||
assignedTo: 'Carlos Villalba',
|
||||
createdAt: '2026-04-08T06:15:00',
|
||||
updatedAt: '2026-04-08T06:45:00',
|
||||
daysOpen: 0,
|
||||
slaPercent: 40,
|
||||
messageCount: 4,
|
||||
lastMessagePreview: 'Ya coordiné la grúa, llega en 20 minutos a tu ubicación.',
|
||||
},
|
||||
{
|
||||
id: 'SP-010',
|
||||
subject: 'Seguimiento renovación póliza vida — vence 30 abril',
|
||||
channel: 'phone',
|
||||
status: 'resolved',
|
||||
priority: 'low',
|
||||
intentCategory: 'policy_question',
|
||||
routingTier: 'tier2_rule',
|
||||
routedQueue: 'renewals',
|
||||
customerId: 'cust-002',
|
||||
customerName: 'Roberto Jiménez Mora',
|
||||
policyId: 'POL-2023-3301',
|
||||
policyNumber: 'AUTO-2023-3301',
|
||||
assignedTo: 'María Fernanda Ortiz',
|
||||
createdAt: '2026-04-03T16:00:00',
|
||||
updatedAt: '2026-04-04T09:00:00',
|
||||
daysOpen: 0,
|
||||
slaPercent: 30,
|
||||
messageCount: 3,
|
||||
lastMessagePreview: 'Cliente confirmó renovación. Proceso completado.',
|
||||
},
|
||||
]
|
||||
|
||||
// ─── Mock Ticket Details ─────────────────────────────────────────────────────
|
||||
|
||||
const sp001Detail: SupportTicketDetail = {
|
||||
...MOCK_SUPPORT_TICKETS[0],
|
||||
customerEmail: 'maria.perez@gmail.com',
|
||||
customerPhone: '+507 6123-4567',
|
||||
linkedPolicies: [
|
||||
{ id: 'POL-2024-4412', number: 'AUTO-2024-4412', carrier: 'ASSA', lob: 'Auto', status: 'Active', renewal: '2027-03-15' },
|
||||
],
|
||||
linkedClaims: [],
|
||||
messages: [
|
||||
{ id: 'msg-001-1', type: 'whatsapp', direction: 'inbound', from: 'María Elena Pérez', to: null, subject: null, body: 'Hola buenas, tengo una consulta sobre mi pago. Me llegó un recibo pero ya hice la transferencia la semana pasada. Pueden verificar?', timestamp: '2026-04-01T09:15:00', aiDigest: 'Customer inquiring about payment status — claims transfer was already made.' },
|
||||
{ id: 'msg-001-2', type: 'system', direction: 'internal', from: 'Sistema', to: null, subject: null, body: 'Ticket creado automáticamente. Intent detectado: Consulta de Pago (92% confianza). Enrutado a Cobranza.', timestamp: '2026-04-01T09:15:05', aiDigest: null },
|
||||
{ id: 'msg-001-3', type: 'whatsapp', direction: 'outbound', from: 'Carlos Villalba', to: 'María Elena Pérez', subject: null, body: 'Buenos días María Elena! Déjame verificar con el departamento de cobranza. ¿Me puede compartir el comprobante de la transferencia?', timestamp: '2026-04-01T09:45:00', aiDigest: null, deliveryStatus: 'read' },
|
||||
{ id: 'msg-001-4', type: 'whatsapp', direction: 'inbound', from: 'María Elena Pérez', to: null, subject: null, body: 'Claro, aquí está el comprobante. Fue el 25 de marzo por $460.', timestamp: '2026-04-01T10:02:00', aiDigest: null },
|
||||
{ id: 'msg-001-5', type: 'internal_note', direction: 'internal', from: 'Carlos Villalba', to: null, subject: null, body: 'Verificado con contabilidad. Pago recibido el 26/03 — recibo cruzado con período anterior. Se ajusta en sistema.', timestamp: '2026-04-01T13:00:00', aiDigest: null },
|
||||
{ id: 'msg-001-6', type: 'whatsapp', direction: 'outbound', from: 'Carlos Villalba', to: 'María Elena Pérez', subject: null, body: 'Perfecto, ya quedó registrado el pago. Gracias María Elena. El recibo que le llegó era del mes anterior, ya se corrigió. Cualquier cosa me avisa!', timestamp: '2026-04-01T14:30:00', aiDigest: null, deliveryStatus: 'read' },
|
||||
],
|
||||
aiSummary: 'Cliente consultó sobre un pago que ya había realizado. Se verificó con contabilidad que la transferencia fue recibida y el recibo cruzado con un período anterior fue corregido. Ticket resuelto satisfactoriamente.',
|
||||
aiSuggestedIntent: 'payment_inquiry',
|
||||
aiConfidence: 0.92,
|
||||
routingTrace: [
|
||||
{ step: 'Channel Detection', result: 'WhatsApp — número registrado: +507 6123-4567', timestamp: '2026-04-01T09:15:01' },
|
||||
{ step: 'Customer Match', result: 'Matched: María Elena Pérez Solano (cust-001)', timestamp: '2026-04-01T09:15:02' },
|
||||
{ step: 'Intent Classification', result: 'payment_inquiry (92% confidence) — keywords: "pago", "recibo", "transferencia"', timestamp: '2026-04-01T09:15:03' },
|
||||
{ step: 'Rule Fired', result: 'Payment intent → Collections queue', timestamp: '2026-04-01T09:15:04' },
|
||||
{ step: 'Agent Assignment', result: 'Carlos Villalba (assigned broker for cust-001)', timestamp: '2026-04-01T09:15:05' },
|
||||
],
|
||||
}
|
||||
|
||||
const sp002Detail: SupportTicketDetail = {
|
||||
...MOCK_SUPPORT_TICKETS[1],
|
||||
customerEmail: 'luis.solis@outlook.com',
|
||||
customerPhone: '+507 6555-1234',
|
||||
linkedPolicies: [
|
||||
{ id: 'POL-2022-2200', number: 'AUTO-2022-2200', carrier: 'Qualitas', lob: 'Auto', status: 'Active', renewal: '2027-01-15' },
|
||||
{ id: 'POL-2022-2201', number: 'AUTO-2022-2201', carrier: 'Qualitas', lob: 'Auto', status: 'Active', renewal: '2027-01-15' },
|
||||
],
|
||||
linkedClaims: [
|
||||
{ id: 'CLM-0048', type: 'Collision', status: 'Under Review' },
|
||||
],
|
||||
messages: [
|
||||
{ id: 'msg-002-1', type: 'email', direction: 'inbound', from: 'luis.solis@outlook.com', to: 'soporte@seguros.com', subject: 'Reporte de accidente — BMW X5 SJO-2200', body: 'Estimados, les informo que tuve un accidente vehicular el día de hoy en la autopista Próspero Fernández. Mi vehículo BMW X5 placas SJO-2200 sufrió daños frontales tras una colisión. Adjunto fotos del daño. Por favor indiquen los pasos a seguir para el reclamo.', timestamp: '2026-04-03T11:22:00', aiDigest: 'Customer reporting vehicle collision on Próspero Fernández highway. BMW X5, front damage. Requesting claim process guidance.' },
|
||||
{ id: 'msg-002-2', type: 'system', direction: 'internal', from: 'Sistema', to: null, subject: null, body: 'Ticket creado. Intent: claim_report (97%). Enrutado a Siniestros. Claim CLM-0048 vinculado automáticamente.', timestamp: '2026-04-03T11:22:10', aiDigest: null },
|
||||
{ id: 'msg-002-3', type: 'email', direction: 'outbound', from: 'maria.ortiz@seguros.com', to: 'luis.solis@outlook.com', subject: 'Re: Reporte de accidente — BMW X5 SJO-2200', body: 'Estimado Luis, lamentamos mucho el accidente. Ya creamos su reclamo bajo el número CLM-0048. Necesitamos:\n\n1. Parte policial (si aplica)\n2. Fotos adicionales del otro vehículo\n3. Datos del otro conductor\n\nQuedo atenta.', timestamp: '2026-04-03T14:00:00', aiDigest: null },
|
||||
{ id: 'msg-002-4', type: 'internal_note', direction: 'internal', from: 'María Fernanda Ortiz', to: null, subject: null, body: 'Cliente VIP — 2 pólizas activas, $5,800/yr premium. Priorizar este caso. Contactar ajustador para inspección rápida.', timestamp: '2026-04-04T08:30:00', aiDigest: null },
|
||||
{ id: 'msg-002-5', type: 'email', direction: 'inbound', from: 'luis.solis@outlook.com', to: 'soporte@seguros.com', subject: 'Re: Reporte de accidente — BMW X5 SJO-2200', body: 'Adjunto las fotos del daño al vehículo tomadas esta mañana. El parte policial lo tengo listo para mañana. El otro conductor no quiso dar sus datos, pero tengo la placa.', timestamp: '2026-04-07T16:45:00', aiDigest: null },
|
||||
],
|
||||
aiSummary: 'Accidente vehicular reportado por correo. BMW X5, daños frontales por colisión en autopista. Claim CLM-0048 creado y vinculado. Pendiente: parte policial y datos del otro conductor. Cliente VIP con 2 pólizas — priorizar.',
|
||||
aiSuggestedIntent: 'claim_report',
|
||||
aiConfidence: 0.97,
|
||||
routingTrace: [
|
||||
{ step: 'Channel Detection', result: 'Email — luis.solis@outlook.com', timestamp: '2026-04-03T11:22:01' },
|
||||
{ step: 'Customer Match', result: 'Matched: Luis Andrés Solís Calderón (cust-004)', timestamp: '2026-04-03T11:22:02' },
|
||||
{ step: 'Intent Classification', result: 'claim_report (97%) — keywords: "accidente", "daños", "colisión", "reclamo"', timestamp: '2026-04-03T11:22:03' },
|
||||
{ step: 'Rule Fired', result: 'Claim intent → Claims queue', timestamp: '2026-04-03T11:22:04' },
|
||||
{ step: 'Policy Link', result: 'Auto-linked POL-2022-2200 (BMW X5, matching plate SJO-2200)', timestamp: '2026-04-03T11:22:05' },
|
||||
],
|
||||
}
|
||||
|
||||
const sp003Detail: SupportTicketDetail = {
|
||||
...MOCK_SUPPORT_TICKETS[2],
|
||||
customerEmail: 'roberto.jimenez@gmail.com',
|
||||
customerPhone: '+507 6222-8899',
|
||||
linkedPolicies: [
|
||||
{ id: 'POL-2023-3301', number: 'AUTO-2023-3301', carrier: 'Qualitas', lob: 'Auto', status: 'Active', renewal: '2027-04-01' },
|
||||
],
|
||||
linkedClaims: [],
|
||||
messages: [
|
||||
{ id: 'msg-003-1', type: 'phone_note', direction: 'inbound', from: 'Roberto Jiménez Mora', to: null, subject: null, body: 'Cliente se presentó en oficina solicitando certificado de seguro vigente para trámite bancario. Necesita documento hoy.', timestamp: '2026-04-04T10:00:00', aiDigest: null },
|
||||
{ id: 'msg-003-2', type: 'system', direction: 'internal', from: 'Sistema', to: null, subject: null, body: 'Cliente conocido (cust-002). Tier 1 auto-route: solicitud de certificado → Operaciones. Auto-fulfill iniciado.', timestamp: '2026-04-04T10:00:05', aiDigest: null },
|
||||
{ id: 'msg-003-3', type: 'internal_note', direction: 'internal', from: 'Ana R.', to: null, subject: null, body: 'Certificado generado y entregado en mano. Cliente satisfecho. Ticket cerrado.', timestamp: '2026-04-04T10:35:00', aiDigest: null },
|
||||
],
|
||||
aiSummary: 'Solicitud de certificado de seguro vigente para trámite bancario. Cliente conocido, auto-enrutado a operaciones. Certificado generado y entregado presencialmente en 35 minutos.',
|
||||
aiSuggestedIntent: 'certificate_request',
|
||||
aiConfidence: 0.99,
|
||||
routingTrace: [
|
||||
{ step: 'Walk-in Registration', result: 'Registered by front desk', timestamp: '2026-04-04T10:00:01' },
|
||||
{ step: 'Customer Match', result: 'Matched: Roberto Jiménez Mora (cust-002)', timestamp: '2026-04-04T10:00:02' },
|
||||
{ step: 'Intent Classification', result: 'certificate_request (99%) — direct request', timestamp: '2026-04-04T10:00:03' },
|
||||
{ step: 'Tier 1 Auto-Fulfill', result: 'Certificate generation triggered for POL-2023-3301', timestamp: '2026-04-04T10:00:05' },
|
||||
],
|
||||
}
|
||||
|
||||
const sp004Detail: SupportTicketDetail = {
|
||||
...MOCK_SUPPORT_TICKETS[3],
|
||||
customerEmail: null,
|
||||
customerPhone: '+507 6789-0123',
|
||||
linkedPolicies: [],
|
||||
linkedClaims: [],
|
||||
messages: [
|
||||
{ id: 'msg-004-1', type: 'whatsapp', direction: 'inbound', from: '+507 6789-0123', to: null, subject: null, body: 'Buenas, cuánto sale un seguro todo riesgo para un BMW X3 2025? Nuevo, recién importado.', timestamp: '2026-04-07T08:30:00', aiDigest: 'Unknown number asking for comprehensive auto insurance quote for new BMW X3 2025.' },
|
||||
{ id: 'msg-004-2', type: 'system', direction: 'internal', from: 'Sistema', to: null, subject: null, body: 'Número no registrado. Intent: sales_interest (89%). Enrutado a Ventas.', timestamp: '2026-04-07T08:30:05', aiDigest: null },
|
||||
{ id: 'msg-004-3', type: 'whatsapp', direction: 'outbound', from: 'María Fernanda Ortiz', to: '+507 6789-0123', subject: null, body: 'Buenos días! Con gusto le cotizamos. Para darle las mejores opciones necesito:\n\n1. Nombre completo\n2. Cédula o pasaporte\n3. Valor del vehículo según factura\n4. Uso (particular o comercial)\n\nQuedo atenta!', timestamp: '2026-04-07T09:15:00', aiDigest: null, deliveryStatus: 'delivered' },
|
||||
],
|
||||
aiSummary: 'Lead entrante vía WhatsApp — persona desconocida consultando precio de seguro todo riesgo para BMW X3 2025 nuevo. Asignado a ventas. Pendiente respuesta del prospecto con datos personales.',
|
||||
aiSuggestedIntent: 'sales_interest',
|
||||
aiConfidence: 0.89,
|
||||
routingTrace: [
|
||||
{ step: 'Channel Detection', result: 'WhatsApp — +507 6789-0123', timestamp: '2026-04-07T08:30:01' },
|
||||
{ step: 'Customer Match', result: 'No match — unknown number', timestamp: '2026-04-07T08:30:02' },
|
||||
{ step: 'Intent Classification', result: 'sales_interest (89%) — keywords: "cuánto sale", "seguro todo riesgo", "nuevo"', timestamp: '2026-04-07T08:30:03' },
|
||||
{ step: 'Rule Fired', result: 'Sales intent → Sales queue', timestamp: '2026-04-07T08:30:04' },
|
||||
],
|
||||
}
|
||||
|
||||
const sp005Detail: SupportTicketDetail = {
|
||||
...MOCK_SUPPORT_TICKETS[4],
|
||||
customerEmail: 'maria.perez@gmail.com',
|
||||
customerPhone: '+507 6123-4567',
|
||||
linkedPolicies: [
|
||||
{ id: 'POL-2024-4412', number: 'AUTO-2024-4412', carrier: 'ASSA', lob: 'Auto', status: 'Active', renewal: '2027-03-15' },
|
||||
],
|
||||
linkedClaims: [
|
||||
{ id: 'CLM-0048', type: 'Collision', status: 'Under Review' },
|
||||
],
|
||||
messages: [
|
||||
{ id: 'msg-005-1', type: 'email', direction: 'inbound', from: 'maria.perez@gmail.com', to: 'soporte@seguros.com', subject: 'QUEJA — demora inaceptable en siniestro CLM-0048', body: 'Estimados, llevo más de 3 semanas esperando respuesta sobre mi siniestro CLM-0048. He llamado varias veces y nadie me da una respuesta clara. Esto es inaceptable. Necesito que alguien con autoridad me contacte hoy mismo o procederé con una queja formal ante la Superintendencia.', timestamp: '2026-04-02T15:00:00', aiDigest: 'Frustrated customer filing formal complaint about claim CLM-0048 delays. Threatening regulatory escalation.' },
|
||||
{ id: 'msg-005-2', type: 'system', direction: 'internal', from: 'Sistema', to: null, subject: null, body: 'Intent ambiguo: complaint (76%) / claim_report (18%). No cumple regla de enrutamiento directo. Enviado a Pool Abierto.', timestamp: '2026-04-02T15:00:05', aiDigest: null },
|
||||
{ id: 'msg-005-3', type: 'internal_note', direction: 'internal', from: 'Carlos Villalba', to: null, subject: null, body: 'Asigno este ticket a mí. Conozco a la clienta — tiene razón, el claim lleva demasiado tiempo. Voy a escalar con el ajustador de ASSA directamente.', timestamp: '2026-04-02T16:30:00', aiDigest: null },
|
||||
{ id: 'msg-005-4', type: 'email', direction: 'outbound', from: 'carlos.villalba@seguros.com', to: 'maria.perez@gmail.com', subject: 'Re: QUEJA — demora inaceptable en siniestro CLM-0048', body: 'Estimada María Elena, le pido disculpas sinceras por la demora. He escalado personalmente su caso con el ajustador de ASSA y tenemos reunión mañana a primera hora. Le daré un update antes del mediodía del viernes. Entiendo su frustración y estoy comprometido a resolverlo.', timestamp: '2026-04-02T17:00:00', aiDigest: null },
|
||||
{ id: 'msg-005-5', type: 'internal_note', direction: 'internal', from: 'Carlos Villalba', to: null, subject: null, body: 'Reunión con ajustador ASSA — dicen que falta un documento del taller. Ya lo solicité. Debería llegar mañana.', timestamp: '2026-04-04T10:00:00', aiDigest: null },
|
||||
{ id: 'msg-005-6', type: 'email', direction: 'outbound', from: 'carlos.villalba@seguros.com', to: 'maria.perez@gmail.com', subject: 'Re: QUEJA — Actualización siniestro CLM-0048', body: 'María Elena, actualización: ASSA necesita un documento adicional del taller donde se hizo la reparación. ¿Podría solicitarle al taller el presupuesto final firmado y sellado? Con eso podemos cerrar la revisión esta semana.', timestamp: '2026-04-04T14:00:00', aiDigest: null },
|
||||
{ id: 'msg-005-7', type: 'email', direction: 'outbound', from: 'carlos.villalba@seguros.com', to: 'maria.perez@gmail.com', subject: 'Re: QUEJA — Seguimiento documentos pendientes', body: 'Hola María Elena, solo un seguimiento amable — estamos pendientes de su respuesta con los documentos adicionales del taller para avanzar con ASSA. Quedo a la orden.', timestamp: '2026-04-06T09:20:00', aiDigest: null },
|
||||
],
|
||||
aiSummary: 'Queja formal por demora en siniestro CLM-0048 (colisión). Cliente amenaza con escalar a Superintendencia. Broker Carlos Villalba se asignó y escaló con ajustador ASSA. Pendiente: documento del taller (presupuesto firmado) para cerrar revisión. Estado: esperando respuesta del cliente.',
|
||||
aiSuggestedIntent: 'complaint',
|
||||
aiConfidence: 0.76,
|
||||
routingTrace: [
|
||||
{ step: 'Channel Detection', result: 'Email — maria.perez@gmail.com', timestamp: '2026-04-02T15:00:01' },
|
||||
{ step: 'Customer Match', result: 'Matched: María Elena Pérez Solano (cust-001)', timestamp: '2026-04-02T15:00:02' },
|
||||
{ step: 'Intent Classification', result: 'Ambiguous — complaint (76%), claim_report (18%)', timestamp: '2026-04-02T15:00:03' },
|
||||
{ step: 'No Rule Match', result: 'Confidence below threshold for auto-routing', timestamp: '2026-04-02T15:00:04' },
|
||||
{ step: 'Routed to Open Pool', result: 'Tier 3 — awaiting manual triage', timestamp: '2026-04-02T15:00:05' },
|
||||
],
|
||||
}
|
||||
|
||||
const sp006Detail: SupportTicketDetail = {
|
||||
...MOCK_SUPPORT_TICKETS[5],
|
||||
customerEmail: 'fernando.arias@transportesnorte.cr',
|
||||
customerPhone: '+507 6888-4444',
|
||||
linkedPolicies: [
|
||||
{ id: 'POL-2020-1100', number: 'AUTO-2020-1100', carrier: 'INS', lob: 'Auto', status: 'Active', renewal: '2026-12-15' },
|
||||
{ id: 'POL-2021-2200', number: 'HOME-2021-2200', carrier: 'ASSA', lob: 'Home', status: 'Lapsed', renewal: '—' },
|
||||
],
|
||||
linkedClaims: [],
|
||||
messages: [
|
||||
{ id: 'msg-006-1', type: 'email', direction: 'inbound', from: 'fernando.arias@transportesnorte.cr', to: 'soporte@seguros.com', subject: 'Consulta — plan salud colectivo para nuestro grupo', body: 'Buenos días, soy Fernando Arias de Transportes del Norte. Quiero explorar opciones de seguro de salud colectivo para nuestros empleados (aprox 50 personas). ¿Qué opciones tienen disponibles y cuál sería el proceso?', timestamp: '2026-04-06T14:10:00', aiDigest: 'Existing corporate customer inquiring about group health insurance for ~50 employees.' },
|
||||
{ id: 'msg-006-2', type: 'system', direction: 'internal', from: 'Sistema', to: null, subject: null, body: 'Cliente conocido (cust-010). Web form con LOB=Health. Tier 1: auto-routed a broker asignado (Ana R.).', timestamp: '2026-04-06T14:10:05', aiDigest: null },
|
||||
{ id: 'msg-006-3', type: 'email', direction: 'outbound', from: 'ana.r@seguros.com', to: 'fernando.arias@transportesnorte.cr', subject: 'Re: Consulta — plan salud colectivo', body: 'Estimado Fernando, qué gusto saludarlo! Con mucho gusto le ayudamos con el plan colectivo de salud. Tenemos opciones con Vida Plena, Salud Global e Integral Medical. Le envío las opciones de cobertura para su grupo con los detalles de cada plan.', timestamp: '2026-04-06T16:00:00', aiDigest: null },
|
||||
{ id: 'msg-006-4', type: 'internal_note', direction: 'internal', from: 'Ana R.', to: null, subject: null, body: 'Cliente ya tiene 2 pólizas (1 activa auto, 1 lapsed home). Oportunidad de cross-sell. Preparar propuesta colectiva con las 3 aseguradoras de salud.', timestamp: '2026-04-07T11:00:00', aiDigest: null },
|
||||
],
|
||||
aiSummary: 'Cliente corporativo existente (Transportes del Norte) consultando sobre plan de salud colectivo para ~50 empleados. Auto-enrutado por Tier 1 a broker asignado. Oportunidad de cross-sell identificada — ya tiene póliza auto activa.',
|
||||
aiSuggestedIntent: 'policy_question',
|
||||
aiConfidence: 0.85,
|
||||
routingTrace: [
|
||||
{ step: 'Channel Detection', result: 'Web Form — LOB: Health', timestamp: '2026-04-06T14:10:01' },
|
||||
{ step: 'Customer Match', result: 'Matched: Fernando Arias Blanco (cust-010)', timestamp: '2026-04-06T14:10:02' },
|
||||
{ step: 'Tier 1 Auto-Route', result: 'Known customer + LOB form → assigned broker', timestamp: '2026-04-06T14:10:03' },
|
||||
{ step: 'Agent Assignment', result: 'Ana R. (assigned broker for cust-010)', timestamp: '2026-04-06T14:10:05' },
|
||||
],
|
||||
}
|
||||
|
||||
const sp007Detail: SupportTicketDetail = {
|
||||
...MOCK_SUPPORT_TICKETS[6],
|
||||
customerEmail: null,
|
||||
customerPhone: '+507 6234-5678',
|
||||
linkedPolicies: [],
|
||||
linkedClaims: [],
|
||||
messages: [
|
||||
{ id: 'msg-007-1', type: 'whatsapp', direction: 'inbound', from: '+507 6234-5678', to: null, subject: null, body: 'Hola buenos días, necesito ayuda con mi seguro, me pueden atender?', timestamp: '2026-04-08T07:45:00', aiDigest: 'Ambiguous request — "need help with my insurance". No specific intent detectable. Unknown customer.' },
|
||||
],
|
||||
aiSummary: 'Mensaje ambiguo de número desconocido. No se puede determinar intención específica. Requiere triage manual por operador del pool abierto.',
|
||||
aiSuggestedIntent: 'general',
|
||||
aiConfidence: 0.34,
|
||||
routingTrace: [
|
||||
{ step: 'Channel Detection', result: 'WhatsApp — +507 6234-5678', timestamp: '2026-04-08T07:45:01' },
|
||||
{ step: 'Customer Match', result: 'No match — unknown number', timestamp: '2026-04-08T07:45:02' },
|
||||
{ step: 'Intent Classification', result: 'Ambiguous — general (34%), policy_question (28%), sales_interest (22%)', timestamp: '2026-04-08T07:45:03' },
|
||||
{ step: 'No Rule Match', result: 'Low confidence across all intents', timestamp: '2026-04-08T07:45:04' },
|
||||
{ step: 'Routed to Open Pool', result: 'Tier 3 — unassigned, awaiting manual triage', timestamp: '2026-04-08T07:45:05' },
|
||||
],
|
||||
}
|
||||
|
||||
const sp008Detail: SupportTicketDetail = {
|
||||
...MOCK_SUPPORT_TICKETS[7],
|
||||
customerEmail: 'luis.solis@outlook.com',
|
||||
customerPhone: '+507 6555-1234',
|
||||
linkedPolicies: [
|
||||
{ id: 'POL-2022-2201', number: 'AUTO-2022-2201', carrier: 'Qualitas', lob: 'Auto', status: 'Active', renewal: '2027-01-15' },
|
||||
],
|
||||
linkedClaims: [],
|
||||
messages: [
|
||||
{ id: 'msg-008-1', type: 'email', direction: 'inbound', from: 'luis.solis@outlook.com', to: 'soporte@seguros.com', subject: 'Agregar conductor adicional a póliza AUTO-2022-2201', body: 'Estimados, necesito agregar a mi esposa como conductor adicional en mi póliza AUTO-2022-2201 (Mercedes GLC). ¿Qué documentos necesitan y cuál es el costo adicional?', timestamp: '2026-04-05T09:00:00', aiDigest: 'Customer requesting driver addition endorsement to auto policy. Asking about requirements and cost.' },
|
||||
{ id: 'msg-008-2', type: 'system', direction: 'internal', from: 'Sistema', to: null, subject: null, body: 'Intent: endorsement (94%). Enrutado a Operaciones.', timestamp: '2026-04-05T09:00:05', aiDigest: null },
|
||||
{ id: 'msg-008-3', type: 'email', direction: 'outbound', from: 'ana.r@seguros.com', to: 'luis.solis@outlook.com', subject: 'Re: Agregar conductor adicional a póliza AUTO-2022-2201', body: 'Estimado Luis, con gusto procesamos el endoso. Necesitamos:\n\n1. Cédula de su esposa (copia)\n2. Licencia de conducir vigente (copia)\n3. Fecha de nacimiento\n\nEl costo adicional depende del perfil — generalmente entre $50-$120/año. Le confirmo el monto exacto una vez tenga los documentos.', timestamp: '2026-04-05T11:30:00', aiDigest: null },
|
||||
{ id: 'msg-008-4', type: 'email', direction: 'inbound', from: 'luis.solis@outlook.com', to: 'soporte@seguros.com', subject: 'Re: Agregar conductor adicional', body: 'Adjunto cédula y licencia de mi esposa Carolina Méndez de Solís. Su fecha de nacimiento es 15 de mayo de 1988.', timestamp: '2026-04-06T14:00:00', aiDigest: null },
|
||||
{ id: 'msg-008-5', type: 'internal_note', direction: 'internal', from: 'Ana R.', to: null, subject: null, body: 'Documentos recibidos. Solicitando cotización de endoso a Qualitas. Pendiente respuesta del carrier para confirmar monto.', timestamp: '2026-04-07T15:30:00', aiDigest: null },
|
||||
],
|
||||
aiSummary: 'Solicitud de endoso para agregar conductor adicional (esposa) a póliza auto Mercedes GLC. Documentos recibidos (cédula + licencia). Pendiente cotización del carrier Qualitas para confirmar costo adicional.',
|
||||
aiSuggestedIntent: 'endorsement',
|
||||
aiConfidence: 0.94,
|
||||
routingTrace: [
|
||||
{ step: 'Channel Detection', result: 'Email — luis.solis@outlook.com', timestamp: '2026-04-05T09:00:01' },
|
||||
{ step: 'Customer Match', result: 'Matched: Luis Andrés Solís Calderón (cust-004)', timestamp: '2026-04-05T09:00:02' },
|
||||
{ step: 'Intent Classification', result: 'endorsement (94%) — keywords: "agregar", "conductor adicional", "endoso"', timestamp: '2026-04-05T09:00:03' },
|
||||
{ step: 'Rule Fired', result: 'Endorsement intent → Operations queue', timestamp: '2026-04-05T09:00:04' },
|
||||
],
|
||||
}
|
||||
|
||||
const sp009Detail: SupportTicketDetail = {
|
||||
...MOCK_SUPPORT_TICKETS[8],
|
||||
customerEmail: 'fernando.arias@transportesnorte.cr',
|
||||
customerPhone: '+507 6888-4444',
|
||||
linkedPolicies: [
|
||||
{ id: 'POL-2020-1100', number: 'AUTO-2020-1100', carrier: 'INS', lob: 'Auto', status: 'Active', renewal: '2026-12-15' },
|
||||
],
|
||||
linkedClaims: [],
|
||||
messages: [
|
||||
{ id: 'msg-009-1', type: 'whatsapp', direction: 'inbound', from: 'Fernando Arias', to: null, subject: null, body: 'URGENTE Carlos!! Acabo de tener un choque frontal con la Hilux en la vía Interamericana. El carro no enciende y necesito grúa YA. Estoy en el km 42 dirección David.', timestamp: '2026-04-08T06:15:00', aiDigest: 'URGENT: Known customer reporting front collision with Toyota Hilux. Vehicle disabled, requesting tow truck. Location: km 42 Interamericana highway.' },
|
||||
{ id: 'msg-009-2', type: 'system', direction: 'internal', from: 'Sistema', to: null, subject: null, body: 'URGENTE — Cliente conocido (cust-010). Intent: claim_report (99%). Tier 1 auto-route a broker asignado. Prioridad: URGENTE.', timestamp: '2026-04-08T06:15:05', aiDigest: null },
|
||||
{ id: 'msg-009-3', type: 'whatsapp', direction: 'outbound', from: 'Carlos Villalba', to: 'Fernando Arias', subject: null, body: 'Fernando tranquilo, ya estoy en eso. ¿Estás bien? ¿Hay heridos? Voy a coordinar la grúa ahora mismo.', timestamp: '2026-04-08T06:20:00', aiDigest: null, deliveryStatus: 'read' },
|
||||
{ id: 'msg-009-4', type: 'whatsapp', direction: 'outbound', from: 'Carlos Villalba', to: 'Fernando Arias', subject: null, body: 'Ya coordiné la grúa, llega en 20 minutos a tu ubicación. INS tiene cobertura de asistencia en carretera incluida en tu póliza. ¿Necesitas que llame a alguien más?', timestamp: '2026-04-08T06:45:00', aiDigest: null, deliveryStatus: 'delivered' },
|
||||
],
|
||||
aiSummary: 'Emergencia: choque frontal con Toyota Hilux en vía Interamericana km 42. Vehículo inhabilitado. Broker coordinó grúa inmediatamente (20 min ETA). Asistencia en carretera cubierta por póliza INS. Pendiente: confirmar estado del conductor y crear claim formal.',
|
||||
aiSuggestedIntent: 'claim_report',
|
||||
aiConfidence: 0.99,
|
||||
routingTrace: [
|
||||
{ step: 'Channel Detection', result: 'WhatsApp — +507 6888-4444 (Fernando Arias)', timestamp: '2026-04-08T06:15:01' },
|
||||
{ step: 'Customer Match', result: 'Matched: Fernando Arias Blanco (cust-010)', timestamp: '2026-04-08T06:15:02' },
|
||||
{ step: 'Intent Classification', result: 'claim_report (99%) — keywords: "choque", "grúa", "URGENTE"', timestamp: '2026-04-08T06:15:03' },
|
||||
{ step: 'Priority Escalation', result: 'Auto-set URGENT — emergency keywords detected', timestamp: '2026-04-08T06:15:04' },
|
||||
{ step: 'Tier 1 Auto-Route', result: 'Known customer → assigned broker Carlos Villalba', timestamp: '2026-04-08T06:15:05' },
|
||||
],
|
||||
}
|
||||
|
||||
const sp010Detail: SupportTicketDetail = {
|
||||
...MOCK_SUPPORT_TICKETS[9],
|
||||
customerEmail: 'roberto.jimenez@gmail.com',
|
||||
customerPhone: '+507 6222-8899',
|
||||
linkedPolicies: [
|
||||
{ id: 'POL-2023-3301', number: 'AUTO-2023-3301', carrier: 'Qualitas', lob: 'Auto', status: 'Active', renewal: '2027-04-01' },
|
||||
],
|
||||
linkedClaims: [],
|
||||
messages: [
|
||||
{ id: 'msg-010-1', type: 'phone_note', direction: 'inbound', from: 'Roberto Jiménez Mora', to: null, subject: null, body: 'Cliente llamó consultando sobre su renovación que vence el 30 de abril. Quiere saber si hay cambio en prima y si puede agregar cobertura de robo.', timestamp: '2026-04-03T16:00:00', aiDigest: 'Customer calling about upcoming renewal (Apr 30). Asking about premium changes and adding theft coverage.' },
|
||||
{ id: 'msg-010-2', type: 'system', direction: 'internal', from: 'Sistema', to: null, subject: null, body: 'Intent: policy_question (78%) / sales_interest (15%). Keyword "renovación" → Renewals queue.', timestamp: '2026-04-03T16:00:05', aiDigest: null },
|
||||
{ id: 'msg-010-3', type: 'internal_note', direction: 'internal', from: 'María Fernanda Ortiz', to: null, subject: null, body: 'Cliente confirmó renovación con cobertura adicional de robo. Prima aumenta $180/año. Proceso completado — renovación efectiva 1 mayo 2027.', timestamp: '2026-04-04T09:00:00', aiDigest: null },
|
||||
],
|
||||
aiSummary: 'Seguimiento telefónico sobre renovación próxima (30 abril). Cliente confirmó renovación con adición de cobertura de robo (+$180/año). Proceso completado.',
|
||||
aiSuggestedIntent: 'policy_question',
|
||||
aiConfidence: 0.78,
|
||||
routingTrace: [
|
||||
{ step: 'Channel Detection', result: 'Phone call logged', timestamp: '2026-04-03T16:00:01' },
|
||||
{ step: 'Customer Match', result: 'Matched: Roberto Jiménez Mora (cust-002)', timestamp: '2026-04-03T16:00:02' },
|
||||
{ step: 'Intent Classification', result: 'policy_question (78%) — keyword: "renovación"', timestamp: '2026-04-03T16:00:03' },
|
||||
{ step: 'Rule Fired', result: 'Renewal keyword → Renewals queue', timestamp: '2026-04-03T16:00:04' },
|
||||
],
|
||||
}
|
||||
|
||||
export const MOCK_TICKET_DETAILS: Record<string, SupportTicketDetail> = {
|
||||
'SP-001': sp001Detail,
|
||||
'SP-002': sp002Detail,
|
||||
'SP-003': sp003Detail,
|
||||
'SP-004': sp004Detail,
|
||||
'SP-005': sp005Detail,
|
||||
'SP-006': sp006Detail,
|
||||
'SP-007': sp007Detail,
|
||||
'SP-008': sp008Detail,
|
||||
'SP-009': sp009Detail,
|
||||
'SP-010': sp010Detail,
|
||||
}
|
||||
|
||||
// ─── Mock Routing Rules ──────────────────────────────────────────────────────
|
||||
|
||||
export const MOCK_ROUTING_RULES: RoutingRule[] = [
|
||||
// Tier 1
|
||||
{ id: 'rule-001', tier: 'tier1_auto', type: 'customer_match', name: 'Known Customer → Assigned Broker', condition: 'When incoming message matches a registered customer, route to their assigned broker', targetQueue: 'operations', targetAgent: null, enabled: true, priority: 1 },
|
||||
{ id: 'rule-002', tier: 'tier1_auto', type: 'lob_specialist', name: 'Web Form LOB → Specialist', condition: 'Web form with LOB selected routes to LOB specialist team', targetQueue: 'operations', targetAgent: null, enabled: true, priority: 2 },
|
||||
{ id: 'rule-003', tier: 'tier1_auto', type: 'doc_auto_fulfill', name: 'Certificate Auto-Fulfill', condition: 'Certificate requests from known customers auto-generate and deliver', targetQueue: 'operations', targetAgent: null, enabled: true, priority: 3 },
|
||||
// Tier 2
|
||||
{ id: 'rule-004', tier: 'tier2_rule', type: 'keyword_intent', name: 'Payment Keywords → Collections', condition: 'Keywords: pago, factura, cobro, recibo, transferencia, mora', targetQueue: 'collections', targetAgent: null, enabled: true, priority: 10 },
|
||||
{ id: 'rule-005', tier: 'tier2_rule', type: 'keyword_intent', name: 'Claim Keywords → Claims', condition: 'Keywords: siniestro, accidente, robo, daño, choque, grúa, colisión', targetQueue: 'claims', targetAgent: null, enabled: true, priority: 11 },
|
||||
{ id: 'rule-006', tier: 'tier2_rule', type: 'keyword_intent', name: 'Sales Keywords → Sales', condition: 'Keywords: cotización, seguro nuevo, precio, cuánto sale, cobertura', targetQueue: 'sales', targetAgent: null, enabled: true, priority: 12 },
|
||||
{ id: 'rule-007', tier: 'tier2_rule', type: 'keyword_intent', name: 'Renewal Keywords → Renewals', condition: 'Keywords: renovación, vencimiento, prórroga, vigencia', targetQueue: 'renewals', targetAgent: null, enabled: true, priority: 13 },
|
||||
{ id: 'rule-008', tier: 'tier2_rule', type: 'keyword_intent', name: 'Endorsement Keywords → Operations', condition: 'Keywords: endoso, agregar, modificar, cambio de beneficiario', targetQueue: 'operations', targetAgent: null, enabled: true, priority: 14 },
|
||||
]
|
||||
@@ -1,20 +0,0 @@
|
||||
{
|
||||
"version": 1,
|
||||
"mappings": [
|
||||
{
|
||||
"catalogFormId": 33,
|
||||
"fields": {
|
||||
"full_name": "txtNombreCompleto",
|
||||
"document_id": "txtCedula"
|
||||
}
|
||||
},
|
||||
{
|
||||
"catalogFormId": 39,
|
||||
"fields": {
|
||||
"plate": "txtPlaca",
|
||||
"vin": "txtChasis",
|
||||
"declared_value": "numValor"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,214 +0,0 @@
|
||||
/**
|
||||
* Mock pipeline data for Quotes overview (mission control). Replace with API when ready.
|
||||
*/
|
||||
|
||||
export type QuotePipelineScopeFilter = 'global' | 'corporate' | 'personal'
|
||||
|
||||
export type QuoteOverviewLob = 'auto' | 'health' | 'life' | 'general_risk' | 'custom'
|
||||
|
||||
export type QuotePipelineStageId = 'intake' | 'quoted' | 'proposal' | 'bind' | 'handoff'
|
||||
|
||||
/**
|
||||
* Unified pipeline stage IDs — mirrors PipelineStage from useSalesPipeline.
|
||||
* Used by the quotes overview kanban so both views share one vocabulary.
|
||||
*/
|
||||
export type UnifiedStageId =
|
||||
| 'customer'
|
||||
| 'get_quotes'
|
||||
| 'waiting_carriers'
|
||||
| 'present_quotes'
|
||||
| 'waiting_client'
|
||||
| 'solicitud'
|
||||
| 'emission'
|
||||
|
||||
export type MockPipelineQuote = {
|
||||
id: string
|
||||
customerLabel: string
|
||||
/** Corporate vs personal — drives pipeline scope filters */
|
||||
party: 'corporate' | 'personal'
|
||||
lob: QuoteOverviewLob
|
||||
/** Sub-path: single, comparativo, fleet, collective, etc. */
|
||||
pathLabel: string
|
||||
stage: QuotePipelineStageId
|
||||
/** Unified pipeline stage — same vocabulary as sales pipeline */
|
||||
unifiedStage: UnifiedStageId
|
||||
owner: string
|
||||
formsDone: number
|
||||
formsTotal: number
|
||||
/** Customer has been notified / has portal activity (mock) */
|
||||
customerInformed: boolean
|
||||
}
|
||||
|
||||
export const QUOTE_PIPELINE_STAGES: { id: QuotePipelineStageId; label: string; hint: string }[] = [
|
||||
{ id: 'intake', label: 'Customer Profile', hint: 'Qualify & rate inputs' },
|
||||
{ id: 'quoted', label: 'Quote Prep', hint: 'Options with carrier' },
|
||||
{ id: 'proposal', label: 'Acceptance Pending', hint: 'Out to customer' },
|
||||
{ id: 'bind', label: 'Emission Pending', hint: 'Submit & conditions' },
|
||||
{ id: 'handoff', label: 'Going Live!', hint: 'Policy / solicitud' }
|
||||
]
|
||||
|
||||
/** Unified pipeline stages — same as sales pipeline. Used in overview chart. */
|
||||
export const UNIFIED_PIPELINE_STAGES: { id: UnifiedStageId; label: string; isWaiting: boolean }[] = [
|
||||
{ id: 'customer', label: 'Customer', isWaiting: false },
|
||||
{ id: 'get_quotes', label: 'Get Quotes', isWaiting: false },
|
||||
{ id: 'waiting_carriers', label: 'Awaiting Carriers', isWaiting: true },
|
||||
{ id: 'present_quotes', label: 'Present Quotes', isWaiting: false },
|
||||
{ id: 'waiting_client', label: 'Awaiting Client', isWaiting: true },
|
||||
{ id: 'solicitud', label: 'Solicitud', isWaiting: false },
|
||||
{ id: 'emission', label: 'Emission', isWaiting: false },
|
||||
]
|
||||
|
||||
export const QUOTE_LOB_OPTIONS: { value: QuoteOverviewLob | 'all'; label: string }[] = [
|
||||
{ value: 'all', label: 'All lines' },
|
||||
{ value: 'auto', label: 'Auto' },
|
||||
{ value: 'health', label: 'Health' },
|
||||
{ value: 'life', label: 'Life' },
|
||||
{ value: 'general_risk', label: 'General risk' },
|
||||
{ value: 'custom', label: 'Custom' }
|
||||
]
|
||||
|
||||
export const MOCK_PIPELINE_QUOTES: MockPipelineQuote[] = [
|
||||
{
|
||||
id: 'q-101',
|
||||
customerLabel: 'Transportes Delta S.A.',
|
||||
party: 'corporate',
|
||||
lob: 'auto',
|
||||
pathLabel: 'Fleet',
|
||||
stage: 'quoted',
|
||||
unifiedStage: 'waiting_carriers',
|
||||
owner: 'A. Morales',
|
||||
formsDone: 2,
|
||||
formsTotal: 5,
|
||||
customerInformed: true
|
||||
},
|
||||
{
|
||||
id: 'q-102',
|
||||
customerLabel: 'María Fernández',
|
||||
party: 'personal',
|
||||
lob: 'auto',
|
||||
pathLabel: 'Comparativo',
|
||||
stage: 'proposal',
|
||||
unifiedStage: 'present_quotes',
|
||||
owner: 'L. Chen',
|
||||
formsDone: 4,
|
||||
formsTotal: 4,
|
||||
customerInformed: true
|
||||
},
|
||||
{
|
||||
id: 'q-103',
|
||||
customerLabel: 'J. Pérez',
|
||||
party: 'personal',
|
||||
lob: 'auto',
|
||||
pathLabel: 'Single',
|
||||
stage: 'intake',
|
||||
unifiedStage: 'customer',
|
||||
owner: 'L. Chen',
|
||||
formsDone: 0,
|
||||
formsTotal: 3,
|
||||
customerInformed: false
|
||||
},
|
||||
{
|
||||
id: 'q-201',
|
||||
customerLabel: 'Clínica Norte',
|
||||
party: 'corporate',
|
||||
lob: 'health',
|
||||
pathLabel: 'Collective',
|
||||
stage: 'bind',
|
||||
unifiedStage: 'solicitud',
|
||||
owner: 'R. Vega',
|
||||
formsDone: 6,
|
||||
formsTotal: 8,
|
||||
customerInformed: true
|
||||
},
|
||||
{
|
||||
id: 'q-202',
|
||||
customerLabel: 'Familia Ortega',
|
||||
party: 'personal',
|
||||
lob: 'health',
|
||||
pathLabel: 'Family',
|
||||
stage: 'quoted',
|
||||
unifiedStage: 'get_quotes',
|
||||
owner: 'A. Morales',
|
||||
formsDone: 3,
|
||||
formsTotal: 6,
|
||||
customerInformed: true
|
||||
},
|
||||
{
|
||||
id: 'q-203',
|
||||
customerLabel: 'Startup Labs',
|
||||
party: 'corporate',
|
||||
lob: 'health',
|
||||
pathLabel: 'Travel',
|
||||
stage: 'intake',
|
||||
unifiedStage: 'customer',
|
||||
owner: 'R. Vega',
|
||||
formsDone: 1,
|
||||
formsTotal: 4,
|
||||
customerInformed: false
|
||||
},
|
||||
{
|
||||
id: 'q-301',
|
||||
customerLabel: 'Holdings Centro',
|
||||
party: 'corporate',
|
||||
lob: 'life',
|
||||
pathLabel: 'Key person',
|
||||
stage: 'proposal',
|
||||
unifiedStage: 'waiting_client',
|
||||
owner: 'L. Chen',
|
||||
formsDone: 2,
|
||||
formsTotal: 5,
|
||||
customerInformed: true
|
||||
},
|
||||
{
|
||||
id: 'q-302',
|
||||
customerLabel: 'Carlos Méndez',
|
||||
party: 'personal',
|
||||
lob: 'life',
|
||||
pathLabel: 'Individual',
|
||||
stage: 'handoff',
|
||||
unifiedStage: 'emission',
|
||||
owner: 'A. Morales',
|
||||
formsDone: 5,
|
||||
formsTotal: 5,
|
||||
customerInformed: true
|
||||
},
|
||||
{
|
||||
id: 'q-401',
|
||||
customerLabel: 'Retail Plaza',
|
||||
party: 'corporate',
|
||||
lob: 'general_risk',
|
||||
pathLabel: 'Corporate',
|
||||
stage: 'quoted',
|
||||
unifiedStage: 'waiting_carriers',
|
||||
owner: 'R. Vega',
|
||||
formsDone: 3,
|
||||
formsTotal: 7,
|
||||
customerInformed: false
|
||||
},
|
||||
{
|
||||
id: 'q-402',
|
||||
customerLabel: 'Ana Ríos',
|
||||
party: 'personal',
|
||||
lob: 'general_risk',
|
||||
pathLabel: 'Personal',
|
||||
stage: 'intake',
|
||||
unifiedStage: 'get_quotes',
|
||||
owner: 'L. Chen',
|
||||
formsDone: 1,
|
||||
formsTotal: 4,
|
||||
customerInformed: true
|
||||
},
|
||||
{
|
||||
id: 'q-501',
|
||||
customerLabel: 'Phone-in — manual rate',
|
||||
party: 'personal',
|
||||
lob: 'custom',
|
||||
pathLabel: 'Manual entry',
|
||||
stage: 'proposal',
|
||||
unifiedStage: 'present_quotes',
|
||||
owner: 'R. Vega',
|
||||
formsDone: 0,
|
||||
formsTotal: 2,
|
||||
customerInformed: false
|
||||
}
|
||||
]
|
||||
@@ -1,35 +0,0 @@
|
||||
import type { RoleRow, SegurosPermissionKey } from '~/types/roles'
|
||||
|
||||
function all(v: boolean): Record<SegurosPermissionKey, boolean> {
|
||||
return {
|
||||
profile: v,
|
||||
portfolio: v,
|
||||
layers: v,
|
||||
tasks: v,
|
||||
billing: v,
|
||||
analytics: v,
|
||||
support: v
|
||||
}
|
||||
}
|
||||
|
||||
export const ROLES_SEGUROS_SEED: RoleRow[] = [
|
||||
{ id: 5, description: 'Agente de Ventas', active: true, seguros: all(true) },
|
||||
{ id: 4, description: 'Supervisor de Ventas', active: true, seguros: all(true) },
|
||||
{ id: 3, description: 'Configurador', active: true, seguros: all(false) },
|
||||
{ id: 2, description: 'Supervisor', active: true, seguros: all(false) },
|
||||
{ id: 1, description: 'Superadministrator', active: true, seguros: all(false) }
|
||||
]
|
||||
|
||||
export const SEGUROS_PERMISSION_COLUMNS: {
|
||||
key: SegurosPermissionKey
|
||||
icon: string
|
||||
label: string
|
||||
}[] = [
|
||||
{ key: 'profile', icon: 'i-heroicons-user', label: 'Perfil' },
|
||||
{ key: 'portfolio', icon: 'i-heroicons-folder', label: 'Cartera / pólizas' },
|
||||
{ key: 'layers', icon: 'i-heroicons-squares-2x2', label: 'Capas / duplicados' },
|
||||
{ key: 'tasks', icon: 'i-heroicons-clipboard-document-check', label: 'Tareas / checklist' },
|
||||
{ key: 'billing', icon: 'i-heroicons-currency-dollar', label: 'Cobros / primas' },
|
||||
{ key: 'analytics', icon: 'i-heroicons-chart-pie', label: 'Indicadores' },
|
||||
{ key: 'support', icon: 'i-heroicons-chat-bubble-left-right', label: 'Soporte' }
|
||||
]
|
||||
@@ -1,34 +0,0 @@
|
||||
export const INSURER_SLUGS = [
|
||||
'acerta',
|
||||
'assa',
|
||||
'ancon',
|
||||
'fedpa',
|
||||
'mapfre',
|
||||
'optima',
|
||||
'palig'
|
||||
] as const
|
||||
|
||||
export type InsurerSlug = (typeof INSURER_SLUGS)[number]
|
||||
|
||||
export const PRODUCT_LINE_SLUGS = [
|
||||
'life',
|
||||
'health_local',
|
||||
'health_international',
|
||||
'auto_full_coverage',
|
||||
'auto_dat_liability',
|
||||
'home',
|
||||
'general_liability',
|
||||
'any'
|
||||
] as const
|
||||
|
||||
export type ProductLineSlug = (typeof PRODUCT_LINE_SLUGS)[number]
|
||||
|
||||
export const INSURER_LABEL: Record<string, string> = {
|
||||
acerta: 'ACERTA',
|
||||
assa: 'ASSA',
|
||||
ancon: 'ANCON',
|
||||
fedpa: 'FEDPA',
|
||||
mapfre: 'MAPFRE',
|
||||
optima: 'OPTIMA',
|
||||
palig: 'PALIG'
|
||||
}
|
||||
@@ -1,10 +1,115 @@
|
||||
<script setup lang="ts">
|
||||
const route = useRoute()
|
||||
const { saved: branding, sidebarTitle } = useBrokerageBranding()
|
||||
const { isSuperAdmin } = useSuperAdmin()
|
||||
useAppTheme()
|
||||
const { sidebarCollapsed, toggleSidebar } = useAppShellLayout()
|
||||
const sidebarFeatures = useSidebarFeatures()
|
||||
|
||||
const STORAGE_KEY_BRANDING = 'policy-ui.branding'
|
||||
const STORAGE_KEY_SUPERADMIN = 'policy-ui.superadmin'
|
||||
const STORAGE_KEY_SIDEBAR_WORKSTATIONS = 'policy-ui.sidebar.workstations'
|
||||
const STORAGE_KEY_SIDEBAR_AI_TOOLS = 'policy-ui.sidebar.ai-tools'
|
||||
const STORAGE_KEY_SIDEBAR_LEADS_HUB = 'policy-ui.sidebar.leads-hub'
|
||||
const STORAGE_KEY_SIDEBAR_COLLAPSED = 'policy-ui.sidebar.collapsed'
|
||||
|
||||
interface BrokerageBrandingState {
|
||||
companyName: string
|
||||
logoDataUrl: string | null
|
||||
logoFileName: string
|
||||
reportPageHeader: string
|
||||
reportPageFooter: string
|
||||
}
|
||||
|
||||
function loadBranding(): BrokerageBrandingState {
|
||||
if (import.meta.client) {
|
||||
const stored = localStorage.getItem(STORAGE_KEY_BRANDING)
|
||||
if (stored) {
|
||||
try {
|
||||
return JSON.parse(stored)
|
||||
} catch {
|
||||
return defaultBranding()
|
||||
}
|
||||
}
|
||||
}
|
||||
return defaultBranding()
|
||||
}
|
||||
|
||||
function defaultBranding(): BrokerageBrandingState {
|
||||
return {
|
||||
companyName: '',
|
||||
logoDataUrl: null,
|
||||
logoFileName: '',
|
||||
reportPageHeader: '',
|
||||
reportPageFooter: ''
|
||||
}
|
||||
}
|
||||
|
||||
const branding = ref<BrokerageBrandingState>(loadBranding())
|
||||
const sidebarTitle = computed(() => branding.value.companyName || 'Segur-OS')
|
||||
|
||||
const isSuperAdmin = computed(() => {
|
||||
if (import.meta.client) {
|
||||
const stored = localStorage.getItem(STORAGE_KEY_SUPERADMIN)
|
||||
return stored !== '0'
|
||||
}
|
||||
return true
|
||||
})
|
||||
|
||||
const sidebarCollapsed = computed({
|
||||
get: () => {
|
||||
if (import.meta.client) {
|
||||
return localStorage.getItem(STORAGE_KEY_SIDEBAR_COLLAPSED) === 'true'
|
||||
}
|
||||
return false
|
||||
},
|
||||
set: (value: boolean) => {
|
||||
if (import.meta.client) {
|
||||
localStorage.setItem(STORAGE_KEY_SIDEBAR_COLLAPSED, String(value))
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
function toggleSidebar() {
|
||||
sidebarCollapsed.value = !sidebarCollapsed.value
|
||||
}
|
||||
|
||||
const sidebarFeatures = reactive({
|
||||
showWorkstations: computed({
|
||||
get: () => {
|
||||
if (import.meta.client) {
|
||||
return localStorage.getItem(STORAGE_KEY_SIDEBAR_WORKSTATIONS) !== 'false'
|
||||
}
|
||||
return true
|
||||
},
|
||||
set: (value: boolean) => {
|
||||
if (import.meta.client) {
|
||||
localStorage.setItem(STORAGE_KEY_SIDEBAR_WORKSTATIONS, String(value))
|
||||
}
|
||||
}
|
||||
}),
|
||||
showAiTools: computed({
|
||||
get: () => {
|
||||
if (import.meta.client) {
|
||||
return localStorage.getItem(STORAGE_KEY_SIDEBAR_AI_TOOLS) !== 'false'
|
||||
}
|
||||
return true
|
||||
},
|
||||
set: (value: boolean) => {
|
||||
if (import.meta.client) {
|
||||
localStorage.setItem(STORAGE_KEY_SIDEBAR_AI_TOOLS, String(value))
|
||||
}
|
||||
}
|
||||
}),
|
||||
showLeadsHub: computed({
|
||||
get: () => {
|
||||
if (import.meta.client) {
|
||||
return localStorage.getItem(STORAGE_KEY_SIDEBAR_LEADS_HUB) !== 'false'
|
||||
}
|
||||
return true
|
||||
},
|
||||
set: (value: boolean) => {
|
||||
if (import.meta.client) {
|
||||
localStorage.setItem(STORAGE_KEY_SIDEBAR_LEADS_HUB, String(value))
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
const openGroups = ref({
|
||||
quotes: false,
|
||||
@@ -12,10 +117,10 @@ const openGroups = ref({
|
||||
cartera: false,
|
||||
customerService: false,
|
||||
workstation: false,
|
||||
aiTools: false
|
||||
aiTools: false,
|
||||
backOffice: false
|
||||
})
|
||||
|
||||
// Auto-open the group matching the current route (but never close others)
|
||||
watch(() => route.path, (p) => {
|
||||
if (p.startsWith('/quotes') && p !== '/quotes/new' && p !== '/quotes/compare') openGroups.value.quotes = true
|
||||
if (p.startsWith('/onboarding') || (p.startsWith('/sales') && !p.startsWith('/sales/leads')) || p === '/quotes/new' || p === '/quotes/compare' || p.startsWith('/registration')) openGroups.value.sales = true
|
||||
@@ -23,6 +128,7 @@ watch(() => route.path, (p) => {
|
||||
if (p.startsWith('/support') || p.startsWith('/claims') || p.startsWith('/collections') || p.startsWith('/renewals') || p.startsWith('/sales/leads')) openGroups.value.customerService = true
|
||||
if (p.startsWith('/workstation')) openGroups.value.workstation = true
|
||||
if (p.startsWith('/ai-tools')) openGroups.value.aiTools = true
|
||||
if (p.startsWith('/back-office')) openGroups.value.backOffice = true
|
||||
}, { immediate: true })
|
||||
|
||||
function toggleGroup(key: string) {
|
||||
@@ -34,7 +140,6 @@ function isActive(path: string, exact = false) {
|
||||
return exact ? p === path : p === path || p.startsWith(`${path}/`)
|
||||
}
|
||||
|
||||
/* ── Parent link class (40px row, 20px icon, 10px gap) ── */
|
||||
function linkClass(path: string, exact = false) {
|
||||
const active = isActive(path, exact)
|
||||
return [
|
||||
@@ -45,7 +150,6 @@ function linkClass(path: string, exact = false) {
|
||||
]
|
||||
}
|
||||
|
||||
/* ── Child link class (text only, 32px row, 13px, indented) ── */
|
||||
function subLinkClass(path: string, exact = false) {
|
||||
const active = isActive(path, exact)
|
||||
return [
|
||||
@@ -73,7 +177,8 @@ function groupBtnClass(key: string) {
|
||||
route.path.startsWith('/renewals') ||
|
||||
route.path.startsWith('/sales/leads'))) ||
|
||||
(key === 'workstation' && route.path.startsWith('/workstation')) ||
|
||||
(key === 'aiTools' && route.path.startsWith('/ai-tools'))
|
||||
(key === 'aiTools' && route.path.startsWith('/ai-tools')) ||
|
||||
(key === 'backOffice' && route.path.startsWith('/back-office'))
|
||||
return [
|
||||
'app-sidebar-link sidebar-parent-link flex w-full items-center text-left',
|
||||
hasActive
|
||||
@@ -90,7 +195,6 @@ async function onTopRefresh() {
|
||||
}
|
||||
}
|
||||
|
||||
// Keyboard shortcut: Ctrl/Cmd + B to toggle sidebar
|
||||
function onKeydown(e: KeyboardEvent) {
|
||||
if ((e.metaKey || e.ctrlKey) && e.key === 'b') {
|
||||
e.preventDefault()
|
||||
@@ -150,12 +254,10 @@ onUnmounted(() => {
|
||||
/>
|
||||
</button>
|
||||
<div v-if="openGroups.quotes" class="sidebar-children">
|
||||
<NuxtLink to="/quotes" active-class="" exact-active-class="" :class="subLinkClass('/quotes', true)">Mission Control</NuxtLink>
|
||||
<NuxtLink to="/quotes/auto" active-class="" exact-active-class="" :class="subLinkClass('/quotes/auto')">Auto</NuxtLink>
|
||||
<NuxtLink to="/quotes/health" active-class="" exact-active-class="" :class="subLinkClass('/quotes/health')">Health</NuxtLink>
|
||||
<NuxtLink to="/quotes/life" active-class="" exact-active-class="" :class="subLinkClass('/quotes/life')">Life</NuxtLink>
|
||||
<NuxtLink to="/quotes/general-risk" active-class="" exact-active-class="" :class="subLinkClass('/quotes/general-risk')">General Risk</NuxtLink>
|
||||
<NuxtLink to="/quotes/custom" active-class="" exact-active-class="" :class="subLinkClass('/quotes/custom')">Custom</NuxtLink>
|
||||
<NuxtLink to="/quotes/new?tab=car" :class="subLinkClass('/quotes/new', true)">Auto</NuxtLink>
|
||||
<NuxtLink to="/quotes/new?tab=life" :class="subLinkClass('/quotes/new')">Life</NuxtLink>
|
||||
<NuxtLink to="/quotes/new?tab=fire_structure" :class="subLinkClass('/quotes/new')">Fire Structure</NuxtLink>
|
||||
<NuxtLink to="/quotes/new?tab=fire_contents" :class="subLinkClass('/quotes/new')">Fire Contents</NuxtLink>
|
||||
</div>
|
||||
|
||||
<button type="button" :class="groupBtnClass('sales')" @click="toggleGroup('sales')">
|
||||
@@ -169,12 +271,8 @@ onUnmounted(() => {
|
||||
<div v-if="openGroups.sales" class="sidebar-children">
|
||||
<NuxtLink to="/onboarding" :class="subLinkClass('/onboarding', true)">Sales Pipeline</NuxtLink>
|
||||
<NuxtLink to="/sales/quick-lead" :class="subLinkClass('/sales/quick-lead')">Quick Lead</NuxtLink>
|
||||
<NuxtLink to="/registration/client" :class="subLinkClass('/registration/client', true)">New Customer</NuxtLink>
|
||||
<NuxtLink to="/quotes/new" :class="subLinkClass('/quotes/new', true)">Get Quotes</NuxtLink>
|
||||
<NuxtLink to="/quotes/compare" :class="subLinkClass('/quotes/compare', true)">Present Quotes</NuxtLink>
|
||||
<NuxtLink to="/onboarding/solicitud" :class="subLinkClass('/onboarding/solicitud', true)">Solicitudes</NuxtLink>
|
||||
<NuxtLink to="/onboarding/emissions" :class="subLinkClass('/onboarding/emissions')">Emissions</NuxtLink>
|
||||
<NuxtLink to="/onboarding/policy-upload/new" :class="subLinkClass('/onboarding/policy-upload')">Nombramiento</NuxtLink>
|
||||
<NuxtLink to="/customers/new" :class="subLinkClass('/customers/new', true)">New Customer</NuxtLink>
|
||||
<NuxtLink to="/quotes/new" :class="subLinkClass('/quotes/new', true)">New Quote</NuxtLink>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -217,9 +315,27 @@ onUnmounted(() => {
|
||||
<UIcon name="i-heroicons-chart-bar-square" class="sidebar-icon shrink-0" />
|
||||
Reports & Analysis
|
||||
</NuxtLink>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── WORKSTATION section ── -->
|
||||
<!-- ── BACK OFFICE section ── -->
|
||||
<p class="app-sidebar-section-label">Back Office</p>
|
||||
|
||||
<div class="flex flex-col">
|
||||
<button type="button" :class="groupBtnClass('backOffice')" @click="toggleGroup('backOffice')">
|
||||
<UIcon name="i-heroicons-inbox-stack" class="sidebar-icon shrink-0" />
|
||||
<span class="flex-1 text-left">Back Office</span>
|
||||
<UIcon
|
||||
:name="openGroups.backOffice ? 'i-heroicons-chevron-down' : 'i-heroicons-chevron-right'"
|
||||
class="sidebar-chevron shrink-0"
|
||||
/>
|
||||
</button>
|
||||
<div v-if="openGroups.backOffice" class="sidebar-children">
|
||||
<NuxtLink to="/back-office/workload" :class="subLinkClass('/back-office/workload', true)">Task List</NuxtLink>
|
||||
<NuxtLink to="/back-office/workload/kanban" :class="subLinkClass('/back-office/workload/kanban')">Kanban Board</NuxtLink>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── WORKSTATION section ── -->
|
||||
<template v-if="sidebarFeatures.showWorkstations || sidebarFeatures.showAiTools">
|
||||
<p class="app-sidebar-section-label">Workstations</p>
|
||||
|
||||
@@ -291,7 +407,7 @@ onUnmounted(() => {
|
||||
class="flex min-h-0 min-w-0 flex-1 flex-col"
|
||||
:data-app-surface="isSettingsRoute ? 'settings' : undefined"
|
||||
>
|
||||
<div class="flex-1 overflow-y-auto" style="padding: 16px 24px 32px;">
|
||||
<div class="flex-1 overflow-y-auto flex flex-col" style="padding: 16px 24px 32px;">
|
||||
<NuxtPage :key="route.fullPath" />
|
||||
</div>
|
||||
</main>
|
||||
|
||||
@@ -1,459 +1,42 @@
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
ANALYTICS_DOMAIN_LABELS,
|
||||
ANALYTICS_METRICS,
|
||||
type AnalyticsDomainId,
|
||||
type AnalyticsChartType,
|
||||
type AnalyticsTimePoint,
|
||||
} from '~/data/mock-analytics'
|
||||
|
||||
usePageTitle('Business Analytics')
|
||||
|
||||
const {
|
||||
state, allMetrics, kpiSummaries, domainMetrics,
|
||||
chartBuilderMetricObj, chartBuilderData, chartBuilderSvgModel,
|
||||
buildSvgModel, sparklinePath, sparklineArea,
|
||||
} = useAnalytics()
|
||||
|
||||
// ── Domain tabs ──
|
||||
const domains: { id: AnalyticsDomainId; label: string }[] = [
|
||||
const domains = [
|
||||
{ id: 'production', label: 'Producción' },
|
||||
{ id: 'claims', label: 'Siniestros' },
|
||||
{ id: 'pipeline', label: 'Pipeline' },
|
||||
{ id: 'service', label: 'Servicio' },
|
||||
]
|
||||
|
||||
// ── Per-card chart type overrides ──
|
||||
const cardChartTypes = ref<Record<string, AnalyticsChartType>>({})
|
||||
function getCardChartType(metricId: string, defaultType: AnalyticsChartType): AnalyticsChartType {
|
||||
return cardChartTypes.value[metricId] ?? defaultType
|
||||
}
|
||||
function setCardChartType(metricId: string, type: AnalyticsChartType) {
|
||||
cardChartTypes.value[metricId] = type
|
||||
}
|
||||
|
||||
// ── Chart builder grouped options ──
|
||||
const chartBuilderGroups = computed(() => {
|
||||
const domainOrder: AnalyticsDomainId[] = ['production', 'claims', 'pipeline', 'service']
|
||||
return domainOrder.map(d => ({
|
||||
label: ANALYTICS_DOMAIN_LABELS[d],
|
||||
metrics: allMetrics.filter(m => m.domain === d && m.data12m.some(p => p.m)),
|
||||
}))
|
||||
})
|
||||
|
||||
// ── Filter valid data points (skip empties) for chart rendering ──
|
||||
function validData(data: AnalyticsTimePoint[]): AnalyticsTimePoint[] {
|
||||
return data.filter(d => d.m)
|
||||
}
|
||||
|
||||
// ── Change badge class ──
|
||||
function changeToneClass(tone: string): string {
|
||||
if (tone === 'positive') return 'an-change-positive'
|
||||
if (tone === 'negative') return 'an-change-negative'
|
||||
return 'an-change-neutral'
|
||||
}
|
||||
const activeDomain = ref('production')
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="an-page">
|
||||
<!-- Header -->
|
||||
<div class="space-y-6">
|
||||
<div>
|
||||
<h1 class="mt-1 text-2xl font-semibold tracking-tight text-[var(--text-primary)]">Business Analytics</h1>
|
||||
<p class="mt-2 text-[14px] leading-relaxed text-[var(--text-muted)]">
|
||||
Consolidated view — production, claims, pipeline, and service KPIs with interactive charts.
|
||||
</p>
|
||||
<h1 class="text-2xl font-semibold text-[var(--text-primary)]">Business Analytics</h1>
|
||||
<p class="text-[var(--text-muted)] mt-1">Track production, claims, pipeline, and service metrics</p>
|
||||
</div>
|
||||
|
||||
<!-- ═══════════ KPI STRIP ═══════════ -->
|
||||
<div class="an-kpi-strip">
|
||||
<div v-for="kpi in kpiSummaries" :key="kpi.id" class="an-kpi-card">
|
||||
<div class="an-kpi-top">
|
||||
<p class="an-kpi-label">{{ kpi.label }}</p>
|
||||
<span :class="['an-change-badge', changeToneClass(kpi.changeTone)]">{{ kpi.change }}</span>
|
||||
</div>
|
||||
<p class="an-kpi-value">{{ kpi.value }}</p>
|
||||
<p class="an-kpi-hint">{{ kpi.hint }}</p>
|
||||
<svg class="an-kpi-spark" viewBox="0 0 112 32" preserveAspectRatio="none">
|
||||
<path :d="sparklineArea(kpi.sparkline)" fill="rgba(1,105,111,0.06)" />
|
||||
<path :d="sparklinePath(kpi.sparkline)" fill="none" stroke="#01696f" stroke-width="1.5" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ═══════════ DOMAIN TABS ═══════════ -->
|
||||
<div class="an-domain-tabs">
|
||||
<!-- Domain tabs -->
|
||||
<div class="flex gap-2">
|
||||
<button
|
||||
v-for="d in domains"
|
||||
:key="d.id"
|
||||
v-for="domain in domains"
|
||||
:key="domain.id"
|
||||
type="button"
|
||||
class="an-domain-tab"
|
||||
:class="state.activeDomain === d.id ? 'an-tab-active' : 'an-tab-inactive'"
|
||||
@click="state.activeDomain = d.id"
|
||||
class="px-4 py-2 rounded-lg font-medium transition-colors"
|
||||
:class="activeDomain === domain.id ? 'bg-[var(--brand)] text-white' : 'bg-[var(--surface)] text-[var(--text-muted)] hover:bg-[var(--card-border)]'"
|
||||
@click="activeDomain = domain.id"
|
||||
>
|
||||
{{ d.label }}
|
||||
{{ domain.label }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- ═══════════ CHART BUILDER ═══════════ -->
|
||||
<div class="an-builder">
|
||||
<div class="an-builder-header">
|
||||
<p class="an-builder-title">
|
||||
<UIcon name="i-heroicons-wrench-screwdriver" class="w-4 h-4" />
|
||||
Chart Builder
|
||||
</p>
|
||||
<p class="text-[12px] text-[var(--text-muted)]">Pick any metric, chart type, and time range.</p>
|
||||
</div>
|
||||
|
||||
<div class="an-builder-controls">
|
||||
<!-- Grouped metric selector -->
|
||||
<select v-model="state.chartBuilderMetric" class="an-builder-select an-builder-select-wide">
|
||||
<optgroup v-for="group in chartBuilderGroups" :key="group.label" :label="group.label">
|
||||
<option v-for="m in group.metrics" :key="m.id" :value="m.id">{{ m.label }}</option>
|
||||
</optgroup>
|
||||
</select>
|
||||
|
||||
<!-- Chart type toggle -->
|
||||
<div class="an-builder-toggle">
|
||||
<button
|
||||
v-for="ct in (['area', 'line', 'bar'] as AnalyticsChartType[])"
|
||||
:key="ct"
|
||||
type="button"
|
||||
class="an-bt-btn"
|
||||
:class="state.chartBuilderType === ct ? 'an-bt-on' : 'an-bt-off'"
|
||||
@click="state.chartBuilderType = ct"
|
||||
>
|
||||
{{ ct.charAt(0).toUpperCase() + ct.slice(1) }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Time range -->
|
||||
<div class="an-builder-toggle">
|
||||
<button
|
||||
v-for="r in (['3m', '6m', '12m'] as const)"
|
||||
:key="r"
|
||||
type="button"
|
||||
class="an-bt-btn"
|
||||
:class="state.chartBuilderRange === r ? 'an-bt-on' : 'an-bt-off'"
|
||||
@click="state.chartBuilderRange = r"
|
||||
>
|
||||
{{ r.toUpperCase() }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Builder chart info -->
|
||||
<div class="an-builder-info">
|
||||
<p class="text-[18px] font-bold text-[var(--text-primary)]">
|
||||
{{ chartBuilderData[chartBuilderData.length - 1]?.display }}
|
||||
</p>
|
||||
<span :class="['an-change-badge', changeToneClass(chartBuilderMetricObj.changeTone)]">{{ chartBuilderMetricObj.change }}</span>
|
||||
<span class="text-[12px] text-[var(--text-muted)] ml-2">{{ chartBuilderMetricObj.label }}</span>
|
||||
</div>
|
||||
|
||||
<!-- Builder SVG -->
|
||||
<svg class="an-builder-svg" :viewBox="`0 0 ${chartBuilderSvgModel.viewW} ${chartBuilderSvgModel.viewH}`" preserveAspectRatio="none">
|
||||
<line
|
||||
v-for="(gy, gi) in chartBuilderSvgModel.gridYs"
|
||||
:key="gi"
|
||||
:x1="chartBuilderSvgModel.padX"
|
||||
:y1="gy"
|
||||
:x2="chartBuilderSvgModel.padX + chartBuilderSvgModel.innerW"
|
||||
:y2="gy"
|
||||
stroke="rgba(0,0,0,0.04)"
|
||||
stroke-width="1"
|
||||
/>
|
||||
<path
|
||||
v-if="state.chartBuilderType === 'area'"
|
||||
:d="chartBuilderSvgModel.areaD"
|
||||
fill="rgba(1,105,111,0.08)"
|
||||
/>
|
||||
<path
|
||||
v-if="state.chartBuilderType !== 'bar'"
|
||||
:d="chartBuilderSvgModel.lineD"
|
||||
fill="none"
|
||||
stroke="#01696f"
|
||||
stroke-width="2"
|
||||
/>
|
||||
<circle
|
||||
v-if="state.chartBuilderType !== 'bar'"
|
||||
v-for="(pt, pi) in chartBuilderSvgModel.points"
|
||||
:key="pi"
|
||||
:cx="pt.x"
|
||||
:cy="pt.y"
|
||||
r="4"
|
||||
fill="#fff"
|
||||
stroke="#01696f"
|
||||
stroke-width="2"
|
||||
/>
|
||||
<rect
|
||||
v-if="state.chartBuilderType === 'bar'"
|
||||
v-for="(bar, bi) in chartBuilderSvgModel.bars"
|
||||
:key="bi"
|
||||
:x="bar.x"
|
||||
:y="bar.y"
|
||||
:width="bar.w"
|
||||
:height="bar.h"
|
||||
rx="4"
|
||||
fill="#01696f"
|
||||
opacity="0.7"
|
||||
/>
|
||||
</svg>
|
||||
|
||||
<!-- Builder x-axis -->
|
||||
<div class="an-chart-xaxis" style="padding: 0 8px;">
|
||||
<span v-for="(d, di) in chartBuilderData" :key="di" class="an-xaxis-label">{{ d.m }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ═══════════ CHART CARDS GRID ═══════════ -->
|
||||
<div class="an-chart-grid">
|
||||
<div
|
||||
v-for="metric in domainMetrics"
|
||||
:key="metric.id"
|
||||
class="an-chart-card"
|
||||
>
|
||||
<!-- Card header -->
|
||||
<div class="an-chart-header">
|
||||
<div class="flex-1 min-w-0">
|
||||
<p class="an-chart-title">{{ metric.label }}</p>
|
||||
<span v-if="metric.change" :class="['an-change-badge', changeToneClass(metric.changeTone)]">{{ metric.change }}</span>
|
||||
</div>
|
||||
<div class="an-chart-type-toggle">
|
||||
<button
|
||||
v-for="ct in (['area', 'line', 'bar'] as AnalyticsChartType[])"
|
||||
:key="ct"
|
||||
type="button"
|
||||
class="an-ct-btn"
|
||||
:class="getCardChartType(metric.id, metric.defaultChartType) === ct ? 'an-ct-on' : 'an-ct-off'"
|
||||
@click="setCardChartType(metric.id, ct)"
|
||||
>
|
||||
{{ ct === 'area' ? '▤' : ct === 'line' ? '⌇' : '▥' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- SVG chart -->
|
||||
<svg class="an-chart-svg" :viewBox="`0 0 ${buildSvgModel(validData(metric.data12m)).viewW} ${buildSvgModel(validData(metric.data12m)).viewH}`" preserveAspectRatio="none">
|
||||
<!-- Grid lines -->
|
||||
<line
|
||||
v-for="(gy, gi) in buildSvgModel(validData(metric.data12m)).gridYs"
|
||||
:key="gi"
|
||||
:x1="buildSvgModel(validData(metric.data12m)).padX"
|
||||
:y1="gy"
|
||||
:x2="buildSvgModel(validData(metric.data12m)).padX + buildSvgModel(validData(metric.data12m)).innerW"
|
||||
:y2="gy"
|
||||
stroke="rgba(0,0,0,0.04)"
|
||||
stroke-width="1"
|
||||
/>
|
||||
|
||||
<!-- Area -->
|
||||
<path
|
||||
v-if="getCardChartType(metric.id, metric.defaultChartType) === 'area'"
|
||||
:d="buildSvgModel(validData(metric.data12m)).areaD"
|
||||
fill="rgba(1,105,111,0.08)"
|
||||
/>
|
||||
<!-- Line -->
|
||||
<path
|
||||
v-if="getCardChartType(metric.id, metric.defaultChartType) !== 'bar'"
|
||||
:d="buildSvgModel(validData(metric.data12m)).lineD"
|
||||
fill="none"
|
||||
stroke="#01696f"
|
||||
stroke-width="2"
|
||||
/>
|
||||
<!-- Points -->
|
||||
<circle
|
||||
v-if="getCardChartType(metric.id, metric.defaultChartType) !== 'bar'"
|
||||
v-for="(pt, pi) in buildSvgModel(validData(metric.data12m)).points"
|
||||
:key="pi"
|
||||
:cx="pt.x"
|
||||
:cy="pt.y"
|
||||
r="3"
|
||||
fill="#fff"
|
||||
stroke="#01696f"
|
||||
stroke-width="1.5"
|
||||
/>
|
||||
<!-- Bars -->
|
||||
<rect
|
||||
v-if="getCardChartType(metric.id, metric.defaultChartType) === 'bar'"
|
||||
v-for="(bar, bi) in buildSvgModel(validData(metric.data12m)).bars"
|
||||
:key="bi"
|
||||
:x="bar.x"
|
||||
:y="bar.y"
|
||||
:width="bar.w"
|
||||
:height="bar.h"
|
||||
rx="3"
|
||||
fill="#01696f"
|
||||
opacity="0.7"
|
||||
/>
|
||||
</svg>
|
||||
|
||||
<!-- X-axis labels -->
|
||||
<div class="an-chart-xaxis">
|
||||
<span v-for="(d, di) in validData(metric.data12m)" :key="di" class="an-xaxis-label">{{ d.m }}</span>
|
||||
</div>
|
||||
|
||||
<!-- Latest value -->
|
||||
<div class="an-chart-latest">
|
||||
<span class="text-[13px] font-semibold text-[var(--text-primary)]">{{ validData(metric.data12m)[validData(metric.data12m).length - 1]?.display }}</span>
|
||||
<span class="text-[11px] text-[var(--text-muted)]">latest</span>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Empty state -->
|
||||
<div class="border border-[var(--card-border)] rounded-lg p-12 text-center">
|
||||
<UIcon name="i-heroicons-chart-bar" class="w-16 h-16 mx-auto text-[var(--text-muted)] mb-4" />
|
||||
<h3 class="text-lg font-medium text-[var(--text-primary)] mb-2">Analytics unavailable</h3>
|
||||
<p class="text-[var(--text-muted)]">Analytics data will be available once you have policies and quotes in the system.</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.an-page {
|
||||
max-width: 72rem;
|
||||
margin: 0 auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 24px;
|
||||
padding-bottom: 3rem;
|
||||
}
|
||||
|
||||
/* ══════════ KPI STRIP ══════════ */
|
||||
.an-kpi-strip {
|
||||
display: grid; grid-template-columns: repeat(6, 1fr); gap: 12px;
|
||||
}
|
||||
.an-kpi-card {
|
||||
padding: 14px 16px; border-radius: 12px;
|
||||
border: 1px solid rgba(0,0,0,0.06); background: #fff;
|
||||
box-shadow: 0 1px 3px rgba(0,0,0,0.03);
|
||||
position: relative; overflow: hidden;
|
||||
}
|
||||
.an-kpi-top { display: flex; align-items: center; justify-content: space-between; gap: 6px; }
|
||||
.an-kpi-label {
|
||||
font-size: 11px; font-weight: 600; text-transform: uppercase;
|
||||
letter-spacing: 0.04em; color: #8a8a86;
|
||||
}
|
||||
.an-kpi-value {
|
||||
margin-top: 4px; font-size: 20px; font-weight: 700;
|
||||
color: var(--text-primary); font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.an-kpi-hint { font-size: 11px; color: var(--text-muted); margin-top: 2px; }
|
||||
.an-kpi-spark {
|
||||
position: absolute; bottom: 0; left: 0; right: 0;
|
||||
width: 100%; height: 32px; opacity: 0.6;
|
||||
}
|
||||
|
||||
@media (max-width: 900px) { .an-kpi-strip { grid-template-columns: repeat(3, 1fr); } }
|
||||
@media (max-width: 500px) { .an-kpi-strip { grid-template-columns: repeat(2, 1fr); } }
|
||||
|
||||
/* ══════════ CHANGE BADGES ══════════ */
|
||||
.an-change-badge {
|
||||
display: inline-flex; padding: 1px 6px; border-radius: 6px;
|
||||
font-size: 10px; font-weight: 700; white-space: nowrap;
|
||||
}
|
||||
.an-change-positive { background: rgba(5,150,105,0.08); color: #059669; }
|
||||
.an-change-negative { background: rgba(193,56,56,0.08); color: #c13838; }
|
||||
.an-change-neutral { background: rgba(0,0,0,0.04); color: #8a8a86; }
|
||||
|
||||
/* ══════════ DOMAIN TABS ══════════ */
|
||||
.an-domain-tabs {
|
||||
display: inline-flex; gap: 2px; padding: 3px;
|
||||
border-radius: 10px; background: rgba(0,0,0,0.04);
|
||||
width: fit-content;
|
||||
}
|
||||
.an-domain-tab {
|
||||
padding: 8px 18px; border-radius: 8px;
|
||||
font-size: 13px; font-weight: 500; border: none;
|
||||
cursor: pointer; transition: all 150ms ease; white-space: nowrap;
|
||||
}
|
||||
.an-tab-active { background: #fff; color: var(--text-primary); box-shadow: 0 1px 3px rgba(0,0,0,0.08); }
|
||||
.an-tab-inactive { background: transparent; color: var(--text-muted); }
|
||||
.an-tab-inactive:hover { color: var(--text-primary); }
|
||||
|
||||
/* ══════════ CHART GRID ══════════ */
|
||||
.an-chart-grid {
|
||||
display: grid; grid-template-columns: repeat(2, 1fr); gap: 16px;
|
||||
}
|
||||
@media (max-width: 700px) { .an-chart-grid { grid-template-columns: 1fr; } }
|
||||
|
||||
.an-chart-card {
|
||||
padding: 16px; border-radius: 12px;
|
||||
border: 1px solid rgba(0,0,0,0.06); background: #fff;
|
||||
box-shadow: 0 1px 3px rgba(0,0,0,0.03);
|
||||
}
|
||||
|
||||
/* ── Chart header ── */
|
||||
.an-chart-header {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
gap: 8px; margin-bottom: 10px;
|
||||
}
|
||||
.an-chart-title { font-size: 13px; font-weight: 600; color: var(--text-primary); }
|
||||
|
||||
.an-chart-type-toggle {
|
||||
display: inline-flex; gap: 1px; padding: 2px;
|
||||
border-radius: 6px; background: rgba(0,0,0,0.03);
|
||||
}
|
||||
.an-ct-btn {
|
||||
width: 24px; height: 22px; border-radius: 4px;
|
||||
font-size: 10px; border: none; cursor: pointer;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
transition: all 100ms ease;
|
||||
}
|
||||
.an-ct-on { background: #01696f; color: #fff; }
|
||||
.an-ct-off { background: transparent; color: #8a8a86; }
|
||||
.an-ct-off:hover { color: var(--text-primary); }
|
||||
|
||||
/* ── SVG chart ── */
|
||||
.an-chart-svg { width: 100%; height: 120px; }
|
||||
|
||||
/* ── X-axis labels ── */
|
||||
.an-chart-xaxis {
|
||||
display: flex; justify-content: space-between;
|
||||
padding: 4px 8px 0;
|
||||
}
|
||||
.an-xaxis-label {
|
||||
font-size: 9px; font-weight: 600; color: #8a8a86;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
/* ── Latest value ── */
|
||||
.an-chart-latest {
|
||||
display: flex; align-items: center; gap: 6px;
|
||||
margin-top: 8px; padding-top: 8px;
|
||||
border-top: 1px solid rgba(0,0,0,0.04);
|
||||
}
|
||||
|
||||
/* ══════════ CHART BUILDER ══════════ */
|
||||
.an-builder {
|
||||
padding: 20px; border-radius: 12px;
|
||||
border: 1px solid rgba(1,105,111,0.12); background: rgba(1,105,111,0.01);
|
||||
}
|
||||
.an-builder-header { margin-bottom: 16px; }
|
||||
.an-builder-title {
|
||||
display: flex; align-items: center; gap: 6px;
|
||||
font-size: 15px; font-weight: 600; color: var(--text-primary);
|
||||
}
|
||||
.an-builder-controls {
|
||||
display: flex; gap: 10px; flex-wrap: wrap; align-items: center;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
.an-builder-select {
|
||||
padding: 6px 12px; border-radius: 8px; font-size: 12px; font-weight: 500;
|
||||
border: 1px solid rgba(0,0,0,0.08); background: #fff; color: var(--text-primary);
|
||||
cursor: pointer;
|
||||
}
|
||||
.an-builder-select-wide { min-width: 200px; }
|
||||
.an-builder-select:focus { outline: none; border-color: #01696f; }
|
||||
|
||||
.an-builder-toggle {
|
||||
display: inline-flex; gap: 1px; padding: 2px;
|
||||
border-radius: 8px; background: rgba(0,0,0,0.04);
|
||||
}
|
||||
.an-bt-btn {
|
||||
padding: 5px 12px; border-radius: 6px; font-size: 11px; font-weight: 600;
|
||||
border: none; cursor: pointer; transition: all 150ms ease; white-space: nowrap;
|
||||
}
|
||||
.an-bt-on { background: #01696f; color: #fff; }
|
||||
.an-bt-off { background: transparent; color: #8a8a86; }
|
||||
.an-bt-off:hover { color: var(--text-primary); }
|
||||
|
||||
.an-builder-info {
|
||||
display: flex; align-items: center; gap: 8px; margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.an-builder-svg { width: 100%; height: 180px; }
|
||||
</style>
|
||||
|
||||
540
app/pages/back-office/workload/[id].vue
Normal file
540
app/pages/back-office/workload/[id].vue
Normal file
@@ -0,0 +1,540 @@
|
||||
<script setup lang="ts">
|
||||
import NestedJsonViewer from '~/components/back-office/NestedJsonViewer.vue'
|
||||
|
||||
const route = useRoute()
|
||||
const id = decodeURIComponent(route.params.id as string)
|
||||
|
||||
usePageTitle(`Task: ${id}`)
|
||||
|
||||
const { data, pending, error, refresh } = useWorkload(`/tasks/${id}`)
|
||||
const task = computed(() => data.value?.data)
|
||||
|
||||
const taskType = computed(() => {
|
||||
const parts = id?.split(':') ?? []
|
||||
return parts[1] || 'unknown'
|
||||
})
|
||||
|
||||
const isQuote = computed(() => taskType.value === 'quote')
|
||||
const isSolicitation = computed(() => taskType.value === 'solicitation')
|
||||
|
||||
const { $document } = useNuxtApp()
|
||||
|
||||
async function downloadDocument(documentId: string) {
|
||||
try {
|
||||
const response = await $document(`/documents/${documentId}/download-url`, {
|
||||
method: 'GET'
|
||||
})
|
||||
if (response.data?.download_url) {
|
||||
window.open(response.data.download_url, '_blank')
|
||||
}
|
||||
} catch (e) {
|
||||
toast.add({ title: 'Failed to get document URL', description: String(e), color: 'red' })
|
||||
}
|
||||
}
|
||||
|
||||
// State-based actions
|
||||
const isSubmitResponseOpen = ref(false)
|
||||
const isSolicitationSubmitOpen = ref(false)
|
||||
const isApproveOpen = ref(false)
|
||||
const isCompleteOpen = ref(false)
|
||||
|
||||
const submitting = ref(false)
|
||||
const toast = useToast()
|
||||
const { $workload } = useNuxtApp()
|
||||
|
||||
// Quote response form
|
||||
const quoteForm = ref({
|
||||
quote_id: '',
|
||||
valid_until: '',
|
||||
recorded_by: '',
|
||||
documents: [] as Array<{ document_id: string; filename: string }>,
|
||||
plans: [{ plan_id: '', name: '', premium: '', coverage_details: {} as Record<string, string> }]
|
||||
})
|
||||
|
||||
// Solicitation response form
|
||||
const solicitationForm = ref({
|
||||
provider_policy_number: '',
|
||||
effective_date: '',
|
||||
expiry_date: ''
|
||||
})
|
||||
|
||||
function addPlan() {
|
||||
quoteForm.value.plans.push({ plan_id: '', name: '', premium: '', coverage_details: {} })
|
||||
}
|
||||
|
||||
function removePlan(i: number) {
|
||||
quoteForm.value.plans.splice(i, 1)
|
||||
}
|
||||
|
||||
function addCoverageDetail(plan: any) {
|
||||
const key = newCoverageKey.value.trim()
|
||||
const value = newCoverageValue.value.trim()
|
||||
if (key && value) {
|
||||
plan.coverage_details[key] = value
|
||||
newCoverageKey.value = ''
|
||||
newCoverageValue.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
const newCoverageKey = ref('')
|
||||
const newCoverageValue = ref('')
|
||||
|
||||
// File upload handling
|
||||
const uploading = ref(false)
|
||||
|
||||
async function uploadFile(file: File): Promise<{ document_id: string; filename: string }> {
|
||||
const formData = new FormData()
|
||||
formData.append('file', file)
|
||||
const response = await $document('/api/documents/upload', {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
})
|
||||
return {
|
||||
document_id: response.data.document_id,
|
||||
filename: file.name
|
||||
}
|
||||
}
|
||||
|
||||
async function handleFilesSelected(event: Event) {
|
||||
const target = event.target as HTMLInputElement
|
||||
const files = target.files
|
||||
if (!files || files.length === 0) return
|
||||
|
||||
uploading.value = true
|
||||
try {
|
||||
for (let i = 0; i < files.length; i++) {
|
||||
const file = files[i]
|
||||
const doc = await uploadFile(file)
|
||||
quoteForm.value.documents.push(doc)
|
||||
}
|
||||
} catch (e: any) {
|
||||
toast.add({ title: 'Upload failed', description: e?.data?.error ?? e.message, color: 'red' })
|
||||
} finally {
|
||||
uploading.value = false
|
||||
// Reset file input
|
||||
target.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
function removeDocument(documentId: string) {
|
||||
quoteForm.value.documents = quoteForm.value.documents.filter(d => d.document_id !== documentId)
|
||||
}
|
||||
|
||||
async function submitResponse() {
|
||||
submitting.value = true
|
||||
try {
|
||||
await $workload(`/tasks/${id}/submit`, {
|
||||
method: 'POST',
|
||||
body: {
|
||||
quote_id: quoteForm.value.quote_id,
|
||||
valid_until: quoteForm.value.valid_until,
|
||||
recorded_by: quoteForm.value.recorded_by,
|
||||
documents: quoteForm.value.documents.map(d => d.document_id),
|
||||
plans: quoteForm.value.plans.map(p => ({
|
||||
plan_id: p.plan_id,
|
||||
name: p.name,
|
||||
premium: parseFloat(p.premium) || 0,
|
||||
coverage_details: p.coverage_details
|
||||
}))
|
||||
}
|
||||
})
|
||||
toast.add({ title: 'Response submitted', color: 'green' })
|
||||
isSubmitResponseOpen.value = false
|
||||
// Reset form
|
||||
quoteForm.value = {
|
||||
quote_id: '',
|
||||
valid_until: '',
|
||||
recorded_by: '',
|
||||
documents: [],
|
||||
plans: [{ plan_id: '', name: '', premium: '', coverage_details: {} }]
|
||||
}
|
||||
await refresh()
|
||||
} catch (e: any) {
|
||||
toast.add({ title: 'Failed', description: e?.data?.error ?? e.message, color: 'red' })
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function openSolicitationModal() {
|
||||
solicitationForm.value = {
|
||||
provider_policy_number: '',
|
||||
effective_date: '',
|
||||
expiry_date: ''
|
||||
}
|
||||
isSolicitationSubmitOpen.value = true
|
||||
}
|
||||
|
||||
async function submitSolicitation() {
|
||||
if (!solicitationForm.value.provider_policy_number ||
|
||||
!solicitationForm.value.effective_date ||
|
||||
!solicitationForm.value.expiry_date) {
|
||||
toast.add({ title: 'Please fill all required fields', color: 'red' })
|
||||
return
|
||||
}
|
||||
|
||||
submitting.value = true
|
||||
try {
|
||||
await $workload(`/tasks/${id}/submit`, {
|
||||
method: 'POST',
|
||||
body: {
|
||||
provider_policy_number: solicitationForm.value.provider_policy_number,
|
||||
effective_date: solicitationForm.value.effective_date,
|
||||
expiry_date: solicitationForm.value.expiry_date
|
||||
}
|
||||
})
|
||||
toast.add({ title: 'Response submitted', color: 'green' })
|
||||
isSolicitationSubmitOpen.value = false
|
||||
await refresh()
|
||||
} catch (e: any) {
|
||||
toast.add({ title: 'Failed', description: e?.data?.error ?? e.message, color: 'red' })
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function approveSubmission() {
|
||||
submitting.value = true
|
||||
try {
|
||||
await $workload(`/tasks/${id}/approve`, { method: 'POST' })
|
||||
toast.add({ title: 'Submission approved', color: 'green' })
|
||||
isApproveOpen.value = false
|
||||
await refresh()
|
||||
} catch (e: any) {
|
||||
toast.add({ title: 'Failed', description: e?.data?.error ?? e.message, color: 'red' })
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function completeTask() {
|
||||
submitting.value = true
|
||||
try {
|
||||
await $workload(`/tasks/${id}/complete`, { method: 'POST', body: { completed_by: 'admin' } })
|
||||
toast.add({ title: 'Task completed', color: 'green' })
|
||||
isCompleteOpen.value = false
|
||||
await refresh()
|
||||
} catch (e: any) {
|
||||
toast.add({ title: 'Failed', description: e?.data?.error ?? e.message, color: 'red' })
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const statusColor = (status: string) => {
|
||||
switch (status) {
|
||||
case 'created': return 'yellow'
|
||||
case 'draft': return 'blue'
|
||||
case 'approved': return 'green'
|
||||
case 'completed': return 'gray'
|
||||
default: return 'gray'
|
||||
}
|
||||
}
|
||||
|
||||
const policyTypeColor = (type: string) => {
|
||||
switch (type) {
|
||||
case 'car': return 'blue'
|
||||
case 'life': return 'purple'
|
||||
case 'fire': return 'orange'
|
||||
default: return 'gray'
|
||||
}
|
||||
}
|
||||
|
||||
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="p-8 space-y-8 bg-gray-50 min-h-screen">
|
||||
<NuxtLink to="/back-office/workload">
|
||||
<UButton icon="i-heroicons-arrow-left" color="gray" variant="ghost">Back to Tasks</UButton>
|
||||
</NuxtLink>
|
||||
|
||||
<UAlert v-if="error" color="red" variant="soft" title="Failed to load task" :description="error.message" />
|
||||
<div v-else-if="pending" class="space-y-4"><UCard v-for="n in 3" :key="n"><div class="h-32 animate-pulse bg-gray-200 rounded" /></UCard></div>
|
||||
|
||||
<template v-else-if="task">
|
||||
<!-- Header -->
|
||||
<div class="flex justify-between items-start">
|
||||
<div class="space-y-2">
|
||||
<div class="flex items-center gap-2 flex-wrap">
|
||||
<UBadge :color="statusColor(task.status)" variant="soft">{{ task.status }}</UBadge>
|
||||
<UBadge :color="policyTypeColor(task.task_info?.policy_type)" variant="outline">{{ task.task_info?.policy_type?.toUpperCase() || '—' }}</UBadge>
|
||||
<UBadge color="gray" variant="outline">{{ taskType }}</UBadge>
|
||||
</div>
|
||||
<h1 class="text-xl font-bold text-slate-900 font-mono">{{ task.application_id }}</h1>
|
||||
<p class="text-gray-500 text-sm">Created {{ formatDate(task.created_at) }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Actions based on state -->
|
||||
<div class="flex gap-2 flex-wrap justify-end">
|
||||
<UButton icon="i-heroicons-arrow-path" color="gray" variant="soft" :loading="pending" @click="refresh()" />
|
||||
|
||||
<!-- State: created -->
|
||||
<template v-if="task.status === 'created'">
|
||||
<UButton v-if="isQuote"
|
||||
icon="i-heroicons-chat-bubble-left-right" color="primary"
|
||||
@click="isSubmitResponseOpen = true">
|
||||
Submit Response
|
||||
</UButton>
|
||||
<UButton v-if="isSolicitation"
|
||||
icon="i-heroicons-chat-bubble-left-right"
|
||||
color="primary"
|
||||
@click="openSolicitationModal">
|
||||
Submit Response
|
||||
</UButton>
|
||||
</template>
|
||||
|
||||
<!-- State: draft -->
|
||||
<UButton v-if="task.status === 'draft'"
|
||||
icon="i-heroicons-check-circle" color="blue" variant="soft"
|
||||
:loading="submitting" @click="approveSubmission">
|
||||
Approve
|
||||
</UButton>
|
||||
|
||||
<!-- State: approved -->
|
||||
<UButton v-if="task.status === 'approved'"
|
||||
icon="i-heroicons-check-badge" color="green"
|
||||
:loading="submitting" @click="completeTask">
|
||||
Complete
|
||||
</UButton>
|
||||
|
||||
<!-- State: completed - no actions -->
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
<!-- Task Info Sidebar (1/3 width) -->
|
||||
<div class="md:col-span-1">
|
||||
<UCard class="sticky top-6">
|
||||
<template #header><p class="font-semibold text-slate-700 text-sm">Task Info</p></template>
|
||||
<div class="space-y-2 text-sm">
|
||||
<div class="flex justify-between"><span class="text-gray-500 text-xs">Task ID</span><span class="font-mono text-xs">{{ task.id }}</span></div>
|
||||
<div class="flex justify-between"><span class="text-gray-500 text-xs">Application ID</span><span class="font-mono text-xs">{{ task.application_id }}</span></div>
|
||||
<div class="flex justify-between"><span class="text-gray-500 text-xs">Provider ID</span><span class="font-mono text-xs">{{ task.task_info?.provider_id }}</span></div>
|
||||
<div v-if="task.task_info?.provider_name" class="flex justify-between"><span class="text-gray-500 text-xs">Provider</span><span class="text-xs">{{ task.task_info.provider_name }}</span></div>
|
||||
<div class="flex justify-between"><span class="text-gray-500 text-xs">Org</span><span class="font-mono text-xs">{{ task.org_id }}</span></div>
|
||||
<div class="flex justify-between"><span class="text-gray-500 text-xs">Created</span><span class="text-xs">{{ formatDate(task.created_at) }}</span></div>
|
||||
<div class="flex justify-between"><span class="text-gray-500 text-xs">Updated</span><span class="text-xs">{{ formatDate(task.updated_at) }}</span></div>
|
||||
<div class="flex justify-between pt-2 border-t">
|
||||
<span class="text-gray-500 text-xs">Status</span>
|
||||
<UBadge :color="statusColor(task.status)" variant="soft" size="xs">{{ task.status }}</UBadge>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<span class="text-gray-500 text-xs">Policy Type</span>
|
||||
<UBadge :color="policyTypeColor(task.task_info?.policy_type)" variant="outline" size="xs">{{ task.task_info?.policy_type?.toUpperCase() || '—' }}</UBadge>
|
||||
</div>
|
||||
</div>
|
||||
</UCard>
|
||||
</div>
|
||||
|
||||
<!-- Request Details Main (2/3 width) -->
|
||||
<div class="md:col-span-2 space-y-6">
|
||||
<UCard>
|
||||
<template #header><p class="font-semibold text-slate-700 text-sm">Request Details</p></template>
|
||||
<div v-if="task.task_info">
|
||||
<NestedJsonViewer :data="task.task_info" />
|
||||
</div>
|
||||
<div v-else class="text-gray-400 text-center py-4">No task info available</div>
|
||||
</UCard>
|
||||
|
||||
<!-- Submission -->
|
||||
<UCard v-if="task.submission">
|
||||
<template #header><p class="font-semibold text-slate-700 text-sm">Response</p></template>
|
||||
<div class="space-y-4 text-sm">
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div v-if="task.submission.quote_id" class="flex justify-between border-b pb-2">
|
||||
<span class="text-gray-500">Quote ID</span>
|
||||
<span class="font-mono text-xs">{{ task.submission.quote_id }}</span>
|
||||
</div>
|
||||
<div v-if="task.submission.valid_until" class="flex justify-between border-b pb-2">
|
||||
<span class="text-gray-500">Valid Until</span>
|
||||
<span class="text-xs">{{ task.submission.valid_until }}</span>
|
||||
</div>
|
||||
<div v-if="task.submission.recorded_by" class="flex justify-between border-b pb-2">
|
||||
<span class="text-gray-500">Recorded By</span>
|
||||
<span class="text-xs">{{ task.submission.recorded_by }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="task.submission.documents?.length" class="border-t pt-4">
|
||||
<div class="text-xs font-medium text-gray-500 mb-2">Documents</div>
|
||||
<div class="space-y-2">
|
||||
<div v-for="(doc, i) in task.submission.documents" :key="i" class="flex items-center gap-3 p-3 bg-gray-50 rounded-lg">
|
||||
<UIcon name="i-heroicons-document" class="w-5 h-5 text-gray-400 flex-shrink-0" />
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="text-sm font-medium text-gray-900 truncate">{{ doc.filename || 'Document' }}</div>
|
||||
<div class="text-xs text-gray-500 truncate font-mono">{{ doc.document_id }}</div>
|
||||
</div>
|
||||
<UButton icon="i-heroicons-arrow-down" size="xs" color="gray" variant="ghost" :loading="false" @click="downloadDocument(doc.document_id)" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="task.submission.plans?.length" class="border-t pt-4">
|
||||
<div class="text-xs font-medium text-gray-500 mb-2">Plans</div>
|
||||
<div class="space-y-3">
|
||||
<div v-for="(plan, i) in task.submission.plans" :key="i" class="border rounded-lg p-3">
|
||||
<div class="grid grid-cols-2 gap-2 mb-2">
|
||||
<div><span class="text-xs text-gray-500">Plan ID</span><div class="font-mono text-xs">{{ plan.plan_id }}</div></div>
|
||||
<div><span class="text-xs text-gray-500">Name</span><div class="text-xs">{{ plan.name }}</div></div>
|
||||
<div><span class="text-xs text-gray-500">Premium</span><div class="text-sm font-semibold text-green-600">${{ plan.premium }}</div></div>
|
||||
</div>
|
||||
<div v-if="plan.coverage_details && Object.keys(plan.coverage_details).length" class="border-t pt-2 mt-2">
|
||||
<div class="text-xs text-gray-500 mb-1">Coverage Details</div>
|
||||
<div class="grid grid-cols-2 gap-1 text-xs">
|
||||
<div v-for="(value, key) in plan.coverage_details" :key="key" class="flex justify-between">
|
||||
<span class="text-gray-600 capitalize">{{ key.replace(/_/g, ' ') }}:</span>
|
||||
<span class="font-medium">{{ value }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</UCard>
|
||||
|
||||
<!-- Attachments -->
|
||||
<UCard v-if="task.attachments?.length">
|
||||
<template #header><p class="font-semibold text-slate-700 text-sm">Attachments</p></template>
|
||||
<div class="space-y-2">
|
||||
<div v-for="(url, i) in task.attachments" :key="i" class="flex items-center gap-3 p-3 bg-gray-50 rounded-lg">
|
||||
<UIcon name="i-heroicons-document" class="w-5 h-5 text-gray-400 flex-shrink-0" />
|
||||
<a :href="url" target="_blank" class="text-sm text-blue-600 hover:underline truncate font-mono flex-1">{{ url }}</a>
|
||||
<UButton icon="i-heroicons-arrow-down" size="xs" color="gray" variant="ghost" />
|
||||
</div>
|
||||
</div>
|
||||
</UCard>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Submission -->
|
||||
<UCard v-if="task.submission">
|
||||
<template #header><p class="font-semibold text-slate-700">Submission</p></template>
|
||||
<pre class="text-xs bg-gray-50 p-3 rounded overflow-x-auto">{{ JSON.stringify(task.submission, null, 2) }}</pre>
|
||||
</UCard>
|
||||
|
||||
<!-- Attachments -->
|
||||
<UCard v-if="task.attachments?.length">
|
||||
<template #header><p class="font-semibold text-slate-700">Attachments</p></template>
|
||||
<div class="space-y-2">
|
||||
<div v-for="(url, i) in task.attachments" :key="i" class="flex items-center gap-3 p-3 bg-gray-50 rounded-lg">
|
||||
<UIcon name="i-heroicons-document" class="w-5 h-5 text-gray-400 flex-shrink-0" />
|
||||
<a :href="url" target="_blank" class="text-sm text-blue-600 hover:underline truncate font-mono">{{ url }}</a>
|
||||
</div>
|
||||
</div>
|
||||
</UCard>
|
||||
</template>
|
||||
|
||||
<!-- Submit Response Slideover (Quote) -->
|
||||
<USlideover v-model:open="isSubmitResponseOpen" side="right">
|
||||
<template #content>
|
||||
<div class="flex flex-col h-full">
|
||||
<div class="flex justify-between items-center p-6 border-b">
|
||||
<div><h2 class="text-lg font-semibold text-slate-900">Submit Quote Response</h2></div>
|
||||
<UButton icon="i-heroicons-x-mark" color="gray" variant="ghost" @click="isSubmitResponseOpen = false" />
|
||||
</div>
|
||||
<div class="flex-1 overflow-y-auto p-6 space-y-6">
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<UFormField label="Quote ID" required>
|
||||
<UInput v-model="quoteForm.quote_id" placeholder="QUOTE-001" class="w-full" />
|
||||
</UFormField>
|
||||
<UFormField label="Valid Until" required>
|
||||
<UInput v-model="quoteForm.valid_until" type="date" class="w-full" />
|
||||
</UFormField>
|
||||
</div>
|
||||
<UFormField label="Responded By" required>
|
||||
<UInput v-model="quoteForm.recorded_by" placeholder="Your name" class="w-full" />
|
||||
</UFormField>
|
||||
<UFormField label="Documents">
|
||||
<div class="space-y-3">
|
||||
<UFileUpload
|
||||
multiple
|
||||
@change="handleFilesSelected"
|
||||
:disabled="uploading"
|
||||
class="w-full"
|
||||
/>
|
||||
<div v-if="quoteForm.documents.length" class="space-y-2">
|
||||
<div v-for="doc in quoteForm.documents" :key="doc.document_id" class="flex items-center gap-3 p-3 bg-gray-50 rounded-lg">
|
||||
<UIcon name="i-heroicons-document" class="w-5 h-5 text-gray-400 flex-shrink-0" />
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="text-sm font-medium text-gray-900 truncate">{{ doc.filename }}</div>
|
||||
<div class="text-xs text-gray-500 truncate font-mono">{{ doc.document_id }}</div>
|
||||
</div>
|
||||
<UButton icon="i-heroicons-x-mark" size="xs" color="red" variant="ghost" @click="removeDocument(doc.document_id)" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</UFormField>
|
||||
<div class="space-y-3">
|
||||
<div class="flex justify-between items-center">
|
||||
<p class="font-medium text-sm text-slate-700">Plans</p>
|
||||
<UButton icon="i-heroicons-plus" size="xs" color="gray" variant="soft" @click="addPlan">Add</UButton>
|
||||
</div>
|
||||
<div v-for="(plan, i) in quoteForm.plans" :key="i" class="border rounded-lg p-4 space-y-3">
|
||||
<div class="flex justify-between items-center">
|
||||
<p class="text-sm font-semibold text-slate-700">Plan {{ i + 1 }}</p>
|
||||
<UButton v-if="quoteForm.plans.length > 1" icon="i-heroicons-trash" size="xs" color="red" variant="ghost" @click="removePlan(i)" />
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<UFormField label="Plan ID"><UInput v-model="plan.plan_id" placeholder="PLAN-001" class="w-full" /></UFormField>
|
||||
<UFormField label="Name"><UInput v-model="plan.name" placeholder="Basic" class="w-full" /></UFormField>
|
||||
<UFormField label="Premium"><UInput v-model="plan.premium" type="number" placeholder="1000" class="w-full" /></UFormField>
|
||||
</div>
|
||||
<UFormField label="Coverage Details">
|
||||
<div class="space-y-2">
|
||||
<div v-for="(value, key) in plan.coverage_details" :key="key" class="flex gap-2 items-center">
|
||||
<span class="text-sm font-medium text-gray-600 w-24 truncate">{{ key }}:</span>
|
||||
<UInput v-model="plan.coverage_details[key]" placeholder="Value" class="flex-1" />
|
||||
<UButton icon="i-heroicons-trash" size="xs" color="red" variant="ghost" @click="delete plan.coverage_details[key]" />
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<UInput v-model="newCoverageKey" placeholder="Key (e.g., liability)" class="flex-1" @keydown.enter="addCoverageDetail(plan)" />
|
||||
<UInput v-model="newCoverageValue" placeholder="Value" class="flex-1" @keydown.enter="addCoverageDetail(plan)" />
|
||||
<UButton size="xs" color="gray" variant="soft" @click="addCoverageDetail(plan)">Add</UButton>
|
||||
</div>
|
||||
</div>
|
||||
</UFormField>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="p-6 border-t flex justify-end gap-3">
|
||||
<UButton color="gray" variant="soft" @click="isSubmitResponseOpen = false">Cancel</UButton>
|
||||
<UButton color="primary" icon="i-heroicons-paper-airplane" :loading="submitting" :disabled="!quoteForm.quote_id || !quoteForm.valid_until || !quoteForm.recorded_by || quoteForm.plans.length === 0 || quoteForm.plans.some(p => !p.name || !p.premium || Object.keys(p.coverage_details || {}).length === 0)" @click="submitResponse">
|
||||
Submit
|
||||
</UButton>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</USlideover>
|
||||
|
||||
<!-- Submit Response Slideover (Solicitation) -->
|
||||
<USlideover v-model:open="isSolicitationSubmitOpen" side="right">
|
||||
<template #content>
|
||||
<div class="flex flex-col h-full">
|
||||
<div class="flex justify-between items-center p-6 border-b">
|
||||
<div><h2 class="text-lg font-semibold text-slate-900">Submit Response</h2></div>
|
||||
<UButton icon="i-heroicons-x-mark" color="gray" variant="ghost" @click="isSolicitationSubmitOpen = false" />
|
||||
</div>
|
||||
<div class="flex-1 overflow-y-auto p-6 space-y-6">
|
||||
<UFormField label="Provider Policy Number" required>
|
||||
<UInput v-model="solicitationForm.provider_policy_number" placeholder="e.g., POL-2024-001" class="w-full" />
|
||||
</UFormField>
|
||||
<UFormField label="Effective Date" required>
|
||||
<UInput v-model="solicitationForm.effective_date" type="date" class="w-full" />
|
||||
</UFormField>
|
||||
<UFormField label="Expiry Date" required>
|
||||
<UInput v-model="solicitationForm.expiry_date" type="date" class="w-full" />
|
||||
</UFormField>
|
||||
</div>
|
||||
<div class="p-6 border-t flex justify-end gap-3">
|
||||
<UButton color="gray" variant="soft" @click="isSolicitationSubmitOpen = false">Cancel</UButton>
|
||||
<UButton color="primary" icon="i-heroicons-paper-airplane" :loading="submitting" :disabled="!solicitationForm.provider_policy_number || !solicitationForm.effective_date || !solicitationForm.expiry_date" @click="submitSolicitation">
|
||||
Submit
|
||||
</UButton>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</USlideover>
|
||||
</div>
|
||||
</template>
|
||||
183
app/pages/back-office/workload/index.vue
Normal file
183
app/pages/back-office/workload/index.vue
Normal file
@@ -0,0 +1,183 @@
|
||||
<script setup lang="ts">
|
||||
import type { SelectItem } from '@nuxt/ui'
|
||||
|
||||
usePageTitle('Workload Tasks')
|
||||
|
||||
const page = ref(1)
|
||||
const statusFilter = ref<string | null>(null)
|
||||
const policyTypeFilter = ref<string | null>(null)
|
||||
|
||||
const statusItems = ref<SelectItem[]>([
|
||||
{ label: 'All Statuses', value: null },
|
||||
{ label: 'Created', value: 'created' },
|
||||
{ label: 'Draft', value: 'draft' },
|
||||
{ label: 'Approved', value: 'approved' },
|
||||
{ label: 'Completed', value: 'completed' }
|
||||
])
|
||||
|
||||
const policyTypeItems = ref<SelectItem[]>([
|
||||
{ label: 'All Types', value: null },
|
||||
{ label: 'Car', value: 'car' },
|
||||
{ label: 'Life', value: 'life' },
|
||||
{ label: 'Fire', value: 'fire' }
|
||||
])
|
||||
|
||||
watch([statusFilter, policyTypeFilter], () => { page.value = 1 })
|
||||
|
||||
const { data, pending, error, refresh } = useWorkload('/tasks', {
|
||||
query: computed(() => ({
|
||||
page: page.value,
|
||||
page_size: 20,
|
||||
...(statusFilter.value && {
|
||||
'filters[0][field]': 'status',
|
||||
'filters[0][op]': '==',
|
||||
'filters[0][value]': statusFilter.value
|
||||
}),
|
||||
...(policyTypeFilter.value && {
|
||||
'filters[1][field]': 'policy_type',
|
||||
'filters[1][op]': '==',
|
||||
'filters[1][value]': policyTypeFilter.value
|
||||
})
|
||||
}))
|
||||
})
|
||||
|
||||
const tasks = computed(() => data.value?.data ?? [])
|
||||
const meta = computed(() => data.value?.meta)
|
||||
const total = computed(() => meta.value?.total_count ?? 0)
|
||||
const totalPages = computed(() => meta.value?.total_pages ?? 0)
|
||||
|
||||
const statusColor = (status: string) => {
|
||||
switch (status) {
|
||||
case 'created': return 'yellow'
|
||||
case 'draft': return 'blue'
|
||||
case 'approved': return 'green'
|
||||
case 'completed': return 'gray'
|
||||
default: return 'gray'
|
||||
}
|
||||
}
|
||||
|
||||
const policyTypeColor = (type: string) => {
|
||||
switch (type) {
|
||||
case 'car': return 'blue'
|
||||
case 'life': return 'purple'
|
||||
case 'fire': return 'orange'
|
||||
default: return 'gray'
|
||||
}
|
||||
}
|
||||
|
||||
const formatDate = (date: string) => {
|
||||
if (!date) return '—'
|
||||
return new Date(date).toLocaleDateString('es-PA', {
|
||||
day: '2-digit', month: 'short', year: 'numeric',
|
||||
hour: '2-digit', minute: '2-digit'
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="p-8 space-y-8 bg-gray-50 min-h-screen">
|
||||
<!-- Header -->
|
||||
<div class="flex justify-between items-center">
|
||||
<div>
|
||||
<h1 class="text-3xl text-slate-900 font-bold">Workload</h1>
|
||||
<p class="text-gray-500 text-sm">Quote & Solicitation Tasks</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
<UBadge color="gray" variant="soft" size="lg">{{ total }} tasks</UBadge>
|
||||
<NuxtLink to="/back-office/workload/kanban">
|
||||
<UButton icon="i-heroicons-squares-2x2" color="gray" variant="soft">
|
||||
Kanban View
|
||||
</UButton>
|
||||
</NuxtLink>
|
||||
<UButton
|
||||
icon="i-heroicons-arrow-path"
|
||||
color="gray"
|
||||
variant="soft"
|
||||
:loading="pending"
|
||||
@click="refresh()"
|
||||
>
|
||||
Refresh
|
||||
</UButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Filters -->
|
||||
<div class="flex gap-4 items-center flex-wrap">
|
||||
<USelect v-model="statusFilter" :items="statusItems" class="w-40" />
|
||||
<USelect v-model="policyTypeFilter" :items="policyTypeItems" class="w-40" />
|
||||
</div>
|
||||
|
||||
<UAlert
|
||||
v-if="error"
|
||||
color="red"
|
||||
variant="soft"
|
||||
title="Failed to load tasks"
|
||||
:description="error.message"
|
||||
/>
|
||||
|
||||
<div v-else-if="pending && tasks.length === 0" class="grid gap-4">
|
||||
<UCard v-for="n in 5" :key="n">
|
||||
<div class="h-20 animate-pulse bg-gray-200 rounded" />
|
||||
</UCard>
|
||||
</div>
|
||||
|
||||
<template v-else>
|
||||
<div class="space-y-3" :class="pending ? 'opacity-60 pointer-events-none' : ''">
|
||||
<NuxtLink
|
||||
v-for="task in tasks"
|
||||
:key="task.id"
|
||||
:to="`/back-office/workload/${encodeURIComponent(task.id)}`"
|
||||
>
|
||||
<UCard class="hover:shadow-md transition-shadow cursor-pointer">
|
||||
<div class="flex items-center justify-between gap-4">
|
||||
<!-- Left -->
|
||||
<div class="flex items-center gap-4 min-w-0">
|
||||
<div class="flex flex-col gap-1">
|
||||
<UBadge :color="statusColor(task.status)" variant="soft" size="xs">
|
||||
{{ task.status }}
|
||||
</UBadge>
|
||||
<UBadge :color="policyTypeColor(task.task_info?.policy_type)" variant="outline" size="xs">
|
||||
{{ task.task_info?.policy_type?.toUpperCase() || '—' }}
|
||||
</UBadge>
|
||||
</div>
|
||||
|
||||
<div class="min-w-0">
|
||||
<p class="font-mono text-sm font-medium text-slate-800 truncate">
|
||||
{{ task.application_id }}
|
||||
</p>
|
||||
<p class="text-xs text-gray-400">
|
||||
Provider: <span class="font-mono">{{ task.provider_id }}</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Right -->
|
||||
<div class="flex items-center gap-6 flex-shrink-0 text-sm text-gray-500">
|
||||
<div class="text-right">
|
||||
<p class="text-xs text-gray-400">Received</p>
|
||||
<p>{{ formatDate(task.created_at) }}</p>
|
||||
</div>
|
||||
<UIcon name="i-heroicons-chevron-right" class="w-4 h-4 text-gray-400" />
|
||||
</div>
|
||||
</div>
|
||||
</UCard>
|
||||
</NuxtLink>
|
||||
|
||||
<div v-if="tasks.length === 0 && !pending" class="text-center py-16 text-gray-400">
|
||||
<UIcon name="i-heroicons-inbox" class="w-12 h-12 mx-auto mb-4" />
|
||||
<p class="lg font-medium">No tasks found</p>
|
||||
<p class="text-sm">Adjust your filters or wait for new requests</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Pagination -->
|
||||
<div v-if="totalPages > 1" class="flex justify-center">
|
||||
<UPagination
|
||||
v-model="page"
|
||||
:total="total"
|
||||
:page-count="20"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
346
app/pages/back-office/workload/kanban.vue
Normal file
346
app/pages/back-office/workload/kanban.vue
Normal file
@@ -0,0 +1,346 @@
|
||||
<script setup lang="ts">
|
||||
import type { SelectItem } from '@nuxt/ui'
|
||||
import KanbanColumn from '~/components/back-office/KanbanColumn.vue'
|
||||
import KanbanTaskCard from '~/components/back-office/KanbanTaskCard.vue'
|
||||
|
||||
usePageTitle('Workload Kanban')
|
||||
|
||||
// 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'
|
||||
}
|
||||
}
|
||||
|
||||
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 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
|
||||
}
|
||||
|
||||
const page = ref(1)
|
||||
const filters = ref({
|
||||
status: null as string | null,
|
||||
policyType: null as string | null,
|
||||
search: ''
|
||||
})
|
||||
|
||||
// Mock user context for now
|
||||
const currentUser = ref({
|
||||
id: 'user-123',
|
||||
name: 'Current User'
|
||||
})
|
||||
|
||||
const statusItems = ref<SelectItem[]>([
|
||||
{ label: 'All Statuses', value: null },
|
||||
{ label: 'Created', value: 'created' },
|
||||
{ label: 'Draft', value: 'draft' },
|
||||
{ label: 'Approved', value: 'approved' },
|
||||
{ label: 'Completed', value: 'completed' }
|
||||
])
|
||||
|
||||
const policyTypeItems = ref<SelectItem[]>([
|
||||
{ label: 'All Types', value: null },
|
||||
{ label: 'Car', value: 'car' },
|
||||
{ label: 'Life', value: 'life' },
|
||||
{ label: 'Fire', value: 'fire' }
|
||||
])
|
||||
|
||||
watch([() => filters.value.status, () => filters.value.policyType, () => filters.value.search], () => {
|
||||
page.value = 1
|
||||
})
|
||||
|
||||
const { data, pending, error, refresh } = useWorkload('/tasks', {
|
||||
query: computed(() => ({
|
||||
page: page.value,
|
||||
page_size: 100,
|
||||
...(filters.value.status && {
|
||||
'filters[0][field]': 'status',
|
||||
'filters[0][op]': '==',
|
||||
'filters[0][value]': filters.value.status
|
||||
}),
|
||||
...(filters.value.policyType && {
|
||||
'filters[1][field]': 'policy_type',
|
||||
'filters[1][op]': '==',
|
||||
'filters[1][value]': filters.value.policyType
|
||||
}),
|
||||
...(filters.value.search && {
|
||||
'filters[2][field]': 'application_id',
|
||||
'filters[2][op]': 'contains',
|
||||
'filters[2][value]': filters.value.search
|
||||
})
|
||||
}))
|
||||
})
|
||||
|
||||
const allTasks = computed(() => data.value?.data ?? [])
|
||||
|
||||
const tasksByStatus = computed(() => ({
|
||||
created: allTasks.value.filter(t => t.status === 'created'),
|
||||
draft: allTasks.value.filter(t => t.status === 'draft'),
|
||||
approved: allTasks.value.filter(t => t.status === 'approved'),
|
||||
completed: allTasks.value.filter(t => t.status === 'completed')
|
||||
}))
|
||||
|
||||
const columns = computed(() => [
|
||||
{ title: 'Created', status: 'created' },
|
||||
{ title: 'Draft', status: 'draft' },
|
||||
{ title: 'Approved', status: 'approved' },
|
||||
{ title: 'Completed', status: 'completed' }
|
||||
])
|
||||
|
||||
// Mobile view state
|
||||
const activeMobileColumn = ref('created')
|
||||
const mobileColumns = ['created', 'draft', 'approved', 'completed']
|
||||
|
||||
// Auto-refresh
|
||||
const autoRefreshInterval = ref(30)
|
||||
const lastRefreshTime = ref<Date | null>(null)
|
||||
const isAutoRefreshing = ref(true)
|
||||
let refreshTimer: NodeJS.Timeout | null = null
|
||||
|
||||
function startAutoRefresh() {
|
||||
if (refreshTimer) clearInterval(refreshTimer)
|
||||
|
||||
refreshTimer = setInterval(() => {
|
||||
if (isAutoRefreshing.value) {
|
||||
refresh()
|
||||
lastRefreshTime.value = new Date()
|
||||
}
|
||||
}, autoRefreshInterval.value * 1000)
|
||||
}
|
||||
|
||||
function stopAutoRefresh() {
|
||||
if (refreshTimer) {
|
||||
clearInterval(refreshTimer)
|
||||
refreshTimer = null
|
||||
}
|
||||
}
|
||||
|
||||
function toggleAutoRefresh() {
|
||||
isAutoRefreshing.value = !isAutoRefreshing.value
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
startAutoRefresh()
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
stopAutoRefresh()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="kanban-board flex-1 flex flex-col min-h-0">
|
||||
<!-- Header -->
|
||||
<div class="flex items-start justify-between mb-4">
|
||||
<div>
|
||||
<h1 class="text-2xl font-semibold tracking-tight text-[var(--text-primary)]">Workload Kanban</h1>
|
||||
<p class="text-sm text-[var(--text-muted)]">{{ allTasks.length }} tasks</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="flex items-center gap-2 text-[12px] text-[var(--text-muted)]">
|
||||
<span class="flex items-center gap-1">
|
||||
<span class="h-2 w-2 rounded-full" :class="stageConfig[columns[0].status].dot" />
|
||||
<span class="text-[11px]">{{ columns[0].title }}</span>
|
||||
<span class="font-semibold">{{ tasksByStatus[columns[0].status].length }}</span>
|
||||
</span>
|
||||
<span class="text-[var(--text-muted)] opacity-50">·</span>
|
||||
<span class="flex items-center gap-1">
|
||||
<span class="h-2 w-2 rounded-full" :class="stageConfig[columns[1].status].dot" />
|
||||
<span class="text-[11px]">{{ columns[1].title }}</span>
|
||||
<span class="font-semibold">{{ tasksByStatus[columns[1].status].length }}</span>
|
||||
</span>
|
||||
<span class="text-[var(--text-muted)] opacity-50">·</span>
|
||||
<span class="flex items-center gap-1">
|
||||
<span class="h-2 w-2 rounded-full" :class="stageConfig[columns[2].status].dot" />
|
||||
<span class="text-[11px]">{{ columns[2].title }}</span>
|
||||
<span class="font-semibold">{{ tasksByStatus[columns[2].status].length }}</span>
|
||||
</span>
|
||||
<span class="text-[var(--text-muted)] opacity-50">·</span>
|
||||
<span class="flex items-center gap-1">
|
||||
<span class="h-2 w-2 rounded-full" :class="stageConfig[columns[3].status].dot" />
|
||||
<span class="text-[11px]">{{ columns[3].title }}</span>
|
||||
<span class="font-semibold">{{ tasksByStatus[columns[3].status].length }}</span>
|
||||
</span>
|
||||
</div>
|
||||
<UButton
|
||||
icon="i-heroicons-arrow-path"
|
||||
color="gray"
|
||||
variant="soft"
|
||||
:loading="pending"
|
||||
@click="refresh"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Filters -->
|
||||
<div class="flex flex-wrap items-center gap-3 rounded-xl border border-[var(--card-border)] bg-[var(--surface)] px-4 py-3 shadow-sm ring-1 ring-[var(--surface)] mb-4">
|
||||
<UInput
|
||||
v-model="filters.search"
|
||||
placeholder="Search by application ID..."
|
||||
icon="i-heroicons-magnifying-glass"
|
||||
size="sm"
|
||||
class="w-64"
|
||||
/>
|
||||
|
||||
<USelect
|
||||
v-model="filters.status"
|
||||
:items="statusItems"
|
||||
size="sm"
|
||||
class="w-36"
|
||||
/>
|
||||
|
||||
<USelect
|
||||
v-model="filters.policyType"
|
||||
:items="policyTypeItems"
|
||||
size="sm"
|
||||
class="w-36"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Error State -->
|
||||
<UAlert
|
||||
v-if="error"
|
||||
color="red"
|
||||
variant="soft"
|
||||
title="Failed to load tasks"
|
||||
:description="error.message"
|
||||
class="mb-4"
|
||||
/>
|
||||
|
||||
<!-- Desktop: 4-column layout -->
|
||||
<div class="kanban-columns desktop flex-1 min-h-0 overflow-x-auto pb-2 flex flex-col">
|
||||
<div class="flex gap-3 flex-1 min-h-0">
|
||||
<div
|
||||
v-for="column in columns"
|
||||
:key="column.status"
|
||||
class="flex w-[240px] shrink-0 flex-col"
|
||||
>
|
||||
<KanbanColumn
|
||||
:title="column.title"
|
||||
:status="column.status"
|
||||
:tasks="tasksByStatus[column.status]"
|
||||
:loading="pending"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Mobile: Single column with tabs -->
|
||||
<div class="kanban-columns mobile flex-1 min-h-0 flex flex-col">
|
||||
<div class="mobile-tabs flex-shrink-0">
|
||||
<button
|
||||
v-for="status in mobileColumns"
|
||||
:key="status"
|
||||
:class="['tab-btn', { active: activeMobileColumn === status }]"
|
||||
@click="activeMobileColumn = status"
|
||||
>
|
||||
{{ status.charAt(0).toUpperCase() + status.slice(1) }}
|
||||
({{ tasksByStatus[status].length }})
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="flex-1 min-h-0 overflow-y-auto">
|
||||
<KanbanColumn
|
||||
:title="activeMobileColumn.charAt(0).toUpperCase() + activeMobileColumn.slice(1)"
|
||||
:status="activeMobileColumn"
|
||||
:tasks="tasksByStatus[activeMobileColumn]"
|
||||
:loading="pending"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.kanban-board {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.25rem;
|
||||
}
|
||||
|
||||
.filter-bar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.kanban-columns.mobile {
|
||||
display: none;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.mobile-tabs {
|
||||
display: flex;
|
||||
gap: 0.25rem;
|
||||
overflow-x: auto;
|
||||
padding-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.tab-btn {
|
||||
flex-shrink: 0;
|
||||
padding: 0.5rem 1rem;
|
||||
border-radius: 0.375rem;
|
||||
border: 1px solid var(--card-border);
|
||||
background: var(--surface);
|
||||
color: var(--text-muted);
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.tab-btn.active {
|
||||
background: var(--brand);
|
||||
color: white;
|
||||
border-color: var(--brand);
|
||||
}
|
||||
|
||||
.tab-btn:hover:not(.active) {
|
||||
background: var(--card-border);
|
||||
}
|
||||
|
||||
/* Responsive */
|
||||
@media (max-width: 768px) {
|
||||
.kanban-columns.desktop {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.kanban-columns.mobile {
|
||||
display: flex;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,506 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { slaColor, CARRIER_STATUS_LABELS, WORKFLOW_STATUS_LABELS } from '~/data/mock-claims'
|
||||
import type { CarrierStatus, BrokerWorkflowStatus } from '~/data/mock-claims'
|
||||
|
||||
usePageTitle('Claims')
|
||||
|
||||
interface Claim {
|
||||
id: string
|
||||
customer: string
|
||||
agent: string
|
||||
line: string
|
||||
type: string
|
||||
carrier: string
|
||||
reserved: string
|
||||
paid: string
|
||||
daysOpen: number
|
||||
priority: 'critical' | 'high' | 'medium' | 'low'
|
||||
status: 'open' | 'under_review' | 'awaiting_docs' | 'approved' | 'denied' | 'closed'
|
||||
docsPending: number
|
||||
opened: string
|
||||
carrierStatus: CarrierStatus
|
||||
workflowStatus: BrokerWorkflowStatus
|
||||
slaPercent: number
|
||||
handler: string
|
||||
}
|
||||
|
||||
const claims = ref<Claim[]>([
|
||||
{ id: 'CLM-0048', customer: 'Hotel Pacífico', agent: 'Marco V.', line: 'General Risk', type: 'Fire damage', carrier: 'ASSA', reserved: '$128,000', paid: '$0', daysOpen: 3, priority: 'critical', status: 'open', docsPending: 4, opened: 'Apr 2, 2026', carrierStatus: 'investigation', workflowStatus: 'waiting_carrier', slaPercent: 110, handler: 'Ana R.' },
|
||||
{ id: 'CLM-0047', customer: 'Empresa ABC S.A.', agent: 'Ana R.', line: 'Auto', type: 'Collision — fleet unit #7', carrier: 'Qualitas', reserved: '$14,200', paid: '$0', daysOpen: 5, priority: 'high', status: 'under_review', docsPending: 2, opened: 'Mar 31, 2026', carrierStatus: 'documentation_pending', workflowStatus: 'waiting_insured_docs', slaPercent: 60, handler: 'Ana R.' },
|
||||
{ id: 'CLM-0046', customer: 'Jorge Herrera', agent: 'Marco V.', line: 'Auto', type: 'Windshield replacement', carrier: 'Qualitas', reserved: '$1,100', paid: '$0', daysOpen: 8, priority: 'low', status: 'awaiting_docs', docsPending: 1, opened: 'Mar 28, 2026', carrierStatus: 'documentation_pending', workflowStatus: 'waiting_insured_docs', slaPercent: 40, handler: 'Marco V.' },
|
||||
{ id: 'CLM-0045', customer: 'Clínica San José', agent: 'Ana R.', line: 'Life', type: 'Surgery pre-auth', carrier: 'Pan-American Life', reserved: '$23,500', paid: '$0', daysOpen: 12, priority: 'high', status: 'under_review', docsPending: 0, opened: 'Mar 24, 2026', carrierStatus: 'reserved', workflowStatus: 'waiting_carrier', slaPercent: 85, handler: 'Ana R.' },
|
||||
{ id: 'CLM-0044', customer: 'Carmen Ruiz', agent: 'Ana R.', line: 'Life', type: 'Outpatient claim', carrier: 'Pan-American Life', reserved: '$3,800', paid: '$3,200', daysOpen: 18, priority: 'medium', status: 'approved', docsPending: 0, opened: 'Mar 18, 2026', carrierStatus: 'settlement_offered', workflowStatus: 'ready_to_close', slaPercent: 50, handler: 'Ana R.' },
|
||||
{ id: 'CLM-0043', customer: 'Supermercado Tico', agent: 'Marco V.', line: 'General Risk', type: 'Customer injury — store premises', carrier: 'Mapfre', reserved: '$45,000', paid: '$0', daysOpen: 22, priority: 'high', status: 'under_review', docsPending: 3, opened: 'Mar 14, 2026', carrierStatus: 'negotiation', workflowStatus: 'client_update_overdue', slaPercent: 100, handler: 'Marco V.' },
|
||||
{ id: 'CLM-0042', customer: 'Isabel Mora', agent: 'Ana R.', line: 'Auto', type: 'Theft — total loss', carrier: 'ASSA', reserved: '$18,500', paid: '$18,500', daysOpen: 35, priority: 'medium', status: 'closed', docsPending: 0, opened: 'Mar 1, 2026', carrierStatus: 'closed', workflowStatus: 'ready_to_close', slaPercent: 95, handler: 'Ana R.' },
|
||||
{ id: 'CLM-0041', customer: 'Manuel Torres', agent: 'Marco V.', line: 'Life', type: 'Disability benefit', carrier: 'Pan-American Life', reserved: '$52,000', paid: '$12,000', daysOpen: 41, priority: 'medium', status: 'approved', docsPending: 0, opened: 'Feb 23, 2026', carrierStatus: 'reserved', workflowStatus: 'waiting_carrier', slaPercent: 70, handler: 'Marco V.' },
|
||||
])
|
||||
|
||||
// ── View toggle ─────────────────────────────────────────────────────────────
|
||||
const viewMode = ref<'my' | 'all'>('all')
|
||||
|
||||
type ClaimFilter = 'all' | 'active' | 'resolved'
|
||||
const activeFilter = ref<ClaimFilter>('all')
|
||||
|
||||
// ── Filter dropdowns ────────────────────────────────────────────────────────
|
||||
const statusFilter = ref('')
|
||||
const carrierFilter = ref('')
|
||||
const lobFilter = ref('')
|
||||
const handlerFilter = ref('')
|
||||
const agingFilter = ref('')
|
||||
const priorityFilter = ref('')
|
||||
|
||||
const uniqueCarriers = computed(() => [...new Set(claims.value.map(c => c.carrier))].sort())
|
||||
const uniqueLobs = computed(() => [...new Set(claims.value.map(c => c.line))].sort())
|
||||
const uniqueHandlers = computed(() => [...new Set(claims.value.map(c => c.handler))].sort())
|
||||
|
||||
const filteredClaims = computed(() => {
|
||||
let result = [...claims.value]
|
||||
|
||||
if (activeFilter.value === 'active') result = result.filter(c => !['closed', 'denied'].includes(c.status))
|
||||
if (activeFilter.value === 'resolved') result = result.filter(c => ['closed', 'denied'].includes(c.status))
|
||||
|
||||
if (statusFilter.value) result = result.filter(c => c.status === statusFilter.value)
|
||||
if (carrierFilter.value) result = result.filter(c => c.carrier === carrierFilter.value)
|
||||
if (lobFilter.value) result = result.filter(c => c.line === lobFilter.value)
|
||||
if (handlerFilter.value) result = result.filter(c => c.handler === handlerFilter.value)
|
||||
if (priorityFilter.value) result = result.filter(c => c.priority === priorityFilter.value)
|
||||
|
||||
if (agingFilter.value) {
|
||||
const ranges: Record<string, [number, number]> = { '0-7': [0, 7], '8-14': [8, 14], '15-30': [15, 30], '30+': [30, 9999] }
|
||||
const [min, max] = ranges[agingFilter.value] ?? [0, 9999]
|
||||
result = result.filter(c => c.daysOpen >= min && c.daysOpen <= max)
|
||||
}
|
||||
|
||||
// Sort: breached first
|
||||
result.sort((a, b) => {
|
||||
const aBreached = a.slaPercent >= 100 ? 0 : 1
|
||||
const bBreached = b.slaPercent >= 100 ? 0 : 1
|
||||
if (aBreached !== bBreached) return aBreached - bBreached
|
||||
return b.slaPercent - a.slaPercent
|
||||
})
|
||||
|
||||
return result
|
||||
})
|
||||
|
||||
const filterCounts = computed(() => ({
|
||||
all: claims.value.length,
|
||||
active: claims.value.filter(c => !['closed', 'denied'].includes(c.status)).length,
|
||||
resolved: claims.value.filter(c => ['closed', 'denied'].includes(c.status)).length,
|
||||
}))
|
||||
|
||||
const kpis = computed(() => {
|
||||
const active = claims.value.filter(c => !['closed', 'denied'].includes(c.status))
|
||||
const underReview = claims.value.filter(c => c.status === 'under_review').length
|
||||
const avgDays = active.length ? Math.round(active.reduce((s, c) => s + c.daysOpen, 0) / active.length) : 0
|
||||
const totalReserved = claims.value.filter(c => c.status !== 'closed').reduce((s, c) => s + parseFloat(c.reserved.replace(/[$,]/g, '')), 0)
|
||||
const breached = claims.value.filter(c => c.slaPercent >= 100 && !['closed', 'denied'].includes(c.status)).length
|
||||
return { openClaims: active.length, underReview, avgDays, totalReserved, breached }
|
||||
})
|
||||
|
||||
const statusMeta: Record<string, { label: string; class: string }> = {
|
||||
open: { label: 'Open', class: 'cl-st-open' },
|
||||
under_review: { label: 'Under Review', class: 'cl-st-review' },
|
||||
awaiting_docs: { label: 'Awaiting Docs', class: 'cl-st-docs' },
|
||||
approved: { label: 'Approved', class: 'cl-st-approved' },
|
||||
denied: { label: 'Denied', class: 'cl-st-denied' },
|
||||
closed: { label: 'Closed', class: 'cl-st-closed' },
|
||||
}
|
||||
|
||||
const priorityMeta: Record<string, { label: string; class: string }> = {
|
||||
critical: { label: 'Critical', class: 'cl-pri-critical' },
|
||||
high: { label: 'High', class: 'cl-pri-high' },
|
||||
medium: { label: 'Med', class: 'cl-pri-medium' },
|
||||
low: { label: 'Low', class: 'cl-pri-low' },
|
||||
}
|
||||
|
||||
const carrierPillClass = (s: CarrierStatus) => {
|
||||
const map: Record<string, string> = {
|
||||
fnol_submitted: 'cl-csp-fnol', acknowledged: 'cl-csp-ack', investigation: 'cl-csp-inv',
|
||||
documentation_pending: 'cl-csp-doc', reserved: 'cl-csp-rsv', negotiation: 'cl-csp-neg',
|
||||
settlement_offered: 'cl-csp-set', closed: 'cl-csp-closed',
|
||||
}
|
||||
return map[s] ?? ''
|
||||
}
|
||||
|
||||
function formatCurrency(n: number) {
|
||||
return '$' + n.toLocaleString('en-US', { minimumFractionDigits: 0 })
|
||||
}
|
||||
|
||||
function clearFilters() {
|
||||
statusFilter.value = ''
|
||||
carrierFilter.value = ''
|
||||
lobFilter.value = ''
|
||||
handlerFilter.value = ''
|
||||
agingFilter.value = ''
|
||||
priorityFilter.value = ''
|
||||
}
|
||||
|
||||
const hasActiveFilters = computed(() => !!(statusFilter.value || carrierFilter.value || lobFilter.value || handlerFilter.value || agingFilter.value || priorityFilter.value))
|
||||
|
||||
const toast = useToast()
|
||||
function handleNewClaim() {
|
||||
toast.add({ title: 'New claim flow coming soon', description: 'This will open the FNOL intake wizard.', color: 'neutral' })
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="cl-page">
|
||||
<!-- Header -->
|
||||
<div class="flex flex-wrap items-end justify-between gap-3">
|
||||
<div class="max-w-xl">
|
||||
<h1 class="mt-1 text-2xl font-semibold tracking-tight text-[var(--text-primary)]">Claims</h1>
|
||||
<p class="mt-1 text-[13px] text-[var(--text-muted)]">
|
||||
Track claims lifecycle from first notice of loss through resolution and payment.
|
||||
</p>
|
||||
</div>
|
||||
<button type="button" class="cl-action-btn-primary" @click="handleNewClaim">
|
||||
<UIcon name="i-heroicons-plus" style="width: 14px; height: 14px;" />
|
||||
New Claim
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- KPI strip -->
|
||||
<div class="cl-kpi-strip">
|
||||
<div class="cl-kpi">
|
||||
<p class="cl-kpi-label">Open claims</p>
|
||||
<p class="cl-kpi-value">{{ kpis.openClaims }}</p>
|
||||
</div>
|
||||
<div class="cl-kpi">
|
||||
<p class="cl-kpi-label">Under review</p>
|
||||
<p class="cl-kpi-value" style="color: #c27b1a;">{{ kpis.underReview }}</p>
|
||||
</div>
|
||||
<div class="cl-kpi">
|
||||
<p class="cl-kpi-label">SLA breached</p>
|
||||
<p class="cl-kpi-value" :style="kpis.breached > 0 ? 'color: #c13838;' : ''">{{ kpis.breached }}</p>
|
||||
</div>
|
||||
<div class="cl-kpi">
|
||||
<p class="cl-kpi-label">Avg days open</p>
|
||||
<p class="cl-kpi-value">{{ kpis.avgDays }}d</p>
|
||||
</div>
|
||||
<div class="cl-kpi">
|
||||
<p class="cl-kpi-label">Total reserved</p>
|
||||
<p class="cl-kpi-value">{{ formatCurrency(kpis.totalReserved) }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- View toggle + Filter tabs -->
|
||||
<div class="cl-controls-row">
|
||||
<div class="cl-view-toggle">
|
||||
<button
|
||||
type="button"
|
||||
class="cl-view-btn"
|
||||
:class="viewMode === 'my' ? 'cl-view-on' : 'cl-view-off'"
|
||||
@click="viewMode = 'my'"
|
||||
>My Claims</button>
|
||||
<button
|
||||
type="button"
|
||||
class="cl-view-btn"
|
||||
:class="viewMode === 'all' ? 'cl-view-on' : 'cl-view-off'"
|
||||
@click="viewMode = 'all'"
|
||||
>All Claims</button>
|
||||
</div>
|
||||
|
||||
<div class="cl-filter-tabs">
|
||||
<button
|
||||
v-for="f in ([
|
||||
{ id: 'all', label: 'All' },
|
||||
{ id: 'active', label: 'Active' },
|
||||
{ id: 'resolved', label: 'Resolved' },
|
||||
] as { id: ClaimFilter; label: string }[])"
|
||||
:key="f.id"
|
||||
type="button"
|
||||
class="cl-filter-tab"
|
||||
:class="activeFilter === f.id ? 'cl-filter-on' : 'cl-filter-off'"
|
||||
@click="activeFilter = f.id"
|
||||
>
|
||||
{{ f.label }}
|
||||
<span class="cl-filter-count" :class="activeFilter === f.id ? 'cl-filter-count-on' : ''">{{ filterCounts[f.id] }}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<span class="text-[11px] text-[var(--text-muted)] ml-auto">{{ filteredClaims.length }} results</span>
|
||||
</div>
|
||||
|
||||
<!-- Filter dropdowns row -->
|
||||
<div class="cl-dropdown-row">
|
||||
<select v-model="statusFilter" class="cl-dropdown">
|
||||
<option value="">Status</option>
|
||||
<option value="open">Open</option>
|
||||
<option value="under_review">Under Review</option>
|
||||
<option value="awaiting_docs">Awaiting Docs</option>
|
||||
<option value="approved">Approved</option>
|
||||
<option value="denied">Denied</option>
|
||||
<option value="closed">Closed</option>
|
||||
</select>
|
||||
<select v-model="carrierFilter" class="cl-dropdown">
|
||||
<option value="">Carrier</option>
|
||||
<option v-for="c in uniqueCarriers" :key="c" :value="c">{{ c }}</option>
|
||||
</select>
|
||||
<select v-model="lobFilter" class="cl-dropdown">
|
||||
<option value="">LOB</option>
|
||||
<option v-for="l in uniqueLobs" :key="l" :value="l">{{ l }}</option>
|
||||
</select>
|
||||
<select v-model="handlerFilter" class="cl-dropdown">
|
||||
<option value="">Handler</option>
|
||||
<option v-for="h in uniqueHandlers" :key="h" :value="h">{{ h }}</option>
|
||||
</select>
|
||||
<select v-model="agingFilter" class="cl-dropdown">
|
||||
<option value="">Aging</option>
|
||||
<option value="0-7">0–7 days</option>
|
||||
<option value="8-14">8–14 days</option>
|
||||
<option value="15-30">15–30 days</option>
|
||||
<option value="30+">30+ days</option>
|
||||
</select>
|
||||
<select v-model="priorityFilter" class="cl-dropdown">
|
||||
<option value="">Priority</option>
|
||||
<option value="critical">Critical</option>
|
||||
<option value="high">High</option>
|
||||
<option value="medium">Medium</option>
|
||||
<option value="low">Low</option>
|
||||
</select>
|
||||
<button v-if="hasActiveFilters" class="cl-clear-btn" @click="clearFilters">
|
||||
<UIcon name="i-heroicons-x-mark" class="w-3 h-3" />
|
||||
Clear
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Claims table -->
|
||||
<div class="cl-table-wrap">
|
||||
<table class="cl-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="width: 28px;"></th>
|
||||
<th>Claim</th>
|
||||
<th>Customer / Agent</th>
|
||||
<th>Line / Type</th>
|
||||
<th>Carrier</th>
|
||||
<th>Status</th>
|
||||
<th class="text-right">Reserved</th>
|
||||
<th class="text-right">Paid</th>
|
||||
<th class="text-right">Days</th>
|
||||
<th>Priority</th>
|
||||
<th class="text-center">Docs</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr
|
||||
v-for="c in filteredClaims"
|
||||
:key="c.id"
|
||||
class="cl-row"
|
||||
:class="{ 'cl-breach-row': c.slaPercent >= 100 && !['closed', 'denied'].includes(c.status) }"
|
||||
style="cursor: pointer;"
|
||||
@click="navigateTo(`/claims/${c.id}`)"
|
||||
>
|
||||
<td><span class="cl-sla-dot" :class="`cl-sla-${slaColor(c.slaPercent)}`" /></td>
|
||||
<td>
|
||||
<NuxtLink :to="`/claims/${c.id}`" class="cl-claim-link" @click.stop>{{ c.id }}</NuxtLink>
|
||||
</td>
|
||||
<td>
|
||||
<p class="text-[13px] font-medium text-[var(--text-primary)]">{{ c.customer || 'Unnamed customer' }}</p>
|
||||
<p class="text-[11px] text-[var(--text-muted)]">{{ c.agent || '—' }}</p>
|
||||
</td>
|
||||
<td>
|
||||
<p class="text-[13px] text-[var(--text-primary)]">{{ c.line }}</p>
|
||||
<p class="text-[11px] text-[var(--text-muted)]">{{ c.type }}</p>
|
||||
</td>
|
||||
<td class="text-[13px] text-[var(--text-muted)]">{{ c.carrier }}</td>
|
||||
<td>
|
||||
<div class="cl-dual-status">
|
||||
<span class="cl-carrier-status-pill" :class="carrierPillClass(c.carrierStatus)">{{ CARRIER_STATUS_LABELS[c.carrierStatus] }}</span>
|
||||
<span class="cl-workflow-status-pill">{{ WORKFLOW_STATUS_LABELS[c.workflowStatus] }}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td class="text-right font-semibold text-[13px] text-[var(--text-primary)]">{{ c.reserved }}</td>
|
||||
<td class="text-right text-[13px]" :class="c.paid !== '$0' ? 'text-[var(--text-primary)] font-medium' : 'text-[var(--text-muted)] opacity-50'">{{ c.paid }}</td>
|
||||
<td class="text-right">
|
||||
<span class="text-[13px] font-bold" :class="c.daysOpen > 30 ? 'text-rose-600' : c.daysOpen > 14 ? 'text-amber-600' : 'text-[var(--text-primary)]'">{{ c.daysOpen }}d</span>
|
||||
</td>
|
||||
<td>
|
||||
<span :class="priorityMeta[c.priority].class">{{ priorityMeta[c.priority].label }}</span>
|
||||
</td>
|
||||
<td class="text-center">
|
||||
<span v-if="c.docsPending > 0" class="cl-docs-badge">{{ c.docsPending }}</span>
|
||||
<span v-else class="text-[11px] text-[var(--text-muted)] opacity-40">—</span>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.cl-page {
|
||||
max-width: 76rem;
|
||||
margin: 0 auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
padding-bottom: 3rem;
|
||||
}
|
||||
|
||||
.cl-action-btn-primary {
|
||||
display: inline-flex; align-items: center; gap: 6px;
|
||||
padding: 8px 16px; border-radius: 8px;
|
||||
background: #01696f; color: #fff;
|
||||
font-size: 13px; font-weight: 500; border: none;
|
||||
cursor: pointer; transition: all 150ms ease; white-space: nowrap;
|
||||
}
|
||||
.cl-action-btn-primary:hover { background: #015458; }
|
||||
|
||||
/* ── KPI strip ── */
|
||||
.cl-kpi-strip {
|
||||
display: grid; grid-template-columns: repeat(5, 1fr); gap: 1px;
|
||||
border-radius: 12px; border: 1px solid rgba(0,0,0,0.06);
|
||||
background: rgba(0,0,0,0.06); box-shadow: 0 1px 3px rgba(0,0,0,0.03);
|
||||
overflow: hidden;
|
||||
}
|
||||
.cl-kpi { padding: 14px 18px; background: #fff; }
|
||||
.cl-kpi:first-child { border-radius: 12px 0 0 12px; }
|
||||
.cl-kpi:last-child { border-radius: 0 12px 12px 0; }
|
||||
.cl-kpi-label {
|
||||
font-size: 11px; font-weight: 600; text-transform: uppercase;
|
||||
letter-spacing: 0.04em; color: #8a8a86;
|
||||
}
|
||||
.cl-kpi-value {
|
||||
margin-top: 4px; font-size: 22px; font-weight: 600;
|
||||
color: var(--text-primary); font-variant-numeric: tabular-nums;
|
||||
}
|
||||
@media (max-width: 640px) { .cl-kpi-strip { grid-template-columns: repeat(2, 1fr); } }
|
||||
|
||||
/* ── Controls row ── */
|
||||
.cl-controls-row {
|
||||
display: flex; align-items: center; gap: 12px; flex-wrap: wrap;
|
||||
}
|
||||
|
||||
/* ── View toggle ── */
|
||||
.cl-view-toggle {
|
||||
display: inline-flex; gap: 1px; padding: 2px;
|
||||
border-radius: 8px; background: rgba(0,0,0,0.04);
|
||||
}
|
||||
.cl-view-btn {
|
||||
padding: 5px 12px; border-radius: 6px; font-size: 12px; font-weight: 600;
|
||||
border: none; cursor: pointer; transition: all 150ms ease; white-space: nowrap;
|
||||
}
|
||||
.cl-view-on { background: #01696f; color: white; }
|
||||
.cl-view-off { background: transparent; color: #8a8a86; }
|
||||
.cl-view-off:hover { color: var(--text-primary); }
|
||||
|
||||
/* ── Filter tabs ── */
|
||||
.cl-filter-tabs {
|
||||
display: inline-flex; gap: 2px; padding: 3px;
|
||||
border-radius: 10px; background: rgba(0,0,0,0.04);
|
||||
}
|
||||
.cl-filter-tab {
|
||||
display: inline-flex; align-items: center; gap: 5px;
|
||||
padding: 6px 12px; border-radius: 8px;
|
||||
font-size: 12px; font-weight: 500; border: none;
|
||||
cursor: pointer; transition: all 150ms ease; white-space: nowrap;
|
||||
}
|
||||
.cl-filter-on { background: #fff; color: var(--text-primary); box-shadow: 0 1px 3px rgba(0,0,0,0.08); }
|
||||
.cl-filter-off { background: transparent; color: var(--text-muted); }
|
||||
.cl-filter-off:hover { color: var(--text-primary); }
|
||||
.cl-filter-count {
|
||||
font-size: 10px; font-weight: 600; padding: 1px 5px;
|
||||
border-radius: 9999px; background: rgba(0,0,0,0.06); color: var(--text-muted);
|
||||
}
|
||||
.cl-filter-count-on { background: rgba(1,105,111,0.1); color: #01696f; }
|
||||
|
||||
/* ── Dropdown filters ── */
|
||||
.cl-dropdown-row { display: flex; gap: 8px; flex-wrap: wrap; align-items: center; }
|
||||
.cl-dropdown {
|
||||
padding: 5px 10px; border-radius: 8px; font-size: 12px; font-weight: 500;
|
||||
border: 1px solid rgba(0,0,0,0.08); background: #fff; color: var(--text-primary);
|
||||
cursor: pointer; min-width: 100px;
|
||||
}
|
||||
.cl-dropdown:focus { outline: none; border-color: #01696f; }
|
||||
.cl-clear-btn {
|
||||
display: inline-flex; align-items: center; gap: 4px; padding: 5px 10px;
|
||||
border-radius: 8px; font-size: 11px; font-weight: 600;
|
||||
background: rgba(193, 56, 56, 0.06); color: #c13838;
|
||||
border: 1px solid rgba(193, 56, 56, 0.15); cursor: pointer;
|
||||
}
|
||||
.cl-clear-btn:hover { background: rgba(193, 56, 56, 0.12); }
|
||||
|
||||
/* ── Table ── */
|
||||
.cl-table-wrap {
|
||||
border-radius: 12px; border: 1px solid rgba(0,0,0,0.06);
|
||||
background: #fff; box-shadow: 0 1px 3px rgba(0,0,0,0.03);
|
||||
overflow-x: auto;
|
||||
}
|
||||
.cl-table { width: 100%; border-collapse: collapse; font-size: 13px; }
|
||||
.cl-table thead th {
|
||||
padding: 10px 14px;
|
||||
font-size: 11px; font-weight: 600; text-transform: uppercase;
|
||||
letter-spacing: 0.04em; color: #8a8a86;
|
||||
border-bottom: 1px solid rgba(0,0,0,0.06);
|
||||
white-space: nowrap; text-align: left;
|
||||
}
|
||||
.cl-table tbody td {
|
||||
padding: 12px 14px; border-bottom: 1px solid rgba(0,0,0,0.04);
|
||||
vertical-align: top;
|
||||
}
|
||||
.cl-row { transition: background 100ms ease; }
|
||||
.cl-row:hover { background: rgba(0,0,0,0.015); }
|
||||
.cl-row:last-child td { border-bottom: none; }
|
||||
|
||||
/* ── Breach row ── */
|
||||
.cl-breach-row { box-shadow: inset 3px 0 0 #c13838; }
|
||||
|
||||
/* ── SLA dot ── */
|
||||
.cl-sla-dot { width: 8px; height: 8px; border-radius: 50%; display: inline-block; }
|
||||
.cl-sla-green { background: #059669; }
|
||||
.cl-sla-amber { background: #c27b1a; }
|
||||
.cl-sla-red { background: #c13838; }
|
||||
|
||||
/* ── Claim link ── */
|
||||
.cl-claim-link {
|
||||
font-family: 'SF Mono', 'Fira Code', monospace;
|
||||
font-size: 12px; font-weight: 600; color: #01696f;
|
||||
text-decoration: none;
|
||||
}
|
||||
.cl-claim-link:hover { text-decoration: underline; }
|
||||
|
||||
/* ── Dual status pills ── */
|
||||
.cl-dual-status { display: flex; flex-direction: column; gap: 3px; }
|
||||
.cl-carrier-status-pill {
|
||||
display: inline-flex; padding: 2px 7px; border-radius: 8px;
|
||||
font-size: 10px; font-weight: 600; white-space: nowrap;
|
||||
}
|
||||
.cl-csp-fnol { background: rgba(59, 130, 246, 0.08); color: #2563eb; }
|
||||
.cl-csp-ack { background: rgba(16, 185, 129, 0.08); color: #059669; }
|
||||
.cl-csp-inv { background: rgba(245, 158, 11, 0.08); color: #d97706; }
|
||||
.cl-csp-doc { background: rgba(147, 51, 234, 0.08); color: #9333ea; }
|
||||
.cl-csp-rsv { background: rgba(1, 105, 111, 0.08); color: #01696f; }
|
||||
.cl-csp-neg { background: rgba(194, 123, 26, 0.08); color: #c27b1a; }
|
||||
.cl-csp-set { background: rgba(16, 185, 129, 0.08); color: #059669; }
|
||||
.cl-csp-closed { background: rgba(138, 138, 134, 0.08); color: #8a8a86; }
|
||||
|
||||
.cl-workflow-status-pill {
|
||||
display: inline-flex; padding: 0; border-radius: 0;
|
||||
font-size: 10px; font-weight: 500; white-space: nowrap;
|
||||
border: none; color: var(--text-muted);
|
||||
}
|
||||
|
||||
/* ── Status badges ── */
|
||||
.cl-st-open { font-size: 11px; font-weight: 600; padding: 2px 8px; border-radius: 9999px; background: rgba(193,56,56,0.08); color: #c13838; white-space: nowrap; }
|
||||
.cl-st-review { font-size: 11px; font-weight: 600; padding: 2px 8px; border-radius: 9999px; background: rgba(194,123,26,0.08); color: #c27b1a; white-space: nowrap; }
|
||||
.cl-st-docs { font-size: 11px; font-weight: 600; padding: 2px 8px; border-radius: 9999px; background: rgba(147,51,234,0.08); color: #9333ea; white-space: nowrap; }
|
||||
.cl-st-approved { font-size: 11px; font-weight: 600; padding: 2px 8px; border-radius: 9999px; background: rgba(1,105,111,0.08); color: #01696f; white-space: nowrap; }
|
||||
.cl-st-denied { font-size: 11px; font-weight: 600; padding: 2px 8px; border-radius: 9999px; background: rgba(0,0,0,0.06); color: #6b6b68; white-space: nowrap; }
|
||||
.cl-st-closed { font-size: 11px; font-weight: 600; padding: 2px 8px; border-radius: 9999px; background: rgba(0,0,0,0.04); color: #8a8a86; white-space: nowrap; }
|
||||
|
||||
/* ── Priority badges ── */
|
||||
.cl-pri-critical { font-size: 10px; font-weight: 700; padding: 1px 7px; border-radius: 9999px; background: rgba(193,56,56,0.12); color: #c13838; white-space: nowrap; }
|
||||
.cl-pri-high { font-size: 10px; font-weight: 600; padding: 1px 7px; border-radius: 9999px; background: rgba(194,123,26,0.08); color: #c27b1a; white-space: nowrap; }
|
||||
.cl-pri-medium { font-size: 10px; font-weight: 600; padding: 1px 7px; border-radius: 9999px; background: rgba(0,0,0,0.05); color: #6b6b68; white-space: nowrap; }
|
||||
.cl-pri-low { font-size: 10px; font-weight: 600; padding: 1px 7px; border-radius: 9999px; background: rgba(0,0,0,0.03); color: #8a8a86; white-space: nowrap; }
|
||||
|
||||
/* ── Docs pending badge ── */
|
||||
.cl-docs-badge {
|
||||
display: inline-flex; align-items: center; justify-content: center;
|
||||
min-width: 20px; height: 20px; padding: 0 5px;
|
||||
border-radius: 9999px; background: rgba(147,51,234,0.08); color: #9333ea;
|
||||
font-size: 11px; font-weight: 700;
|
||||
}
|
||||
</style>
|
||||
@@ -1,672 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
definePageMeta({ ssr: false, layout: false })
|
||||
|
||||
const route = useRoute()
|
||||
const token = route.params.token as string
|
||||
|
||||
// ── Mock claim lookup by token ────────────────────────────────────────────────
|
||||
interface IntakeClaim {
|
||||
id: string
|
||||
customerName: string
|
||||
policyNumber: string
|
||||
carrier: string
|
||||
lob: 'Auto' | 'Life' | 'General Risk' | 'Home'
|
||||
handler: string
|
||||
expiresAt: string
|
||||
}
|
||||
|
||||
const MOCK_TOKENS: Record<string, IntakeClaim> = {
|
||||
'tk_hp_048_a3f1': { id: 'CLM-0048', customerName: 'Hotel Pacífico S.A.', policyNumber: 'PROP-2024-HP-001', carrier: 'ASSA', lob: 'General Risk', handler: 'Ana R.', expiresAt: '2026-04-09T14:30:00Z' },
|
||||
'tk_abc_047_b7e2': { id: 'CLM-0047', customerName: 'Empresa ABC S.A.', policyNumber: 'AUTO-2024-FLEET-007', carrier: 'Qualitas', lob: 'Auto', handler: 'Ana R.', expiresAt: '2026-04-07T11:00:00Z' },
|
||||
'tk_st_043_c9d4': { id: 'CLM-0043', customerName: 'Supermercado Tico S.A.', policyNumber: 'GL-2023-ST-001', carrier: 'Mapfre', lob: 'General Risk', handler: 'Marco V.', expiresAt: '2026-03-20T16:00:00Z' },
|
||||
'demo-auto': { id: 'CLM-DEMO-A', customerName: 'Demo Auto Client', policyNumber: 'AUTO-DEMO-001', carrier: 'ASSA', lob: 'Auto', handler: 'Ana R.', expiresAt: '2026-12-31T23:59:00Z' },
|
||||
'demo-life': { id: 'CLM-DEMO-L', customerName: 'Demo Life Client', policyNumber: 'LIFE-DEMO-001', carrier: 'Pan-American Life', lob: 'Life', handler: 'Ana R.', expiresAt: '2026-12-31T23:59:00Z' },
|
||||
}
|
||||
|
||||
const claim = computed(() => MOCK_TOKENS[token] ?? null)
|
||||
const expired = computed(() => {
|
||||
if (!claim.value) return false
|
||||
return new Date(claim.value.expiresAt) < new Date()
|
||||
})
|
||||
|
||||
// ── Steps ─────────────────────────────────────────────────────────────────────
|
||||
const currentStep = ref(0)
|
||||
const submitted = ref(false)
|
||||
|
||||
const steps = computed(() => {
|
||||
const base = [
|
||||
{ id: 'incident', label: 'Incident Details' },
|
||||
{ id: 'parties', label: claim.value?.lob === 'Auto' ? 'Vehicles & Parties' : claim.value?.lob === 'Life' ? 'Patient & Provider' : 'Property & Parties' },
|
||||
{ id: 'documents', label: 'Documents & Photos' },
|
||||
{ id: 'review', label: 'Review & Submit' },
|
||||
]
|
||||
return base
|
||||
})
|
||||
|
||||
function nextStep() { if (currentStep.value < steps.value.length - 1) currentStep.value++ }
|
||||
function prevStep() { if (currentStep.value > 0) currentStep.value-- }
|
||||
function submitForm() { submitted.value = true }
|
||||
|
||||
// ── Form data ─────────────────────────────────────────────────────────────────
|
||||
const form = reactive({
|
||||
// Step 1: Incident
|
||||
incidentDate: '',
|
||||
incidentTime: '',
|
||||
incidentLocation: '',
|
||||
incidentDescription: '',
|
||||
|
||||
// Step 2: Auto-specific
|
||||
vehicleMake: '',
|
||||
vehicleModel: '',
|
||||
vehicleYear: '',
|
||||
vehiclePlate: '',
|
||||
vehicleColor: '',
|
||||
otherDriverName: '',
|
||||
otherDriverPhone: '',
|
||||
otherDriverInsurance: '',
|
||||
otherDriverPlate: '',
|
||||
witnessName: '',
|
||||
witnessPhone: '',
|
||||
|
||||
// Step 2: Life-specific
|
||||
patientName: '',
|
||||
patientDob: '',
|
||||
patientCedula: '',
|
||||
providerName: '',
|
||||
providerAddress: '',
|
||||
diagnosis: '',
|
||||
treatmentDates: '',
|
||||
|
||||
// Step 2: Property/General Risk
|
||||
propertyAddress: '',
|
||||
propertyType: '',
|
||||
damageDescription: '',
|
||||
emergencyServicesCalled: false,
|
||||
thirdPartyInvolved: false,
|
||||
|
||||
// Step 3: Documents
|
||||
photoDescriptions: [] as string[],
|
||||
hasSignedFud: false,
|
||||
additionalNotes: '',
|
||||
})
|
||||
|
||||
// ── Photo uploads (mock) ──────────────────────────────────────────────────────
|
||||
const photoSlots = computed(() => {
|
||||
if (!claim.value) return []
|
||||
if (claim.value.lob === 'Auto') return [
|
||||
{ id: 'front', label: 'Front of vehicle' },
|
||||
{ id: 'rear', label: 'Rear of vehicle' },
|
||||
{ id: 'left', label: 'Left side' },
|
||||
{ id: 'right', label: 'Right side' },
|
||||
{ id: 'damage', label: 'Close-up of damage' },
|
||||
{ id: 'fud', label: 'FUD firmado — foto del documento (si aplica)', optional: true },
|
||||
]
|
||||
if (claim.value.lob === 'Life') return [
|
||||
{ id: 'prescription', label: 'Medical prescription' },
|
||||
{ id: 'referral', label: 'Specialist referral' },
|
||||
{ id: 'records', label: 'Medical records' },
|
||||
]
|
||||
return [
|
||||
{ id: 'damage1', label: 'Damage photo 1' },
|
||||
{ id: 'damage2', label: 'Damage photo 2' },
|
||||
{ id: 'damage3', label: 'Additional photos' },
|
||||
{ id: 'report', label: 'Fire/police report (if available)', optional: true },
|
||||
]
|
||||
})
|
||||
|
||||
const uploadedPhotos = ref<Record<string, boolean>>({})
|
||||
function mockUpload(slotId: string) { uploadedPhotos.value[slotId] = true }
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="ci-page">
|
||||
<!-- Segur-OS branding bar -->
|
||||
<div class="ci-brand-bar">
|
||||
<span class="ci-brand-logo">Segur-OS</span>
|
||||
<span class="ci-brand-tag">Client Intake Form</span>
|
||||
</div>
|
||||
|
||||
<!-- Invalid / expired token -->
|
||||
<template v-if="!claim">
|
||||
<div class="ci-error">
|
||||
<div class="ci-error-icon">!</div>
|
||||
<h2>Invalid Link</h2>
|
||||
<p>This intake form link is invalid or has expired. Please contact your broker for a new link.</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-else-if="expired">
|
||||
<div class="ci-error">
|
||||
<div class="ci-error-icon">⏱</div>
|
||||
<h2>Link Expired</h2>
|
||||
<p>This intake form link expired on {{ new Date(claim.expiresAt).toLocaleDateString() }}. Please contact your broker to request a new link.</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Success state -->
|
||||
<template v-else-if="submitted">
|
||||
<div class="ci-success">
|
||||
<div class="ci-success-icon">✓</div>
|
||||
<h2>Thank You</h2>
|
||||
<p>Your claim information for <strong>{{ claim.id }}</strong> has been submitted successfully.</p>
|
||||
<p class="ci-success-sub">Your broker {{ claim.handler }} will review the information and follow up with you shortly.</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Main form -->
|
||||
<template v-else>
|
||||
<!-- Claim context header -->
|
||||
<div class="ci-context">
|
||||
<div class="ci-context-left">
|
||||
<h1 class="ci-context-title">{{ claim.customerName }}</h1>
|
||||
<p class="ci-context-meta">{{ claim.id }} · {{ claim.policyNumber }} · {{ claim.carrier }}</p>
|
||||
</div>
|
||||
<div class="ci-context-right">
|
||||
<span class="ci-context-lob">{{ claim.lob }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Step indicator -->
|
||||
<div class="ci-steps">
|
||||
<div
|
||||
v-for="(step, idx) in steps"
|
||||
:key="step.id"
|
||||
class="ci-step"
|
||||
:class="{
|
||||
'ci-step-done': idx < currentStep,
|
||||
'ci-step-active': idx === currentStep,
|
||||
'ci-step-pending': idx > currentStep,
|
||||
}"
|
||||
>
|
||||
<div class="ci-step-circle">
|
||||
<span v-if="idx < currentStep">✓</span>
|
||||
<span v-else>{{ idx + 1 }}</span>
|
||||
</div>
|
||||
<span class="ci-step-label">{{ step.label }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Step content -->
|
||||
<div class="ci-card">
|
||||
<!-- ═══ Step 1: Incident Details ═══ -->
|
||||
<template v-if="currentStep === 0">
|
||||
<h2 class="ci-section-title">Incident Details</h2>
|
||||
<p class="ci-section-desc">When and where did the incident occur?</p>
|
||||
|
||||
<div class="ci-field-grid">
|
||||
<div class="ci-field">
|
||||
<label class="ci-label">Date of Incident</label>
|
||||
<input v-model="form.incidentDate" type="date" class="ci-input" />
|
||||
</div>
|
||||
<div class="ci-field">
|
||||
<label class="ci-label">Time (approximate)</label>
|
||||
<input v-model="form.incidentTime" type="time" class="ci-input" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="ci-field">
|
||||
<label class="ci-label">Location</label>
|
||||
<input v-model="form.incidentLocation" type="text" class="ci-input" placeholder="Street address, intersection, or description" />
|
||||
</div>
|
||||
<div class="ci-field">
|
||||
<label class="ci-label">Description of what happened</label>
|
||||
<textarea v-model="form.incidentDescription" class="ci-textarea" rows="4" placeholder="Describe the incident in detail..." />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- ═══ Step 2: Auto ═══ -->
|
||||
<template v-if="currentStep === 1 && claim.lob === 'Auto'">
|
||||
<h2 class="ci-section-title">Vehicles & Parties</h2>
|
||||
<p class="ci-section-desc">Your vehicle and other parties involved.</p>
|
||||
|
||||
<h3 class="ci-subsection">Your Vehicle</h3>
|
||||
<div class="ci-field-grid">
|
||||
<div class="ci-field">
|
||||
<label class="ci-label">Make</label>
|
||||
<input v-model="form.vehicleMake" type="text" class="ci-input" placeholder="Toyota" />
|
||||
</div>
|
||||
<div class="ci-field">
|
||||
<label class="ci-label">Model</label>
|
||||
<input v-model="form.vehicleModel" type="text" class="ci-input" placeholder="Hilux" />
|
||||
</div>
|
||||
<div class="ci-field">
|
||||
<label class="ci-label">Year</label>
|
||||
<input v-model="form.vehicleYear" type="text" class="ci-input" placeholder="2024" />
|
||||
</div>
|
||||
<div class="ci-field">
|
||||
<label class="ci-label">Plate</label>
|
||||
<input v-model="form.vehiclePlate" type="text" class="ci-input" placeholder="ABC-123" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="ci-field">
|
||||
<label class="ci-label">Color</label>
|
||||
<input v-model="form.vehicleColor" type="text" class="ci-input" placeholder="White" />
|
||||
</div>
|
||||
|
||||
<h3 class="ci-subsection">Other Driver</h3>
|
||||
<div class="ci-field-grid">
|
||||
<div class="ci-field">
|
||||
<label class="ci-label">Name</label>
|
||||
<input v-model="form.otherDriverName" type="text" class="ci-input" />
|
||||
</div>
|
||||
<div class="ci-field">
|
||||
<label class="ci-label">Phone</label>
|
||||
<input v-model="form.otherDriverPhone" type="tel" class="ci-input" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="ci-field-grid">
|
||||
<div class="ci-field">
|
||||
<label class="ci-label">Insurance Company</label>
|
||||
<input v-model="form.otherDriverInsurance" type="text" class="ci-input" />
|
||||
</div>
|
||||
<div class="ci-field">
|
||||
<label class="ci-label">Plate Number</label>
|
||||
<input v-model="form.otherDriverPlate" type="text" class="ci-input" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h3 class="ci-subsection">Witness (if any)</h3>
|
||||
<div class="ci-field-grid">
|
||||
<div class="ci-field">
|
||||
<label class="ci-label">Name</label>
|
||||
<input v-model="form.witnessName" type="text" class="ci-input" />
|
||||
</div>
|
||||
<div class="ci-field">
|
||||
<label class="ci-label">Phone</label>
|
||||
<input v-model="form.witnessPhone" type="tel" class="ci-input" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- ═══ Step 2: Life ═══ -->
|
||||
<template v-if="currentStep === 1 && claim.lob === 'Life'">
|
||||
<h2 class="ci-section-title">Patient & Provider</h2>
|
||||
<p class="ci-section-desc">Information about the patient and medical provider.</p>
|
||||
|
||||
<h3 class="ci-subsection">Patient Information</h3>
|
||||
<div class="ci-field-grid">
|
||||
<div class="ci-field">
|
||||
<label class="ci-label">Patient Name</label>
|
||||
<input v-model="form.patientName" type="text" class="ci-input" />
|
||||
</div>
|
||||
<div class="ci-field">
|
||||
<label class="ci-label">Date of Birth</label>
|
||||
<input v-model="form.patientDob" type="date" class="ci-input" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="ci-field">
|
||||
<label class="ci-label">Cédula / ID</label>
|
||||
<input v-model="form.patientCedula" type="text" class="ci-input" />
|
||||
</div>
|
||||
|
||||
<h3 class="ci-subsection">Medical Provider</h3>
|
||||
<div class="ci-field">
|
||||
<label class="ci-label">Provider / Hospital Name</label>
|
||||
<input v-model="form.providerName" type="text" class="ci-input" />
|
||||
</div>
|
||||
<div class="ci-field">
|
||||
<label class="ci-label">Provider Address</label>
|
||||
<input v-model="form.providerAddress" type="text" class="ci-input" />
|
||||
</div>
|
||||
<div class="ci-field">
|
||||
<label class="ci-label">Diagnosis</label>
|
||||
<textarea v-model="form.diagnosis" class="ci-textarea" rows="3" placeholder="Describe the diagnosis or reason for treatment..." />
|
||||
</div>
|
||||
<div class="ci-field">
|
||||
<label class="ci-label">Treatment Dates</label>
|
||||
<input v-model="form.treatmentDates" type="text" class="ci-input" placeholder="e.g. April 1–5, 2026" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- ═══ Step 2: General Risk / Home ═══ -->
|
||||
<template v-if="currentStep === 1 && (claim.lob === 'General Risk' || claim.lob === 'Home')">
|
||||
<h2 class="ci-section-title">Property & Parties</h2>
|
||||
<p class="ci-section-desc">Details about the affected property.</p>
|
||||
|
||||
<div class="ci-field">
|
||||
<label class="ci-label">Property Address</label>
|
||||
<input v-model="form.propertyAddress" type="text" class="ci-input" />
|
||||
</div>
|
||||
<div class="ci-field">
|
||||
<label class="ci-label">Property Type</label>
|
||||
<select v-model="form.propertyType" class="ci-input">
|
||||
<option value="">Select...</option>
|
||||
<option value="commercial">Commercial</option>
|
||||
<option value="residential">Residential</option>
|
||||
<option value="industrial">Industrial</option>
|
||||
<option value="other">Other</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="ci-field">
|
||||
<label class="ci-label">Damage Description</label>
|
||||
<textarea v-model="form.damageDescription" class="ci-textarea" rows="4" placeholder="Describe the damage in detail..." />
|
||||
</div>
|
||||
<div class="ci-field-row">
|
||||
<label class="ci-checkbox">
|
||||
<input v-model="form.emergencyServicesCalled" type="checkbox" />
|
||||
<span>Emergency services were called (fire, police, ambulance)</span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="ci-field-row">
|
||||
<label class="ci-checkbox">
|
||||
<input v-model="form.thirdPartyInvolved" type="checkbox" />
|
||||
<span>Third parties are involved</span>
|
||||
</label>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- ═══ Step 3: Documents & Photos ═══ -->
|
||||
<template v-if="currentStep === 2">
|
||||
<h2 class="ci-section-title">Documents & Photos</h2>
|
||||
<p class="ci-section-desc">Upload photos and supporting documents. Take clear, well-lit photos.</p>
|
||||
|
||||
<div class="ci-photo-grid">
|
||||
<div v-for="slot in photoSlots" :key="slot.id" class="ci-photo-slot">
|
||||
<div class="ci-photo-box" :class="{ 'ci-photo-uploaded': uploadedPhotos[slot.id] }" @click="mockUpload(slot.id)">
|
||||
<template v-if="uploadedPhotos[slot.id]">
|
||||
<span class="ci-photo-check">✓</span>
|
||||
</template>
|
||||
<template v-else>
|
||||
<span class="ci-photo-plus">+</span>
|
||||
</template>
|
||||
</div>
|
||||
<span class="ci-photo-label">{{ slot.label }}</span>
|
||||
<span v-if="slot.optional" class="ci-photo-optional">Optional</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="ci-field" style="margin-top: 20px;">
|
||||
<label class="ci-label">Additional Notes</label>
|
||||
<textarea v-model="form.additionalNotes" class="ci-textarea" rows="3" placeholder="Anything else your broker should know..." />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- ═══ Step 4: Review & Submit ═══ -->
|
||||
<template v-if="currentStep === 3">
|
||||
<h2 class="ci-section-title">Review & Submit</h2>
|
||||
<p class="ci-section-desc">Please review your information before submitting.</p>
|
||||
|
||||
<div class="ci-review-section">
|
||||
<h3 class="ci-review-heading">Incident</h3>
|
||||
<div class="ci-review-grid">
|
||||
<div class="ci-review-item">
|
||||
<span class="ci-review-label">Date</span>
|
||||
<span class="ci-review-value">{{ form.incidentDate || '—' }}</span>
|
||||
</div>
|
||||
<div class="ci-review-item">
|
||||
<span class="ci-review-label">Time</span>
|
||||
<span class="ci-review-value">{{ form.incidentTime || '—' }}</span>
|
||||
</div>
|
||||
<div class="ci-review-item ci-review-full">
|
||||
<span class="ci-review-label">Location</span>
|
||||
<span class="ci-review-value">{{ form.incidentLocation || '—' }}</span>
|
||||
</div>
|
||||
<div class="ci-review-item ci-review-full">
|
||||
<span class="ci-review-label">Description</span>
|
||||
<span class="ci-review-value">{{ form.incidentDescription || '—' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="claim.lob === 'Auto'" class="ci-review-section">
|
||||
<h3 class="ci-review-heading">Vehicle</h3>
|
||||
<div class="ci-review-grid">
|
||||
<div class="ci-review-item">
|
||||
<span class="ci-review-label">Vehicle</span>
|
||||
<span class="ci-review-value">{{ form.vehicleYear }} {{ form.vehicleMake }} {{ form.vehicleModel }}</span>
|
||||
</div>
|
||||
<div class="ci-review-item">
|
||||
<span class="ci-review-label">Plate</span>
|
||||
<span class="ci-review-value">{{ form.vehiclePlate || '—' }}</span>
|
||||
</div>
|
||||
<div class="ci-review-item">
|
||||
<span class="ci-review-label">Other Driver</span>
|
||||
<span class="ci-review-value">{{ form.otherDriverName || 'Not provided' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="claim.lob === 'Life'" class="ci-review-section">
|
||||
<h3 class="ci-review-heading">Patient & Provider</h3>
|
||||
<div class="ci-review-grid">
|
||||
<div class="ci-review-item">
|
||||
<span class="ci-review-label">Patient</span>
|
||||
<span class="ci-review-value">{{ form.patientName || '—' }}</span>
|
||||
</div>
|
||||
<div class="ci-review-item">
|
||||
<span class="ci-review-label">Provider</span>
|
||||
<span class="ci-review-value">{{ form.providerName || '—' }}</span>
|
||||
</div>
|
||||
<div class="ci-review-item ci-review-full">
|
||||
<span class="ci-review-label">Diagnosis</span>
|
||||
<span class="ci-review-value">{{ form.diagnosis || '—' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="claim.lob === 'General Risk' || claim.lob === 'Home'" class="ci-review-section">
|
||||
<h3 class="ci-review-heading">Property</h3>
|
||||
<div class="ci-review-grid">
|
||||
<div class="ci-review-item">
|
||||
<span class="ci-review-label">Address</span>
|
||||
<span class="ci-review-value">{{ form.propertyAddress || '—' }}</span>
|
||||
</div>
|
||||
<div class="ci-review-item">
|
||||
<span class="ci-review-label">Type</span>
|
||||
<span class="ci-review-value">{{ form.propertyType || '—' }}</span>
|
||||
</div>
|
||||
<div class="ci-review-item ci-review-full">
|
||||
<span class="ci-review-label">Damage</span>
|
||||
<span class="ci-review-value">{{ form.damageDescription || '—' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="ci-review-section">
|
||||
<h3 class="ci-review-heading">Documents</h3>
|
||||
<p class="ci-review-photos">{{ Object.values(uploadedPhotos).filter(Boolean).length }} of {{ photoSlots.length }} photos uploaded</p>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- Navigation buttons -->
|
||||
<div class="ci-nav">
|
||||
<button v-if="currentStep > 0" class="ci-btn-back" @click="prevStep">
|
||||
← Back
|
||||
</button>
|
||||
<div class="ci-nav-spacer" />
|
||||
<button v-if="currentStep < steps.length - 1" class="ci-btn-next" @click="nextStep">
|
||||
Continue →
|
||||
</button>
|
||||
<button v-if="currentStep === steps.length - 1" class="ci-btn-submit" @click="submitForm">
|
||||
Submit Claim Information
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Footer -->
|
||||
<div class="ci-footer">
|
||||
<p>Powered by <strong>Segur-OS</strong> · This form does not require a login</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* =====================================================================
|
||||
CLIENT INTAKE FORM — mobile-first, no layout, ci- prefix
|
||||
===================================================================== */
|
||||
|
||||
.ci-page {
|
||||
max-width: 640px;
|
||||
margin: 0 auto;
|
||||
padding: 0 16px 48px;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||
color: #1a1a1a;
|
||||
min-height: 100vh;
|
||||
background: #f8f8f6;
|
||||
}
|
||||
|
||||
/* ── Brand bar ── */
|
||||
.ci-brand-bar {
|
||||
display: flex; align-items: center; gap: 10px;
|
||||
padding: 16px 0; border-bottom: 1px solid rgba(0,0,0,0.06);
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.ci-brand-logo { font-size: 16px; font-weight: 800; color: #01696f; letter-spacing: -0.02em; }
|
||||
.ci-brand-tag { font-size: 12px; color: #8a8a86; font-weight: 500; }
|
||||
|
||||
/* ── Error / expired ── */
|
||||
.ci-error { text-align: center; padding: 60px 16px; }
|
||||
.ci-error-icon { font-size: 40px; margin-bottom: 12px; }
|
||||
.ci-error h2 { font-size: 20px; font-weight: 700; margin-bottom: 8px; }
|
||||
.ci-error p { font-size: 14px; color: #5c5650; line-height: 1.6; }
|
||||
|
||||
/* ── Success ── */
|
||||
.ci-success { text-align: center; padding: 60px 16px; }
|
||||
.ci-success-icon {
|
||||
display: inline-flex; align-items: center; justify-content: center;
|
||||
width: 56px; height: 56px; border-radius: 50%;
|
||||
background: rgba(1, 105, 111, 0.1); color: #01696f;
|
||||
font-size: 28px; font-weight: 700; margin-bottom: 16px;
|
||||
}
|
||||
.ci-success h2 { font-size: 22px; font-weight: 700; margin-bottom: 8px; }
|
||||
.ci-success p { font-size: 14px; color: #3a3a3a; line-height: 1.6; }
|
||||
.ci-success-sub { color: #8a8a86; margin-top: 8px; }
|
||||
|
||||
/* ── Context header ── */
|
||||
.ci-context {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
gap: 12px; padding: 12px 16px;
|
||||
background: white; border: 1px solid rgba(0,0,0,0.06);
|
||||
border-radius: 12px; margin-bottom: 16px;
|
||||
}
|
||||
.ci-context-title { font-size: 16px; font-weight: 700; }
|
||||
.ci-context-meta { font-size: 12px; color: #8a8a86; margin-top: 2px; }
|
||||
.ci-context-lob {
|
||||
display: inline-flex; padding: 4px 10px;
|
||||
border-radius: 8px; font-size: 11px; font-weight: 700;
|
||||
background: rgba(1, 105, 111, 0.08); color: #01696f;
|
||||
text-transform: uppercase; letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
/* ── Step indicator ── */
|
||||
.ci-steps {
|
||||
display: flex; gap: 4px; margin-bottom: 20px; overflow-x: auto;
|
||||
}
|
||||
.ci-step {
|
||||
display: flex; align-items: center; gap: 6px; padding: 8px 12px;
|
||||
border-radius: 8px; font-size: 12px; font-weight: 500;
|
||||
white-space: nowrap; flex: 1; min-width: 0;
|
||||
}
|
||||
.ci-step-circle {
|
||||
width: 24px; height: 24px; border-radius: 50%;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
font-size: 11px; font-weight: 700; flex-shrink: 0;
|
||||
}
|
||||
.ci-step-label { overflow: hidden; text-overflow: ellipsis; }
|
||||
.ci-step-done { color: #01696f; }
|
||||
.ci-step-done .ci-step-circle { background: #01696f; color: white; }
|
||||
.ci-step-active { color: #1a1a1a; background: white; box-shadow: 0 1px 3px rgba(0,0,0,0.06); }
|
||||
.ci-step-active .ci-step-circle { background: #01696f; color: white; }
|
||||
.ci-step-pending { color: #8a8a86; }
|
||||
.ci-step-pending .ci-step-circle { background: rgba(0,0,0,0.06); color: #8a8a86; }
|
||||
|
||||
/* ── Card ── */
|
||||
.ci-card {
|
||||
background: white; border: 1px solid rgba(0,0,0,0.06);
|
||||
border-radius: 12px; padding: 20px 16px;
|
||||
}
|
||||
|
||||
/* ── Section ── */
|
||||
.ci-section-title { font-size: 18px; font-weight: 700; margin-bottom: 4px; }
|
||||
.ci-section-desc { font-size: 13px; color: #8a8a86; margin-bottom: 20px; }
|
||||
.ci-subsection { font-size: 14px; font-weight: 600; margin: 20px 0 10px; padding-top: 16px; border-top: 1px solid rgba(0,0,0,0.06); }
|
||||
|
||||
/* ── Fields ── */
|
||||
.ci-field { margin-bottom: 14px; }
|
||||
.ci-field-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; margin-bottom: 14px; }
|
||||
.ci-field-row { margin-bottom: 12px; }
|
||||
.ci-label { display: block; font-size: 12px; font-weight: 600; color: #5c5650; margin-bottom: 4px; text-transform: uppercase; letter-spacing: 0.03em; }
|
||||
.ci-input {
|
||||
width: 100%; padding: 10px 12px; border: 1px solid rgba(0,0,0,0.1);
|
||||
border-radius: 8px; font-size: 14px; color: #1a1a1a;
|
||||
background: white; transition: border-color 150ms ease;
|
||||
}
|
||||
.ci-input:focus { outline: none; border-color: #01696f; }
|
||||
.ci-textarea {
|
||||
width: 100%; padding: 10px 12px; border: 1px solid rgba(0,0,0,0.1);
|
||||
border-radius: 8px; font-size: 14px; color: #1a1a1a;
|
||||
resize: vertical; font-family: inherit;
|
||||
}
|
||||
.ci-textarea:focus { outline: none; border-color: #01696f; }
|
||||
.ci-checkbox {
|
||||
display: flex; align-items: flex-start; gap: 8px; cursor: pointer;
|
||||
font-size: 14px; color: #3a3a3a;
|
||||
}
|
||||
.ci-checkbox input { margin-top: 3px; accent-color: #01696f; }
|
||||
|
||||
/* ── Photo grid ── */
|
||||
.ci-photo-grid {
|
||||
display: grid; grid-template-columns: repeat(auto-fill, minmax(140px, 1fr)); gap: 12px;
|
||||
}
|
||||
.ci-photo-slot { display: flex; flex-direction: column; align-items: center; gap: 6px; }
|
||||
.ci-photo-box {
|
||||
width: 100%; aspect-ratio: 4/3; border: 2px dashed rgba(0,0,0,0.12);
|
||||
border-radius: 10px; display: flex; align-items: center; justify-content: center;
|
||||
cursor: pointer; transition: all 150ms ease; background: rgba(0,0,0,0.02);
|
||||
}
|
||||
.ci-photo-box:hover { border-color: #01696f; background: rgba(1, 105, 111, 0.03); }
|
||||
.ci-photo-uploaded { border-style: solid; border-color: #01696f; background: rgba(1, 105, 111, 0.06); }
|
||||
.ci-photo-plus { font-size: 24px; color: #8a8a86; }
|
||||
.ci-photo-check { font-size: 24px; color: #01696f; font-weight: 700; }
|
||||
.ci-photo-label { font-size: 11px; color: #5c5650; text-align: center; line-height: 1.3; }
|
||||
.ci-photo-optional { font-size: 10px; color: #8a8a86; font-style: italic; }
|
||||
|
||||
/* ── Review ── */
|
||||
.ci-review-section { margin-bottom: 20px; }
|
||||
.ci-review-heading { font-size: 14px; font-weight: 700; margin-bottom: 10px; padding-bottom: 6px; border-bottom: 1px solid rgba(0,0,0,0.06); }
|
||||
.ci-review-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; }
|
||||
.ci-review-full { grid-column: 1 / -1; }
|
||||
.ci-review-item { }
|
||||
.ci-review-label { display: block; font-size: 10px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.04em; color: #8a8a86; margin-bottom: 2px; }
|
||||
.ci-review-value { font-size: 14px; color: #1a1a1a; }
|
||||
.ci-review-photos { font-size: 13px; color: #5c5650; }
|
||||
|
||||
/* ── Navigation ── */
|
||||
.ci-nav {
|
||||
display: flex; align-items: center; gap: 12px;
|
||||
margin-top: 16px; padding: 0 4px;
|
||||
}
|
||||
.ci-nav-spacer { flex: 1; }
|
||||
.ci-btn-back {
|
||||
padding: 10px 20px; border-radius: 10px; font-size: 14px; font-weight: 600;
|
||||
background: white; color: #5c5650; border: 1px solid rgba(0,0,0,0.1);
|
||||
cursor: pointer;
|
||||
}
|
||||
.ci-btn-back:hover { color: #1a1a1a; border-color: rgba(0,0,0,0.2); }
|
||||
.ci-btn-next {
|
||||
padding: 10px 24px; border-radius: 10px; font-size: 14px; font-weight: 600;
|
||||
background: #01696f; color: white; border: none; cursor: pointer;
|
||||
}
|
||||
.ci-btn-next:hover { opacity: 0.9; }
|
||||
.ci-btn-submit {
|
||||
padding: 12px 28px; border-radius: 10px; font-size: 14px; font-weight: 700;
|
||||
background: #01696f; color: white; border: none; cursor: pointer;
|
||||
}
|
||||
.ci-btn-submit:hover { opacity: 0.9; }
|
||||
|
||||
/* ── Footer ── */
|
||||
.ci-footer {
|
||||
text-align: center; padding: 24px 0; margin-top: 32px;
|
||||
border-top: 1px solid rgba(0,0,0,0.06);
|
||||
font-size: 12px; color: #8a8a86;
|
||||
}
|
||||
|
||||
/* ── Responsive ── */
|
||||
@media (max-width: 480px) {
|
||||
.ci-field-grid { grid-template-columns: 1fr; }
|
||||
.ci-photo-grid { grid-template-columns: repeat(2, 1fr); }
|
||||
.ci-review-grid { grid-template-columns: 1fr; }
|
||||
.ci-steps { gap: 2px; }
|
||||
.ci-step-label { display: none; }
|
||||
}
|
||||
</style>
|
||||
@@ -1,416 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
definePageMeta({ ssr: false })
|
||||
usePageTitle('Claims Settings')
|
||||
|
||||
// ── SLA Rules ─────────────────────────────────────────────────────────────────
|
||||
interface SlaRule {
|
||||
lob: string
|
||||
targetDays: number
|
||||
tier1Pct: number
|
||||
tier2Pct: number
|
||||
tier3Pct: number
|
||||
}
|
||||
|
||||
const slaRules = ref<SlaRule[]>([
|
||||
{ lob: 'Auto', targetDays: 14, tier1Pct: 50, tier2Pct: 75, tier3Pct: 100 },
|
||||
{ lob: 'Life', targetDays: 21, tier1Pct: 50, tier2Pct: 75, tier3Pct: 100 },
|
||||
{ lob: 'General Risk', targetDays: 30, tier1Pct: 50, tier2Pct: 75, tier3Pct: 100 },
|
||||
{ lob: 'Home', targetDays: 21, tier1Pct: 50, tier2Pct: 75, tier3Pct: 100 },
|
||||
{ lob: 'Fianza', targetDays: 10, tier1Pct: 50, tier2Pct: 75, tier3Pct: 100 },
|
||||
])
|
||||
|
||||
function tierDays(rule: SlaRule, pct: number) {
|
||||
return Math.round(rule.targetDays * pct / 100)
|
||||
}
|
||||
|
||||
// ── Escalation Tiers ──────────────────────────────────────────────────────────
|
||||
interface EscalationTier {
|
||||
threshold: string
|
||||
action: string
|
||||
notify: string
|
||||
}
|
||||
|
||||
const escalationTiers = ref<EscalationTier[]>([
|
||||
{ threshold: '50% of SLA', action: 'Notify handler', notify: 'Handler' },
|
||||
{ threshold: '75% of SLA', action: 'Notify handler + manager', notify: 'Handler, Manager' },
|
||||
{ threshold: '100% of SLA', action: 'Auto-escalate to manager', notify: 'Manager, Team Lead' },
|
||||
])
|
||||
|
||||
// ── Required Document Gates ───────────────────────────────────────────────────
|
||||
interface DocGate {
|
||||
status: string
|
||||
docTypes: string[]
|
||||
}
|
||||
|
||||
const docGateStatuses = ['FNOL Submitted', 'Investigation', 'Documentation Pending', 'Reserved', 'Negotiation', 'Settlement']
|
||||
const docTypes = ['FNOL Form', 'Police Report', 'Photos', 'Estimates', 'Medical Records', 'Proof of Loss', 'Settlement Letter']
|
||||
|
||||
const docGates = ref<Record<string, Record<string, boolean>>>({})
|
||||
|
||||
// Initialize doc gates
|
||||
for (const status of docGateStatuses) {
|
||||
docGates.value[status] = {}
|
||||
for (const doc of docTypes) {
|
||||
// Defaults: FNOL always required, Photos after investigation
|
||||
if (doc === 'FNOL Form') docGates.value[status][doc] = true
|
||||
else if (doc === 'Photos' && ['Investigation', 'Documentation Pending', 'Reserved', 'Negotiation', 'Settlement'].includes(status)) docGates.value[status][doc] = true
|
||||
else if (doc === 'Estimates' && ['Reserved', 'Negotiation', 'Settlement'].includes(status)) docGates.value[status][doc] = true
|
||||
else if (doc === 'Settlement Letter' && status === 'Settlement') docGates.value[status][doc] = true
|
||||
else docGates.value[status][doc] = false
|
||||
}
|
||||
}
|
||||
|
||||
function toggleDocGate(status: string, doc: string) {
|
||||
docGates.value[status][doc] = !docGates.value[status][doc]
|
||||
}
|
||||
|
||||
// ── Alert Thresholds ──────────────────────────────────────────────────────────
|
||||
const alertThresholds = reactive({
|
||||
reserveIncreasePct: 25,
|
||||
ageDays: 30,
|
||||
carrierNonResponseDays: 5,
|
||||
documentOverdueDays: 7,
|
||||
})
|
||||
|
||||
// ── Form Templates ────────────────────────────────────────────────────────────
|
||||
interface FormTemplate {
|
||||
id: string
|
||||
name: string
|
||||
carrier: string
|
||||
lob: string
|
||||
active: boolean
|
||||
}
|
||||
|
||||
const formTemplates = ref<FormTemplate[]>([
|
||||
{ id: 'ft-1', name: 'Informe de Accidente', carrier: 'ASSA', lob: 'Auto', active: true },
|
||||
{ id: 'ft-2', name: 'Informe de Accidente', carrier: 'Qualitas', lob: 'Auto', active: true },
|
||||
{ id: 'ft-3', name: 'Aviso de Pérdida', carrier: 'ASSA', lob: 'General Risk', active: true },
|
||||
{ id: 'ft-4', name: 'Aviso de Pérdida', carrier: 'Mapfre', lob: 'General Risk', active: true },
|
||||
{ id: 'ft-5', name: 'Reclamos Médicos', carrier: 'Pan-American Life', lob: 'Life', active: true },
|
||||
{ id: 'ft-6', name: 'Reclamos Médicos', carrier: 'ASSA', lob: 'Life', active: false },
|
||||
])
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="cs-page">
|
||||
<!-- Header -->
|
||||
<div class="cs-header">
|
||||
<div>
|
||||
<NuxtLink to="/claims" class="cs-back-link">
|
||||
<UIcon name="i-heroicons-arrow-left" class="w-3.5 h-3.5" />
|
||||
Back to Claims
|
||||
</NuxtLink>
|
||||
<h1 class="cs-title">Claims Settings</h1>
|
||||
<p class="cs-subtitle">Configure SLA rules, escalation tiers, required documents, and alert thresholds.</p>
|
||||
</div>
|
||||
<button class="cs-save-btn">
|
||||
<UIcon name="i-heroicons-check" class="w-4 h-4" />
|
||||
Save Changes
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- ═══ Section 1: SLA Rule Builder ═══ -->
|
||||
<div class="cs-card">
|
||||
<div class="cs-card-header">
|
||||
<UIcon name="i-heroicons-clock" class="w-5 h-5" />
|
||||
<div>
|
||||
<h2 class="cs-card-title">SLA Rule Builder</h2>
|
||||
<p class="cs-card-desc">Set target resolution days per line of business. Escalation tiers auto-compute from percentages.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="cs-table-wrap">
|
||||
<table class="cs-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Line of Business</th>
|
||||
<th>Target Days</th>
|
||||
<th>Tier 1 ({{ slaRules[0]?.tier1Pct ?? 50 }}%)</th>
|
||||
<th>Tier 2 ({{ slaRules[0]?.tier2Pct ?? 75 }}%)</th>
|
||||
<th>Tier 3 ({{ slaRules[0]?.tier3Pct ?? 100 }}%)</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="rule in slaRules" :key="rule.lob">
|
||||
<td class="cs-td-bold">{{ rule.lob }}</td>
|
||||
<td>
|
||||
<input v-model.number="rule.targetDays" type="number" min="1" max="365" class="cs-input-sm" />
|
||||
</td>
|
||||
<td class="cs-td-computed">{{ tierDays(rule, rule.tier1Pct) }} days</td>
|
||||
<td class="cs-td-computed">{{ tierDays(rule, rule.tier2Pct) }} days</td>
|
||||
<td class="cs-td-computed cs-td-red">{{ tierDays(rule, rule.tier3Pct) }} days</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ═══ Section 2: Escalation Tiers ═══ -->
|
||||
<div class="cs-card">
|
||||
<div class="cs-card-header">
|
||||
<UIcon name="i-heroicons-bell-alert" class="w-5 h-5" />
|
||||
<div>
|
||||
<h2 class="cs-card-title">Escalation Tiers</h2>
|
||||
<p class="cs-card-desc">Actions triggered when SLA thresholds are reached.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="cs-escalation-list">
|
||||
<div v-for="(tier, idx) in escalationTiers" :key="idx" class="cs-escalation-row">
|
||||
<div class="cs-escalation-dot" :class="idx === 0 ? 'cs-dot-green' : idx === 1 ? 'cs-dot-amber' : 'cs-dot-red'" />
|
||||
<div class="cs-escalation-content">
|
||||
<span class="cs-escalation-threshold">{{ tier.threshold }}</span>
|
||||
<div class="cs-escalation-fields">
|
||||
<div class="cs-field-inline">
|
||||
<label class="cs-label-sm">Action</label>
|
||||
<input v-model="tier.action" type="text" class="cs-input-med" />
|
||||
</div>
|
||||
<div class="cs-field-inline">
|
||||
<label class="cs-label-sm">Notify</label>
|
||||
<input v-model="tier.notify" type="text" class="cs-input-med" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ═══ Section 3: Required Document Gates ═══ -->
|
||||
<div class="cs-card">
|
||||
<div class="cs-card-header">
|
||||
<UIcon name="i-heroicons-folder-open" class="w-5 h-5" />
|
||||
<div>
|
||||
<h2 class="cs-card-title">Required Document Gates</h2>
|
||||
<p class="cs-card-desc">Check which documents are required at each carrier status stage. Missing docs generate tasks automatically.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="cs-table-wrap cs-matrix-wrap">
|
||||
<table class="cs-table cs-matrix">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Status ↓ / Doc →</th>
|
||||
<th v-for="doc in docTypes" :key="doc" class="cs-th-rotated">
|
||||
<span>{{ doc }}</span>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="status in docGateStatuses" :key="status">
|
||||
<td class="cs-td-bold">{{ status }}</td>
|
||||
<td v-for="doc in docTypes" :key="doc" class="cs-td-check" @click="toggleDocGate(status, doc)">
|
||||
<span v-if="docGates[status][doc]" class="cs-check-on">✓</span>
|
||||
<span v-else class="cs-check-off">·</span>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ═══ Section 4: Alert Thresholds ═══ -->
|
||||
<div class="cs-card">
|
||||
<div class="cs-card-header">
|
||||
<UIcon name="i-heroicons-exclamation-triangle" class="w-5 h-5" />
|
||||
<div>
|
||||
<h2 class="cs-card-title">Alert Thresholds</h2>
|
||||
<p class="cs-card-desc">Configure when the system flags claims for attention.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="cs-alert-grid">
|
||||
<div class="cs-alert-item">
|
||||
<label class="cs-label">Reserve Increase Trigger</label>
|
||||
<div class="cs-input-group">
|
||||
<input v-model.number="alertThresholds.reserveIncreasePct" type="number" min="1" max="100" class="cs-input-sm" />
|
||||
<span class="cs-input-suffix">% increase</span>
|
||||
</div>
|
||||
<p class="cs-alert-help">Alert when reserve changes by more than this percentage.</p>
|
||||
</div>
|
||||
<div class="cs-alert-item">
|
||||
<label class="cs-label">Claim Age Warning</label>
|
||||
<div class="cs-input-group">
|
||||
<input v-model.number="alertThresholds.ageDays" type="number" min="1" max="365" class="cs-input-sm" />
|
||||
<span class="cs-input-suffix">days</span>
|
||||
</div>
|
||||
<p class="cs-alert-help">Highlight claims older than this threshold.</p>
|
||||
</div>
|
||||
<div class="cs-alert-item">
|
||||
<label class="cs-label">Carrier Non-Response</label>
|
||||
<div class="cs-input-group">
|
||||
<input v-model.number="alertThresholds.carrierNonResponseDays" type="number" min="1" max="30" class="cs-input-sm" />
|
||||
<span class="cs-input-suffix">days</span>
|
||||
</div>
|
||||
<p class="cs-alert-help">Suggest escalation when carrier hasn't responded.</p>
|
||||
</div>
|
||||
<div class="cs-alert-item">
|
||||
<label class="cs-label">Document Overdue</label>
|
||||
<div class="cs-input-group">
|
||||
<input v-model.number="alertThresholds.documentOverdueDays" type="number" min="1" max="30" class="cs-input-sm" />
|
||||
<span class="cs-input-suffix">days</span>
|
||||
</div>
|
||||
<p class="cs-alert-help">Flag overdue required documents after this many days.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ═══ Section 5: Carrier Form Templates ═══ -->
|
||||
<div class="cs-card">
|
||||
<div class="cs-card-header">
|
||||
<UIcon name="i-heroicons-document-duplicate" class="w-5 h-5" />
|
||||
<div>
|
||||
<h2 class="cs-card-title">Carrier Form Templates</h2>
|
||||
<p class="cs-card-desc">Manage which carrier-specific forms are available for generation. Government forms (FUD) are not managed here.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="cs-table-wrap">
|
||||
<table class="cs-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Form Name</th>
|
||||
<th>Carrier</th>
|
||||
<th>LOB</th>
|
||||
<th>Active</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="ft in formTemplates" :key="ft.id">
|
||||
<td class="cs-td-bold">{{ ft.name }}</td>
|
||||
<td>{{ ft.carrier }}</td>
|
||||
<td>{{ ft.lob }}</td>
|
||||
<td>
|
||||
<button
|
||||
class="cs-toggle"
|
||||
:class="ft.active ? 'cs-toggle-on' : 'cs-toggle-off'"
|
||||
@click="ft.active = !ft.active"
|
||||
>
|
||||
<span class="cs-toggle-dot" />
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* =====================================================================
|
||||
CLAIMS SETTINGS — scoped, cs- prefix
|
||||
===================================================================== */
|
||||
|
||||
.cs-page {
|
||||
max-width: 64rem; margin: 0 auto;
|
||||
display: flex; flex-direction: column; gap: 24px; padding-bottom: 48px;
|
||||
}
|
||||
|
||||
/* ── Header ── */
|
||||
.cs-header { display: flex; align-items: flex-start; justify-content: space-between; gap: 16px; flex-wrap: wrap; }
|
||||
.cs-back-link {
|
||||
display: inline-flex; align-items: center; gap: 5px;
|
||||
font-size: 12px; font-weight: 500; color: #8a8a86;
|
||||
text-decoration: none; margin-bottom: 8px; transition: color 150ms ease;
|
||||
}
|
||||
.cs-back-link:hover { color: #01696f; }
|
||||
.cs-title { font-size: 22px; font-weight: 700; color: #1a1a1a; }
|
||||
.cs-subtitle { font-size: 13px; color: #8a8a86; margin-top: 4px; }
|
||||
.cs-save-btn {
|
||||
display: inline-flex; align-items: center; gap: 6px; padding: 8px 18px;
|
||||
border-radius: 10px; font-size: 13px; font-weight: 600;
|
||||
background: #01696f; color: white; border: none; cursor: pointer;
|
||||
}
|
||||
.cs-save-btn:hover { opacity: 0.9; }
|
||||
|
||||
/* ── Card ── */
|
||||
.cs-card {
|
||||
background: #fff; border: 1px solid rgba(0,0,0,0.06);
|
||||
border-radius: 12px; padding: 20px; box-shadow: 0 1px 3px rgba(0,0,0,0.03);
|
||||
}
|
||||
.cs-card-header { display: flex; align-items: flex-start; gap: 12px; margin-bottom: 16px; color: #01696f; }
|
||||
.cs-card-title { font-size: 16px; font-weight: 700; color: #1a1a1a; }
|
||||
.cs-card-desc { font-size: 12px; color: #8a8a86; margin-top: 2px; }
|
||||
|
||||
/* ── Table ── */
|
||||
.cs-table-wrap { overflow-x: auto; }
|
||||
.cs-table { width: 100%; border-collapse: collapse; font-size: 13px; }
|
||||
.cs-table thead th {
|
||||
padding: 8px 12px; font-size: 10px; font-weight: 600;
|
||||
text-transform: uppercase; letter-spacing: 0.04em;
|
||||
color: #8a8a86; border-bottom: 1px solid rgba(0,0,0,0.06);
|
||||
text-align: left; white-space: nowrap;
|
||||
}
|
||||
.cs-table tbody td {
|
||||
padding: 10px 12px; border-bottom: 1px solid rgba(0,0,0,0.04);
|
||||
vertical-align: middle;
|
||||
}
|
||||
.cs-table tbody tr:last-child td { border-bottom: none; }
|
||||
.cs-td-bold { font-weight: 600; }
|
||||
.cs-td-computed { color: #5c5650; font-variant-numeric: tabular-nums; }
|
||||
.cs-td-red { color: #c13838; font-weight: 600; }
|
||||
|
||||
/* ── Inputs ── */
|
||||
.cs-input-sm {
|
||||
width: 72px; padding: 5px 8px; border: 1px solid rgba(0,0,0,0.1);
|
||||
border-radius: 6px; font-size: 13px; text-align: center;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.cs-input-sm:focus { outline: none; border-color: #01696f; }
|
||||
.cs-input-med {
|
||||
flex: 1; padding: 5px 10px; border: 1px solid rgba(0,0,0,0.1);
|
||||
border-radius: 6px; font-size: 13px;
|
||||
}
|
||||
.cs-input-med:focus { outline: none; border-color: #01696f; }
|
||||
.cs-input-group { display: flex; align-items: center; gap: 6px; }
|
||||
.cs-input-suffix { font-size: 12px; color: #8a8a86; }
|
||||
.cs-label { display: block; font-size: 13px; font-weight: 600; color: #1a1a1a; margin-bottom: 4px; }
|
||||
.cs-label-sm { font-size: 10px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.03em; color: #8a8a86; margin-bottom: 2px; }
|
||||
|
||||
/* ── Escalation ── */
|
||||
.cs-escalation-list { display: flex; flex-direction: column; gap: 16px; }
|
||||
.cs-escalation-row { display: flex; align-items: flex-start; gap: 12px; }
|
||||
.cs-escalation-dot { width: 10px; height: 10px; border-radius: 50%; margin-top: 6px; flex-shrink: 0; }
|
||||
.cs-dot-green { background: #059669; }
|
||||
.cs-dot-amber { background: #c27b1a; }
|
||||
.cs-dot-red { background: #c13838; }
|
||||
.cs-escalation-content { flex: 1; }
|
||||
.cs-escalation-threshold { font-size: 14px; font-weight: 700; display: block; margin-bottom: 8px; }
|
||||
.cs-escalation-fields { display: flex; gap: 12px; flex-wrap: wrap; }
|
||||
.cs-field-inline { display: flex; flex-direction: column; flex: 1; min-width: 180px; }
|
||||
|
||||
/* ── Document Matrix ── */
|
||||
.cs-matrix-wrap { max-height: 500px; }
|
||||
.cs-matrix th, .cs-matrix td { text-align: center; }
|
||||
.cs-matrix td:first-child, .cs-matrix th:first-child { text-align: left; }
|
||||
.cs-th-rotated span {
|
||||
writing-mode: vertical-lr; transform: rotate(180deg);
|
||||
font-size: 10px; white-space: nowrap;
|
||||
}
|
||||
.cs-td-check { cursor: pointer; padding: 6px 8px !important; }
|
||||
.cs-td-check:hover { background: rgba(1, 105, 111, 0.04); }
|
||||
.cs-check-on { color: #01696f; font-weight: 700; font-size: 16px; }
|
||||
.cs-check-off { color: rgba(0,0,0,0.15); font-size: 20px; }
|
||||
|
||||
/* ── Alert Grid ── */
|
||||
.cs-alert-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 20px; }
|
||||
.cs-alert-item { }
|
||||
.cs-alert-help { font-size: 11px; color: #8a8a86; margin-top: 4px; }
|
||||
|
||||
/* ── Toggle ── */
|
||||
.cs-toggle {
|
||||
width: 36px; height: 20px; border-radius: 10px; border: none;
|
||||
cursor: pointer; position: relative; transition: background 200ms ease;
|
||||
padding: 0;
|
||||
}
|
||||
.cs-toggle-on { background: #01696f; }
|
||||
.cs-toggle-off { background: rgba(0,0,0,0.15); }
|
||||
.cs-toggle-dot {
|
||||
display: block; width: 16px; height: 16px; border-radius: 50%;
|
||||
background: white; position: absolute; top: 2px;
|
||||
transition: left 200ms ease;
|
||||
}
|
||||
.cs-toggle-on .cs-toggle-dot { left: 18px; }
|
||||
.cs-toggle-off .cs-toggle-dot { left: 2px; }
|
||||
|
||||
/* ── Responsive ── */
|
||||
@media (max-width: 640px) {
|
||||
.cs-alert-grid { grid-template-columns: 1fr; }
|
||||
.cs-escalation-fields { flex-direction: column; }
|
||||
}
|
||||
</style>
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,43 +1,24 @@
|
||||
<script setup lang="ts">
|
||||
import { refDebounced } from '@vueuse/core'
|
||||
import { MOCK_CUSTOMERS, fmtMoney } from '~/data/mock-customers'
|
||||
const { isFavorite, toggleFavorite } = useClientFavorites()
|
||||
usePageTitle('Customers')
|
||||
|
||||
const page = ref(1)
|
||||
const search = ref('')
|
||||
const customerTypeFilter = ref<string | null>(null)
|
||||
const agentFilter = ref<string | null>(null)
|
||||
const paymentFilter = ref<string | null>(null)
|
||||
const sortBy = ref<string>('name_asc')
|
||||
const viewMode = ref<'card' | 'list'>('card')
|
||||
const debouncedSearch = refDebounced(search, 300)
|
||||
|
||||
const customerTypeItems = [
|
||||
{ label: 'All Types', value: null },
|
||||
{ label: 'Individual', value: 'individual' },
|
||||
{ label: 'Corporate', value: 'corporate' }
|
||||
]
|
||||
|
||||
const agentItems = [
|
||||
{ label: 'All Agents', value: null },
|
||||
...([...new Set(MOCK_CUSTOMERS.map(c => c.agent))].sort().map(a => ({ label: a, value: a })))
|
||||
]
|
||||
|
||||
const paymentItems = [
|
||||
{ label: 'All Payments', value: null },
|
||||
{ label: 'Current', value: 'Current' },
|
||||
{ label: 'Overdue', value: 'Overdue' },
|
||||
{ label: 'Grace period', value: 'Grace period' },
|
||||
{ label: 'N/A', value: 'N/A' },
|
||||
{ label: 'All Types', value: null },
|
||||
{ label: 'Individual', value: 'individual' },
|
||||
{ label: 'Corporate', value: 'corporate' }
|
||||
]
|
||||
|
||||
const sortItems = [
|
||||
{ label: 'Name (A–Z)', value: 'name_asc' },
|
||||
{ label: 'Premium (high–low)', value: 'premium_desc' },
|
||||
{ label: 'Policies (most)', value: 'policies_desc' },
|
||||
{ label: 'Name (A–Z)', value: 'name_asc' }
|
||||
]
|
||||
|
||||
watch([debouncedSearch, customerTypeFilter, agentFilter, paymentFilter], () => { page.value = 1 })
|
||||
watch([debouncedSearch, customerTypeFilter], () => { page.value = 1 })
|
||||
|
||||
const { data, pending, refresh } = useCustomer('/customers', {
|
||||
query: computed(() => {
|
||||
@@ -46,120 +27,50 @@ const { data, pending, refresh } = useCustomer('/customers', {
|
||||
|
||||
if (debouncedSearch.value) {
|
||||
filters[`filters[${i}][field]`] = 'search'
|
||||
filters[`filters[${i}][op]`] = '=='
|
||||
filters[`filters[${i}][op]`] = '=='
|
||||
filters[`filters[${i}][value]`] = debouncedSearch.value
|
||||
i++
|
||||
}
|
||||
|
||||
if (customerTypeFilter.value) {
|
||||
filters[`filters[${i}][field]`] = 'customer_type'
|
||||
filters[`filters[${i}][op]`] = '=='
|
||||
filters[`filters[${i}][op]`] = '=='
|
||||
filters[`filters[${i}][value]`] = customerTypeFilter.value
|
||||
i++
|
||||
}
|
||||
|
||||
return {
|
||||
'page[number]': page.value,
|
||||
'page[size]': 20,
|
||||
'page_size': 20,
|
||||
'page': page.value,
|
||||
...filters
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
/* ── Mock fallback rows (shown when API returns nothing) ── */
|
||||
const allMockRows = MOCK_CUSTOMERS.map((m) => ({
|
||||
id: m.id,
|
||||
customer_type: m.type.toLowerCase(),
|
||||
first_name: m.name.split(' ')[0],
|
||||
last_name: m.name.split(' ').slice(1).join(' '),
|
||||
commercial_name: null,
|
||||
legal_name: null,
|
||||
ruc: null,
|
||||
email: m.email,
|
||||
phone: m.phone,
|
||||
birth_date: m.birthDate,
|
||||
gender: m.gender.toLowerCase(),
|
||||
legal_rep_name: null,
|
||||
document_id: m.documentId,
|
||||
_mock: m
|
||||
}))
|
||||
const customers = computed(() => data.value?.data ?? [])
|
||||
const meta = computed(() => data.value?.meta)
|
||||
|
||||
const mockRows = computed(() => {
|
||||
let rows = allMockRows
|
||||
// Filter by search
|
||||
const q = debouncedSearch.value.toLowerCase().trim()
|
||||
if (q) {
|
||||
rows = rows.filter(r => {
|
||||
const name = `${r.first_name} ${r.last_name}`.toLowerCase()
|
||||
return name.includes(q)
|
||||
|| (r.email ?? '').toLowerCase().includes(q)
|
||||
|| (r.phone ?? '').includes(q)
|
||||
|| (r.document_id ?? '').toLowerCase().includes(q)
|
||||
|| (r._mock.agent ?? '').toLowerCase().includes(q)
|
||||
|| r._mock.tags.some(t => t.toLowerCase().includes(q))
|
||||
})
|
||||
}
|
||||
// Filter by type
|
||||
if (customerTypeFilter.value) {
|
||||
rows = rows.filter(r => r.customer_type === customerTypeFilter.value)
|
||||
}
|
||||
// Filter by agent
|
||||
if (agentFilter.value) {
|
||||
rows = rows.filter(r => r._mock.agent === agentFilter.value)
|
||||
}
|
||||
// Filter by payment status
|
||||
if (paymentFilter.value) {
|
||||
rows = rows.filter(r => r._mock.paymentStatus === paymentFilter.value)
|
||||
}
|
||||
// Sort
|
||||
const sorted = [...rows]
|
||||
switch (sortBy.value) {
|
||||
case 'name_asc':
|
||||
sorted.sort((a, b) => `${a.first_name} ${a.last_name}`.localeCompare(`${b.first_name} ${b.last_name}`))
|
||||
break
|
||||
case 'premium_desc':
|
||||
sorted.sort((a, b) => b._mock.policies.reduce((s: number, p: any) => s + p.premium, 0) - a._mock.policies.reduce((s: number, p: any) => s + p.premium, 0))
|
||||
break
|
||||
case 'policies_desc':
|
||||
sorted.sort((a, b) => b._mock.policies.length - a._mock.policies.length)
|
||||
break
|
||||
}
|
||||
return sorted
|
||||
})
|
||||
|
||||
const apiCustomers = computed(() => data.value?.data ?? [])
|
||||
const customers = computed(() => apiCustomers.value.length > 0 ? apiCustomers.value : mockRows.value)
|
||||
const meta = computed(() => data.value?.meta)
|
||||
|
||||
const usingMock = computed(() => apiCustomers.value.length === 0 && mockRows.value.length > 0)
|
||||
|
||||
// display helpers
|
||||
const customerName = (c: any) => {
|
||||
function customerName(c: any) {
|
||||
if (c.customer_type === 'corporate') return c.commercial_name || c.legal_name || 'Unnamed company'
|
||||
const full = [c.first_name, c.last_name].filter(Boolean).join(' ')
|
||||
return full || 'Unnamed customer'
|
||||
}
|
||||
|
||||
const customerSubtitle = (c: any) => {
|
||||
if (c._mock) {
|
||||
const m = c._mock
|
||||
const total = m.policies.reduce((s: number, p: any) => s + p.premium, 0)
|
||||
return `${m.policies.length} ${m.policies.length === 1 ? 'policy' : 'policies'} · ${fmtMoney(total)}/yr`
|
||||
}
|
||||
function customerSubtitle(c: any) {
|
||||
return c.customer_type === 'corporate' ? c.ruc : c.email
|
||||
}
|
||||
|
||||
const customerTypeColor = (type: string) =>
|
||||
type === 'corporate' ? 'purple' : 'blue'
|
||||
function customerTypeColor(type: string) {
|
||||
return type === 'corporate' ? 'purple' : 'blue'
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="ci-root mx-auto max-w-7xl pb-12">
|
||||
<!-- Header -->
|
||||
<div class="ci-header">
|
||||
<div class="ci-header-left">
|
||||
<h1 class="ci-title">Customers</h1>
|
||||
<span class="ci-count-badge">{{ usingMock ? customers.length : (meta?.total_count ?? 0) }}</span>
|
||||
<span class="ci-count-badge">{{ meta?.total_count ?? 0 }}</span>
|
||||
</div>
|
||||
<div class="ci-header-right">
|
||||
<NuxtLink to="/customers/new">
|
||||
@@ -171,17 +82,14 @@ const customerTypeColor = (type: string) =>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Filter bar -->
|
||||
<div class="ci-filter-bar">
|
||||
<UInput
|
||||
v-model="search"
|
||||
icon="i-heroicons-magnifying-glass"
|
||||
placeholder="Search by name, email, RUC, agent..."
|
||||
placeholder="Search by name, email, RUC..."
|
||||
class="ci-filter-search"
|
||||
/>
|
||||
<USelect v-model="customerTypeFilter" :items="customerTypeItems" class="ci-filter-select" />
|
||||
<USelect v-model="agentFilter" :items="agentItems" class="ci-filter-select" />
|
||||
<USelect v-model="paymentFilter" :items="paymentItems" class="ci-filter-select" />
|
||||
<USelect v-model="sortBy" :items="sortItems" class="ci-filter-select" />
|
||||
<UButton icon="i-heroicons-arrow-path" color="neutral" variant="ghost" size="sm" :loading="pending" @click="refresh()">Refresh</UButton>
|
||||
<div class="ci-view-toggle">
|
||||
@@ -194,15 +102,13 @@ const customerTypeColor = (type: string) =>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Loading -->
|
||||
<div v-if="pending && !usingMock" class="grid gap-6 md:grid-cols-2 lg:grid-cols-3">
|
||||
<div v-if="pending" class="grid gap-6 md:grid-cols-2 lg:grid-cols-3">
|
||||
<UCard v-for="n in 6" :key="n">
|
||||
<div class="h-32 animate-pulse bg-gray-200 rounded" />
|
||||
</UCard>
|
||||
</div>
|
||||
|
||||
<template v-else>
|
||||
<!-- ═══ Card View ═══ -->
|
||||
<div v-if="viewMode === 'card'" class="grid gap-6 md:grid-cols-2 lg:grid-cols-3">
|
||||
<NuxtLink v-for="c in customers" :key="c.id" :to="`/customers/${c.id}`">
|
||||
<UCard class="hover:shadow-md transition-shadow cursor-pointer h-full">
|
||||
@@ -215,48 +121,11 @@ const customerTypeColor = (type: string) =>
|
||||
<p class="text-sm text-gray-400 truncate">{{ customerSubtitle(c) }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-1.5 flex-shrink-0">
|
||||
<button
|
||||
type="button"
|
||||
class="w-6 h-6 rounded flex items-center justify-center transition-all"
|
||||
:class="isFavorite(c.id) ? 'text-amber-400 hover:text-amber-500' : 'text-gray-300 hover:text-amber-400'"
|
||||
title="Toggle favorite"
|
||||
@click.prevent.stop="toggleFavorite(c.id)"
|
||||
>
|
||||
<UIcon :name="isFavorite(c.id) ? 'i-heroicons-star-solid' : 'i-heroicons-star'" style="width: 16px; height: 16px;" />
|
||||
</button>
|
||||
<UBadge :color="customerTypeColor(c.customer_type)" variant="soft" size="xs">
|
||||
{{ c.customer_type === 'corporate' ? 'Corporate' : 'Individual' }}
|
||||
</UBadge>
|
||||
</div>
|
||||
<UBadge :color="customerTypeColor(c.customer_type)" variant="soft" size="xs">
|
||||
{{ c.customer_type === 'corporate' ? 'Corporate' : 'Individual' }}
|
||||
</UBadge>
|
||||
</div>
|
||||
|
||||
<!-- Mock client details -->
|
||||
<template v-if="c._mock">
|
||||
<div class="space-y-1 text-sm pt-2 border-t">
|
||||
<div class="flex justify-between">
|
||||
<span class="text-gray-500">Phone</span>
|
||||
<span>{{ c._mock.phone }}</span>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<span class="text-gray-500">Agent</span>
|
||||
<span>{{ c._mock.agent }}</span>
|
||||
</div>
|
||||
<div class="flex justify-between items-center">
|
||||
<span class="text-gray-500">Payment</span>
|
||||
<span
|
||||
class="text-xs font-medium px-1.5 py-0.5 rounded-full"
|
||||
:class="c._mock.paymentStatus === 'Current' ? 'bg-emerald-50 text-emerald-700' : c._mock.paymentStatus === 'Overdue' ? 'bg-rose-50 text-rose-700' : 'bg-amber-50 text-amber-700'"
|
||||
>{{ c._mock.paymentStatus }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="c._mock.tags.length" class="flex gap-1 flex-wrap pt-1">
|
||||
<span v-for="tag in c._mock.tags" :key="tag" class="text-[10px] px-1.5 py-0.5 rounded bg-gray-100 text-gray-500">{{ tag }}</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Individual fields (API) -->
|
||||
<template v-else>
|
||||
<div v-if="c.customer_type !== 'corporate'" class="space-y-1 text-sm pt-2 border-t">
|
||||
<div class="flex justify-between">
|
||||
<span class="text-gray-500">Phone</span>
|
||||
@@ -272,7 +141,6 @@ const customerTypeColor = (type: string) =>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Corporate fields (API) -->
|
||||
<div v-else class="space-y-1 text-sm pt-2 border-t">
|
||||
<div class="flex justify-between">
|
||||
<span class="text-gray-500">Legal Name</span>
|
||||
@@ -287,7 +155,6 @@ const customerTypeColor = (type: string) =>
|
||||
<span>{{ c.legal_rep_name ?? '—' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</UCard>
|
||||
</NuxtLink>
|
||||
@@ -299,7 +166,6 @@ const customerTypeColor = (type: string) =>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ═══ List View ═══ -->
|
||||
<div v-else class="ci-list-card">
|
||||
<table class="ci-list-table">
|
||||
<thead>
|
||||
@@ -308,16 +174,11 @@ const customerTypeColor = (type: string) =>
|
||||
<th class="ci-list-th">Type</th>
|
||||
<th class="ci-list-th">Email</th>
|
||||
<th class="ci-list-th">Phone</th>
|
||||
<th class="ci-list-th">Agent</th>
|
||||
<th class="ci-list-th" style="text-align: right;">Policies</th>
|
||||
<th class="ci-list-th" style="text-align: right;">Premium</th>
|
||||
<th class="ci-list-th">Payment</th>
|
||||
<th class="ci-list-th" style="width: 40px;" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-if="customers.length === 0">
|
||||
<td colspan="9" class="ci-list-empty">
|
||||
<td colspan="4" class="ci-list-empty">
|
||||
<UIcon name="i-heroicons-users" class="w-10 h-10 mx-auto mb-3" />
|
||||
<p class="text-base font-medium">No customers found</p>
|
||||
<p class="text-sm">Try adjusting your search or create a new customer</p>
|
||||
@@ -344,32 +205,6 @@ const customerTypeColor = (type: string) =>
|
||||
</td>
|
||||
<td class="ci-list-td ci-list-td--secondary">{{ c.email ?? '—' }}</td>
|
||||
<td class="ci-list-td ci-list-td--secondary">{{ c.phone ?? '—' }}</td>
|
||||
<td class="ci-list-td ci-list-td--secondary">{{ c._mock ? c._mock.agent : '—' }}</td>
|
||||
<td class="ci-list-td" style="text-align: right;">
|
||||
{{ c._mock ? c._mock.policies.length : '—' }}
|
||||
</td>
|
||||
<td class="ci-list-td" style="text-align: right; font-variant-numeric: tabular-nums;">
|
||||
{{ c._mock ? fmtMoney(c._mock.policies.reduce((s: number, p: any) => s + p.premium, 0)) + '/yr' : '—' }}
|
||||
</td>
|
||||
<td class="ci-list-td">
|
||||
<span
|
||||
v-if="c._mock"
|
||||
class="text-xs font-medium px-1.5 py-0.5 rounded-full"
|
||||
:class="c._mock.paymentStatus === 'Current' ? 'bg-emerald-50 text-emerald-700' : c._mock.paymentStatus === 'Overdue' ? 'bg-rose-50 text-rose-700' : 'bg-amber-50 text-amber-700'"
|
||||
>{{ c._mock.paymentStatus }}</span>
|
||||
<span v-else class="ci-list-td--secondary">—</span>
|
||||
</td>
|
||||
<td class="ci-list-td" style="text-align: center;">
|
||||
<button
|
||||
type="button"
|
||||
class="w-6 h-6 rounded flex items-center justify-center transition-all"
|
||||
:class="isFavorite(c.id) ? 'text-amber-400 hover:text-amber-500' : 'text-gray-300 hover:text-amber-400'"
|
||||
title="Toggle favorite"
|
||||
@click.prevent.stop="toggleFavorite(c.id)"
|
||||
>
|
||||
<UIcon :name="isFavorite(c.id) ? 'i-heroicons-star-solid' : 'i-heroicons-star'" style="width: 14px; height: 14px;" />
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
</NuxtLink>
|
||||
</tbody>
|
||||
@@ -388,7 +223,6 @@ const customerTypeColor = (type: string) =>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* ── Header ── */
|
||||
.ci-root { display: flex; flex-direction: column; gap: 16px; }
|
||||
.ci-header { display: flex; align-items: center; justify-content: space-between; }
|
||||
.ci-header-left { display: flex; align-items: center; gap: 10px; }
|
||||
@@ -398,12 +232,10 @@ const customerTypeColor = (type: string) =>
|
||||
.ci-btn-primary { display: inline-flex; align-items: center; gap: 6px; padding: 7px 14px; font-size: 12px; font-weight: 600; color: #fff; background: #01696f; border: none; border-radius: 8px; cursor: pointer; transition: background 0.15s; }
|
||||
.ci-btn-primary:hover { background: #015258; }
|
||||
|
||||
/* ── Filter bar ── */
|
||||
.ci-filter-bar { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
|
||||
.ci-filter-search { width: 260px; }
|
||||
.ci-filter-select { width: auto; min-width: 130px; }
|
||||
|
||||
/* ── View toggle ── */
|
||||
.ci-view-toggle {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
@@ -434,7 +266,6 @@ const customerTypeColor = (type: string) =>
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
|
||||
/* ── List view card ── */
|
||||
.ci-list-card {
|
||||
background: #fff;
|
||||
border-radius: 12px;
|
||||
|
||||
@@ -1,777 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import type { WelcomeDashboardKpi } from '~/types/welcome-dashboard'
|
||||
import {
|
||||
DASHBOARD_PRESET_ORDER,
|
||||
DASHBOARD_ROLE_PRESETS,
|
||||
DASHBOARD_WIDGETS,
|
||||
type DashboardRolePresetId,
|
||||
type DashboardWidgetId
|
||||
} from '~/composables/useDashboardHomeWidgets'
|
||||
|
||||
const { saved: homeBranding } = useBrokerageBranding()
|
||||
const welcome = useWelcomeDashboard()
|
||||
|
||||
const {
|
||||
widgets,
|
||||
widgetOrder,
|
||||
layoutUnlocked,
|
||||
activePreset,
|
||||
isPresetDirty,
|
||||
applyPreset,
|
||||
setWidget,
|
||||
reapplySelectedPreset,
|
||||
reorderWidgets
|
||||
} = useDashboardHomeWidgets()
|
||||
|
||||
const dashConfigOpen = ref(false)
|
||||
const draggingWidget = ref<DashboardWidgetId | null>(null)
|
||||
|
||||
function onDragStart(wid: DashboardWidgetId, e: DragEvent) {
|
||||
if (!layoutUnlocked.value) { e.preventDefault(); return }
|
||||
draggingWidget.value = wid
|
||||
try { e.dataTransfer?.setData('text/plain', wid); if (e.dataTransfer) e.dataTransfer.effectAllowed = 'move' } catch { /* */ }
|
||||
}
|
||||
function onDragEnd() { draggingWidget.value = null }
|
||||
function onDropSection(target: DashboardWidgetId, e: DragEvent) {
|
||||
e.preventDefault()
|
||||
if (!layoutUnlocked.value) return
|
||||
const raw = draggingWidget.value ?? e.dataTransfer?.getData('text/plain')
|
||||
const from = raw as DashboardWidgetId | undefined
|
||||
if (!from || from === target) return
|
||||
reorderWidgets(from, target)
|
||||
draggingWidget.value = null
|
||||
}
|
||||
function toggleLayoutUnlock() { layoutUnlocked.value = !layoutUnlocked.value }
|
||||
|
||||
const presetSelectItems = computed(() =>
|
||||
DASHBOARD_PRESET_ORDER.map((id) => ({ label: DASHBOARD_ROLE_PRESETS[id].label, value: id }))
|
||||
)
|
||||
const activePresetHint = computed(() => DASHBOARD_ROLE_PRESETS[activePreset.value]?.hint ?? '')
|
||||
|
||||
watch(() => welcome.value.productName, (name) => {
|
||||
if (typeof document !== 'undefined') document.title = name ? `Home \u00b7 ${name}` : 'Home'
|
||||
}, { immediate: true })
|
||||
|
||||
/* ---- sparkline helpers ---- */
|
||||
const kpiSparkSeries: Record<string, number[]> = {
|
||||
ms: [42, 44, 41, 46, 48, 45, 49, 52, 50, 54, 53, 56],
|
||||
mr: [58, 59, 57, 61, 62, 60, 63, 65, 64, 67, 66, 68],
|
||||
ren: [72, 70, 74, 73, 75, 76, 74, 77, 78, 76, 79, 80],
|
||||
late: [55, 52, 54, 50, 48, 47, 45, 44, 43, 42, 40, 38]
|
||||
}
|
||||
|
||||
function smoothSparklinePath(points: number[], w = 112, h = 32, pad = 2) {
|
||||
const max = Math.max(...points); const min = Math.min(...points); const r = max - min || 1
|
||||
const pts = points.map((p, i, arr) => ({
|
||||
x: pad + (i / Math.max(1, arr.length - 1)) * (w - pad * 2),
|
||||
y: pad + (1 - (p - min) / r) * (h - pad * 2)
|
||||
}))
|
||||
if (pts.length < 2) return ''
|
||||
let d = `M ${pts[0]!.x},${pts[0]!.y}`
|
||||
for (let i = 0; i < pts.length - 1; i++) {
|
||||
const p0 = pts[i]!; const p1 = pts[i + 1]!; const cx = (p0.x + p1.x) / 2
|
||||
d += ` C ${cx},${p0.y} ${cx},${p1.y} ${p1.x},${p1.y}`
|
||||
}
|
||||
return d
|
||||
}
|
||||
function smoothSparklineArea(points: number[], w = 112, h = 32, pad = 2) {
|
||||
const path = smoothSparklinePath(points, w, h, pad)
|
||||
if (!path) return ''
|
||||
const pts = points.map((p, i, arr) => ({
|
||||
x: pad + (i / Math.max(1, arr.length - 1)) * (w - pad * 2),
|
||||
y: pad + (1 - (p - Math.min(...points)) / (Math.max(...points) - Math.min(...points) || 1)) * (h - pad * 2)
|
||||
}))
|
||||
return `${path} L ${pts[pts.length - 1]!.x},${h} L ${pts[0]!.x},${h} Z`
|
||||
}
|
||||
|
||||
/* ---- GWP chart ---- */
|
||||
const gwpTrend = [
|
||||
{ m: 'Oct', v: 72, display: '$4.52M' }, { m: 'Nov', v: 68, display: '$4.28M' },
|
||||
{ m: 'Dec', v: 76, display: '$4.71M' }, { m: 'Jan', v: 74, display: '$4.61M' },
|
||||
{ m: 'Feb', v: 81, display: '$4.98M' }, { m: 'Mar', v: 88, display: '$5.41M' }
|
||||
] as const
|
||||
const gwpChartLayout = { viewW: 400, viewH: 152, padX: 8, padY: 14 } as const
|
||||
const gwpChartModel = computed(() => {
|
||||
const pts = gwpTrend.map((b) => b.v)
|
||||
const { viewW, viewH, padX, padY } = gwpChartLayout
|
||||
const innerW = viewW - padX * 2; const innerH = viewH - padY * 2
|
||||
const maxV = Math.max(...pts, 1); const span = maxV || 1
|
||||
const points = pts.map((p, i) => ({
|
||||
x: padX + (i / Math.max(1, pts.length - 1)) * innerW,
|
||||
y: padY + (1 - p / span) * innerH, v: p
|
||||
}))
|
||||
const bottomY = padY + innerH; const first = points[0]!; const last = points[points.length - 1]!
|
||||
let areaD = `M ${first.x},${bottomY} L ${first.x},${first.y}`
|
||||
let lineD = `M ${first.x},${first.y}`
|
||||
for (let i = 1; i < points.length; i++) { areaD += ` L ${points[i]!.x},${points[i]!.y}`; lineD += ` L ${points[i]!.x},${points[i]!.y}` }
|
||||
areaD += ` L ${last.x},${bottomY} Z`
|
||||
const gridYs = [0, 0.5, 1].map((t) => padY + t * innerH)
|
||||
return { areaPath: areaD, linePath: lineD, points, gridYs, viewW, viewH, padX, innerW }
|
||||
})
|
||||
const gwpLatest = computed(() => gwpTrend[gwpTrend.length - 1]!)
|
||||
|
||||
/* ---- Pipeline data ---- */
|
||||
const QUOTED_PIPELINE_PREMIUM_M = 6.2
|
||||
const quotedPipelineSummaryCards = [
|
||||
{ label: 'Total book', value: '$42.8M', hint: 'In force' },
|
||||
{ label: 'Quoted pipeline', value: '$6.2M', hint: 'Open quotes' },
|
||||
{ label: 'YTD new sales', value: '$18.4M', hint: 'Bound new biz' }
|
||||
] as const
|
||||
const pipelineMixSegments = [
|
||||
{ label: 'Commercial', pct: 38 }, { label: 'Personal', pct: 29 },
|
||||
{ label: 'Benefits', pct: 22 }, { label: 'Other', pct: 11 }
|
||||
] as const
|
||||
const pipelineMixRows = computed(() =>
|
||||
pipelineMixSegments.map((row) => ({ ...row, premiumM: (QUOTED_PIPELINE_PREMIUM_M * row.pct) / 100 }))
|
||||
)
|
||||
function formatPremiumM(n: number) { return `$${n.toFixed(2)}M` }
|
||||
|
||||
/* ---- Tone helpers ---- */
|
||||
function changeToneClass(tone: WelcomeDashboardKpi['changeTone']) {
|
||||
switch (tone) {
|
||||
case 'positive': return 'h2-tone-pos'
|
||||
case 'negative': return 'h2-tone-neg'
|
||||
default: return 'h2-tone-neutral'
|
||||
}
|
||||
}
|
||||
|
||||
type AlertToneMeta = { icon: string; label: string; railStyle: string; iconColor: string; bg: string }
|
||||
function alertToneMeta(tone: string): AlertToneMeta {
|
||||
switch (tone) {
|
||||
case 'error': return { icon: 'i-heroicons-exclamation-circle', label: 'Critical', railStyle: 'background:#c13838', iconColor: 'text-rose-600', bg: 'bg-rose-50/60' }
|
||||
case 'warning': return { icon: 'i-heroicons-exclamation-triangle', label: 'Attention', railStyle: 'background:#c27b1a', iconColor: 'text-amber-600', bg: 'bg-amber-50/60' }
|
||||
case 'success': return { icon: 'i-heroicons-check-circle', label: 'Update', railStyle: 'background:#0f7b5f', iconColor: 'text-emerald-700', bg: 'bg-emerald-50/60' }
|
||||
default: return { icon: 'i-heroicons-information-circle', label: 'Notice', railStyle: 'background:#8c857d', iconColor: 'text-stone-500', bg: 'bg-stone-100/60' }
|
||||
}
|
||||
}
|
||||
const alertsWithMeta = computed(() => welcome.value.alerts.map((a) => ({ ...a, meta: alertToneMeta(a.tone) })))
|
||||
|
||||
/* ---- Operations command bar data ---- */
|
||||
const opsMetrics = [
|
||||
{ id: 'prod', label: 'Production MTD', value: '$1.24M', target: '$1.18M', status: 'on-track' as const, icon: 'i-heroicons-banknotes' },
|
||||
{ id: 'ren', label: 'Renewals due', value: '23', target: 'next 30d', status: 'attention' as const, icon: 'i-heroicons-arrow-path' },
|
||||
{ id: 'coll', label: 'Collections at risk', value: '$184K', target: '3 accounts', status: 'warning' as const, icon: 'i-heroicons-exclamation-triangle' },
|
||||
{ id: 'claims', label: 'Claims pending', value: '7', target: 'avg 4.2d open', status: 'neutral' as const, icon: 'i-heroicons-shield-exclamation' },
|
||||
{ id: 'svc', label: 'Service backlog', value: '12', target: 'SLA: 94%', status: 'on-track' as const, icon: 'i-heroicons-inbox-stack' }
|
||||
]
|
||||
|
||||
function opsStatusClass(status: 'on-track' | 'attention' | 'warning' | 'neutral') {
|
||||
switch (status) {
|
||||
case 'on-track': return 'h2-ops-on-track'
|
||||
case 'attention': return 'h2-ops-attention'
|
||||
case 'warning': return 'h2-ops-warning'
|
||||
default: return 'h2-ops-neutral'
|
||||
}
|
||||
}
|
||||
|
||||
function opsStatusDotStyle(status: 'on-track' | 'attention' | 'warning' | 'neutral') {
|
||||
switch (status) {
|
||||
case 'on-track': return 'background:#0f7b5f'
|
||||
case 'attention': return 'background:#0d5c63'
|
||||
case 'warning': return 'background:#c27b1a'
|
||||
default: return 'background:#8c857d'
|
||||
}
|
||||
}
|
||||
|
||||
/* ---- Quote lines ---- */
|
||||
const quoteLines = [
|
||||
{ to: '/quotes/auto', label: 'Auto', hint: 'Motor, fleet & bind', icon: 'i-heroicons-truck' },
|
||||
{ to: '/quotes/health', label: 'Health', hint: 'Collective & individual', icon: 'i-heroicons-heart' },
|
||||
{ to: '/quotes/life', label: 'Life', hint: 'Individual & corporate', icon: 'i-heroicons-user-group' },
|
||||
{ to: '/quotes/general-risk', label: 'General risk', hint: 'Liability & specialty', icon: 'i-heroicons-building-office-2' },
|
||||
{ to: '/quotes/custom', label: 'Custom', hint: 'Ad hoc products', icon: 'i-heroicons-puzzle-piece' }
|
||||
] as const
|
||||
|
||||
/* ---- Time ---- */
|
||||
const timeGreeting = computed(() => {
|
||||
const h = new Date().getHours()
|
||||
if (h < 12) return 'Good morning'
|
||||
if (h < 17) return 'Good afternoon'
|
||||
return 'Good evening'
|
||||
})
|
||||
const currentDate = computed(() =>
|
||||
new Intl.DateTimeFormat('en-US', { weekday: 'long', month: 'long', day: 'numeric' }).format(new Date())
|
||||
)
|
||||
|
||||
/* ---- Segment colors (petroleum palette) ---- */
|
||||
/* Segment colors as inline styles (Tailwind v4 doesn't resolve teal) */
|
||||
const segColorStyles = ['background:#0d5c63', 'background:#1a8a8a', 'background:#2dd4bf', 'background:#a8a29e']
|
||||
const segDotStyles = ['background:#0d5c63;outline:2px solid rgba(13,92,99,0.2);outline-offset:1px', 'background:#1a8a8a;outline:2px solid rgba(26,138,138,0.2);outline-offset:1px', 'background:#2dd4bf;outline:2px solid rgba(45,212,191,0.2);outline-offset:1px', 'background:#a8a29e;outline:2px solid rgba(168,162,158,0.2);outline-offset:1px']
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="h2 relative min-h-full pb-12">
|
||||
|
||||
<HomeDashboardWidgetBlocks
|
||||
:widget-order="widgetOrder"
|
||||
:widgets="widgets"
|
||||
:layout-unlocked="layoutUnlocked"
|
||||
:dragging-widget="draggingWidget"
|
||||
@drag-start="onDragStart"
|
||||
@drag-end="onDragEnd"
|
||||
@drop="onDropSection"
|
||||
>
|
||||
<!-- ==================== HERO: Operations Command Bar ==================== -->
|
||||
<template #hero>
|
||||
<div class="space-y-4">
|
||||
<!-- Slim greeting strip -->
|
||||
<div class="flex flex-wrap items-center justify-between gap-3 px-1">
|
||||
<div class="flex items-baseline gap-3">
|
||||
<h1 class="text-xl font-semibold tracking-tight text-[var(--h2-fg)]">
|
||||
{{ timeGreeting }}, {{ welcome.greetingName }}
|
||||
</h1>
|
||||
<span class="text-xs text-[var(--h2-muted)]">{{ currentDate }}</span>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<NuxtLink to="/onboarding">
|
||||
<UButton size="sm" color="neutral" variant="outline" class="h2-btn-outline" icon="i-heroicons-arrow-trending-up">
|
||||
Pipeline
|
||||
</UButton>
|
||||
</NuxtLink>
|
||||
<NuxtLink to="/quotes">
|
||||
<UButton size="sm" color="primary" class="h2-btn-primary" icon="i-heroicons-document-text">
|
||||
New quote
|
||||
</UButton>
|
||||
</NuxtLink>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Operations rail: 5-cell command strip -->
|
||||
<div class="h2-card h2-card-flush grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-5">
|
||||
<div
|
||||
v-for="(op, i) in opsMetrics"
|
||||
:key="op.id"
|
||||
class="relative flex items-start gap-3 p-4"
|
||||
:class="[
|
||||
i < opsMetrics.length - 1 ? 'h2-cell-border' : '',
|
||||
opsStatusClass(op.status)
|
||||
]"
|
||||
>
|
||||
<!-- Status rail (left edge accent) -->
|
||||
<div class="absolute inset-y-2 left-0 rounded-full" :style="opsStatusDotStyle(op.status) + ';width:3px'" />
|
||||
|
||||
<div class="pl-2.5">
|
||||
<div class="flex items-center gap-1.5">
|
||||
<UIcon :name="op.icon" class="h-3.5 w-3.5 text-[var(--h2-muted)]" />
|
||||
<p class="text-[11px] font-semibold uppercase tracking-wider text-[var(--h2-muted)]">{{ op.label }}</p>
|
||||
</div>
|
||||
<p class="mt-1 font-mono text-xl font-bold tabular-nums tracking-tight text-[var(--h2-fg)]">{{ op.value }}</p>
|
||||
<p class="mt-0.5 text-[11px] text-[var(--h2-muted)]">{{ op.target }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- ==================== MILESTONE ==================== -->
|
||||
<template #milestone>
|
||||
<div class="h2-card h2-rail-success flex flex-wrap items-center gap-4 px-5 py-3.5">
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="flex h-7 w-7 items-center justify-center rounded-lg bg-[var(--h2-success)]/10">
|
||||
<UIcon name="i-heroicons-check-badge" class="h-4 w-4 text-[var(--h2-success)]" />
|
||||
</div>
|
||||
<span class="text-sm font-semibold text-[var(--h2-success)]">On track</span>
|
||||
</div>
|
||||
<div class="h-4 w-px bg-[var(--h2-border)]" />
|
||||
<span class="text-sm text-[var(--h2-fg)]">
|
||||
Premium <strong class="font-semibold">$1.24M</strong> vs $1.18M
|
||||
</span>
|
||||
<div class="h-4 w-px bg-[var(--h2-border)] hidden sm:block" />
|
||||
<span class="text-sm text-[var(--h2-fg)] hidden sm:inline">
|
||||
Policies <strong class="font-semibold">42</strong> / 40
|
||||
</span>
|
||||
<span class="ml-auto text-[11px] font-medium uppercase tracking-wider text-[var(--h2-muted)]">MTD plan</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- ==================== PERFORMANCE KPIs ==================== -->
|
||||
<template #performance>
|
||||
<section class="space-y-4" aria-labelledby="perf-h2">
|
||||
<div class="h2-section-header">
|
||||
<h2 id="perf-h2" class="h2-section-title">Today at a glance</h2>
|
||||
<p class="h2-section-sub">Headline operational metrics</p>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-3 sm:grid-cols-2 xl:grid-cols-4">
|
||||
<div
|
||||
v-for="k in welcome.performanceKpis"
|
||||
:key="k.id"
|
||||
class="h2-card group h2-rail-accent overflow-hidden p-4 transition-all duration-200 hover:shadow-md"
|
||||
>
|
||||
<p class="text-[11px] font-semibold uppercase tracking-wider text-[var(--h2-muted)]">{{ k.label }}</p>
|
||||
<div class="mt-1.5 flex items-end gap-2.5">
|
||||
<p class="font-mono text-2xl font-bold tabular-nums tracking-tight text-[var(--h2-fg)]">
|
||||
{{ k.value }}
|
||||
</p>
|
||||
<p v-if="k.change" class="mb-0.5 text-xs font-semibold" :class="changeToneClass(k.changeTone)">
|
||||
{{ k.change }}
|
||||
</p>
|
||||
</div>
|
||||
<p v-if="k.hint" class="mt-1 text-[11px] leading-snug text-[var(--h2-muted)]">{{ k.hint }}</p>
|
||||
|
||||
<div class="mt-2.5 h-7 w-full">
|
||||
<svg class="h-full w-full" viewBox="0 0 112 32" fill="none" aria-hidden="true">
|
||||
<defs>
|
||||
<linearGradient :id="`sg2-${k.id}`" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" stop-color="var(--h2-accent)" stop-opacity="0.16" />
|
||||
<stop offset="100%" stop-color="var(--h2-accent)" stop-opacity="0" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<path v-if="kpiSparkSeries[k.id]" :d="smoothSparklineArea(kpiSparkSeries[k.id]!)" :fill="`url(#sg2-${k.id})`" />
|
||||
<path
|
||||
v-if="kpiSparkSeries[k.id]" :d="smoothSparklinePath(kpiSparkSeries[k.id]!)"
|
||||
fill="none" stroke="var(--h2-accent)" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"
|
||||
class="opacity-60 transition-opacity group-hover:opacity-100"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<!-- ==================== TASKS & ALERTS ==================== -->
|
||||
<template #tasks_alerts>
|
||||
<div class="grid gap-4 lg:grid-cols-2 lg:items-start">
|
||||
<!-- Tasks -->
|
||||
<div class="h2-card overflow-hidden">
|
||||
<div class="h2-card-header">
|
||||
<div class="h2-icon-box"><UIcon name="i-heroicons-clipboard-document-check" class="h-4 w-4" /></div>
|
||||
<div>
|
||||
<p class="text-sm font-semibold text-[var(--h2-fg)]">Today's tasks</p>
|
||||
<p class="text-[11px] text-[var(--h2-muted)]">{{ welcome.dailyTasks.length }} items prioritized</p>
|
||||
</div>
|
||||
</div>
|
||||
<ul class="h2-list">
|
||||
<li
|
||||
v-for="task in welcome.dailyTasks"
|
||||
:key="task.id"
|
||||
class="h2-list-item"
|
||||
>
|
||||
<div
|
||||
class="mt-1 h-2 w-2 shrink-0 rounded-full"
|
||||
:class="task.emphasis ? 'bg-[var(--h2-warning)]' : 'bg-[var(--h2-border)]'"
|
||||
/>
|
||||
<span
|
||||
class="text-[13px] leading-snug"
|
||||
:class="task.emphasis ? 'font-medium text-[var(--h2-fg)]' : 'text-[var(--h2-fg-secondary)]'"
|
||||
>{{ task.title }}</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<!-- Alerts -->
|
||||
<div class="h2-card overflow-hidden">
|
||||
<div class="h2-card-header">
|
||||
<div class="h2-icon-box h2-icon-box-error"><UIcon name="i-heroicons-bell-alert" class="h-4 w-4" /></div>
|
||||
<div>
|
||||
<p class="text-sm font-semibold text-[var(--h2-fg)]">Alerts</p>
|
||||
<p class="text-[11px] text-[var(--h2-muted)]">Exceptions needing attention</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="space-y-px px-3 pb-3">
|
||||
<div
|
||||
v-for="alert in alertsWithMeta"
|
||||
:key="alert.id"
|
||||
class="h2-alert-row"
|
||||
:class="alert.meta.bg"
|
||||
>
|
||||
<!-- Status rail -->
|
||||
<div class="absolute inset-y-1.5 left-0 rounded-full" :style="alert.meta.railStyle + ';width:3px'" />
|
||||
<UIcon :name="alert.meta.icon" class="mt-0.5 h-4 w-4 shrink-0 pl-2" :class="alert.meta.iconColor" />
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="text-[10px] font-bold uppercase tracking-wider" :class="alert.meta.iconColor">{{ alert.meta.label }}</p>
|
||||
<p class="mt-0.5 text-[13px] leading-snug text-[var(--h2-fg)]">{{ alert.message }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- ==================== CHARTS ==================== -->
|
||||
<template #charts>
|
||||
<section class="grid gap-4 lg:grid-cols-5" aria-labelledby="ch-h2">
|
||||
<!-- GWP -->
|
||||
<div class="lg:col-span-3 h2-card overflow-hidden">
|
||||
<div class="flex flex-wrap items-start justify-between gap-3 px-5 pt-5">
|
||||
<div>
|
||||
<h2 id="ch-h2" class="text-sm font-semibold text-[var(--h2-fg)]">GWP written</h2>
|
||||
<p class="mt-0.5 text-[11px] text-[var(--h2-muted)]">Trailing 6 months</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="h2-badge-success">+6.2%</span>
|
||||
<span class="font-mono text-lg font-bold tabular-nums text-[var(--h2-fg)]">{{ gwpLatest.display }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="px-2 pb-2 pt-3">
|
||||
<div class="overflow-hidden rounded-lg bg-[var(--h2-surface-inset)] ring-1 ring-[var(--h2-border-strong)]">
|
||||
<svg class="h-auto w-full" :viewBox="`0 0 ${gwpChartModel.viewW} ${gwpChartModel.viewH}`" role="img">
|
||||
<title>Gross written premium trend</title>
|
||||
<defs>
|
||||
<linearGradient id="gwp2" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" stop-color="var(--h2-accent)" stop-opacity="0.2" />
|
||||
<stop offset="60%" stop-color="var(--h2-accent)" stop-opacity="0.04" />
|
||||
<stop offset="100%" stop-color="var(--h2-accent)" stop-opacity="0" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<line v-for="(gy, i) in gwpChartModel.gridYs" :key="i" class="stroke-[var(--h2-border)]" stroke-width="1"
|
||||
:x1="gwpChartModel.padX" :y1="gy" :x2="gwpChartModel.padX + gwpChartModel.innerW" :y2="gy" />
|
||||
<path :d="gwpChartModel.areaPath" fill="url(#gwp2)" />
|
||||
<path :d="gwpChartModel.linePath" fill="none" stroke="var(--h2-accent)" stroke-width="2.25" stroke-linecap="round" stroke-linejoin="round" />
|
||||
<g v-for="(pt, i) in gwpChartModel.points" :key="i">
|
||||
<circle :cx="pt.x" :cy="pt.y" r="4.5" fill="var(--h2-surface)" stroke="var(--h2-accent)" stroke-width="2" />
|
||||
</g>
|
||||
</svg>
|
||||
<div class="flex justify-between border-t border-[var(--h2-border)] px-3 pb-2 pt-1.5">
|
||||
<div v-for="row in gwpTrend" :key="row.m" class="flex-1 text-center">
|
||||
<p class="font-mono text-[10px] font-semibold tabular-nums text-[var(--h2-fg)]">{{ row.display }}</p>
|
||||
<p class="text-[10px] text-[var(--h2-muted)]">{{ row.m }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Pipeline -->
|
||||
<div class="lg:col-span-2 h2-card flex flex-col overflow-hidden">
|
||||
<div class="px-5 pt-5">
|
||||
<h2 class="text-sm font-semibold text-[var(--h2-fg)]">Pipeline</h2>
|
||||
<p class="mt-0.5 text-[11px] text-[var(--h2-muted)]">Book, open quotes & YTD</p>
|
||||
</div>
|
||||
<!-- Summary trio -->
|
||||
<div class="mx-4 mt-4 grid grid-cols-3 overflow-hidden rounded-lg ring-1 ring-[var(--h2-border-strong)]">
|
||||
<div v-for="item in quotedPipelineSummaryCards" :key="item.label" class="bg-[var(--h2-surface-inset)] px-3 py-3 text-center ring-1 ring-[var(--h2-border)]">
|
||||
<p class="font-mono text-base font-bold tabular-nums text-[var(--h2-fg)]">{{ item.value }}</p>
|
||||
<p class="mt-0.5 text-[10px] font-medium uppercase tracking-wider text-[var(--h2-muted)]">{{ item.label }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Segment mix -->
|
||||
<div class="mt-4 flex-1 px-5 pb-5">
|
||||
<p class="text-[11px] font-semibold uppercase tracking-wider text-[var(--h2-muted)]">Segment mix</p>
|
||||
<div class="mt-2.5 flex h-2.5 w-full overflow-hidden rounded-md" style="outline:1px solid var(--h2-border)">
|
||||
<div v-for="(row, i) in pipelineMixRows" :key="row.label" class="h-full" :style="segColorStyles[i] + ';width:' + row.pct + '%'" />
|
||||
</div>
|
||||
<div class="mt-3.5 space-y-2">
|
||||
<div v-for="(row, i) in pipelineMixRows" :key="row.label" class="flex items-center gap-2">
|
||||
<div class="h-2 w-2 shrink-0 rounded-sm" :style="segDotStyles[i]" />
|
||||
<span class="flex-1 truncate text-[12px] font-medium text-[var(--h2-fg)]">{{ row.label }}</span>
|
||||
<span class="font-mono text-[12px] tabular-nums text-[var(--h2-muted)]">{{ formatPremiumM(row.premiumM) }}</span>
|
||||
<span class="w-8 text-right text-[11px] tabular-nums text-[var(--h2-muted)]">{{ row.pct }}%</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<!-- ==================== BROKERAGE HEALTH ==================== -->
|
||||
<template #brokerage_health>
|
||||
<section v-if="welcome.ceoKpis?.length" class="space-y-4">
|
||||
<div class="h2-section-header">
|
||||
<h2 class="h2-section-title">Brokerage health</h2>
|
||||
<p class="h2-section-sub">YTD and trailing measures</p>
|
||||
</div>
|
||||
<div class="grid gap-3 sm:grid-cols-2 xl:grid-cols-4">
|
||||
<div v-for="k in welcome.ceoKpis" :key="k.id" class="h2-card h2-rail-accent p-4 transition-all hover:shadow-md">
|
||||
<p class="text-[11px] font-semibold uppercase tracking-wider text-[var(--h2-muted)]">{{ k.label }}</p>
|
||||
<p class="mt-1.5 font-mono text-xl font-bold tabular-nums tracking-tight text-[var(--h2-fg)]">{{ k.value }}</p>
|
||||
<p v-if="k.change" class="mt-1 text-xs font-semibold" :class="changeToneClass(k.changeTone)">{{ k.change }}</p>
|
||||
<p v-if="k.hint" class="mt-1 text-[11px] text-[var(--h2-muted)]">{{ k.hint }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<!-- ==================== QUOTES LINE ==================== -->
|
||||
<template #quotes_line>
|
||||
<section class="space-y-4" aria-labelledby="h2q">
|
||||
<div class="flex flex-wrap items-end justify-between gap-3">
|
||||
<div class="h2-section-header">
|
||||
<h2 id="h2q" class="h2-section-title">Quotes</h2>
|
||||
<p class="h2-section-sub">Start a new quote by line of business</p>
|
||||
</div>
|
||||
<NuxtLink to="/quotes" class="text-sm font-semibold h2-text-accent transition hover:h2-text-accent-hover">
|
||||
View all →
|
||||
</NuxtLink>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-5">
|
||||
<NuxtLink v-for="line in quoteLines" :key="line.to" :to="line.to" class="group">
|
||||
<div class="h2-card h2-card-hover flex h-full flex-col items-center justify-center px-4 py-5 text-center transition-all duration-200">
|
||||
<div class="flex h-11 w-11 items-center justify-center rounded-xl h2-accent-icon transition-transform duration-200 group-hover:scale-105">
|
||||
<UIcon :name="line.icon" class="h-5 w-5" />
|
||||
</div>
|
||||
<p class="mt-3 text-sm font-semibold text-[var(--h2-fg)]">{{ line.label }}</p>
|
||||
<p class="mt-0.5 text-[11px] leading-snug text-[var(--h2-muted)]">{{ line.hint }}</p>
|
||||
</div>
|
||||
</NuxtLink>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<!-- ==================== QUICK LINKS ==================== -->
|
||||
<template #quick_links>
|
||||
<section class="space-y-4">
|
||||
<div class="h2-section-header">
|
||||
<h2 class="h2-section-title">Quick links</h2>
|
||||
<p class="h2-section-sub">Jump to operational areas</p>
|
||||
</div>
|
||||
<div class="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||
<NuxtLink
|
||||
v-for="link in welcome.quickLinks" :key="link.to" :to="link.to"
|
||||
class="group h2-card h2-card-hover flex gap-3.5 p-4 transition-all duration-200"
|
||||
>
|
||||
<div class="flex h-10 w-10 shrink-0 items-center justify-center rounded-xl h2-accent-icon transition-transform duration-200 group-hover:scale-105">
|
||||
<UIcon :name="link.icon" class="h-5 w-5" />
|
||||
</div>
|
||||
<div class="min-w-0">
|
||||
<p class="font-semibold text-[var(--h2-fg)] transition-colors group-hover:h2-text-accent">{{ link.label }}</p>
|
||||
<p class="mt-0.5 text-[12px] leading-snug text-[var(--h2-fg-secondary)]">{{ link.description }}</p>
|
||||
</div>
|
||||
</NuxtLink>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
</HomeDashboardWidgetBlocks>
|
||||
|
||||
<!-- Layout controls -->
|
||||
<div class="mx-auto mt-12 flex max-w-6xl flex-col gap-3 border-t border-[var(--h2-border)] pt-6 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<UButton
|
||||
:icon="layoutUnlocked ? 'i-heroicons-arrows-up-down' : 'i-heroicons-lock-closed'"
|
||||
:color="layoutUnlocked ? 'primary' : 'neutral'" variant="soft" class="h2-btn-outline"
|
||||
@click="toggleLayoutUnlock"
|
||||
>{{ layoutUnlocked ? 'Reorder on' : 'Reorder off' }}</UButton>
|
||||
<p class="max-w-md text-xs text-[var(--h2-muted)]">Drag blocks by the grip when reorder is on.</p>
|
||||
</div>
|
||||
<UButton icon="i-heroicons-squares-2x2" color="primary" class="h2-btn-primary shrink-0" @click="dashConfigOpen = true">
|
||||
Layout & widgets
|
||||
</UButton>
|
||||
</div>
|
||||
|
||||
<!-- Slideover -->
|
||||
<USlideover v-model:open="dashConfigOpen" side="right">
|
||||
<template #content>
|
||||
<div class="flex h-full max-w-md flex-col bg-[var(--h2-surface)] sm:max-w-lg">
|
||||
<div class="flex shrink-0 items-start justify-between gap-3 border-b border-[var(--h2-border)] p-6">
|
||||
<div class="min-w-0">
|
||||
<h2 class="text-lg font-semibold text-[var(--h2-fg)]">Dashboard layout</h2>
|
||||
<p class="mt-1 text-sm text-[var(--h2-fg-secondary)]">Choose a role preset or toggle sections.</p>
|
||||
</div>
|
||||
<UButton icon="i-heroicons-x-mark" color="neutral" variant="ghost" class="shrink-0" aria-label="Close" @click="dashConfigOpen = false" />
|
||||
</div>
|
||||
<div class="min-h-0 flex-1 overflow-y-auto p-6">
|
||||
<div class="space-y-6">
|
||||
<div>
|
||||
<p class="text-[11px] font-semibold uppercase tracking-wider text-[var(--h2-muted)]">Role preset</p>
|
||||
<USelect :model-value="activePreset" :items="presetSelectItems" value-key="value" label-key="label" class="mt-2 w-full"
|
||||
@update:model-value="applyPreset($event as DashboardRolePresetId)" />
|
||||
<p class="mt-2 text-xs text-[var(--h2-muted)]">{{ activePresetHint }}</p>
|
||||
<UButton v-if="isPresetDirty" size="xs" color="neutral" variant="soft" class="mt-2" @click="reapplySelectedPreset">Reset to preset</UButton>
|
||||
</div>
|
||||
<div class="border-t border-[var(--h2-border)] pt-4">
|
||||
<p class="text-[11px] font-semibold uppercase tracking-wider text-[var(--h2-muted)]">Sections</p>
|
||||
<ul class="mt-3 space-y-4">
|
||||
<li v-for="w in DASHBOARD_WIDGETS" :key="w.id" class="flex items-start justify-between gap-3">
|
||||
<div class="min-w-0">
|
||||
<p class="text-sm font-medium text-[var(--h2-fg)]">{{ w.label }}</p>
|
||||
<p class="text-xs text-[var(--h2-muted)]">{{ w.description }}</p>
|
||||
</div>
|
||||
<USwitch :model-value="widgets[w.id]" @update:model-value="setWidget(w.id, $event)" />
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</USlideover>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* =====================================================================
|
||||
HOME 2 — PETROLEUM / STONE DESIGN SYSTEM (scoped)
|
||||
===================================================================== */
|
||||
|
||||
/* ---- Palette ---- */
|
||||
.h2 {
|
||||
/* Petroleum accent scale */
|
||||
--h2-accent: #0d5c63;
|
||||
--h2-accent-hover: #0a4a50;
|
||||
--h2-accent-muted: #1a8a8a;
|
||||
--h2-accent-soft: #0d5c63 / 0.08;
|
||||
|
||||
/* Surfaces — stone/warm-gray with real depth */
|
||||
--h2-page-bg: var(--page-bg, #f4f2ef);
|
||||
--h2-surface: #faf9f7;
|
||||
--h2-surface-raised:#ffffff;
|
||||
--h2-surface-inset: #f0eeeb;
|
||||
|
||||
/* Foregrounds */
|
||||
--h2-fg: #1a1a1a;
|
||||
--h2-fg-secondary: #5c5650;
|
||||
--h2-muted: #8c857d;
|
||||
|
||||
/* Borders — two tiers for depth */
|
||||
--h2-border: #e5e0da;
|
||||
--h2-border-strong: #d5cfc8;
|
||||
|
||||
/* Semantic — assertive */
|
||||
--h2-success: #0f7b5f;
|
||||
--h2-warning: #c27b1a;
|
||||
--h2-error: #c13838;
|
||||
--h2-info: #0d5c63;
|
||||
|
||||
background: var(--h2-page-bg);
|
||||
}
|
||||
|
||||
/* ---- Card system ---- */
|
||||
.h2-card {
|
||||
background: var(--h2-surface-raised);
|
||||
border-radius: 12px;
|
||||
border: 1px solid var(--h2-border);
|
||||
box-shadow: 0 1px 2px rgba(0,0,0,0.04), 0 1px 3px rgba(0,0,0,0.02);
|
||||
}
|
||||
.h2-card-flush {
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* ---- Status rail motif (left-edge accent strip) ---- */
|
||||
.h2-rail-accent { border-left: 3px solid var(--h2-accent); }
|
||||
.h2-rail-success { border-left: 3px solid var(--h2-success); }
|
||||
.h2-rail-warning { border-left: 3px solid var(--h2-warning); }
|
||||
.h2-rail-error { border-left: 3px solid var(--h2-error); }
|
||||
|
||||
/* ---- Section headers ---- */
|
||||
.h2-section-header { }
|
||||
.h2-section-title {
|
||||
font-size: 0.9375rem; /* 15px */
|
||||
font-weight: 650;
|
||||
letter-spacing: -0.01em;
|
||||
color: var(--h2-fg);
|
||||
}
|
||||
.h2-section-sub {
|
||||
margin-top: 2px;
|
||||
font-size: 0.75rem;
|
||||
color: var(--h2-muted);
|
||||
}
|
||||
|
||||
/* ---- Card headers (icon + title) ---- */
|
||||
.h2-card-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
padding: 1rem 1.25rem;
|
||||
border-bottom: 1px solid var(--h2-border);
|
||||
}
|
||||
.h2-icon-box {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
border-radius: 8px;
|
||||
background: color-mix(in srgb, var(--h2-accent) 10%, transparent);
|
||||
color: var(--h2-accent);
|
||||
}
|
||||
.h2-icon-box-error {
|
||||
background: color-mix(in srgb, var(--h2-error) 10%, transparent);
|
||||
color: var(--h2-error);
|
||||
}
|
||||
|
||||
/* ---- Lists ---- */
|
||||
.h2-list {
|
||||
padding: 0 1.25rem;
|
||||
}
|
||||
.h2-list-item {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 0.625rem;
|
||||
padding: 0.625rem 0;
|
||||
border-bottom: 1px solid var(--h2-border);
|
||||
}
|
||||
.h2-list-item:last-child { border-bottom: 0; padding-bottom: 1rem; }
|
||||
.h2-list-item:first-child { padding-top: 0.75rem; }
|
||||
|
||||
/* ---- Alert rows ---- */
|
||||
.h2-alert-row {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 0.5rem;
|
||||
padding: 0.75rem 0.75rem 0.75rem 1rem;
|
||||
border-radius: 8px;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
|
||||
/* ---- Ops cell borders ---- */
|
||||
.h2-cell-border {
|
||||
border-right: 1px solid var(--h2-border);
|
||||
}
|
||||
@media (max-width: 639px) {
|
||||
.h2-cell-border:nth-child(2n) { border-right: none; }
|
||||
.h2-cell-border { border-bottom: 1px solid var(--h2-border); }
|
||||
}
|
||||
|
||||
/* ---- Ops status background tints ---- */
|
||||
.h2-ops-on-track { background: color-mix(in srgb, var(--h2-success) 3%, transparent); }
|
||||
.h2-ops-attention { background: color-mix(in srgb, var(--h2-accent) 3%, transparent); }
|
||||
.h2-ops-warning { background: color-mix(in srgb, var(--h2-warning) 3%, transparent); }
|
||||
.h2-ops-neutral { background: transparent; }
|
||||
|
||||
/* ---- Tone classes ---- */
|
||||
.h2-tone-pos { color: var(--h2-success); }
|
||||
.h2-tone-neg { color: var(--h2-error); }
|
||||
.h2-tone-neutral { color: var(--h2-muted); }
|
||||
|
||||
/* ---- Badges ---- */
|
||||
.h2-badge-success {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
border-radius: 9999px;
|
||||
padding: 0.125rem 0.625rem;
|
||||
font-size: 0.6875rem;
|
||||
font-weight: 600;
|
||||
background: color-mix(in srgb, var(--h2-success) 10%, transparent);
|
||||
color: var(--h2-success);
|
||||
border: 1px solid color-mix(in srgb, var(--h2-success) 20%, transparent);
|
||||
}
|
||||
|
||||
/* ---- Buttons ---- */
|
||||
.h2-btn-primary {
|
||||
background: var(--h2-accent) !important;
|
||||
color: #fff !important;
|
||||
border-radius: 10px !important;
|
||||
border: none !important;
|
||||
box-shadow: 0 1px 3px rgba(13,92,99,0.25), 0 1px 2px rgba(13,92,99,0.15) !important;
|
||||
transition: background 0.15s, box-shadow 0.15s !important;
|
||||
}
|
||||
.h2-btn-primary:hover {
|
||||
background: var(--h2-accent-hover) !important;
|
||||
box-shadow: 0 2px 6px rgba(13,92,99,0.3), 0 1px 3px rgba(13,92,99,0.2) !important;
|
||||
}
|
||||
.h2-btn-outline {
|
||||
border-radius: 10px !important;
|
||||
border-color: var(--h2-border-strong) !important;
|
||||
color: var(--h2-fg) !important;
|
||||
}
|
||||
|
||||
/* ---- Accent icon tile ---- */
|
||||
.h2-accent-icon {
|
||||
background: rgba(13, 92, 99, 0.08);
|
||||
color: #0d5c63;
|
||||
border: 1px solid rgba(13, 92, 99, 0.15);
|
||||
}
|
||||
|
||||
/* ---- Accent text ---- */
|
||||
.h2-text-accent { color: #0d5c63; }
|
||||
.h2-text-accent-hover { color: #0a4a50; }
|
||||
|
||||
/* ---- Card hover state ---- */
|
||||
.h2-card-hover:hover {
|
||||
box-shadow: 0 4px 12px rgba(0,0,0,0.06), 0 1px 3px rgba(0,0,0,0.04);
|
||||
border-color: rgba(13, 92, 99, 0.25);
|
||||
}
|
||||
</style>
|
||||
3445
app/pages/index.vue
3445
app/pages/index.vue
File diff suppressed because it is too large
Load Diff
@@ -1,14 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
usePageTitle('New Active Lead')
|
||||
</script>
|
||||
<template>
|
||||
<div class="mx-auto max-w-2xl space-y-6">
|
||||
<div>
|
||||
<h1 class="mt-0.5 text-2xl font-semibold tracking-tight text-[var(--text-primary)]">New Active Lead</h1>
|
||||
</div>
|
||||
<div class="rounded-xl border border-dashed border-[var(--card-border)] bg-[var(--surface)] px-8 py-12 text-center shadow-sm">
|
||||
<p class="text-sm text-[var(--text-muted)] opacity-70">Active lead entry form coming online.</p>
|
||||
<NuxtLink to="/onboarding" class="mt-4 inline-block text-[12px] font-medium text-[var(--brand)] hover:text-[var(--brand)]">← Sales Pipeline</NuxtLink>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,364 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
definePageMeta({ ssr: false })
|
||||
usePageTitle('Emissions review')
|
||||
|
||||
const toast = useToast()
|
||||
const { items, approve, sendToInsurer, markInForce } = useEmissionsQueue()
|
||||
|
||||
const pending = computed(() => items.value.filter((x) => x.status === 'pending_review'))
|
||||
const rest = computed(() => items.value.filter((x) => x.status !== 'pending_review'))
|
||||
|
||||
function onApprove(id: string) {
|
||||
approve(id)
|
||||
toast.add({ title: 'Marked approved', color: 'success' })
|
||||
}
|
||||
|
||||
function onSend(id: string) {
|
||||
sendToInsurer(id)
|
||||
toast.add({ title: 'Marked sent to insurer', color: 'success' })
|
||||
}
|
||||
|
||||
function onInForce(id: string) {
|
||||
markInForce(id)
|
||||
toast.add({ title: 'Marked in force', color: 'success' })
|
||||
}
|
||||
|
||||
/* ── Mock pipeline data for when queue is empty ── */
|
||||
const mockEmissions = [
|
||||
{ id: 'EM-2025-0041', customer: 'María Elena Pérez', insurer: 'ASSA', line: 'Auto', product: '2023 Toyota RAV4 — Comprehensive', premium: '$1,840', status: 'pending_review' as const, submitted: '2025-04-03', agent: 'Ana R.', docs: 3, docsTotal: 3 },
|
||||
{ id: 'EM-2025-0040', customer: 'Roberto Jiménez Mora', insurer: 'Pan-American Life', line: 'Life', product: 'Whole life — $150K', premium: '$1,440', status: 'approved' as const, submitted: '2025-04-02', agent: 'Ana R.', docs: 4, docsTotal: 4 },
|
||||
{ id: 'EM-2025-0039', customer: 'Luis Andrés Solís', insurer: 'Blue Cross', line: 'Health', product: 'Family health — Platinum', premium: '$8,400', status: 'sent_to_insurer' as const, submitted: '2025-04-01', agent: 'Ana R.', docs: 5, docsTotal: 5 },
|
||||
{ id: 'EM-2025-0038', customer: 'Sofía Campos Rojas', insurer: 'INS', line: 'Auto', product: '2024 Mazda CX-30 — Comprehensive', premium: '$1,380', status: 'pending_review' as const, submitted: '2025-03-30', agent: 'Marco V.', docs: 2, docsTotal: 3 },
|
||||
{ id: 'EM-2025-0037', customer: 'Carolina Fallas Vargas', insurer: 'ASSA', line: 'Renter', product: "Renter's insurance — Paraíso apt", premium: '$320', status: 'in_force' as const, submitted: '2025-03-28', agent: 'Marco V.', docs: 2, docsTotal: 2 },
|
||||
{ id: 'EM-2025-0036', customer: 'Roberto Jiménez Mora', insurer: 'ASSA', line: 'Home', product: 'Homeowner — Belén residence', premium: '$890', status: 'in_force' as const, submitted: '2025-03-25', agent: 'Ana R.', docs: 4, docsTotal: 4 },
|
||||
]
|
||||
|
||||
type EmissionStatus = 'pending_review' | 'approved' | 'sent_to_insurer' | 'in_force'
|
||||
|
||||
const statusMeta: Record<EmissionStatus, { label: string; class: string; icon: string }> = {
|
||||
pending_review: { label: 'Pending review', class: 'em-status-pending', icon: 'i-heroicons-clock' },
|
||||
approved: { label: 'Approved', class: 'em-status-approved', icon: 'i-heroicons-check' },
|
||||
sent_to_insurer: { label: 'Sent to insurer', class: 'em-status-sent', icon: 'i-heroicons-paper-airplane' },
|
||||
in_force: { label: 'In force', class: 'em-status-force', icon: 'i-heroicons-shield-check' },
|
||||
}
|
||||
|
||||
const activeFilter = ref<EmissionStatus | 'all'>('all')
|
||||
const filterTabs: { id: EmissionStatus | 'all'; label: string; count: number }[] = [
|
||||
{ id: 'all', label: 'All', count: mockEmissions.length },
|
||||
{ id: 'pending_review', label: 'Pending', count: mockEmissions.filter(e => e.status === 'pending_review').length },
|
||||
{ id: 'approved', label: 'Approved', count: mockEmissions.filter(e => e.status === 'approved').length },
|
||||
{ id: 'sent_to_insurer', label: 'Sent', count: mockEmissions.filter(e => e.status === 'sent_to_insurer').length },
|
||||
{ id: 'in_force', label: 'In force', count: mockEmissions.filter(e => e.status === 'in_force').length },
|
||||
]
|
||||
|
||||
const filteredEmissions = computed(() => {
|
||||
if (activeFilter.value === 'all') return mockEmissions
|
||||
return mockEmissions.filter(e => e.status === activeFilter.value)
|
||||
})
|
||||
|
||||
/* ── KPI summary ── */
|
||||
const kpis = [
|
||||
{ label: 'Pending review', value: mockEmissions.filter(e => e.status === 'pending_review').length.toString(), sub: 'Awaiting QA', dot: 'background: #c27b1a' },
|
||||
{ label: 'Approved', value: mockEmissions.filter(e => e.status === 'approved').length.toString(), sub: 'Ready to send', dot: 'background: #01696f' },
|
||||
{ label: 'Sent to insurer', value: mockEmissions.filter(e => e.status === 'sent_to_insurer').length.toString(), sub: 'Awaiting response', dot: 'background: #7c3aed' },
|
||||
{ label: 'In force', value: mockEmissions.filter(e => e.status === 'in_force').length.toString(), sub: 'This month', dot: 'background: #0f7b5f' },
|
||||
]
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="em mx-auto max-w-5xl space-y-6 pb-12">
|
||||
<!-- Back -->
|
||||
<NuxtLink to="/onboarding" class="inline-flex">
|
||||
<UButton color="neutral" variant="ghost" size="sm" icon="i-heroicons-arrow-left">Pipeline</UButton>
|
||||
</NuxtLink>
|
||||
|
||||
<!-- Sales flow indicator -->
|
||||
<SalesFlowIndicator current-stage="emission" />
|
||||
|
||||
<!-- Header -->
|
||||
<div class="flex flex-wrap items-start justify-between gap-4">
|
||||
<div>
|
||||
<h1 class="mt-1 text-2xl font-semibold tracking-tight text-[var(--text-primary)]">Emissions Review</h1>
|
||||
<p class="mt-1 max-w-2xl text-[13px] text-[var(--text-muted)]">
|
||||
Completed intakes land here for brokerage QA before submission to the carrier.
|
||||
</p>
|
||||
</div>
|
||||
<NuxtLink to="/onboarding/solicitud">
|
||||
<UButton size="sm" color="primary" icon="i-heroicons-plus">New solicitud</UButton>
|
||||
</NuxtLink>
|
||||
</div>
|
||||
|
||||
<!-- KPI Strip -->
|
||||
<div class="em-kpi-strip">
|
||||
<div v-for="(kpi, i) in kpis" :key="kpi.label" class="em-kpi">
|
||||
<p class="em-kpi-label">{{ kpi.label }}</p>
|
||||
<p class="em-kpi-value">{{ kpi.value }}</p>
|
||||
<div class="mt-1 flex items-center gap-1.5">
|
||||
<span class="em-kpi-dot" :style="kpi.dot" />
|
||||
<p class="text-[11px] text-[var(--text-muted)]">{{ kpi.sub }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Filter tabs -->
|
||||
<div class="em-tabs">
|
||||
<button
|
||||
v-for="tab in filterTabs"
|
||||
:key="tab.id"
|
||||
type="button"
|
||||
class="em-tab"
|
||||
:class="activeFilter === tab.id ? 'em-tab-on' : 'em-tab-off'"
|
||||
@click="activeFilter = tab.id"
|
||||
>
|
||||
{{ tab.label }}
|
||||
<span class="em-tab-count" :class="activeFilter === tab.id ? 'em-tab-count-on' : ''">{{ tab.count }}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Emissions table -->
|
||||
<div class="em-card">
|
||||
<div class="em-card-head">
|
||||
<UIcon name="i-heroicons-document-check" style="width: 16px; height: 16px; color: #01696f;" />
|
||||
<span>Emissions queue</span>
|
||||
<span class="ml-auto text-[11px] text-[var(--text-muted)]">{{ filteredEmissions.length }} items</span>
|
||||
</div>
|
||||
|
||||
<div v-if="filteredEmissions.length === 0" class="px-6 py-12 text-center">
|
||||
<UIcon name="i-heroicons-inbox-stack" style="width: 40px; height: 40px; color: #c0c0bc; margin: 0 auto 12px;" />
|
||||
<p class="text-[13px] text-[var(--text-muted)]">No emissions in this status</p>
|
||||
</div>
|
||||
|
||||
<table v-else class="em-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>Customer</th>
|
||||
<th>Insurer</th>
|
||||
<th>Product</th>
|
||||
<th>Premium</th>
|
||||
<th>Docs</th>
|
||||
<th>Status</th>
|
||||
<th>Submitted</th>
|
||||
<th class="text-right">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="em in filteredEmissions" :key="em.id">
|
||||
<td class="font-mono text-[11px] font-medium">{{ em.id }}</td>
|
||||
<td>
|
||||
<p class="text-[13px] font-medium text-[var(--text-primary)]">{{ em.customer }}</p>
|
||||
<p class="text-[11px] text-[var(--text-muted)]">{{ em.agent }}</p>
|
||||
</td>
|
||||
<td class="text-[13px]">{{ em.insurer }}</td>
|
||||
<td>
|
||||
<p class="text-[13px] text-[var(--text-primary)]">{{ em.line }}</p>
|
||||
<p class="text-[11px] text-[var(--text-muted)] max-w-[200px] truncate">{{ em.product }}</p>
|
||||
</td>
|
||||
<td class="text-[13px] font-medium tabular-nums">{{ em.premium }}</td>
|
||||
<td>
|
||||
<span class="em-doc-badge" :class="em.docs === em.docsTotal ? 'em-doc-complete' : 'em-doc-partial'">
|
||||
{{ em.docs }}/{{ em.docsTotal }}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<span :class="statusMeta[em.status].class">
|
||||
{{ statusMeta[em.status].label }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="text-[12px] tabular-nums text-[var(--text-muted)]">{{ em.submitted }}</td>
|
||||
<td class="text-right">
|
||||
<div class="flex justify-end gap-1">
|
||||
<button v-if="em.status === 'pending_review'" type="button" class="em-action-btn em-action-approve" title="Approve">
|
||||
<UIcon name="i-heroicons-check" style="width: 14px; height: 14px;" />
|
||||
</button>
|
||||
<button v-if="em.status === 'approved'" type="button" class="em-action-btn em-action-send" title="Send to insurer">
|
||||
<UIcon name="i-heroicons-paper-airplane" style="width: 14px; height: 14px;" />
|
||||
</button>
|
||||
<button v-if="em.status === 'sent_to_insurer'" type="button" class="em-action-btn em-action-force" title="Mark in force">
|
||||
<UIcon name="i-heroicons-shield-check" style="width: 14px; height: 14px;" />
|
||||
</button>
|
||||
<button type="button" class="em-action-btn" title="View details">
|
||||
<UIcon name="i-heroicons-eye" style="width: 14px; height: 14px;" />
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Queue from composable (if any real items exist) -->
|
||||
<div v-if="items.length > 0" class="em-card">
|
||||
<div class="em-card-head">
|
||||
<UIcon name="i-heroicons-queue-list" style="width: 16px; height: 16px; color: #01696f;" />
|
||||
<span>Live queue (from solicitud intake)</span>
|
||||
</div>
|
||||
<table class="em-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Created</th>
|
||||
<th>Customer</th>
|
||||
<th>Insurer</th>
|
||||
<th>Sub-ramo</th>
|
||||
<th>Line</th>
|
||||
<th>Status</th>
|
||||
<th class="text-right">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="row in items" :key="row.id">
|
||||
<td class="font-mono text-[11px]">{{ row.createdAt.slice(0, 10) }}</td>
|
||||
<td class="text-[13px]">{{ row.customerLabel }}</td>
|
||||
<td class="text-[13px]">{{ row.insurerSlug }}</td>
|
||||
<td class="text-[13px]">{{ row.subRamoKey }}</td>
|
||||
<td class="text-[13px]">{{ row.productLine }}</td>
|
||||
<td>
|
||||
<span :class="statusMeta[row.status as EmissionStatus]?.class ?? 'em-status-pending'">
|
||||
{{ statusMeta[row.status as EmissionStatus]?.label ?? row.status }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="text-right">
|
||||
<div class="flex justify-end gap-1">
|
||||
<button v-if="row.status === 'pending_review'" type="button" class="em-action-btn em-action-approve" @click="onApprove(row.id)">
|
||||
<UIcon name="i-heroicons-check" style="width: 14px; height: 14px;" />
|
||||
</button>
|
||||
<button v-if="row.status === 'approved'" type="button" class="em-action-btn em-action-send" @click="onSend(row.id)">
|
||||
<UIcon name="i-heroicons-paper-airplane" style="width: 14px; height: 14px;" />
|
||||
</button>
|
||||
<button v-if="row.status === 'sent_to_insurer'" type="button" class="em-action-btn em-action-force" @click="onInForce(row.id)">
|
||||
<UIcon name="i-heroicons-shield-check" style="width: 14px; height: 14px;" />
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.em-section-label {
|
||||
font-size: 11px; font-weight: 600; text-transform: uppercase;
|
||||
letter-spacing: 0.06em; color: #8a8a86; margin-bottom: 4px;
|
||||
}
|
||||
|
||||
/* ── KPI strip ── */
|
||||
.em-kpi-strip {
|
||||
display: grid; grid-template-columns: repeat(4, 1fr); gap: 1px;
|
||||
border-radius: 12px; border: 1px solid rgba(0,0,0,0.06);
|
||||
background: rgba(0,0,0,0.06); box-shadow: 0 1px 3px rgba(0,0,0,0.03);
|
||||
overflow: hidden;
|
||||
}
|
||||
.em-kpi {
|
||||
padding: 16px 20px; background: #ffffff;
|
||||
}
|
||||
.em-kpi:first-child { border-radius: 12px 0 0 12px; }
|
||||
.em-kpi:last-child { border-radius: 0 12px 12px 0; }
|
||||
.em-kpi-label {
|
||||
font-size: 11px; font-weight: 600; text-transform: uppercase;
|
||||
letter-spacing: 0.04em; color: #8a8a86;
|
||||
}
|
||||
.em-kpi-value {
|
||||
margin-top: 4px; font-size: 22px; font-weight: 600;
|
||||
color: var(--text-primary); font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.em-kpi-dot {
|
||||
width: 6px; height: 6px; border-radius: 50%; flex-shrink: 0;
|
||||
}
|
||||
@media (max-width: 640px) {
|
||||
.em-kpi-strip { grid-template-columns: repeat(2, 1fr); }
|
||||
}
|
||||
|
||||
/* ── Tabs ── */
|
||||
.em-tabs {
|
||||
display: inline-flex; gap: 2px; padding: 3px;
|
||||
border-radius: 10px; background: rgba(0,0,0,0.04);
|
||||
}
|
||||
.em-tab {
|
||||
display: inline-flex; align-items: center; gap: 5px;
|
||||
padding: 6px 14px; border-radius: 8px;
|
||||
font-size: 13px; font-weight: 500;
|
||||
border: none; cursor: pointer; transition: all 150ms ease;
|
||||
}
|
||||
.em-tab-on { background: #fff; color: var(--text-primary); box-shadow: 0 1px 3px rgba(0,0,0,0.08); }
|
||||
.em-tab-off { background: transparent; color: var(--text-muted); }
|
||||
.em-tab-off:hover { color: var(--text-primary); }
|
||||
.em-tab-count {
|
||||
font-size: 10px; font-weight: 600; padding: 1px 5px;
|
||||
border-radius: 9999px; background: rgba(0,0,0,0.06); color: var(--text-muted);
|
||||
}
|
||||
.em-tab-count-on { background: rgba(1,105,111,0.1); color: #01696f; }
|
||||
|
||||
/* ── Card ── */
|
||||
.em-card {
|
||||
border-radius: 12px; border: 1px solid rgba(0,0,0,0.06);
|
||||
background: #ffffff; box-shadow: 0 1px 3px rgba(0,0,0,0.03);
|
||||
overflow: hidden;
|
||||
}
|
||||
.em-card-head {
|
||||
display: flex; align-items: center; gap: 8px;
|
||||
padding: 14px 20px; border-bottom: 1px solid rgba(0,0,0,0.06);
|
||||
font-size: 13px; font-weight: 600; color: var(--text-primary);
|
||||
}
|
||||
|
||||
/* ── Table ── */
|
||||
.em-table {
|
||||
width: 100%; border-collapse: collapse; font-size: 13px;
|
||||
}
|
||||
.em-table th {
|
||||
text-align: left; padding: 10px 16px;
|
||||
font-size: 11px; font-weight: 600; text-transform: uppercase;
|
||||
letter-spacing: 0.04em; color: #8a8a86;
|
||||
border-bottom: 1px solid rgba(0,0,0,0.06);
|
||||
}
|
||||
.em-table td {
|
||||
padding: 12px 16px; color: var(--text-primary);
|
||||
border-bottom: 1px solid rgba(0,0,0,0.04);
|
||||
vertical-align: top;
|
||||
}
|
||||
.em-table tr:last-child td { border-bottom: none; }
|
||||
.em-table tr:hover td { background: rgba(0,0,0,0.015); }
|
||||
|
||||
/* ── Status badges ── */
|
||||
.em-status-pending {
|
||||
font-size: 10px; font-weight: 600; padding: 2px 8px; border-radius: 9999px;
|
||||
background: rgba(194,123,26,0.08); color: #964219;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.em-status-approved {
|
||||
font-size: 10px; font-weight: 600; padding: 2px 8px; border-radius: 9999px;
|
||||
background: rgba(1,105,111,0.08); color: #01696f;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.em-status-sent {
|
||||
font-size: 10px; font-weight: 600; padding: 2px 8px; border-radius: 9999px;
|
||||
background: rgba(124,58,237,0.08); color: #7c3aed;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.em-status-force {
|
||||
font-size: 10px; font-weight: 600; padding: 2px 8px; border-radius: 9999px;
|
||||
background: rgba(15,123,95,0.08); color: #0f7b5f;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* ── Doc badge ── */
|
||||
.em-doc-badge {
|
||||
font-size: 10px; font-weight: 600; padding: 2px 6px; border-radius: 4px;
|
||||
}
|
||||
.em-doc-complete { background: rgba(15,123,95,0.08); color: #0f7b5f; }
|
||||
.em-doc-partial { background: rgba(194,123,26,0.08); color: #964219; }
|
||||
|
||||
/* ── Action buttons ── */
|
||||
.em-action-btn {
|
||||
display: inline-flex; align-items: center; justify-content: center;
|
||||
width: 28px; height: 28px; border-radius: 6px;
|
||||
border: none; cursor: pointer;
|
||||
background: rgba(0,0,0,0.03); color: #8a8a86;
|
||||
transition: all 150ms ease;
|
||||
}
|
||||
.em-action-btn:hover { background: rgba(0,0,0,0.06); color: var(--text-primary); }
|
||||
.em-action-approve:hover { background: rgba(15,123,95,0.1); color: #0f7b5f; }
|
||||
.em-action-send:hover { background: rgba(1,105,111,0.1); color: #01696f; }
|
||||
.em-action-force:hover { background: rgba(15,123,95,0.1); color: #0f7b5f; }
|
||||
</style>
|
||||
@@ -1,251 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
usePageTitle('Sales Pipeline')
|
||||
|
||||
type Stage = 'Lead' | 'Qualified' | 'Proposal' | 'Negotiation' | 'Won'
|
||||
|
||||
interface Deal {
|
||||
id: string
|
||||
customer: string
|
||||
product: string
|
||||
line: 'Auto' | 'Health' | 'Life' | 'General Risk' | 'Custom'
|
||||
premium: number
|
||||
agent: string
|
||||
stage: Stage
|
||||
daysInStage: number
|
||||
urgent: boolean
|
||||
note?: string
|
||||
}
|
||||
|
||||
const deals: Deal[] = [
|
||||
{ id: 'D-001', customer: 'María Pérez', product: 'Auto Individual', line: 'Auto', premium: 1200, agent: 'Ana R.', stage: 'Lead', daysInStage: 1, urgent: false },
|
||||
{ id: 'D-002', customer: 'Empresa ABC S.A.', product: 'Fleet Auto', line: 'Auto', premium: 18400, agent: 'Carlos M.', stage: 'Lead', daysInStage: 3, urgent: false },
|
||||
{ id: 'D-003', customer: 'Jorge Herrera', product: 'Vida Individual', line: 'Life', premium: 3200, agent: 'Ana R.', stage: 'Lead', daysInStage: 0, urgent: false, note: 'Referral from client D-051' },
|
||||
{ id: 'D-004', customer: 'Clínica San José', product: 'Salud Grupal', line: 'Health', premium: 42000, agent: 'Luis F.', stage: 'Qualified', daysInStage: 5, urgent: false },
|
||||
{ id: 'D-005', customer: 'Carmen Ruiz', product: 'Salud Individual', line: 'Health', premium: 2800, agent: 'Ana R.', stage: 'Qualified', daysInStage: 2, urgent: false },
|
||||
{ id: 'D-006', customer: 'Constructora Delta', product: 'Todo Riesgo', line: 'General Risk', premium: 55000, agent: 'Carlos M.', stage: 'Qualified', daysInStage: 7, urgent: true, note: 'RFQ deadline Friday' },
|
||||
{ id: 'D-007', customer: 'Rodrigo Blanco', product: 'Vida + Accidentes', line: 'Life', premium: 4100, agent: 'Luis F.', stage: 'Qualified', daysInStage: 4, urgent: false },
|
||||
{ id: 'D-008', customer: 'Hotel Pacífico', product: 'Incendio y Robo', line: 'General Risk', premium: 28000, agent: 'Carlos M.', stage: 'Proposal', daysInStage: 6, urgent: false },
|
||||
{ id: 'D-009', customer: 'Supermercado Tico', product: 'Responsabilidad Civil', line: 'General Risk', premium: 9800, agent: 'Ana R.', stage: 'Proposal', daysInStage: 9, urgent: true, note: 'Follow up required today' },
|
||||
{ id: 'D-010', customer: 'Isabel Mora', product: 'Auto Individual', line: 'Auto', premium: 980, agent: 'Luis F.', stage: 'Proposal', daysInStage: 3, urgent: false },
|
||||
{ id: 'D-011', customer: 'Banco Regional', product: 'Colectivo Vida', line: 'Life', premium: 120000, agent: 'Carlos M.', stage: 'Negotiation', daysInStage: 14, urgent: true, note: 'Legal review pending' },
|
||||
{ id: 'D-012', customer: 'Farmacia Salud', product: 'Todo Riesgo', line: 'General Risk', premium: 17500, agent: 'Luis F.', stage: 'Negotiation', daysInStage: 8, urgent: false },
|
||||
{ id: 'D-013', customer: 'Andrea Cascante', product: 'Salud Individual', line: 'Health', premium: 2200, agent: 'Ana R.', stage: 'Won', daysInStage: 0, urgent: false },
|
||||
{ id: 'D-014', customer: 'Transportes del Sur', product: 'Fleet Auto', line: 'Auto', premium: 24000, agent: 'Carlos M.', stage: 'Won', daysInStage: 0, urgent: false },
|
||||
{ id: 'D-015', customer: 'Manuel Torres', product: 'Vida Individual', line: 'Life', premium: 5600, agent: 'Luis F.', stage: 'Won', daysInStage: 0, urgent: false },
|
||||
]
|
||||
|
||||
const search = ref('')
|
||||
const filterLine = ref<string>('all')
|
||||
const filterAgent = ref<string>('all')
|
||||
|
||||
const lineOptions = [
|
||||
{ label: 'All Lines', value: 'all' },
|
||||
{ label: 'Auto', value: 'Auto' },
|
||||
{ label: 'Health', value: 'Health' },
|
||||
{ label: 'Life', value: 'Life' },
|
||||
{ label: 'General Risk', value: 'General Risk' },
|
||||
{ label: 'Custom', value: 'Custom' },
|
||||
]
|
||||
|
||||
const agentOptions = [
|
||||
{ label: 'All Agents', value: 'all' },
|
||||
{ label: 'Ana R.', value: 'Ana R.' },
|
||||
{ label: 'Carlos M.', value: 'Carlos M.' },
|
||||
{ label: 'Luis F.', value: 'Luis F.' },
|
||||
]
|
||||
|
||||
const stages: Stage[] = ['Lead', 'Qualified', 'Proposal', 'Negotiation', 'Won']
|
||||
|
||||
const stageLabel: Record<Stage, string> = {
|
||||
Lead: 'Lead',
|
||||
Qualified: 'Data Collection',
|
||||
Proposal: 'Quotation',
|
||||
Negotiation: 'Solicitud',
|
||||
Won: 'Emision',
|
||||
}
|
||||
|
||||
const stageConfig: Record<Stage, { color: string; dot: string; headerBg: string }> = {
|
||||
Lead: { color: 'text-[var(--text-muted)]', dot: 'bg-[var(--text-muted)]', headerBg: 'bg-[var(--surface)] border-[var(--card-border)]' },
|
||||
Qualified: { color: 'text-[var(--brand)]', dot: 'bg-[var(--brand)]', headerBg: 'bg-[var(--brand-faint)] border-[var(--brand-soft)]' },
|
||||
Proposal: { color: 'text-violet-700', dot: 'bg-violet-400', headerBg: 'bg-violet-50 border-violet-200' },
|
||||
Negotiation: { color: 'text-amber-700', dot: 'bg-amber-400', headerBg: 'bg-amber-50 border-amber-200' },
|
||||
Won: { color: 'text-emerald-700', dot: 'bg-emerald-500', headerBg: 'bg-emerald-50 border-emerald-200' },
|
||||
}
|
||||
|
||||
const lineColors: Record<string, string> = {
|
||||
Auto: 'bg-[var(--brand-soft)] text-[var(--brand)]',
|
||||
Health: 'bg-emerald-100 text-emerald-700',
|
||||
Life: 'bg-violet-100 text-violet-700',
|
||||
'General Risk': 'bg-amber-100 text-amber-700',
|
||||
Custom: 'bg-[var(--badge-muted-bg)] text-[var(--text-muted)]',
|
||||
}
|
||||
|
||||
const filteredDeals = computed(() => {
|
||||
return deals.filter(d => {
|
||||
const q = search.value.toLowerCase()
|
||||
const matchSearch = !q || d.customer.toLowerCase().includes(q) || d.product.toLowerCase().includes(q) || d.id.toLowerCase().includes(q)
|
||||
const matchLine = filterLine.value === 'all' || d.line === filterLine.value
|
||||
const matchAgent = filterAgent.value === 'all' || d.agent === filterAgent.value
|
||||
return matchSearch && matchLine && matchAgent
|
||||
})
|
||||
})
|
||||
|
||||
function stageDeals(stage: Stage) {
|
||||
return filteredDeals.value.filter(d => d.stage === stage)
|
||||
}
|
||||
|
||||
function stageTotal(stage: Stage) {
|
||||
return stageDeals(stage).reduce((s, d) => s + d.premium, 0)
|
||||
}
|
||||
|
||||
function fmt(n: number) {
|
||||
return n >= 1000 ? `$${(n / 1000).toFixed(n % 1000 === 0 ? 0 : 1)}k` : `$${n}`
|
||||
}
|
||||
|
||||
function daysLabel(n: number) {
|
||||
if (n === 0) return 'Today'
|
||||
return `${n}d`
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col gap-5">
|
||||
<!-- Header -->
|
||||
<div class="flex items-start justify-between">
|
||||
<div>
|
||||
<h1 class="mt-1 text-2xl font-semibold tracking-tight text-[var(--text-primary)]">Sales Pipeline</h1>
|
||||
<p class="mt-1 text-[13px] text-[var(--text-muted)]">{{ filteredDeals.length }} opportunities · {{ fmt(filteredDeals.reduce((s, d) => s + d.premium, 0)) }} total premium</p>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<NuxtLink to="/onboarding/solicitud">
|
||||
<UButton size="sm" color="primary" icon="i-heroicons-plus">New Solicitud</UButton>
|
||||
</NuxtLink>
|
||||
<NuxtLink to="/sales/quick-lead">
|
||||
<UButton size="sm" color="primary" variant="soft" icon="i-heroicons-user-plus">Quick Lead</UButton>
|
||||
</NuxtLink>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Search + Filters -->
|
||||
<div class="flex flex-wrap items-center gap-3 rounded-xl border border-[var(--card-border)] bg-[var(--surface)] px-4 py-3 shadow-sm ring-1 ring-[var(--surface)]">
|
||||
<UInput
|
||||
v-model="search"
|
||||
icon="i-heroicons-magnifying-glass"
|
||||
placeholder="Search customer, product, ID…"
|
||||
size="sm"
|
||||
class="w-64"
|
||||
/>
|
||||
<USelect
|
||||
v-model="filterLine"
|
||||
:items="lineOptions"
|
||||
size="sm"
|
||||
class="w-36"
|
||||
/>
|
||||
<USelect
|
||||
v-model="filterAgent"
|
||||
:items="agentOptions"
|
||||
size="sm"
|
||||
class="w-36"
|
||||
/>
|
||||
<div class="ml-auto flex items-center gap-2 text-[12px] text-[var(--text-muted)]">
|
||||
<span
|
||||
v-for="stage in stages"
|
||||
:key="stage"
|
||||
class="flex items-center gap-1"
|
||||
>
|
||||
<span class="h-2 w-2 rounded-full" :class="stageConfig[stage].dot" />
|
||||
<span class="text-[11px]">{{ stageLabel[stage] }}</span>
|
||||
<span class="font-semibold">{{ stageDeals(stage).length }}</span>
|
||||
<span class="text-[var(--text-muted)] opacity-50 last:hidden">·</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Kanban board -->
|
||||
<div class="flex gap-3 overflow-x-auto pb-2">
|
||||
<div
|
||||
v-for="stage in stages"
|
||||
:key="stage"
|
||||
class="flex w-[230px] shrink-0 flex-col gap-2"
|
||||
>
|
||||
<!-- Column header -->
|
||||
<div
|
||||
class="flex items-center justify-between rounded-lg border px-3 py-2"
|
||||
:class="stageConfig[stage].headerBg"
|
||||
>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="h-2 w-2 rounded-full" :class="stageConfig[stage].dot" />
|
||||
<span class="text-[13px] font-semibold" :class="stageConfig[stage].color">{{ stageLabel[stage] }}</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-1.5">
|
||||
<span class="text-[11px] text-[var(--text-muted)]">{{ fmt(stageTotal(stage)) }}</span>
|
||||
<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">
|
||||
{{ stageDeals(stage).length }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Empty state -->
|
||||
<div
|
||||
v-if="stageDeals(stage).length === 0"
|
||||
class="rounded-xl border border-dashed border-[var(--card-border)] bg-[var(--surface)]/50 px-3 py-8 text-center"
|
||||
>
|
||||
<p class="text-[12px] text-[var(--text-muted)] opacity-70">No deals in this stage</p>
|
||||
</div>
|
||||
|
||||
<!-- Deal cards -->
|
||||
<div
|
||||
v-for="deal in stageDeals(stage)"
|
||||
:key="deal.id"
|
||||
class="group rounded-xl border px-3.5 py-3 shadow-sm ring-1 transition hover:shadow-md"
|
||||
:class="deal.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'"
|
||||
>
|
||||
<!-- Customer + product line badge -->
|
||||
<div class="flex items-start justify-between gap-1">
|
||||
<p class="text-[13px] font-semibold leading-snug text-[var(--text-primary)]">{{ deal.customer }}</p>
|
||||
<span
|
||||
class="ml-1 shrink-0 rounded-md px-1.5 py-0.5 text-[10px] font-semibold"
|
||||
:class="lineColors[deal.line]"
|
||||
>{{ deal.line }}</span>
|
||||
</div>
|
||||
|
||||
<!-- Product -->
|
||||
<p class="mt-0.5 text-[12px] text-[var(--text-muted)]">{{ deal.product }}</p>
|
||||
|
||||
<!-- Note -->
|
||||
<p v-if="deal.note" class="mt-1.5 text-[11px] italic text-amber-600">{{ deal.note }}</p>
|
||||
|
||||
<!-- 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.5">
|
||||
<!-- Urgent flag -->
|
||||
<span
|
||||
v-if="deal.urgent"
|
||||
class="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>
|
||||
<span class="text-[12px] font-bold text-[var(--text-primary)]">{{ fmt(deal.premium) }}</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 text-[11px] text-[var(--text-muted)] opacity-70">
|
||||
<span>{{ deal.agent }}</span>
|
||||
<span>·</span>
|
||||
<span>{{ daysLabel(deal.daysInStage) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Legend -->
|
||||
<div class="flex flex-wrap gap-4 rounded-lg border border-[var(--card-border)]/60 bg-[var(--surface)] px-4 py-2.5 text-[11px] text-[var(--text-muted)] shadow-sm">
|
||||
<span class="flex items-center gap-1.5"><span class="inline-flex items-center gap-1 rounded-full bg-rose-100 px-1.5 py-0.5 text-[9px] font-bold uppercase text-rose-600"><span class="h-1.5 w-1.5 rounded-full bg-rose-500" />Urgent</span> Action required</span>
|
||||
<span class="flex items-center gap-1.5"><span class="font-bold text-[var(--text-primary)]">$18k</span> Total premium at stage</span>
|
||||
<span>Days in stage shown on each card</span>
|
||||
<NuxtLink to="/onboarding/emissions" class="ml-auto font-medium text-[var(--brand)] hover:text-[var(--brand)]">Emissions queue →</NuxtLink>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,727 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
usePageTitle('Nombramiento')
|
||||
|
||||
const intakeMode = ref<'scan' | 'manual'>('scan')
|
||||
const customerMode = ref<'existing' | 'new'>('existing')
|
||||
const customerSearch = ref('')
|
||||
|
||||
const uploadState = ref<'idle' | 'uploading' | 'processing' | 'review'>('idle')
|
||||
const fileName = ref('')
|
||||
const dragOver = ref(false)
|
||||
|
||||
/* ── Extracted policy data (mock — would come from AI model) ── */
|
||||
const extracted = reactive({
|
||||
policyNumber: '',
|
||||
carrier: '',
|
||||
lob: '',
|
||||
effectiveDate: '',
|
||||
expirationDate: '',
|
||||
premium: '',
|
||||
insuredName: '',
|
||||
insuredId: '',
|
||||
insuredEmail: '',
|
||||
insuredPhone: '',
|
||||
currentBroker: '',
|
||||
coverageSummary: '',
|
||||
customerMatch: null as null | 'existing' | 'new',
|
||||
matchedCustomerId: null as null | string,
|
||||
matchedCustomerName: null as null | string,
|
||||
confidence: 0,
|
||||
})
|
||||
|
||||
function simulateUpload(name: string) {
|
||||
fileName.value = name
|
||||
uploadState.value = 'uploading'
|
||||
|
||||
setTimeout(() => {
|
||||
uploadState.value = 'processing'
|
||||
|
||||
setTimeout(() => {
|
||||
// Simulate AI extraction results
|
||||
extracted.policyNumber = 'POL-2024-88412'
|
||||
extracted.carrier = 'ASSA Compania de Seguros'
|
||||
extracted.lob = 'Auto'
|
||||
extracted.effectiveDate = '2024-06-15'
|
||||
extracted.expirationDate = '2025-06-15'
|
||||
extracted.premium = '$1,840.00'
|
||||
extracted.insuredName = 'María Elena Pérez Solano'
|
||||
extracted.insuredId = '1-0456-0812'
|
||||
extracted.insuredEmail = 'maria.perez@email.com'
|
||||
extracted.insuredPhone = '+506 8834-2291'
|
||||
extracted.currentBroker = 'Seguros Internacionales S.A.'
|
||||
extracted.coverageSummary = 'Comprehensive auto coverage, $50K liability, $25K collision, roadside assistance included.'
|
||||
extracted.customerMatch = 'existing'
|
||||
extracted.matchedCustomerId = 'C-1042'
|
||||
extracted.matchedCustomerName = 'María Pérez'
|
||||
extracted.confidence = 94
|
||||
|
||||
uploadState.value = 'review'
|
||||
}, 2000)
|
||||
}, 1200)
|
||||
}
|
||||
|
||||
function onFileSelect(e: Event) {
|
||||
const input = e.target as HTMLInputElement
|
||||
const file = input.files?.[0]
|
||||
if (file) simulateUpload(file.name)
|
||||
}
|
||||
|
||||
function onDrop(e: DragEvent) {
|
||||
e.preventDefault()
|
||||
dragOver.value = false
|
||||
const file = e.dataTransfer?.files?.[0]
|
||||
if (file) simulateUpload(file.name)
|
||||
}
|
||||
|
||||
function reset() {
|
||||
uploadState.value = 'idle'
|
||||
fileName.value = ''
|
||||
extracted.policyNumber = ''
|
||||
extracted.carrier = ''
|
||||
extracted.customerMatch = null
|
||||
extracted.confidence = 0
|
||||
}
|
||||
|
||||
const toast = useToast()
|
||||
|
||||
function confirmTransfer() {
|
||||
toast.add({
|
||||
title: 'Nombramiento initiated',
|
||||
description: `Broker of record transfer started for ${extracted.policyNumber}. The customer profile has been updated.`,
|
||||
color: 'success'
|
||||
})
|
||||
reset()
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="mx-auto max-w-4xl space-y-6 pb-12">
|
||||
<!-- Back + header -->
|
||||
<div class="flex flex-wrap items-center justify-between gap-3">
|
||||
<NuxtLink to="/onboarding" class="inline-flex">
|
||||
<UButton color="neutral" variant="ghost" size="sm" icon="i-heroicons-arrow-left">
|
||||
Sales Pipeline
|
||||
</UButton>
|
||||
</NuxtLink>
|
||||
</div>
|
||||
|
||||
<div class="max-w-2xl">
|
||||
<h1 class="mt-1 text-2xl font-semibold tracking-tight text-[var(--text-primary)]">Nombramiento</h1>
|
||||
<p class="mt-2 text-[14px] leading-relaxed text-[var(--text-muted)]">
|
||||
Register a policy and become the broker of record. Scan a document with AI or enter details manually, then link to an existing customer or create a new one.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Intake mode toggle -->
|
||||
<div v-if="uploadState === 'idle'" class="flex flex-col gap-4">
|
||||
<div class="nom-mode-toggle">
|
||||
<button
|
||||
type="button"
|
||||
class="nom-mode-btn"
|
||||
:class="intakeMode === 'scan' ? 'nom-mode-active' : 'nom-mode-inactive'"
|
||||
@click="intakeMode = 'scan'"
|
||||
>
|
||||
<UIcon name="i-heroicons-sparkles" style="width: 16px; height: 16px;" />
|
||||
AI scan
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="nom-mode-btn"
|
||||
:class="intakeMode === 'manual' ? 'nom-mode-active' : 'nom-mode-inactive'"
|
||||
@click="intakeMode = 'manual'"
|
||||
>
|
||||
<UIcon name="i-heroicons-pencil-square" style="width: 16px; height: 16px;" />
|
||||
Manual entry
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Customer association -->
|
||||
<div class="nom-customer-section">
|
||||
<p class="nom-label">Customer</p>
|
||||
<div class="mt-2 flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
class="nom-customer-btn"
|
||||
:class="customerMode === 'existing' ? 'nom-customer-active' : 'nom-customer-inactive'"
|
||||
@click="customerMode = 'existing'"
|
||||
>
|
||||
<UIcon name="i-heroicons-user-circle" style="width: 16px; height: 16px;" />
|
||||
Existing customer
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="nom-customer-btn"
|
||||
:class="customerMode === 'new' ? 'nom-customer-active' : 'nom-customer-inactive'"
|
||||
@click="customerMode = 'new'"
|
||||
>
|
||||
<UIcon name="i-heroicons-user-plus" style="width: 16px; height: 16px;" />
|
||||
New customer
|
||||
</button>
|
||||
</div>
|
||||
<div v-if="customerMode === 'existing'" class="mt-3">
|
||||
<UInput
|
||||
v-model="customerSearch"
|
||||
icon="i-heroicons-magnifying-glass"
|
||||
placeholder="Search by name, ID, or email..."
|
||||
size="sm"
|
||||
class="max-w-sm"
|
||||
/>
|
||||
<p class="mt-1.5 text-[11px] text-[var(--text-muted)]">Select the customer this policy belongs to. AI scan will also attempt automatic matching.</p>
|
||||
</div>
|
||||
<div v-else class="mt-3">
|
||||
<p class="text-[12px] text-[var(--text-muted)]">A new customer profile will be created from the policy details.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ═══ AI SCAN PATH ═══ -->
|
||||
|
||||
<!-- Upload zone — idle state (scan mode) -->
|
||||
<div
|
||||
v-if="uploadState === 'idle' && intakeMode === 'scan'"
|
||||
class="nom-upload-zone"
|
||||
:class="{ 'nom-upload-zone-active': dragOver }"
|
||||
@dragover.prevent="dragOver = true"
|
||||
@dragleave="dragOver = false"
|
||||
@drop="onDrop"
|
||||
>
|
||||
<div class="flex flex-col items-center gap-3 text-center">
|
||||
<div class="nom-icon-ring">
|
||||
<UIcon name="i-heroicons-document-arrow-up" style="width: 24px; height: 24px;" />
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-[14px] font-medium text-[var(--text-primary)]">Upload policy document</p>
|
||||
<p class="mt-1 text-[13px] text-[var(--text-muted)]">
|
||||
Drop a PDF here, or click to browse. AI will read the policy and extract all fields.
|
||||
</p>
|
||||
</div>
|
||||
<label class="nom-browse-btn">
|
||||
Browse files
|
||||
<input type="file" accept=".pdf,.png,.jpg,.jpeg" class="sr-only" @change="onFileSelect" />
|
||||
</label>
|
||||
<p class="text-[11px] text-[var(--text-muted)] opacity-60">PDF, PNG, or JPG up to 25 MB</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ═══ MANUAL ENTRY PATH ═══ -->
|
||||
<div v-if="uploadState === 'idle' && intakeMode === 'manual'" class="nom-data-card">
|
||||
<div class="nom-data-header">
|
||||
<p class="text-[14px] font-semibold text-[var(--text-primary)]">Policy details</p>
|
||||
<p class="text-[13px] text-[var(--text-muted)]">Enter the policy information manually. All fields can be edited later.</p>
|
||||
</div>
|
||||
|
||||
<div class="nom-data-grid">
|
||||
<div class="nom-field">
|
||||
<label class="nom-label">Policy number</label>
|
||||
<UInput placeholder="e.g. POL-2024-00001" size="sm" />
|
||||
</div>
|
||||
<div class="nom-field">
|
||||
<label class="nom-label">Carrier</label>
|
||||
<UInput placeholder="Carrier name" size="sm" />
|
||||
</div>
|
||||
<div class="nom-field">
|
||||
<label class="nom-label">Line of business</label>
|
||||
<USelect :items="[{ label: 'Auto', value: 'auto' }, { label: 'Health', value: 'health' }, { label: 'Life', value: 'life' }, { label: 'General Risk', value: 'general-risk' }, { label: 'Other', value: 'other' }]" placeholder="Select..." size="sm" />
|
||||
</div>
|
||||
<div class="nom-field">
|
||||
<label class="nom-label">Premium</label>
|
||||
<UInput placeholder="$0.00" size="sm" />
|
||||
</div>
|
||||
<div class="nom-field">
|
||||
<label class="nom-label">Effective date</label>
|
||||
<UInput size="sm" type="date" />
|
||||
</div>
|
||||
<div class="nom-field">
|
||||
<label class="nom-label">Expiration date</label>
|
||||
<UInput size="sm" type="date" />
|
||||
</div>
|
||||
<div class="nom-field">
|
||||
<label class="nom-label">Previous broker</label>
|
||||
<UInput placeholder="Outgoing brokerage (if any)" size="sm" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="nom-data-divider" />
|
||||
|
||||
<div class="nom-data-grid" v-if="customerMode === 'new'">
|
||||
<div class="nom-field">
|
||||
<label class="nom-label">Insured name</label>
|
||||
<UInput placeholder="Full legal name" size="sm" />
|
||||
</div>
|
||||
<div class="nom-field">
|
||||
<label class="nom-label">ID number</label>
|
||||
<UInput placeholder="Cédula or passport" size="sm" />
|
||||
</div>
|
||||
<div class="nom-field">
|
||||
<label class="nom-label">Email</label>
|
||||
<UInput placeholder="email@example.com" size="sm" />
|
||||
</div>
|
||||
<div class="nom-field">
|
||||
<label class="nom-label">Phone</label>
|
||||
<UInput placeholder="+506 0000-0000" size="sm" />
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="px-5 pb-2">
|
||||
<p class="text-[12px] text-[var(--text-muted)] italic">Customer details will be pulled from the selected existing profile.</p>
|
||||
</div>
|
||||
|
||||
<div class="nom-data-divider" />
|
||||
|
||||
<div class="px-5 pb-5">
|
||||
<label class="nom-label">Coverage notes</label>
|
||||
<UTextarea placeholder="Optional — describe coverage, limits, deductibles..." size="sm" :rows="2" class="mt-1.5" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Manual entry actions -->
|
||||
<div v-if="uploadState === 'idle' && intakeMode === 'manual'" class="flex flex-wrap items-center justify-end gap-2">
|
||||
<UButton color="neutral" variant="outline">Save as draft</UButton>
|
||||
<UButton color="primary">Register policy</UButton>
|
||||
</div>
|
||||
|
||||
<!-- Uploading state -->
|
||||
<div v-else-if="uploadState === 'uploading'" class="nom-status-card">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="nom-spinner" />
|
||||
<div>
|
||||
<p class="text-[14px] font-medium text-[var(--text-primary)]">Uploading {{ fileName }}</p>
|
||||
<p class="mt-0.5 text-[13px] text-[var(--text-muted)]">Sending document to processing pipeline...</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Processing state -->
|
||||
<div v-else-if="uploadState === 'processing'" class="nom-status-card">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="nom-spinner" />
|
||||
<div>
|
||||
<p class="text-[14px] font-medium text-[var(--text-primary)]">AI is reading the policy</p>
|
||||
<p class="mt-0.5 text-[13px] text-[var(--text-muted)]">Extracting insured details, coverage terms, carrier info, and matching against existing customers...</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Review state — extracted data -->
|
||||
<template v-else-if="uploadState === 'review'">
|
||||
<!-- Confidence bar -->
|
||||
<div class="nom-confidence-strip">
|
||||
<div class="flex items-center gap-2">
|
||||
<UIcon name="i-heroicons-sparkles" style="width: 16px; height: 16px; color: #01696f;" />
|
||||
<span class="text-[13px] font-medium text-[var(--text-primary)]">AI extraction complete</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="nom-confidence-bar-track">
|
||||
<div class="nom-confidence-bar-fill" :style="`width: ${extracted.confidence}%`" />
|
||||
</div>
|
||||
<span class="nom-confidence-badge">{{ extracted.confidence }}%</span>
|
||||
<span class="text-[10px] font-semibold uppercase" :style="extracted.confidence >= 90 ? 'color: #059669' : extracted.confidence >= 70 ? 'color: #d97706' : 'color: #dc2626'">
|
||||
{{ extracted.confidence >= 90 ? 'High' : extracted.confidence >= 70 ? 'Medium' : 'Low' }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Customer match -->
|
||||
<div v-if="extracted.customerMatch === 'existing'" class="nom-match-card nom-match-existing">
|
||||
<UIcon name="i-heroicons-user-circle" style="width: 20px; height: 20px; flex-shrink: 0;" />
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="text-[13px] font-medium text-[var(--text-primary)]">
|
||||
Matched to existing customer: <strong>{{ extracted.matchedCustomerName }}</strong>
|
||||
<span class="text-[var(--text-muted)]"> ({{ extracted.matchedCustomerId }})</span>
|
||||
</p>
|
||||
<p class="mt-0.5 text-[12px] text-[var(--text-muted)]">This policy will be added to their existing profile.</p>
|
||||
</div>
|
||||
<UButton size="xs" color="neutral" variant="soft">Change</UButton>
|
||||
</div>
|
||||
<div v-else-if="extracted.customerMatch === 'new'" class="nom-match-card nom-match-new">
|
||||
<UIcon name="i-heroicons-user-plus" style="width: 20px; height: 20px; flex-shrink: 0;" />
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="text-[13px] font-medium text-[var(--text-primary)]">New customer will be created</p>
|
||||
<p class="mt-0.5 text-[12px] text-[var(--text-muted)]">No matching customer found. A new profile will be set up from the extracted data.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Extracted fields -->
|
||||
<div class="nom-data-card">
|
||||
<div class="nom-data-header">
|
||||
<p class="text-[14px] font-semibold text-[var(--text-primary)]">Policy details</p>
|
||||
<p class="text-[13px] text-[var(--text-muted)]">Review and correct any fields before initiating the transfer.</p>
|
||||
</div>
|
||||
|
||||
<div class="nom-data-grid">
|
||||
<div class="nom-field">
|
||||
<label class="nom-label">Policy number</label>
|
||||
<UInput :model-value="extracted.policyNumber" size="sm" />
|
||||
</div>
|
||||
<div class="nom-field">
|
||||
<label class="nom-label">Carrier</label>
|
||||
<UInput :model-value="extracted.carrier" size="sm" />
|
||||
</div>
|
||||
<div class="nom-field">
|
||||
<label class="nom-label">Line of business</label>
|
||||
<UInput :model-value="extracted.lob" size="sm" />
|
||||
</div>
|
||||
<div class="nom-field">
|
||||
<label class="nom-label">Premium</label>
|
||||
<UInput :model-value="extracted.premium" size="sm" />
|
||||
</div>
|
||||
<div class="nom-field">
|
||||
<label class="nom-label">Effective date</label>
|
||||
<UInput :model-value="extracted.effectiveDate" size="sm" type="date" />
|
||||
</div>
|
||||
<div class="nom-field">
|
||||
<label class="nom-label">Expiration date</label>
|
||||
<UInput :model-value="extracted.expirationDate" size="sm" type="date" />
|
||||
</div>
|
||||
<div class="nom-field">
|
||||
<label class="nom-label">Current broker</label>
|
||||
<UInput :model-value="extracted.currentBroker" size="sm" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="nom-data-divider" />
|
||||
|
||||
<div class="nom-data-grid">
|
||||
<div class="nom-field">
|
||||
<label class="nom-label">Insured name</label>
|
||||
<UInput :model-value="extracted.insuredName" size="sm" />
|
||||
</div>
|
||||
<div class="nom-field">
|
||||
<label class="nom-label">ID number</label>
|
||||
<UInput :model-value="extracted.insuredId" size="sm" />
|
||||
</div>
|
||||
<div class="nom-field">
|
||||
<label class="nom-label">Email</label>
|
||||
<UInput :model-value="extracted.insuredEmail" size="sm" />
|
||||
</div>
|
||||
<div class="nom-field">
|
||||
<label class="nom-label">Phone</label>
|
||||
<UInput :model-value="extracted.insuredPhone" size="sm" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="nom-data-divider" />
|
||||
|
||||
<div class="px-5 pb-5">
|
||||
<label class="nom-label">Coverage summary</label>
|
||||
<UTextarea :model-value="extracted.coverageSummary" size="sm" :rows="2" class="mt-1.5" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="flex flex-wrap items-center justify-between gap-3">
|
||||
<UButton color="neutral" variant="soft" @click="reset">
|
||||
Start over
|
||||
</UButton>
|
||||
<div class="flex gap-2">
|
||||
<UButton color="neutral" variant="outline">
|
||||
Save as draft
|
||||
</UButton>
|
||||
<UButton color="primary" @click="confirmTransfer">
|
||||
Initiate transfer
|
||||
</UButton>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- How it works -->
|
||||
<div v-if="uploadState === 'idle'" class="nom-info-section">
|
||||
<p class="text-[13px] font-semibold text-[var(--text-primary)]">How it works</p>
|
||||
<ol class="nom-steps">
|
||||
<li>
|
||||
<span class="nom-step-num">1</span>
|
||||
<div>
|
||||
<p class="text-[13px] font-medium text-[var(--text-primary)]">Upload the policy</p>
|
||||
<p class="text-[12px] text-[var(--text-muted)]">Drop a PDF or image of the policy from the outgoing brokerage.</p>
|
||||
</div>
|
||||
</li>
|
||||
<li>
|
||||
<span class="nom-step-num">2</span>
|
||||
<div>
|
||||
<p class="text-[13px] font-medium text-[var(--text-primary)]">AI extracts the data</p>
|
||||
<p class="text-[12px] text-[var(--text-muted)]">Policy number, carrier, coverage, insured details, and dates are read automatically.</p>
|
||||
</div>
|
||||
</li>
|
||||
<li>
|
||||
<span class="nom-step-num">3</span>
|
||||
<div>
|
||||
<p class="text-[13px] font-medium text-[var(--text-primary)]">Customer matching</p>
|
||||
<p class="text-[12px] text-[var(--text-muted)]">The system checks if the insured is an existing customer or creates a new profile.</p>
|
||||
</div>
|
||||
</li>
|
||||
<li>
|
||||
<span class="nom-step-num">4</span>
|
||||
<div>
|
||||
<p class="text-[13px] font-medium text-[var(--text-primary)]">Review and transfer</p>
|
||||
<p class="text-[12px] text-[var(--text-muted)]">Verify the extracted fields, then initiate the broker of record change.</p>
|
||||
</div>
|
||||
</li>
|
||||
</ol>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* ── Mode toggle ── */
|
||||
.nom-mode-toggle {
|
||||
display: inline-flex;
|
||||
gap: 2px;
|
||||
padding: 3px;
|
||||
border-radius: 10px;
|
||||
background: rgba(0, 0, 0, 0.04);
|
||||
}
|
||||
.nom-mode-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 6px 14px;
|
||||
border-radius: 8px;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
transition: all 150ms ease;
|
||||
}
|
||||
.nom-mode-active {
|
||||
background: #ffffff;
|
||||
color: var(--text-primary);
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
.nom-mode-inactive {
|
||||
background: transparent;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.nom-mode-inactive:hover {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
/* ── Customer association ── */
|
||||
.nom-customer-section {
|
||||
padding: 16px 20px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid rgba(0, 0, 0, 0.06);
|
||||
background: var(--surface);
|
||||
}
|
||||
.nom-customer-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 5px 12px;
|
||||
border-radius: 8px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
border: 1px solid;
|
||||
cursor: pointer;
|
||||
transition: all 150ms ease;
|
||||
}
|
||||
.nom-customer-active {
|
||||
background: rgba(1, 105, 111, 0.06);
|
||||
border-color: rgba(1, 105, 111, 0.2);
|
||||
color: #01696f;
|
||||
}
|
||||
.nom-customer-inactive {
|
||||
background: transparent;
|
||||
border-color: rgba(0, 0, 0, 0.08);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.nom-customer-inactive:hover {
|
||||
border-color: rgba(0, 0, 0, 0.15);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
/* ── Upload drop zone ── */
|
||||
.nom-upload-zone {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 220px;
|
||||
padding: 40px 24px;
|
||||
border: 1.5px dashed rgba(0, 0, 0, 0.12);
|
||||
border-radius: 12px;
|
||||
background: var(--surface);
|
||||
transition: border-color 150ms ease, background 150ms ease;
|
||||
cursor: pointer;
|
||||
}
|
||||
.nom-upload-zone:hover,
|
||||
.nom-upload-zone-active {
|
||||
border-color: #01696f;
|
||||
background: rgba(1, 105, 111, 0.02);
|
||||
}
|
||||
|
||||
.nom-icon-ring {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: 12px;
|
||||
background: rgba(1, 105, 111, 0.06);
|
||||
color: #01696f;
|
||||
}
|
||||
|
||||
.nom-browse-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 6px 16px;
|
||||
border-radius: 8px;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: #01696f;
|
||||
background: rgba(1, 105, 111, 0.08);
|
||||
cursor: pointer;
|
||||
transition: background 150ms ease;
|
||||
}
|
||||
.nom-browse-btn:hover {
|
||||
background: rgba(1, 105, 111, 0.14);
|
||||
}
|
||||
|
||||
/* ── Status card (uploading / processing) ── */
|
||||
.nom-status-card {
|
||||
padding: 24px;
|
||||
border-radius: 12px;
|
||||
border: 1px solid rgba(0, 0, 0, 0.06);
|
||||
background: var(--surface);
|
||||
}
|
||||
|
||||
.nom-spinner {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border: 2px solid rgba(1, 105, 111, 0.15);
|
||||
border-top-color: #01696f;
|
||||
border-radius: 50%;
|
||||
animation: nom-spin 0.8s linear infinite;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
@keyframes nom-spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
/* ── Confidence strip ── */
|
||||
.nom-confidence-strip {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 12px 16px;
|
||||
border-radius: 10px;
|
||||
background: rgba(1, 105, 111, 0.04);
|
||||
border: 1px solid rgba(1, 105, 111, 0.1);
|
||||
}
|
||||
|
||||
.nom-confidence-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 2px 10px;
|
||||
border-radius: 9999px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
background: rgba(1, 105, 111, 0.1);
|
||||
color: #01696f;
|
||||
}
|
||||
.nom-confidence-bar-track {
|
||||
width: 80px;
|
||||
height: 6px;
|
||||
border-radius: 3px;
|
||||
background: rgba(0,0,0,0.06);
|
||||
overflow: hidden;
|
||||
}
|
||||
.nom-confidence-bar-fill {
|
||||
height: 100%;
|
||||
border-radius: 3px;
|
||||
background: #01696f;
|
||||
transition: width 600ms ease;
|
||||
}
|
||||
|
||||
/* ── Customer match cards ── */
|
||||
.nom-match-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 14px 16px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid;
|
||||
}
|
||||
.nom-match-existing {
|
||||
background: rgba(1, 105, 111, 0.03);
|
||||
border-color: rgba(1, 105, 111, 0.1);
|
||||
color: #01696f;
|
||||
}
|
||||
.nom-match-new {
|
||||
background: rgba(0, 0, 0, 0.02);
|
||||
border-color: rgba(0, 0, 0, 0.08);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
/* ── Data card ── */
|
||||
.nom-data-card {
|
||||
border-radius: 12px;
|
||||
border: 1px solid rgba(0, 0, 0, 0.06);
|
||||
background: #ffffff;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.03);
|
||||
overflow: hidden;
|
||||
}
|
||||
.nom-data-header {
|
||||
padding: 20px 20px 16px;
|
||||
border-bottom: 1px solid rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
.nom-data-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 16px;
|
||||
padding: 20px;
|
||||
}
|
||||
@media (max-width: 639px) {
|
||||
.nom-data-grid { grid-template-columns: 1fr; }
|
||||
}
|
||||
.nom-data-divider {
|
||||
height: 1px;
|
||||
background: rgba(0, 0, 0, 0.06);
|
||||
margin: 0 20px;
|
||||
}
|
||||
.nom-field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
.nom-label {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
color: #8a8a86;
|
||||
}
|
||||
|
||||
/* ── Info section ── */
|
||||
.nom-info-section {
|
||||
padding: 20px;
|
||||
border-radius: 12px;
|
||||
border: 1px solid rgba(0, 0, 0, 0.06);
|
||||
background: var(--surface);
|
||||
}
|
||||
.nom-steps {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
margin-top: 16px;
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
}
|
||||
.nom-steps li {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
}
|
||||
.nom-step-num {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 8px;
|
||||
background: rgba(1, 105, 111, 0.06);
|
||||
color: #01696f;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
flex-shrink: 0;
|
||||
margin-top: 1px;
|
||||
}
|
||||
</style>
|
||||
@@ -1,14 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
usePageTitle('New Potential Lead')
|
||||
</script>
|
||||
<template>
|
||||
<div class="mx-auto max-w-2xl space-y-6">
|
||||
<div>
|
||||
<h1 class="mt-0.5 text-2xl font-semibold tracking-tight text-[var(--text-primary)]">New Potential Lead</h1>
|
||||
</div>
|
||||
<div class="rounded-xl border border-dashed border-[var(--card-border)] bg-[var(--surface)] px-8 py-12 text-center shadow-sm">
|
||||
<p class="text-sm text-[var(--text-muted)] opacity-70">Potential lead entry form coming online.</p>
|
||||
<NuxtLink to="/onboarding" class="mt-4 inline-block text-[12px] font-medium text-[var(--brand)] hover:text-[var(--brand)]">← Sales Pipeline</NuxtLink>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,311 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import type { FormCatalogProductLine, FormCatalogSelection } from '~/types/form-catalog'
|
||||
import { useFormsCatalog } from '~/composables/useFormsCatalog'
|
||||
|
||||
definePageMeta({ ssr: false })
|
||||
usePageTitle('Nueva solicitud')
|
||||
|
||||
const route = useRoute()
|
||||
const toast = useToast()
|
||||
|
||||
/* ── Pipeline bar ── */
|
||||
const { deals: allDeals } = useSalesPipeline()
|
||||
const activeDealId = ref<string | null>(route.query.deal as string | null)
|
||||
const activeDeals = computed(() => allDeals.value.filter(d => d.currentStage !== 'emission').slice(0, 10))
|
||||
const pipelineDeal = computed(() => {
|
||||
if (activeDealId.value) return allDeals.value.find(d => d.id === activeDealId.value) ?? null
|
||||
return null
|
||||
})
|
||||
function onPipelineNavigate(stage: string) {
|
||||
const stageRoutes: Record<string, string> = {
|
||||
customer: '/quotes/new', get_quotes: '/quotes/new',
|
||||
present_quotes: '/quotes/compare', solicitud: '/onboarding/solicitud', emission: '/onboarding/emissions',
|
||||
}
|
||||
if (stageRoutes[stage]) navigateTo(stageRoutes[stage])
|
||||
}
|
||||
|
||||
const {
|
||||
filterRows: resolveForms,
|
||||
insurerItems,
|
||||
subRamoItems,
|
||||
productLineItems,
|
||||
fieldGroupsForMatched
|
||||
} = useFormsCatalog()
|
||||
|
||||
const { profile, touch } = useCustomerProfileVault()
|
||||
const { enqueue } = useEmissionsQueue()
|
||||
|
||||
const insurerSlug = ref<string | null>(null)
|
||||
const subRamoKey = ref<string | null>(null)
|
||||
const personKind = ref<'natural' | 'juridica'>('natural')
|
||||
const productLine = ref<FormCatalogProductLine | 'any'>('any')
|
||||
|
||||
const subRamoOptions = computed(() => subRamoItems(insurerSlug.value))
|
||||
|
||||
watch(insurerSlug, () => {
|
||||
subRamoKey.value = null
|
||||
})
|
||||
|
||||
const bindToken = computed(() => {
|
||||
const b = route.query.bind
|
||||
return typeof b === 'string' ? b : null
|
||||
})
|
||||
|
||||
const selection = computed(
|
||||
(): FormCatalogSelection => ({
|
||||
insurerSlug: insurerSlug.value,
|
||||
subRamoKey: subRamoKey.value,
|
||||
personKind: personKind.value,
|
||||
productLine: productLine.value
|
||||
})
|
||||
)
|
||||
|
||||
const matchedForms = computed(() => resolveForms(selection.value))
|
||||
const fieldGroups = computed(() => fieldGroupsForMatched(matchedForms.value))
|
||||
|
||||
const personItems = [
|
||||
{ label: 'Natural', value: 'natural' as const },
|
||||
{ label: 'Jurídica', value: 'juridica' as const }
|
||||
]
|
||||
|
||||
async function copyLabel(label: string) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(label)
|
||||
toast.add({ title: 'Copied', color: 'success' })
|
||||
} catch {
|
||||
toast.add({ title: 'Could not copy', color: 'error' })
|
||||
}
|
||||
}
|
||||
|
||||
const draftSavedAt = ref<string | null>(null)
|
||||
|
||||
function saveProfileDraft() {
|
||||
touch()
|
||||
draftSavedAt.value = new Date().toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit' })
|
||||
toast.add({ title: 'Profile draft saved locally', color: 'success' })
|
||||
}
|
||||
|
||||
function submitToEmissions() {
|
||||
if (!insurerSlug.value || !subRamoKey.value) {
|
||||
toast.add({ title: 'Select insurer and sub-ramo', color: 'error' })
|
||||
return
|
||||
}
|
||||
enqueue({
|
||||
customerLabel: profile.value.full_name || 'Customer',
|
||||
insurerSlug: insurerSlug.value,
|
||||
subRamoKey: subRamoKey.value,
|
||||
productLine: String(productLine.value),
|
||||
bindToken: bindToken.value ?? undefined
|
||||
})
|
||||
toast.add({ title: 'Added to emissions queue', color: 'success' })
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="sol mx-auto max-w-5xl space-y-6 pb-12">
|
||||
<!-- Back -->
|
||||
<NuxtLink to="/onboarding" class="inline-flex">
|
||||
<UButton color="neutral" variant="ghost" size="sm" icon="i-heroicons-arrow-left">Pipeline</UButton>
|
||||
</NuxtLink>
|
||||
|
||||
<!-- Sales flow indicator -->
|
||||
<SalesFlowIndicator current-stage="solicitud" />
|
||||
|
||||
<UAlert
|
||||
v-if="bindToken"
|
||||
color="info"
|
||||
variant="soft"
|
||||
title="Broker intake link"
|
||||
:description="`Bind token: ${bindToken}`"
|
||||
/>
|
||||
|
||||
<div class="flex flex-wrap items-start justify-between gap-4">
|
||||
<div>
|
||||
<h1 class="mt-1 text-2xl font-semibold tracking-tight text-[var(--text-primary)]">New Solicitud</h1>
|
||||
<p class="mt-1 max-w-2xl text-[13px] text-[var(--text-muted)]">
|
||||
Choose insurer, sub-ramo, person type, and product line. Required forms come from the
|
||||
<NuxtLink to="/settings/forms" class="text-[#01696f] hover:underline">forms library</NuxtLink>.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Pipeline bar -->
|
||||
<div v-if="activeDeals.length > 0">
|
||||
<div v-if="!pipelineDeal" style="padding: 12px 16px; border-radius: 12px; border: 1px solid rgba(0,0,0,0.06); background: #fff; box-shadow: 0 1px 3px rgba(0,0,0,0.03);">
|
||||
<div class="flex items-center gap-2 text-[12px] text-[var(--text-muted)]">
|
||||
<UIcon name="i-heroicons-arrow-path" style="width: 13px; height: 13px; opacity: 0.5;" />
|
||||
<span class="font-medium">Continue an active deal:</span>
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-2 mt-2">
|
||||
<button v-for="d in activeDeals" :key="d.id" type="button" style="display:inline-flex;align-items:center;gap:6px;padding:6px 12px;border-radius:8px;border:1px solid rgba(0,0,0,0.06);background:#fff;font-size:12px;cursor:pointer;" @click="activeDealId = d.id">
|
||||
<span class="font-semibold">{{ d.customerName.split(' ').slice(0, 2).join(' ') }}</span>
|
||||
<span style="font-size:10px;font-weight:600;padding:0 5px;border-radius:9999px;background:rgba(1,105,111,0.07);color:#01696f;">{{ d.productLine }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<template v-else>
|
||||
<div class="flex items-center justify-between mb-1">
|
||||
<span class="text-[11px] font-semibold uppercase tracking-wider text-[#8a8a86]">Active Deal</span>
|
||||
<button type="button" class="text-[11px] text-[var(--text-muted)] hover:text-[var(--text-primary)]" @click="activeDealId = null">Switch deal</button>
|
||||
</div>
|
||||
<SalesPipelineBar :deal="pipelineDeal" @navigate="onPipelineNavigate" />
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<div class="sol-card">
|
||||
<div class="sol-card-head">
|
||||
<UIcon name="i-heroicons-adjustments-horizontal" style="width: 16px; height: 16px; color: #01696f;" />
|
||||
<span>Selection</span>
|
||||
</div>
|
||||
<div class="sol-card-body grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<UFormField label="Aseguradora" required>
|
||||
<USelect
|
||||
v-model="insurerSlug"
|
||||
:items="insurerItems"
|
||||
value-key="value"
|
||||
label-key="label"
|
||||
placeholder="Select…"
|
||||
class="w-full"
|
||||
/>
|
||||
</UFormField>
|
||||
<UFormField label="Sub-ramo" required>
|
||||
<USelect
|
||||
v-model="subRamoKey"
|
||||
:items="subRamoOptions"
|
||||
value-key="value"
|
||||
label-key="label"
|
||||
placeholder="Choose insurer first"
|
||||
:disabled="!insurerSlug"
|
||||
class="w-full"
|
||||
/>
|
||||
</UFormField>
|
||||
<UFormField label="Tipo de persona">
|
||||
<USelect v-model="personKind" :items="personItems" value-key="value" label-key="label" class="w-full" />
|
||||
</UFormField>
|
||||
<UFormField
|
||||
label="Product line"
|
||||
description="Required for health (local/intl) and auto (full vs DAT). Use “Any” for generic rows only."
|
||||
>
|
||||
<USelect
|
||||
v-model="productLine"
|
||||
:items="productLineItems"
|
||||
value-key="value"
|
||||
label-key="label"
|
||||
class="w-full"
|
||||
/>
|
||||
</UFormField>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="fieldGroups.length" class="sol-card">
|
||||
<div class="sol-card-head">
|
||||
<UIcon name="i-heroicons-rectangle-group" style="width: 16px; height: 16px; color: #01696f;" />
|
||||
<span>Field groups (review / autofill)</span>
|
||||
</div>
|
||||
<div class="sol-card-body space-y-6">
|
||||
<div v-for="g in fieldGroups" :key="g.id" class="rounded-lg border border-[rgba(0,0,0,0.06)] bg-[rgba(0,0,0,0.015)] p-4">
|
||||
<h3 class="text-[13px] font-semibold text-[var(--text-primary)]">{{ g.title }}</h3>
|
||||
<p class="text-[11px] text-[var(--text-muted)]">{{ g.description }}</p>
|
||||
<p class="mt-2 font-mono text-[10px] text-[var(--text-muted)] opacity-70">Keys: {{ g.fieldKeys.join(', ') }}</p>
|
||||
</div>
|
||||
<div class="grid gap-3 sm:grid-cols-2">
|
||||
<UFormField label="Nombre completo (profile)">
|
||||
<UInput v-model="profile.full_name" class="w-full" />
|
||||
</UFormField>
|
||||
<UFormField label="Documento ID">
|
||||
<UInput v-model="profile.document_id" class="w-full" />
|
||||
</UFormField>
|
||||
<UFormField label="Placa (auto)">
|
||||
<UInput v-model="profile.plate" class="w-full" />
|
||||
</UFormField>
|
||||
<UFormField label="Valor declarado">
|
||||
<UInput v-model="profile.declared_value" class="w-full" />
|
||||
</UFormField>
|
||||
</div>
|
||||
<UButton color="neutral" variant="soft" size="sm" @click="saveProfileDraft">Save profile draft</UButton>
|
||||
<span v-if="draftSavedAt" class="text-[11px] text-emerald-600 font-medium">
|
||||
<UIcon name="i-heroicons-check-circle" style="width: 13px; height: 13px; vertical-align: -2px;" /> Saved at {{ draftSavedAt }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="sol-card">
|
||||
<div class="sol-card-head">
|
||||
<UIcon name="i-heroicons-document-text" style="width: 16px; height: 16px; color: #01696f;" />
|
||||
<span>Forms to complete</span>
|
||||
<span class="ml-auto text-[11px] font-medium text-[var(--text-muted)]">{{ matchedForms.length }} forms</span>
|
||||
</div>
|
||||
<div class="sol-card-body">
|
||||
<div v-if="!insurerSlug || !subRamoKey" class="text-[13px] text-[var(--text-muted)] py-2">
|
||||
Select insurer and sub-ramo to list required templates.
|
||||
</div>
|
||||
<div v-else-if="matchedForms.length === 0" class="text-[13px] text-amber-700 py-2">
|
||||
No rows match this combination. Try another product line.
|
||||
</div>
|
||||
<div v-else class="space-y-2">
|
||||
<div
|
||||
v-for="row in matchedForms"
|
||||
:key="row.id"
|
||||
class="flex flex-wrap items-center justify-between gap-3 rounded-lg border border-[rgba(0,0,0,0.06)] bg-[rgba(0,0,0,0.015)] px-4 py-3"
|
||||
>
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="font-mono text-[13px] font-semibold text-[var(--text-primary)]">{{ row.id }}</p>
|
||||
<p class="truncate text-[11px] text-[var(--text-muted)]">{{ row.description }}</p>
|
||||
<a
|
||||
:href="row.fileUrl"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="mt-1 inline-block break-all text-[12px] text-[#01696f] hover:underline"
|
||||
>
|
||||
{{ row.fileLabel }}
|
||||
</a>
|
||||
</div>
|
||||
<div class="flex shrink-0 items-center gap-2">
|
||||
<UButton
|
||||
v-if="row.kind === 'identity'"
|
||||
icon="i-heroicons-document-duplicate"
|
||||
color="neutral"
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
aria-label="Copy file name"
|
||||
@click="copyLabel(row.fileLabel)"
|
||||
/>
|
||||
<span v-if="row.badge != null" class="text-[10px] font-medium px-1.5 py-0.5 rounded-full bg-[rgba(1,105,111,0.08)] text-[#01696f]">{{ row.badge }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="matchedForms.length" class="sol-card-footer">
|
||||
<NuxtLink to="/onboarding/emissions">
|
||||
<UButton color="neutral" variant="soft" size="sm">Open emissions queue</UButton>
|
||||
</NuxtLink>
|
||||
<UButton color="primary" size="sm" icon="i-heroicons-paper-airplane" @click="submitToEmissions">
|
||||
Send to emissions review
|
||||
</UButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.sol-section-label {
|
||||
font-size: 11px; font-weight: 600; text-transform: uppercase;
|
||||
letter-spacing: 0.06em; color: #8a8a86; margin-bottom: 4px;
|
||||
}
|
||||
.sol-card {
|
||||
border-radius: 12px; border: 1px solid rgba(0,0,0,0.06);
|
||||
background: #ffffff; box-shadow: 0 1px 3px rgba(0,0,0,0.03);
|
||||
overflow: hidden;
|
||||
}
|
||||
.sol-card-head {
|
||||
display: flex; align-items: center; gap: 8px;
|
||||
padding: 14px 20px; border-bottom: 1px solid rgba(0,0,0,0.06);
|
||||
font-size: 13px; font-weight: 600; color: var(--text-primary);
|
||||
}
|
||||
.sol-card-body { padding: 20px; }
|
||||
.sol-card-footer {
|
||||
display: flex; justify-content: flex-end; gap: 8px;
|
||||
padding: 14px 20px; border-top: 1px solid rgba(0,0,0,0.06);
|
||||
}
|
||||
</style>
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,454 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
const route = useRoute()
|
||||
const applicationId = route.params.application_id as string
|
||||
|
||||
const { data, error, pending, refresh } = usePolicy(`/policies/${applicationId}`)
|
||||
const policy = computed(() => data.value?.data)
|
||||
|
||||
// ── Accept plan ──────────────────────────────────────────────────────────────
|
||||
const isAcceptOpen = ref(false)
|
||||
const accepting = ref(false)
|
||||
const selectedQuote = ref<any>(null)
|
||||
const selectedPlan = ref<any>(null)
|
||||
const solicitationFields = ref<Record<string, string>>({})
|
||||
const toast = useToast()
|
||||
const { $policy } = useNuxtApp()
|
||||
|
||||
function openAccept(quote: any, plan: any) {
|
||||
selectedQuote.value = quote
|
||||
selectedPlan.value = plan
|
||||
solicitationFields.value = {}
|
||||
isAcceptOpen.value = true
|
||||
}
|
||||
|
||||
async function submitAccept() {
|
||||
accepting.value = true
|
||||
try {
|
||||
await $policy(`/policies/${applicationId}/accept`, {
|
||||
method: 'POST',
|
||||
body: {
|
||||
quote_id: selectedQuote.value.quote_id,
|
||||
plan_id: selectedPlan.value.plan_id,
|
||||
solicitation_fields: solicitationFields.value
|
||||
}
|
||||
})
|
||||
toast.add({ title: 'Plan accepted — solicitation in progress', color: 'green' })
|
||||
isAcceptOpen.value = false
|
||||
await refresh()
|
||||
} catch (e: any) {
|
||||
toast.add({ title: 'Failed to accept plan', description: e?.data?.error ?? e.message, color: 'red' })
|
||||
} finally {
|
||||
accepting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// ── Solicitation PDF ─────────────────────────────────────────────────────────
|
||||
const solicitationUrl = ref<string | null>(null)
|
||||
const loadingPdf = ref(false)
|
||||
const pdfError = ref<string | null>(null)
|
||||
|
||||
async function loadSolicitationUrl() {
|
||||
if (!policy.value?.solicitation_id) return
|
||||
loadingPdf.value = true
|
||||
pdfError.value = null
|
||||
try {
|
||||
const res = await $policy(`/car-policies/${applicationId}/solicitation-url`) as any
|
||||
solicitationUrl.value = res.download_url
|
||||
} catch (e: any) {
|
||||
pdfError.value = e?.data?.error ?? 'Failed to load document'
|
||||
} finally {
|
||||
loadingPdf.value = false
|
||||
}
|
||||
}
|
||||
|
||||
watch(() => policy.value?.solicitation_id, (id) => {
|
||||
if (id) loadSolicitationUrl()
|
||||
}, { immediate: true })
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────────────────────
|
||||
const quotes = computed(() => {
|
||||
if (!policy.value?.quotes) return []
|
||||
return Object.entries(policy.value.quotes).map(([provider_id, q]: [string, any]) => ({
|
||||
provider_id, ...q
|
||||
}))
|
||||
})
|
||||
|
||||
const allPlans = computed(() =>
|
||||
quotes.value.flatMap((q: any) =>
|
||||
(q.plans ?? []).map((p: any) => ({
|
||||
...p,
|
||||
provider_id: q.provider_id,
|
||||
valid_until: q.valid_until,
|
||||
quote_id: q.quote_id
|
||||
}))
|
||||
)
|
||||
)
|
||||
|
||||
const canAccept = computed(() => policy.value?.status === 'quotes_received')
|
||||
|
||||
const statusColor = (s: string) => ({
|
||||
quote_requested: 'yellow',
|
||||
quotes_received: 'blue',
|
||||
solicitation_sent: 'purple',
|
||||
active: 'green'
|
||||
}[s] ?? 'gray')
|
||||
|
||||
const statusLabel = (s: string) => ({
|
||||
quote_requested: 'Quote Requested',
|
||||
quotes_received: 'Quotes Received',
|
||||
solicitation_sent: 'Solicitation Sent',
|
||||
active: 'Active'
|
||||
}[s] ?? s)
|
||||
|
||||
const clientTypeLabel = (ct: string) => ct === 'juridico' ? 'Jurídico' : 'Natural'
|
||||
const clientTypeColor = (ct: string) => ct === 'juridico' ? 'purple' : 'blue'
|
||||
|
||||
const formatDate = (d: string) =>
|
||||
d ? new Date(d).toLocaleDateString('es-PA', { day: '2-digit', month: 'short', year: 'numeric' }) : '—'
|
||||
|
||||
const formatDateTime = (d: string) =>
|
||||
d ? new Date(d).toLocaleString('es-PA') : '—'
|
||||
|
||||
// ── Applicant display — handles both natural and juridico ────────────────────
|
||||
const applicantRows = computed(() => {
|
||||
const info = policy.value?.applicant_info ?? {}
|
||||
const ct = policy.value?.client_type
|
||||
|
||||
if (ct === 'juridico') {
|
||||
return [
|
||||
{ label: 'Company', value: info.company_name ?? info['company_name'] },
|
||||
{ label: 'RUC', value: info.ruc ?? info['ruc'] },
|
||||
{ label: 'Legal Rep', value: info.legal_rep_name ?? info['legal_rep_name'] },
|
||||
{ label: 'Rep Document', value: info.legal_rep_document ?? info['legal_rep_document'] }
|
||||
]
|
||||
}
|
||||
|
||||
return [
|
||||
{ label: 'Name', value: info.name ?? info['name'] },
|
||||
{ label: 'DOB', value: formatDate(info.date_of_birth ?? info['date_of_birth']) },
|
||||
{ label: 'Document', value: info.document_id ?? info['document_id'] }
|
||||
]
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="p-8 space-y-8 bg-gray-50 min-h-screen">
|
||||
<NuxtLink to="/policies">
|
||||
<UButton icon="i-heroicons-arrow-left" color="gray" variant="ghost">Back to Policies</UButton>
|
||||
</NuxtLink>
|
||||
|
||||
<UAlert v-if="error" color="red" variant="soft" title="Failed to load policy" :description="error.message" />
|
||||
|
||||
<div v-else-if="pending" class="space-y-4">
|
||||
<UCard v-for="n in 4" :key="n"><div class="h-32 animate-pulse bg-gray-200 rounded" /></UCard>
|
||||
</div>
|
||||
|
||||
<template v-else-if="policy">
|
||||
<!-- Header -->
|
||||
<div class="flex justify-between items-start">
|
||||
<div class="space-y-2">
|
||||
<div class="flex items-center gap-2">
|
||||
<UBadge :color="statusColor(policy.status)" variant="soft">
|
||||
{{ statusLabel(policy.status) }}
|
||||
</UBadge>
|
||||
<UBadge :color="clientTypeColor(policy.client_type)" variant="outline">
|
||||
{{ clientTypeLabel(policy.client_type) }}
|
||||
</UBadge>
|
||||
<UBadge color="gray" variant="outline">CAR</UBadge>
|
||||
</div>
|
||||
<h1 class="text-2xl font-semibold text-[var(--text-primary)]">{{ policy.applicant_display_name }}</h1>
|
||||
<p class="text-gray-500 text-sm font-mono">{{ policy.application_id }}</p>
|
||||
</div>
|
||||
<UButton icon="i-heroicons-arrow-path" color="gray" variant="soft" :loading="pending" @click="refresh()" />
|
||||
</div>
|
||||
|
||||
<!-- Info grid -->
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
|
||||
<!-- Applicant — dynamic rows based on client_type -->
|
||||
<UCard>
|
||||
<template #header>
|
||||
<p class="font-semibold text-[var(--text-primary)] flex items-center gap-2">
|
||||
<UIcon name="i-heroicons-user" class="w-4 h-4" />
|
||||
{{ policy.client_type === 'juridico' ? 'Legal Entity' : 'Applicant' }}
|
||||
<UBadge :color="clientTypeColor(policy.client_type)" variant="soft" size="xs">
|
||||
{{ clientTypeLabel(policy.client_type) }}
|
||||
</UBadge>
|
||||
</p>
|
||||
</template>
|
||||
<div class="space-y-2 text-sm">
|
||||
<div v-for="row in applicantRows" :key="row.label" class="flex justify-between">
|
||||
<span class="text-gray-500">{{ row.label }}</span>
|
||||
<span class="font-medium font-mono text-xs">{{ row.value ?? '—' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</UCard>
|
||||
|
||||
<!-- Vehicle -->
|
||||
<UCard>
|
||||
<template #header>
|
||||
<p class="font-semibold text-[var(--text-primary)] flex items-center gap-2">
|
||||
<UIcon name="i-heroicons-truck" class="w-4 h-4" /> Vehicle
|
||||
</p>
|
||||
</template>
|
||||
<div class="space-y-2 text-sm">
|
||||
<div class="flex justify-between"><span class="text-gray-500">Plate</span><span class="font-mono font-medium">{{ policy.plate }}</span></div>
|
||||
<div class="flex justify-between"><span class="text-gray-500">Vehicle</span><span>{{ policy.year }} {{ policy.make }} {{ policy.model }}</span></div>
|
||||
<div class="flex justify-between"><span class="text-gray-500">Value</span><span>${{ Number(policy.car_value).toLocaleString() }}</span></div>
|
||||
<div class="flex justify-between"><span class="text-gray-500">Type</span><span class="capitalize">{{ policy.car_type }} / {{ policy.use_type }}</span></div>
|
||||
<div class="flex justify-between"><span class="text-gray-500">Chassis</span><span class="font-mono text-xs">{{ policy.chassis_number }}</span></div>
|
||||
<div class="flex justify-between"><span class="text-gray-500">Engine</span><span class="font-mono text-xs">{{ policy.engine_number }}</span></div>
|
||||
</div>
|
||||
</UCard>
|
||||
|
||||
<!-- Issued policy -->
|
||||
<UCard v-if="policy.policy_number">
|
||||
<template #header>
|
||||
<p class="font-semibold text-[var(--text-primary)] flex items-center gap-2">
|
||||
<UIcon name="i-heroicons-check-badge" class="w-4 h-4 text-green-500" /> Policy
|
||||
</p>
|
||||
</template>
|
||||
<div class="space-y-2 text-sm">
|
||||
<div class="flex justify-between"><span class="text-gray-500">Policy #</span><span class="font-mono font-medium text-green-600">{{ policy.policy_number }}</span></div>
|
||||
<div class="flex justify-between"><span class="text-gray-500">Premium</span><span class="font-semibold">${{ policy.premium }}</span></div>
|
||||
<div class="flex justify-between"><span class="text-gray-500">Effective</span><span>{{ formatDate(policy.effective_date) }}</span></div>
|
||||
<div class="flex justify-between"><span class="text-gray-500">Expires</span><span>{{ formatDate(policy.expiry_date) }}</span></div>
|
||||
<div class="flex justify-between"><span class="text-gray-500">Issued</span><span>{{ formatDateTime(policy.issued_at) }}</span></div>
|
||||
</div>
|
||||
</UCard>
|
||||
|
||||
<!-- Providers -->
|
||||
<UCard>
|
||||
<template #header>
|
||||
<p class="font-semibold text-[var(--text-primary)] flex items-center gap-2">
|
||||
<UIcon name="i-heroicons-building-office" class="w-4 h-4" /> Providers
|
||||
<UBadge color="gray" variant="soft" size="xs">{{ policy.selected_providers?.length ?? 0 }}</UBadge>
|
||||
</p>
|
||||
</template>
|
||||
<div class="space-y-2">
|
||||
<div
|
||||
v-for="pid in policy.selected_providers" :key="pid"
|
||||
class="flex justify-between items-center text-sm p-2 bg-gray-50 rounded-lg"
|
||||
>
|
||||
<span class="font-mono text-xs text-gray-600">{{ pid }}</span>
|
||||
<UBadge :color="policy.quotes?.[pid] ? 'green' : 'yellow'" variant="soft" size="xs">
|
||||
{{ policy.quotes?.[pid] ? 'Quote received' : 'Pending' }}
|
||||
</UBadge>
|
||||
</div>
|
||||
</div>
|
||||
</UCard>
|
||||
</div>
|
||||
|
||||
<!-- Quote comparison + accept -->
|
||||
<UCard v-if="quotes.length > 0">
|
||||
<template #header>
|
||||
<div class="flex justify-between items-center">
|
||||
<p class="font-semibold text-[var(--text-primary)] flex items-center gap-2">
|
||||
<UIcon name="i-heroicons-table-cells" class="w-4 h-4" /> Quote Comparison
|
||||
<UBadge color="gray" variant="soft" size="xs">{{ allPlans.length }} plans</UBadge>
|
||||
</p>
|
||||
<UBadge v-if="policy.accepted_plan_id" color="green" variant="soft">Plan Accepted</UBadge>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full text-sm">
|
||||
<thead>
|
||||
<tr class="border-b">
|
||||
<th class="text-left py-3 px-4 text-gray-500 font-medium w-36">Feature</th>
|
||||
<th
|
||||
v-for="plan in allPlans" :key="plan.plan_id"
|
||||
class="py-3 px-4 text-center min-w-44"
|
||||
:class="plan.plan_id === policy.accepted_plan_id ? 'bg-green-50' : ''"
|
||||
>
|
||||
<div class="space-y-1">
|
||||
<UBadge v-if="plan.plan_id === policy.accepted_plan_id" color="green" variant="soft" size="xs">
|
||||
Selected
|
||||
</UBadge>
|
||||
<p class="font-semibold text-[var(--text-primary)]">{{ plan.name }}</p>
|
||||
<p class="text-xs font-mono text-gray-400">{{ plan.provider_id?.slice(0, 8) }}...</p>
|
||||
</div>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr class="border-b bg-gray-50">
|
||||
<td class="py-3 px-4 font-medium text-gray-600">Premium</td>
|
||||
<td v-for="plan in allPlans" :key="plan.plan_id" class="py-3 px-4 text-center"
|
||||
:class="plan.plan_id === policy.accepted_plan_id ? 'bg-green-50' : ''">
|
||||
<span class="font-bold text-lg text-[var(--text-primary)]">${{ Number(plan.premium).toLocaleString() }}</span>
|
||||
<span class="text-xs text-gray-400 block">/year</span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr class="border-b">
|
||||
<td class="py-3 px-4 font-medium text-gray-600">Deductible</td>
|
||||
<td v-for="plan in allPlans" :key="plan.plan_id" class="py-3 px-4 text-center"
|
||||
:class="plan.plan_id === policy.accepted_plan_id ? 'bg-green-50' : ''">
|
||||
<span v-if="plan.deductible">${{ Number(plan.deductible).toLocaleString() }}</span>
|
||||
<span v-else class="text-gray-400">—</span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr class="border-b bg-gray-50">
|
||||
<td class="py-3 px-4 font-medium text-gray-600">Coverage Limit</td>
|
||||
<td v-for="plan in allPlans" :key="plan.plan_id" class="py-3 px-4 text-center"
|
||||
:class="plan.plan_id === policy.accepted_plan_id ? 'bg-green-50' : ''">
|
||||
<span v-if="plan.coverage_limit">${{ Number(plan.coverage_limit).toLocaleString() }}</span>
|
||||
<span v-else class="text-gray-400">—</span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr class="border-b">
|
||||
<td class="py-3 px-4 font-medium text-gray-600">Valid Until</td>
|
||||
<td v-for="plan in allPlans" :key="plan.plan_id" class="py-3 px-4 text-center"
|
||||
:class="plan.plan_id === policy.accepted_plan_id ? 'bg-green-50' : ''">
|
||||
{{ formatDate(plan.valid_until) }}
|
||||
</td>
|
||||
</tr>
|
||||
<tr class="border-b bg-gray-50">
|
||||
<td class="py-3 px-4 font-medium text-gray-600 align-top pt-4">Coverage</td>
|
||||
<td v-for="plan in allPlans" :key="plan.plan_id"
|
||||
class="py-3 px-4 text-center align-top pt-4"
|
||||
:class="plan.plan_id === policy.accepted_plan_id ? 'bg-green-50' : ''">
|
||||
<p class="text-xs text-gray-600 leading-relaxed">{{ plan.coverage_details }}</p>
|
||||
</td>
|
||||
</tr>
|
||||
<!-- Accept row -->
|
||||
<tr v-if="canAccept">
|
||||
<td class="py-4 px-4" />
|
||||
<td v-for="plan in allPlans" :key="plan.plan_id" class="py-4 px-4 text-center">
|
||||
<UButton
|
||||
color="primary" size="sm" icon="i-heroicons-check"
|
||||
@click="openAccept({ quote_id: plan.quote_id, provider_id: plan.provider_id, valid_until: plan.valid_until }, plan)"
|
||||
>
|
||||
Accept
|
||||
</UButton>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</UCard>
|
||||
|
||||
<!-- Solicitation PDF -->
|
||||
<UCard v-if="policy.solicitation_id">
|
||||
<template #header>
|
||||
<div class="flex justify-between items-center">
|
||||
<p class="font-semibold text-[var(--text-primary)] flex items-center gap-2">
|
||||
<UIcon name="i-heroicons-document-text" class="w-4 h-4" /> Solicitation Document
|
||||
</p>
|
||||
<div class="flex items-center gap-2">
|
||||
<UBadge color="purple" variant="soft" size="xs">{{ policy.solicitation_id?.slice(0, 8) }}...</UBadge>
|
||||
<UButton
|
||||
icon="i-heroicons-arrow-path" color="gray" variant="ghost" size="xs"
|
||||
:loading="loadingPdf" @click="loadSolicitationUrl()"
|
||||
>
|
||||
Refresh URL
|
||||
</UButton>
|
||||
<UButton
|
||||
v-if="solicitationUrl"
|
||||
icon="i-heroicons-arrow-top-right-on-square"
|
||||
color="gray" variant="soft" size="xs"
|
||||
:to="solicitationUrl" target="_blank"
|
||||
>
|
||||
Open
|
||||
</UButton>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div v-if="loadingPdf" class="h-64 flex items-center justify-center text-gray-400">
|
||||
<UIcon name="i-heroicons-document-arrow-down" class="w-8 h-8 animate-pulse" />
|
||||
</div>
|
||||
<UAlert v-else-if="pdfError" color="red" variant="soft" :description="pdfError" />
|
||||
<iframe
|
||||
v-else-if="solicitationUrl"
|
||||
:src="solicitationUrl"
|
||||
class="w-full rounded-lg border"
|
||||
style="height: 600px;"
|
||||
/>
|
||||
<div v-else class="h-32 flex items-center justify-center text-gray-400 text-sm">
|
||||
Solicitation not yet generated
|
||||
</div>
|
||||
</UCard>
|
||||
</template>
|
||||
|
||||
<!-- Accept Slideover -->
|
||||
<USlideover v-model:open="isAcceptOpen" side="right">
|
||||
<template #content>
|
||||
<div class="flex flex-col h-full">
|
||||
<div class="flex justify-between items-center p-6 border-b">
|
||||
<div>
|
||||
<h2 class="text-lg font-semibold text-[var(--text-primary)]">Accept Plan</h2>
|
||||
<p v-if="selectedPlan" class="text-sm text-gray-500">
|
||||
{{ selectedPlan.name }} — ${{ Number(selectedPlan.premium).toLocaleString() }}/yr
|
||||
</p>
|
||||
</div>
|
||||
<UButton icon="i-heroicons-x-mark" color="gray" variant="ghost" @click="isAcceptOpen = false" />
|
||||
</div>
|
||||
|
||||
<div class="flex-1 overflow-y-auto p-6 space-y-6">
|
||||
<!-- Plan summary -->
|
||||
<div v-if="selectedPlan" class="bg-primary-50 border border-primary-200 rounded-lg p-4 text-sm space-y-2">
|
||||
<div class="flex justify-between">
|
||||
<span class="text-primary-600">Plan</span>
|
||||
<span class="font-semibold">{{ selectedPlan.name }}</span>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<span class="text-primary-600">Premium</span>
|
||||
<span class="font-bold text-primary-900">${{ Number(selectedPlan.premium).toLocaleString() }}/yr</span>
|
||||
</div>
|
||||
<div v-if="selectedPlan.deductible" class="flex justify-between">
|
||||
<span class="text-primary-600">Deductible</span>
|
||||
<span>${{ Number(selectedPlan.deductible).toLocaleString() }}</span>
|
||||
</div>
|
||||
<div v-if="selectedPlan.coverage_limit" class="flex justify-between">
|
||||
<span class="text-primary-600">Coverage Limit</span>
|
||||
<span>${{ Number(selectedPlan.coverage_limit).toLocaleString() }}</span>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<span class="text-primary-600">Provider</span>
|
||||
<span class="font-mono text-xs">{{ selectedQuote?.provider_id?.slice(0, 12) }}...</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Optional solicitation fields -->
|
||||
<div class="space-y-3">
|
||||
<div class="flex justify-between items-center">
|
||||
<p class="font-medium text-sm text-[var(--text-primary)]">Additional Fields</p>
|
||||
<UButton
|
||||
icon="i-heroicons-plus" color="gray" variant="soft" size="xs"
|
||||
@click="solicitationFields[`field_${Object.keys(solicitationFields).length + 1}`] = ''"
|
||||
>
|
||||
Add Field
|
||||
</UButton>
|
||||
</div>
|
||||
<p class="text-xs text-gray-400">
|
||||
Optional provider-specific fields for the solicitation form.
|
||||
</p>
|
||||
<div v-for="(val, key) in solicitationFields" :key="key" class="flex gap-2 items-end">
|
||||
<UFormField :label="String(key)" class="flex-1">
|
||||
<UInput v-model="solicitationFields[key]" class="w-full" />
|
||||
</UFormField>
|
||||
<UButton icon="i-heroicons-trash" color="red" variant="ghost" size="sm"
|
||||
@click="delete solicitationFields[key]" />
|
||||
</div>
|
||||
<p v-if="Object.keys(solicitationFields).length === 0" class="text-xs text-gray-400 italic">
|
||||
No additional fields — the PDF will be filled from policy data automatically.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="p-6 border-t flex justify-end gap-3">
|
||||
<UButton color="gray" variant="soft" @click="isAcceptOpen = false">Cancel</UButton>
|
||||
<UButton
|
||||
color="primary" icon="i-heroicons-check"
|
||||
:loading="accepting"
|
||||
@click="submitAccept"
|
||||
>
|
||||
Confirm & Generate Solicitation
|
||||
</UButton>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</USlideover>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,174 +1,84 @@
|
||||
<script setup lang="ts">
|
||||
import { MOCK_CUSTOMERS, fmtMoney, type MockPolicy } from '~/data/mock-customers'
|
||||
|
||||
definePageMeta({ ssr: false })
|
||||
usePageTitle('Book of Business')
|
||||
|
||||
const { items } = useEmissionsQueue()
|
||||
const inForce = computed(() => items.value.filter((x) => x.status === 'in_force'))
|
||||
|
||||
/* ── Flatten all mock policies with customer info ── */
|
||||
type FlatPolicy = MockPolicy & { customerName: string; customerId: string }
|
||||
|
||||
const allPolicies = computed<FlatPolicy[]>(() => {
|
||||
const rows: FlatPolicy[] = []
|
||||
for (const cust of MOCK_CUSTOMERS) {
|
||||
for (const pol of cust.policies) {
|
||||
rows.push({ ...pol, customerName: cust.name, customerId: cust.id })
|
||||
}
|
||||
const { data, pending } = usePolicy('/policies', {
|
||||
query: {
|
||||
'page_size': 100
|
||||
}
|
||||
return rows
|
||||
})
|
||||
|
||||
/* ── KPIs ── */
|
||||
const totalGWP = computed(() => allPolicies.value.reduce((s, p) => s + p.premium, 0))
|
||||
const activePolicies = computed(() => allPolicies.value.filter((p) => p.status === 'Active').length)
|
||||
const retentionRate = computed(() => {
|
||||
const total = allPolicies.value.length
|
||||
const active = allPolicies.value.filter((p) => p.status === 'Active').length
|
||||
return total > 0 ? Math.round((active / total) * 100) : 0
|
||||
})
|
||||
const policies = computed(() => data.value?.data ?? [])
|
||||
|
||||
const totalGWP = computed(() => policies.value.reduce((s, p) => s + (p.premium || 0), 0))
|
||||
const activePolicies = computed(() => policies.value.filter((p) => p.status === 'issued').length)
|
||||
const avgPremium = computed(() => {
|
||||
const count = allPolicies.value.length
|
||||
const count = policies.value.length
|
||||
return count > 0 ? Math.round(totalGWP.value / count) : 0
|
||||
})
|
||||
|
||||
/* ── Line of business breakdown ── */
|
||||
type LineRow = { line: string; count: number; totalPremium: number; pctOfBook: number; icon: string }
|
||||
function fmtMoney(n: number) {
|
||||
return new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD', minimumFractionDigits: 0, maximumFractionDigits: 0 }).format(n)
|
||||
}
|
||||
|
||||
const lineBreakdown = computed<LineRow[]>(() => {
|
||||
const map = new Map<string, { count: number; total: number; icon: string }>()
|
||||
for (const pol of allPolicies.value) {
|
||||
const existing = map.get(pol.line)
|
||||
if (existing) {
|
||||
existing.count++
|
||||
existing.total += pol.premium
|
||||
} else {
|
||||
map.set(pol.line, { count: 1, total: pol.premium, icon: lineIcon(pol.line) })
|
||||
}
|
||||
function formatDate(d: string) {
|
||||
if (!d) return '—'
|
||||
return new Date(d).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })
|
||||
}
|
||||
|
||||
function policyApplicantName(p: any) {
|
||||
const info = p.insured
|
||||
if (!info || typeof info !== 'object') return '—'
|
||||
if (info.type === 'corporate') {
|
||||
return info.company_name || '—'
|
||||
}
|
||||
const gwp = totalGWP.value || 1
|
||||
const rows: LineRow[] = []
|
||||
for (const [line, data] of map.entries()) {
|
||||
rows.push({
|
||||
line,
|
||||
count: data.count,
|
||||
totalPremium: data.total,
|
||||
pctOfBook: Math.round((data.total / gwp) * 100),
|
||||
icon: data.icon
|
||||
})
|
||||
return info.name || '—'
|
||||
}
|
||||
|
||||
function policyDetailsSummary(p: any) {
|
||||
const d = p.policy_details
|
||||
if (!d || typeof d !== 'object') return '—'
|
||||
if (p.policy_type === 'car') {
|
||||
const parts = [d.year, d.make, d.model].filter((x: any) => x !== undefined && x !== null && String(x) !== '')
|
||||
return parts.length ? parts.map(String).join(' ') : '—'
|
||||
}
|
||||
rows.sort((a, b) => b.totalPremium - a.totalPremium)
|
||||
return rows
|
||||
})
|
||||
|
||||
/* ── Top carriers ── */
|
||||
type CarrierRow = { carrier: string; count: number; gwp: number; pctShare: number }
|
||||
|
||||
const topCarriers = computed<CarrierRow[]>(() => {
|
||||
const map = new Map<string, { count: number; gwp: number }>()
|
||||
for (const pol of allPolicies.value) {
|
||||
const existing = map.get(pol.carrier)
|
||||
if (existing) {
|
||||
existing.count++
|
||||
existing.gwp += pol.premium
|
||||
} else {
|
||||
map.set(pol.carrier, { count: 1, gwp: pol.premium })
|
||||
}
|
||||
if (p.policy_type === 'life') {
|
||||
return `Life · ${d.coverage_amount || 0} USD`
|
||||
}
|
||||
const gwp = totalGWP.value || 1
|
||||
const rows: CarrierRow[] = []
|
||||
for (const [carrier, data] of map.entries()) {
|
||||
rows.push({
|
||||
carrier,
|
||||
count: data.count,
|
||||
gwp: data.gwp,
|
||||
pctShare: Math.round((data.gwp / gwp) * 100)
|
||||
})
|
||||
if (p.policy_type === 'fire_structure' || p.policy_type === 'fire_contents') {
|
||||
return d.location || '—'
|
||||
}
|
||||
rows.sort((a, b) => b.gwp - a.gwp)
|
||||
return rows
|
||||
})
|
||||
return '—'
|
||||
}
|
||||
|
||||
/* ── Recent activity ── */
|
||||
type ActivityItem = { date: string; text: string; type: string; customerName: string }
|
||||
|
||||
const recentActivity = computed<ActivityItem[]>(() => {
|
||||
const events: ActivityItem[] = []
|
||||
for (const cust of MOCK_CUSTOMERS) {
|
||||
for (const evt of cust.activity) {
|
||||
if (evt.type === 'policy' || evt.type === 'renewal' || evt.type === 'claim') {
|
||||
events.push({ ...evt, customerName: cust.name })
|
||||
}
|
||||
}
|
||||
}
|
||||
const order = ['Today', 'Yesterday']
|
||||
events.sort((a, b) => {
|
||||
const ai = order.indexOf(a.date)
|
||||
const bi = order.indexOf(b.date)
|
||||
if (ai >= 0 && bi >= 0) return ai - bi
|
||||
if (ai >= 0) return -1
|
||||
if (bi >= 0) return 1
|
||||
return b.date.localeCompare(a.date)
|
||||
})
|
||||
return events.slice(0, 5)
|
||||
})
|
||||
|
||||
/* ── Helpers ── */
|
||||
function lineIcon(line: string) {
|
||||
switch (line) {
|
||||
case 'Auto': return 'i-heroicons-truck'
|
||||
case 'Health': return 'i-heroicons-heart'
|
||||
case 'Life': return 'i-heroicons-shield-check'
|
||||
case 'Home': return 'i-heroicons-home-modern'
|
||||
case 'Renter': return 'i-heroicons-home-modern'
|
||||
case 'Umbrella': return 'i-heroicons-shield-exclamation'
|
||||
default: return 'i-heroicons-document'
|
||||
function statusColor(status: string) {
|
||||
switch (status) {
|
||||
case 'quote_requested': return 'yellow'
|
||||
case 'quotes_received': return 'blue'
|
||||
case 'solicitation_sent': return 'purple'
|
||||
case 'issued': return 'green'
|
||||
default: return 'gray'
|
||||
}
|
||||
}
|
||||
|
||||
function lineColorClass(line: string) {
|
||||
switch (line) {
|
||||
case 'Auto': return 'bk-line-auto'
|
||||
case 'Health': return 'bk-line-health'
|
||||
case 'Life': return 'bk-line-life'
|
||||
case 'Home': return 'bk-line-home'
|
||||
case 'Renter': return 'bk-line-home'
|
||||
case 'Umbrella': return 'bk-line-umbrella'
|
||||
default: return 'bk-line-default'
|
||||
function statusLabel(status: string) {
|
||||
switch (status) {
|
||||
case 'quote_requested': return 'Quote Requested'
|
||||
case 'quotes_received': return 'Quotes Received'
|
||||
case 'solicitation_sent': return 'Solicitation Sent'
|
||||
case 'issued': return 'Issued'
|
||||
default: return status
|
||||
}
|
||||
}
|
||||
|
||||
function activityIcon(type: string) {
|
||||
switch (type) {
|
||||
case 'policy': return 'i-heroicons-document-check'
|
||||
case 'renewal': return 'i-heroicons-arrow-path'
|
||||
case 'claim': return 'i-heroicons-shield-exclamation'
|
||||
default: return 'i-heroicons-document'
|
||||
}
|
||||
}
|
||||
|
||||
function activityColor(type: string) {
|
||||
switch (type) {
|
||||
case 'policy': return 'background: rgba(1,105,111,0.08); color: #01696f;'
|
||||
case 'renewal': return 'background: rgba(245,158,11,0.08); color: #d97706;'
|
||||
case 'claim': return 'background: rgba(244,63,94,0.08); color: #e11d48;'
|
||||
default: return 'background: rgba(0,0,0,0.04); color: #8a8a86;'
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Tab state ── */
|
||||
const activeTab = ref<'overview' | 'carriers'>('overview')
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="bk-root mx-auto max-w-6xl space-y-6 pb-12">
|
||||
<!-- Header -->
|
||||
<div class="flex items-start justify-between">
|
||||
<div>
|
||||
<h1 class="mt-1 text-2xl font-semibold tracking-tight text-[var(--text-primary)]">Book of Business</h1>
|
||||
<p class="mt-1 text-[13px] text-[var(--text-muted)]">
|
||||
Consolidated view of all policies, lines, and carriers
|
||||
Consolidated view of all policies
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
@@ -181,252 +91,85 @@ const activeTab = ref<'overview' | 'carriers'>('overview')
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Process note -->
|
||||
<div style="display: flex; align-items: flex-start; gap: 10px; padding: 14px 16px; border-radius: 12px; background: rgba(147,51,234,0.05); border: 1px solid rgba(147,51,234,0.12);">
|
||||
<UIcon name="i-heroicons-beaker" style="width: 16px; height: 16px; color: #9333ea; flex-shrink: 0; margin-top: 1px;" />
|
||||
<div>
|
||||
<p style="font-size: 13px; font-weight: 600; color: #7c3aed;">Cartera Global in development</p>
|
||||
<p style="font-size: 12px; color: #8b5cf6; margin-top: 2px; line-height: 1.5;">Portfolio views, carrier breakdowns, retention analytics, and book-level reporting are actively being defined. Layout and feature scope may change.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- KPI strip -->
|
||||
<div class="bk-kpi-strip">
|
||||
<div class="bk-kpi-item">
|
||||
<span class="bk-kpi-label">Total Book GWP</span>
|
||||
<span class="bk-kpi-value">{{ fmtMoney(totalGWP) }}<span class="bk-kpi-suffix">/yr</span></span>
|
||||
</div>
|
||||
<div class="bk-kpi-divider" />
|
||||
<div class="bk-kpi-item">
|
||||
<span class="bk-kpi-label">Active Policies</span>
|
||||
<span class="bk-kpi-value">{{ activePolicies }}</span>
|
||||
</div>
|
||||
<div class="bk-kpi-divider" />
|
||||
<div class="bk-kpi-item">
|
||||
<span class="bk-kpi-label">Retention Rate</span>
|
||||
<span class="bk-kpi-value">{{ retentionRate }}%</span>
|
||||
</div>
|
||||
<div class="bk-kpi-divider" />
|
||||
<div class="bk-kpi-item">
|
||||
<span class="bk-kpi-label">Avg Premium</span>
|
||||
<span class="bk-kpi-value">{{ fmtMoney(avgPremium) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- In-force from emissions queue (real data) -->
|
||||
<div v-if="inForce.length > 0" class="bk-card bk-card-flush">
|
||||
<div class="bk-card-head">
|
||||
<span>In-Force from Emissions</span>
|
||||
<span class="bk-card-head-count">{{ inForce.length }}</span>
|
||||
</div>
|
||||
<div class="bk-table-wrap">
|
||||
<table class="bk-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="bk-th">Customer</th>
|
||||
<th class="bk-th">Insurer</th>
|
||||
<th class="bk-th">Product</th>
|
||||
<th class="bk-th">Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="row in inForce" :key="row.id" class="bk-row">
|
||||
<td class="bk-td">
|
||||
<span class="bk-text-primary">{{ row.customerLabel }}</span>
|
||||
</td>
|
||||
<td class="bk-td">
|
||||
<span class="bk-text-muted">{{ row.insurerSlug }}</span>
|
||||
</td>
|
||||
<td class="bk-td">
|
||||
<span class="bk-text-muted">{{ row.subRamoKey }} · {{ row.productLine }}</span>
|
||||
</td>
|
||||
<td class="bk-td">
|
||||
<span class="bk-status-badge bk-status-active">In force</span>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tab toggle -->
|
||||
<div class="bk-tab-container">
|
||||
<button
|
||||
class="bk-tab"
|
||||
:class="{ 'bk-tab-active': activeTab === 'overview' }"
|
||||
@click="activeTab = 'overview'"
|
||||
>
|
||||
Overview
|
||||
</button>
|
||||
<button
|
||||
class="bk-tab"
|
||||
:class="{ 'bk-tab-active': activeTab === 'carriers' }"
|
||||
@click="activeTab = 'carriers'"
|
||||
>
|
||||
Carriers
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Overview tab -->
|
||||
<template v-if="activeTab === 'overview'">
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
<!-- Line of Business breakdown -->
|
||||
<div class="bk-card bk-card-flush">
|
||||
<div class="bk-card-head">
|
||||
<span>By Line of Business</span>
|
||||
<span class="bk-card-head-count">{{ lineBreakdown.length }} lines</span>
|
||||
</div>
|
||||
<div class="bk-table-wrap">
|
||||
<table class="bk-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="bk-th">Line</th>
|
||||
<th class="bk-th" style="text-align: center;">Policies</th>
|
||||
<th class="bk-th" style="text-align: right;">Premium</th>
|
||||
<th class="bk-th" style="text-align: right; width: 80px;">% of Book</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="row in lineBreakdown" :key="row.line" class="bk-row">
|
||||
<td class="bk-td">
|
||||
<div class="flex items-center gap-2.5">
|
||||
<div class="bk-line-icon-wrap" :class="lineColorClass(row.line)">
|
||||
<UIcon :name="row.icon" class="w-3.5 h-3.5" />
|
||||
</div>
|
||||
<span class="bk-text-primary" style="font-weight: 600;">{{ row.line }}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td class="bk-td" style="text-align: center;">
|
||||
<span class="bk-text-muted">{{ row.count }}</span>
|
||||
</td>
|
||||
<td class="bk-td" style="text-align: right;">
|
||||
<span class="bk-text-primary" style="font-weight: 600;">{{ fmtMoney(row.totalPremium) }}</span>
|
||||
</td>
|
||||
<td class="bk-td" style="text-align: right;">
|
||||
<div class="bk-pct-cell">
|
||||
<div class="bk-pct-bar">
|
||||
<div class="bk-pct-bar-fill" :style="{ width: row.pctOfBook + '%' }" />
|
||||
</div>
|
||||
<span class="bk-pct-label">{{ row.pctOfBook }}%</span>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div v-if="pending" class="bk-card">
|
||||
<div style="padding: 20px;">
|
||||
<div v-for="n in 8" :key="n" class="pol-skeleton-row">
|
||||
<div class="pol-skeleton" style="width: 80px; height: 12px;" />
|
||||
<div class="pol-skeleton" style="width: 140px; height: 12px;" />
|
||||
<div class="pol-skeleton" style="width: 50px; height: 18px; border-radius: 10px;" />
|
||||
<div class="pol-skeleton" style="width: 100px; height: 12px;" />
|
||||
<div class="pol-skeleton" style="width: 100px; height: 12px;" />
|
||||
<div class="pol-skeleton" style="width: 70px; height: 12px;" />
|
||||
<div class="pol-skeleton" style="width: 50px; height: 18px; border-radius: 10px;" />
|
||||
<div class="pol-skeleton" style="width: 80px; height: 12px;" />
|
||||
<div class="pol-skeleton" style="width: 60px; height: 12px;" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Recent Activity -->
|
||||
<div class="bk-card bk-card-flush">
|
||||
<div class="bk-card-head">
|
||||
<span>Recent Activity</span>
|
||||
<span class="bk-card-head-count">Last 5</span>
|
||||
</div>
|
||||
<div class="bk-activity-list">
|
||||
<div v-for="(evt, i) in recentActivity" :key="i" class="bk-activity-item">
|
||||
<div class="bk-activity-icon" :style="activityColor(evt.type)">
|
||||
<UIcon :name="activityIcon(evt.type)" class="w-3.5 h-3.5" />
|
||||
</div>
|
||||
<div class="bk-activity-body">
|
||||
<p class="bk-activity-text">{{ evt.text }}</p>
|
||||
<p class="bk-activity-meta">{{ evt.customerName }} · {{ evt.date }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="recentActivity.length === 0" class="bk-empty-small">
|
||||
<p>No recent policy activity</p>
|
||||
</div>
|
||||
</div>
|
||||
<template v-else>
|
||||
<div class="bk-kpi-strip">
|
||||
<div class="bk-kpi-item">
|
||||
<span class="bk-kpi-label">Total Book GWP</span>
|
||||
<span class="bk-kpi-value">{{ fmtMoney(totalGWP) }}<span class="bk-kpi-suffix">/yr</span></span>
|
||||
</div>
|
||||
<div class="bk-kpi-divider" />
|
||||
<div class="bk-kpi-item">
|
||||
<span class="bk-kpi-label">Active Policies</span>
|
||||
<span class="bk-kpi-value">{{ activePolicies }}</span>
|
||||
</div>
|
||||
<div class="bk-kpi-divider" />
|
||||
<div class="bk-kpi-item">
|
||||
<span class="bk-kpi-label">Avg Premium</span>
|
||||
<span class="bk-kpi-value">{{ fmtMoney(avgPremium) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Premium distribution visual -->
|
||||
<div class="bk-card bk-card-flush">
|
||||
<div class="bk-card-head">
|
||||
<span>Premium Distribution</span>
|
||||
</div>
|
||||
<div style="padding: 20px;">
|
||||
<div class="bk-dist-bar">
|
||||
<div
|
||||
v-for="row in lineBreakdown"
|
||||
:key="row.line"
|
||||
class="bk-dist-segment"
|
||||
:class="lineColorClass(row.line)"
|
||||
:style="{ width: row.pctOfBook + '%' }"
|
||||
:title="`${row.line}: ${row.pctOfBook}%`"
|
||||
/>
|
||||
</div>
|
||||
<div class="bk-dist-legend">
|
||||
<div v-for="row in lineBreakdown" :key="row.line" class="bk-dist-legend-item">
|
||||
<span class="bk-dist-dot" :class="lineColorClass(row.line)" />
|
||||
<span class="bk-dist-legend-label">{{ row.line }}</span>
|
||||
<span class="bk-dist-legend-pct">{{ row.pctOfBook }}%</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Carriers tab -->
|
||||
<template v-if="activeTab === 'carriers'">
|
||||
<div class="bk-card bk-card-flush">
|
||||
<div class="bk-card-head">
|
||||
<span>Top Carriers</span>
|
||||
<span class="bk-card-head-count">{{ topCarriers.length }} carriers</span>
|
||||
<span>Policies</span>
|
||||
<span class="bk-card-head-count">{{ policies.length }}</span>
|
||||
</div>
|
||||
<div class="bk-table-wrap">
|
||||
<table class="bk-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="bk-th" style="width: 36px;">#</th>
|
||||
<th class="bk-th">Carrier</th>
|
||||
<th class="bk-th" style="text-align: center;">Policies</th>
|
||||
<th class="bk-th" style="text-align: right;">GWP</th>
|
||||
<th class="bk-th" style="text-align: right; width: 100px;">Share</th>
|
||||
<th class="bk-th">Applicant</th>
|
||||
<th class="bk-th">Type</th>
|
||||
<th class="bk-th">Details</th>
|
||||
<th class="bk-th">Status</th>
|
||||
<th class="bk-th">Submitted</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="(row, idx) in topCarriers" :key="row.carrier" class="bk-row">
|
||||
<tr v-for="policy in policies" :key="policy.application_id" class="bk-row">
|
||||
<td class="bk-td">
|
||||
<span class="bk-rank">{{ idx + 1 }}</span>
|
||||
<p class="bk-text-primary">{{ policyApplicantName(policy) }}</p>
|
||||
</td>
|
||||
<td class="bk-td">
|
||||
<span class="bk-text-primary" style="font-weight: 600;">{{ row.carrier }}</span>
|
||||
<span class="bk-text-muted capitalize">{{ policy.policy_type }}</span>
|
||||
</td>
|
||||
<td class="bk-td" style="text-align: center;">
|
||||
<span class="bk-text-muted">{{ row.count }}</span>
|
||||
<td class="bk-td">
|
||||
<span class="bk-text-muted">{{ policyDetailsSummary(policy) }}</span>
|
||||
</td>
|
||||
<td class="bk-td" style="text-align: right;">
|
||||
<span class="bk-text-primary" style="font-weight: 600;">{{ fmtMoney(row.gwp) }}</span>
|
||||
<td class="bk-td">
|
||||
<span
|
||||
class="bk-status-badge"
|
||||
:class="`bk-status-${statusColor(policy.status)}`"
|
||||
>{{ statusLabel(policy.status) }}</span>
|
||||
</td>
|
||||
<td class="bk-td" style="text-align: right;">
|
||||
<div class="bk-pct-cell">
|
||||
<div class="bk-pct-bar">
|
||||
<div class="bk-pct-bar-fill" :style="{ width: row.pctShare + '%' }" />
|
||||
</div>
|
||||
<span class="bk-pct-label">{{ row.pctShare }}%</span>
|
||||
</div>
|
||||
<td class="bk-td">
|
||||
<span class="bk-renewal-date">{{ formatDate(policy.submitted_at) }}</span>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Carrier premium breakdown -->
|
||||
<div class="bk-card bk-card-flush">
|
||||
<div class="bk-card-head">
|
||||
<span>Carrier Premium Share</span>
|
||||
</div>
|
||||
<div style="padding: 20px;">
|
||||
<div class="bk-carrier-bars">
|
||||
<div v-for="row in topCarriers" :key="row.carrier" class="bk-carrier-bar-row">
|
||||
<span class="bk-carrier-bar-label">{{ row.carrier }}</span>
|
||||
<div class="bk-carrier-bar-track">
|
||||
<div class="bk-carrier-bar-fill" :style="{ width: row.pctShare + '%' }" />
|
||||
</div>
|
||||
<span class="bk-carrier-bar-value">{{ fmtMoney(row.gwp) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="policies.length === 0" class="bk-empty">
|
||||
<UIcon name="i-heroicons-document-text" class="w-10 h-10" style="color: #8a8a86; opacity: 0.5;" />
|
||||
<p class="bk-empty-title">No policies found</p>
|
||||
<p class="bk-empty-sub">Create a new policy to get started</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -434,19 +177,15 @@ const activeTab = ref<'overview' | 'carriers'>('overview')
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* =====================================================================
|
||||
BOOK OF BUSINESS — DESIGN SYSTEM (scoped, bk- prefix)
|
||||
===================================================================== */
|
||||
|
||||
.bk-root {
|
||||
--bk-brand: #01696f;
|
||||
--bk-brand-soft: rgba(1, 105, 111, 0.06);
|
||||
--bk-border: rgba(0, 0, 0, 0.06);
|
||||
--bk-border-strong: rgba(0, 0, 0, 0.08);
|
||||
--bk-muted: #8a8a86;
|
||||
--bk-surface: #ffffff;
|
||||
}
|
||||
|
||||
|
||||
/* ---- Card system ---- */
|
||||
.bk-card {
|
||||
background: #ffffff;
|
||||
border-radius: 12px;
|
||||
@@ -475,7 +214,6 @@ const activeTab = ref<'overview' | 'carriers'>('overview')
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
/* ---- KPI strip ---- */
|
||||
.bk-kpi-strip {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -519,7 +257,6 @@ const activeTab = ref<'overview' | 'carriers'>('overview')
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* ---- Buttons ---- */
|
||||
.bk-btn-secondary {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
@@ -539,35 +276,6 @@ const activeTab = ref<'overview' | 'carriers'>('overview')
|
||||
color: #01696f;
|
||||
}
|
||||
|
||||
/* ---- Tab toggle ---- */
|
||||
.bk-tab-container {
|
||||
display: inline-flex;
|
||||
background: rgba(0, 0, 0, 0.04);
|
||||
border-radius: 8px;
|
||||
padding: 3px;
|
||||
gap: 2px;
|
||||
}
|
||||
.bk-tab {
|
||||
padding: 6px 16px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: #8a8a86;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
transition: all 150ms ease;
|
||||
}
|
||||
.bk-tab:hover {
|
||||
color: var(--text-primary, #1a1a1a);
|
||||
}
|
||||
.bk-tab-active {
|
||||
background: #ffffff;
|
||||
color: var(--text-primary, #1a1a1a);
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
|
||||
/* ---- Table ---- */
|
||||
.bk-table-wrap {
|
||||
overflow-x: auto;
|
||||
}
|
||||
@@ -602,7 +310,6 @@ const activeTab = ref<'overview' | 'carriers'>('overview')
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
/* ---- Text helpers ---- */
|
||||
.bk-text-primary {
|
||||
font-size: 13px;
|
||||
color: var(--text-primary, #1a1a1a);
|
||||
@@ -612,7 +319,6 @@ const activeTab = ref<'overview' | 'carriers'>('overview')
|
||||
color: var(--text-muted, #5c5650);
|
||||
}
|
||||
|
||||
/* ---- Status badge ---- */
|
||||
.bk-status-badge {
|
||||
display: inline-block;
|
||||
font-size: 10px;
|
||||
@@ -621,216 +327,53 @@ const activeTab = ref<'overview' | 'carriers'>('overview')
|
||||
border-radius: 10px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.bk-status-active { background: rgba(16, 185, 129, 0.1); color: #059669; }
|
||||
.bk-status-yellow { background: rgba(245, 158, 11, 0.1); color: #d97706; }
|
||||
.bk-status-blue { background: rgba(59, 130, 246, 0.1); color: #2563eb; }
|
||||
.bk-status-purple { background: rgba(139, 92, 246, 0.1); color: #7c3aed; }
|
||||
.bk-status-green { background: rgba(16, 185, 129, 0.1); color: #059669; }
|
||||
.bk-status-gray { background: rgba(0, 0, 0, 0.05); color: #8a8a86; }
|
||||
|
||||
/* ---- Line icon wrap ---- */
|
||||
.bk-line-icon-wrap {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 7px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* ---- Line colors (used for icons, dist segments, dots) ---- */
|
||||
.bk-line-auto { background: rgba(59, 130, 246, 0.08); color: #2563eb; }
|
||||
.bk-line-health { background: rgba(236, 72, 153, 0.08); color: #db2777; }
|
||||
.bk-line-life { background: rgba(16, 185, 129, 0.08); color: #059669; }
|
||||
.bk-line-home { background: rgba(245, 158, 11, 0.08); color: #d97706; }
|
||||
.bk-line-umbrella { background: rgba(139, 92, 246, 0.08); color: #7c3aed; }
|
||||
.bk-line-default { background: rgba(0, 0, 0, 0.04); color: #8a8a86; }
|
||||
|
||||
/* ---- Percentage cell ---- */
|
||||
.bk-pct-cell {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
.bk-pct-bar {
|
||||
width: 48px;
|
||||
height: 4px;
|
||||
background: rgba(0, 0, 0, 0.05);
|
||||
border-radius: 2px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.bk-pct-bar-fill {
|
||||
height: 100%;
|
||||
background: #01696f;
|
||||
border-radius: 2px;
|
||||
transition: width 300ms ease;
|
||||
}
|
||||
.bk-pct-label {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: #8a8a86;
|
||||
min-width: 28px;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
/* ---- Rank badge ---- */
|
||||
.bk-rank {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
border-radius: 6px;
|
||||
background: rgba(0, 0, 0, 0.04);
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
color: #8a8a86;
|
||||
}
|
||||
|
||||
/* ---- Distribution bar ---- */
|
||||
.bk-dist-bar {
|
||||
display: flex;
|
||||
height: 10px;
|
||||
border-radius: 5px;
|
||||
overflow: hidden;
|
||||
gap: 2px;
|
||||
}
|
||||
.bk-dist-segment {
|
||||
height: 100%;
|
||||
min-width: 4px;
|
||||
border-radius: 3px;
|
||||
transition: width 300ms ease;
|
||||
}
|
||||
/* Reuse line color classes for segment backgrounds */
|
||||
.bk-dist-segment.bk-line-auto { background: #3b82f6; }
|
||||
.bk-dist-segment.bk-line-health { background: #ec4899; }
|
||||
.bk-dist-segment.bk-line-life { background: #10b981; }
|
||||
.bk-dist-segment.bk-line-home { background: #f59e0b; }
|
||||
.bk-dist-segment.bk-line-umbrella { background: #8b5cf6; }
|
||||
.bk-dist-segment.bk-line-default { background: #8a8a86; }
|
||||
|
||||
.bk-dist-legend {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 16px;
|
||||
margin-top: 14px;
|
||||
}
|
||||
.bk-dist-legend-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
.bk-dist-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 2px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
/* Dot colors match segment colors */
|
||||
.bk-dist-dot.bk-line-auto { background: #3b82f6; }
|
||||
.bk-dist-dot.bk-line-health { background: #ec4899; }
|
||||
.bk-dist-dot.bk-line-life { background: #10b981; }
|
||||
.bk-dist-dot.bk-line-home { background: #f59e0b; }
|
||||
.bk-dist-dot.bk-line-umbrella { background: #8b5cf6; }
|
||||
.bk-dist-dot.bk-line-default { background: #8a8a86; }
|
||||
|
||||
.bk-dist-legend-label {
|
||||
.bk-renewal-date {
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
color: var(--text-primary, #1a1a1a);
|
||||
}
|
||||
.bk-dist-legend-pct {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: #8a8a86;
|
||||
}
|
||||
|
||||
/* ---- Carrier horizontal bars ---- */
|
||||
.bk-carrier-bars {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
.bk-carrier-bar-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
.bk-carrier-bar-label {
|
||||
width: 120px;
|
||||
flex-shrink: 0;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary, #1a1a1a);
|
||||
text-align: right;
|
||||
}
|
||||
.bk-carrier-bar-track {
|
||||
flex: 1;
|
||||
height: 8px;
|
||||
background: rgba(0, 0, 0, 0.04);
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.bk-carrier-bar-fill {
|
||||
height: 100%;
|
||||
background: linear-gradient(90deg, #01696f, #018f97);
|
||||
border-radius: 4px;
|
||||
transition: width 300ms ease;
|
||||
}
|
||||
.bk-carrier-bar-value {
|
||||
width: 80px;
|
||||
flex-shrink: 0;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--text-muted, #5c5650);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* ---- Activity list ---- */
|
||||
.bk-activity-list {
|
||||
.bk-empty {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.bk-activity-item {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
padding: 14px 20px;
|
||||
border-bottom: 1px solid rgba(0, 0, 0, 0.03);
|
||||
transition: background 120ms ease;
|
||||
}
|
||||
.bk-activity-item:hover {
|
||||
background: rgba(0, 0, 0, 0.01);
|
||||
}
|
||||
.bk-activity-item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
.bk-activity-icon {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 7px;
|
||||
flex-shrink: 0;
|
||||
padding: 48px 20px;
|
||||
text-align: center;
|
||||
}
|
||||
.bk-activity-body {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
.bk-activity-text {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
.bk-empty-title {
|
||||
margin-top: 12px;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary, #1a1a1a);
|
||||
line-height: 1.4;
|
||||
}
|
||||
.bk-activity-meta {
|
||||
font-size: 11px;
|
||||
.bk-empty-sub {
|
||||
margin-top: 4px;
|
||||
font-size: 12px;
|
||||
color: #8a8a86;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
/* ---- Empty small ---- */
|
||||
.bk-empty-small {
|
||||
padding: 32px 20px;
|
||||
text-align: center;
|
||||
font-size: 13px;
|
||||
color: #8a8a86;
|
||||
.pol-skeleton-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
padding: 10px 0;
|
||||
border-bottom: 1px solid rgba(0, 0, 0, 0.03);
|
||||
}
|
||||
.pol-skeleton-row:last-child { border-bottom: none; }
|
||||
.pol-skeleton {
|
||||
height: 12px;
|
||||
border-radius: 4px;
|
||||
background: rgba(0, 0, 0, 0.05);
|
||||
animation: pol-pulse 1.5s ease-in-out infinite;
|
||||
}
|
||||
@keyframes pol-pulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.4; }
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -2,464 +2,157 @@
|
||||
definePageMeta({ ssr: false })
|
||||
usePageTitle('Colectivos · Cartera')
|
||||
|
||||
const { accounts, activeAccounts, totalMembers, totalDependents, totalPremium } = useColectivos()
|
||||
|
||||
/* ── Filters & sort ── */
|
||||
|
||||
const search = ref('')
|
||||
const viewMode = ref<'card' | 'list'>('card')
|
||||
const lobFilter = ref<string>('all')
|
||||
const statusFilter = ref<string>('all')
|
||||
const carrierFilter = ref<string>('all')
|
||||
const agentFilter = ref<string>('all')
|
||||
const sortBy = ref<string>('premium_desc')
|
||||
|
||||
const lobOptions = [
|
||||
{ label: 'All LOBs', value: 'all' },
|
||||
{ label: 'Health', value: 'Health' },
|
||||
{ label: 'Life', value: 'Life' },
|
||||
{ label: 'Disability', value: 'Disability' },
|
||||
]
|
||||
|
||||
const statusOptions = [
|
||||
{ label: 'All statuses', value: 'all' },
|
||||
{ label: 'Active', value: 'active' },
|
||||
{ label: 'Onboarding', value: 'onboarding' },
|
||||
{ label: 'Renewal Due', value: 'renewal_due' },
|
||||
{ label: 'Suspended', value: 'suspended' },
|
||||
]
|
||||
|
||||
const sortOptions = [
|
||||
{ label: 'Premium (high → low)', value: 'premium_desc' },
|
||||
{ label: 'Members (high → low)', value: 'members_desc' },
|
||||
{ label: 'Renewal date', value: 'renewal' },
|
||||
{ label: 'Alphabetical', value: 'alpha' },
|
||||
]
|
||||
|
||||
const carrierOptions = computed(() => [
|
||||
{ label: 'All Carriers', value: 'all' },
|
||||
...([...new Set(accounts.value.map(a => a.carrier))].sort().map(c => ({ label: c, value: c })))
|
||||
])
|
||||
|
||||
const agentOptions = computed(() => [
|
||||
{ label: 'All Agents', value: 'all' },
|
||||
...([...new Set(accounts.value.map(a => a.agent))].sort().map(a => ({ label: a, value: a })))
|
||||
])
|
||||
|
||||
/* ── Derived data ── */
|
||||
|
||||
const onboardingCount = computed(() =>
|
||||
accounts.value.filter(a => a.status === 'onboarding').length,
|
||||
)
|
||||
const renewalDueCount = computed(() =>
|
||||
accounts.value.filter(a => a.status === 'renewal_due').length,
|
||||
)
|
||||
const suspendedCount = computed(() =>
|
||||
accounts.value.filter(a => a.status === 'suspended').length,
|
||||
)
|
||||
const totalMonthlyPremium = computed(() =>
|
||||
accounts.value.reduce((s, a) => s + a.monthlyPremium, 0),
|
||||
)
|
||||
const avgCommission = computed(() => {
|
||||
if (!accounts.value.length) return 0
|
||||
return accounts.value.reduce((s, a) => s + a.commissionPct, 0) / accounts.value.length
|
||||
})
|
||||
|
||||
const filtered = computed(() => {
|
||||
let rows = [...accounts.value]
|
||||
|
||||
if (lobFilter.value !== 'all') rows = rows.filter(a => a.lob === lobFilter.value)
|
||||
if (statusFilter.value !== 'all') rows = rows.filter(a => a.status === statusFilter.value)
|
||||
if (carrierFilter.value !== 'all') rows = rows.filter(a => a.carrier === carrierFilter.value)
|
||||
if (agentFilter.value !== 'all') rows = rows.filter(a => a.agent === agentFilter.value)
|
||||
|
||||
const q = search.value.trim().toLowerCase()
|
||||
if (q) {
|
||||
rows = rows.filter(a =>
|
||||
a.name.toLowerCase().includes(q) ||
|
||||
a.carrier.toLowerCase().includes(q) ||
|
||||
a.product.toLowerCase().includes(q),
|
||||
)
|
||||
const { data, pending } = usePolicy('/policies', {
|
||||
query: {
|
||||
'page_size': 100
|
||||
}
|
||||
|
||||
switch (sortBy.value) {
|
||||
case 'premium_desc': rows.sort((a, b) => b.annualPremium - a.annualPremium); break
|
||||
case 'members_desc': rows.sort((a, b) => b.totalMembers - a.totalMembers); break
|
||||
case 'renewal': rows.sort((a, b) => a.renewalDate.localeCompare(b.renewalDate)); break
|
||||
case 'alpha': rows.sort((a, b) => a.name.localeCompare(b.name)); break
|
||||
}
|
||||
|
||||
return rows
|
||||
})
|
||||
|
||||
const filteredTotalAnnual = computed(() =>
|
||||
filtered.value.reduce((s, a) => s + a.annualPremium, 0),
|
||||
)
|
||||
const filteredTotalMembers = computed(() =>
|
||||
filtered.value.reduce((s, a) => s + a.totalMembers, 0),
|
||||
)
|
||||
const filteredTotalDependents = computed(() =>
|
||||
filtered.value.reduce((s, a) => s + a.dependentsCount, 0),
|
||||
)
|
||||
|
||||
/* ── Portfolio health segments ── */
|
||||
|
||||
const healthSegments = computed(() => {
|
||||
const total = accounts.value.length || 1
|
||||
const active = activeAccounts.value.length
|
||||
const onboarding = onboardingCount.value
|
||||
const renewal = renewalDueCount.value
|
||||
const suspended = suspendedCount.value
|
||||
return [
|
||||
{ label: 'Active', count: active, pct: (active / total) * 100, color: '#16a34a' },
|
||||
{ label: 'Onboarding', count: onboarding, pct: (onboarding / total) * 100, color: '#3b82f6' },
|
||||
{ label: 'Renewal Due', count: renewal, pct: (renewal / total) * 100, color: '#f59e0b' },
|
||||
{ label: 'Suspended', count: suspended, pct: (suspended / total) * 100, color: '#dc2626' },
|
||||
]
|
||||
})
|
||||
|
||||
/* ── KPI cards config ── */
|
||||
|
||||
const kpiCards = computed(() => [
|
||||
{
|
||||
label: 'Total Groups',
|
||||
value: accounts.value.length.toString(),
|
||||
icon: 'i-heroicons-building-office-2',
|
||||
iconBg: 'rgba(1,105,111,0.08)',
|
||||
iconColor: '#01696f',
|
||||
accent: '',
|
||||
},
|
||||
{
|
||||
label: 'Active Groups',
|
||||
value: activeAccounts.value.length.toString(),
|
||||
icon: 'i-heroicons-check-badge',
|
||||
iconBg: 'rgba(22,163,74,0.08)',
|
||||
iconColor: '#16a34a',
|
||||
accent: '',
|
||||
},
|
||||
{
|
||||
label: 'Total Members',
|
||||
value: totalMembers.value.toLocaleString(),
|
||||
icon: 'i-heroicons-users',
|
||||
iconBg: 'rgba(59,130,246,0.08)',
|
||||
iconColor: '#3b82f6',
|
||||
accent: '',
|
||||
},
|
||||
{
|
||||
label: 'Total Dependents',
|
||||
value: totalDependents.value.toLocaleString(),
|
||||
icon: 'i-heroicons-user-plus',
|
||||
iconBg: 'rgba(139,92,246,0.08)',
|
||||
iconColor: '#8b5cf6',
|
||||
accent: '',
|
||||
},
|
||||
{
|
||||
label: 'Annual Premium',
|
||||
value: fmtCurrency(totalPremium.value),
|
||||
icon: 'i-heroicons-banknotes',
|
||||
iconBg: 'rgba(1,105,111,0.08)',
|
||||
iconColor: '#01696f',
|
||||
accent: 'gc-kpi--teal',
|
||||
},
|
||||
{
|
||||
label: 'Monthly Premium',
|
||||
value: fmtCurrency(totalMonthlyPremium.value),
|
||||
icon: 'i-heroicons-calendar-days',
|
||||
iconBg: 'rgba(1,105,111,0.08)',
|
||||
iconColor: '#01696f',
|
||||
accent: 'gc-kpi--teal',
|
||||
},
|
||||
{
|
||||
label: 'Renewals Due',
|
||||
value: renewalDueCount.value.toString(),
|
||||
icon: 'i-heroicons-clock',
|
||||
iconBg: 'rgba(245,158,11,0.08)',
|
||||
iconColor: '#f59e0b',
|
||||
accent: 'gc-kpi--amber',
|
||||
},
|
||||
{
|
||||
label: 'Onboarding',
|
||||
value: onboardingCount.value.toString(),
|
||||
icon: 'i-heroicons-arrow-path',
|
||||
iconBg: 'rgba(59,130,246,0.08)',
|
||||
iconColor: '#3b82f6',
|
||||
accent: '',
|
||||
},
|
||||
{
|
||||
label: 'Avg Commission',
|
||||
value: avgCommission.value.toFixed(1) + '%',
|
||||
icon: 'i-heroicons-chart-bar',
|
||||
iconBg: 'rgba(139,92,246,0.08)',
|
||||
iconColor: '#8b5cf6',
|
||||
accent: '',
|
||||
},
|
||||
])
|
||||
|
||||
/* ── Helpers ── */
|
||||
const policies = computed(() => data.value?.data ?? [])
|
||||
|
||||
function fmtCurrency(n: number) {
|
||||
return new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD', minimumFractionDigits: 0, maximumFractionDigits: 0 }).format(n)
|
||||
}
|
||||
|
||||
function fmtDate(d: string) {
|
||||
if (!d) return '—'
|
||||
return new Date(d).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })
|
||||
}
|
||||
|
||||
function lobColor(lob: string) {
|
||||
switch (lob) {
|
||||
case 'Health': return 'success'
|
||||
case 'Life': return 'info'
|
||||
case 'Disability': return 'warning'
|
||||
default: return 'neutral'
|
||||
function policyApplicantName(p: any) {
|
||||
const info = p.insured
|
||||
if (!info || typeof info !== 'object') return '—'
|
||||
if (info.type === 'corporate') {
|
||||
return info.company_name || '—'
|
||||
}
|
||||
return info.name || '—'
|
||||
}
|
||||
|
||||
function policyDetailsSummary(p: any) {
|
||||
const d = p.policy_details
|
||||
if (!d || typeof d !== 'object') return '—'
|
||||
if (p.policy_type === 'car') {
|
||||
const parts = [d.year, d.make, d.model].filter((x: any) => x !== undefined && x !== null && String(x) !== '')
|
||||
return parts.length ? parts.map(String).join(' ') : '—'
|
||||
}
|
||||
if (p.policy_type === 'life') {
|
||||
return `Life · ${d.coverage_amount || 0} USD`
|
||||
}
|
||||
if (p.policy_type === 'fire_structure' || p.policy_type === 'fire_contents') {
|
||||
return d.location || '—'
|
||||
}
|
||||
return '—'
|
||||
}
|
||||
|
||||
function statusColor(status: string) {
|
||||
switch (status) {
|
||||
case 'quote_requested': return 'yellow'
|
||||
case 'quotes_received': return 'blue'
|
||||
case 'solicitation_sent': return 'purple'
|
||||
case 'issued': return 'green'
|
||||
default: return 'gray'
|
||||
}
|
||||
}
|
||||
|
||||
function statusBadge(s: string) {
|
||||
switch (s) {
|
||||
case 'active': return { label: 'Active', color: 'success' as const }
|
||||
case 'onboarding': return { label: 'Onboarding', color: 'info' as const }
|
||||
case 'renewal_due': return { label: 'Renewal Due', color: 'warning' as const }
|
||||
case 'quoting': return { label: 'Quoting', color: 'neutral' as const }
|
||||
case 'suspended': return { label: 'Suspended', color: 'error' as const }
|
||||
case 'cancelled': return { label: 'Cancelled', color: 'neutral' as const }
|
||||
default: return { label: s, color: 'neutral' as const }
|
||||
function statusLabel(status: string) {
|
||||
switch (status) {
|
||||
case 'quote_requested': return 'Quote Requested'
|
||||
case 'quotes_received': return 'Quotes Received'
|
||||
case 'solicitation_sent': return 'Solicitation Sent'
|
||||
case 'issued': return 'Issued'
|
||||
default: return status
|
||||
}
|
||||
}
|
||||
|
||||
function renewalClass(d: string) {
|
||||
const diff = (new Date(d).getTime() - Date.now()) / 86_400_000
|
||||
if (diff < 0) return 'gc-renewal--overdue'
|
||||
if (diff <= 30) return 'gc-renewal--urgent'
|
||||
if (diff <= 90) return 'gc-renewal--soon'
|
||||
return ''
|
||||
}
|
||||
|
||||
function renewalDotClass(d: string) {
|
||||
const diff = (new Date(d).getTime() - Date.now()) / 86_400_000
|
||||
if (diff < 0) return 'gc-rdot gc-rdot--red'
|
||||
if (diff <= 30) return 'gc-rdot gc-rdot--orange'
|
||||
if (diff <= 90) return 'gc-rdot gc-rdot--amber'
|
||||
return ''
|
||||
}
|
||||
|
||||
function initials(name: string) {
|
||||
return name.split(/\s+/).slice(0, 2).map(w => w[0]).join('').toUpperCase()
|
||||
}
|
||||
|
||||
function initialsColor(name: string) {
|
||||
const colors = ['#01696f', '#3b82f6', '#8b5cf6', '#16a34a', '#ea580c', '#dc2626', '#ca8a04']
|
||||
let hash = 0
|
||||
for (let i = 0; i < name.length; i++) hash = name.charCodeAt(i) + ((hash << 5) - hash)
|
||||
return colors[Math.abs(hash) % colors.length]
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="gc-page">
|
||||
<!-- Header -->
|
||||
<div class="gc-header">
|
||||
<div class="gc-header-left">
|
||||
<h1 class="gc-title">Colectivos</h1>
|
||||
<span class="gc-count-badge">{{ filtered.length }}</span>
|
||||
<span class="gc-count-badge">{{ policies.length }}</span>
|
||||
</div>
|
||||
<div class="gc-header-right">
|
||||
<NuxtLink to="/support/collectivos" class="gc-header-link">
|
||||
<UIcon name="i-heroicons-cog-6-tooth" class="gc-header-link-icon" />
|
||||
Go to Operations
|
||||
<NuxtLink to="/policies" class="gc-header-link">
|
||||
<UIcon name="i-heroicons-arrow-left" class="gc-header-link-icon" />
|
||||
All Policies
|
||||
</NuxtLink>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Filter bar -->
|
||||
<div class="gc-filters">
|
||||
<UInput
|
||||
v-model="search"
|
||||
icon="i-heroicons-magnifying-glass"
|
||||
placeholder="Search group name, carrier, product..."
|
||||
class="gc-filter-search"
|
||||
/>
|
||||
<USelect v-model="lobFilter" :items="lobOptions" value-key="value" label-key="label" class="gc-filter-select" />
|
||||
<USelect v-model="statusFilter" :items="statusOptions" value-key="value" label-key="label" class="gc-filter-select" />
|
||||
<USelect v-model="carrierFilter" :items="carrierOptions" value-key="value" label-key="label" class="gc-filter-select" />
|
||||
<USelect v-model="agentFilter" :items="agentOptions" value-key="value" label-key="label" class="gc-filter-select" />
|
||||
<USelect v-model="sortBy" :items="sortOptions" value-key="value" label-key="label" class="gc-filter-select" />
|
||||
<div class="gc-view-toggle">
|
||||
<button type="button" :class="['gc-view-toggle-btn', viewMode === 'card' && 'gc-view-toggle-btn--active']" title="Card view" @click="viewMode = 'card'">
|
||||
<UIcon name="i-heroicons-squares-2x2" style="width: 16px; height: 16px;" />
|
||||
</button>
|
||||
<button type="button" :class="['gc-view-toggle-btn', viewMode === 'list' && 'gc-view-toggle-btn--active']" title="List view" @click="viewMode = 'list'">
|
||||
<UIcon name="i-heroicons-bars-3" style="width: 16px; height: 16px;" />
|
||||
</button>
|
||||
<div v-if="pending" class="gc-card">
|
||||
<div style="padding: 20px;">
|
||||
<div v-for="n in 8" :key="n" class="pol-skeleton-row">
|
||||
<div class="pol-skeleton" style="width: 80px; height: 12px;" />
|
||||
<div class="pol-skeleton" style="width: 140px; height: 12px;" />
|
||||
<div class="pol-skeleton" style="width: 50px; height: 18px; border-radius: 10px;" />
|
||||
<div class="pol-skeleton" style="width: 100px; height: 12px;" />
|
||||
<div class="pol-skeleton" style="width: 100px; height: 12px;" />
|
||||
<div class="pol-skeleton" style="width: 70px; height: 12px;" />
|
||||
<div class="pol-skeleton" style="width: 50px; height: 18px; border-radius: 10px;" />
|
||||
<div class="pol-skeleton" style="width: 80px; height: 12px;" />
|
||||
<div class="pol-skeleton" style="width: 60px; height: 12px;" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Card View -->
|
||||
<div v-if="viewMode === 'card'" class="gc-card-grid">
|
||||
<NuxtLink
|
||||
v-for="a in filtered"
|
||||
:key="a.id"
|
||||
:to="`/support/collectivos/${a.id}`"
|
||||
class="gc-card-item"
|
||||
>
|
||||
<div class="gc-card-item__top">
|
||||
<span class="gc-avatar" :style="{ background: initialsColor(a.name) + '12', color: initialsColor(a.name) }">{{ initials(a.name) }}</span>
|
||||
<div style="flex: 1; min-width: 0;">
|
||||
<p class="gc-card-item__name">{{ a.name }}</p>
|
||||
<span class="gc-ruc">{{ a.ruc }}</span>
|
||||
</div>
|
||||
<span class="gc-lob-pill" :class="'gc-lob--' + lobColor(a.lob)">{{ a.lob }}</span>
|
||||
</div>
|
||||
<div class="gc-card-item__body">
|
||||
<div class="gc-card-item__field">
|
||||
<span class="gc-card-item__label">Carrier</span>
|
||||
<span class="gc-card-item__value">{{ a.carrier }}</span>
|
||||
</div>
|
||||
<div class="gc-card-item__field">
|
||||
<span class="gc-card-item__label">Members</span>
|
||||
<span class="gc-card-item__value">{{ a.totalMembers.toLocaleString() }} <span class="gc-dependents">({{ a.dependentsCount }})</span></span>
|
||||
</div>
|
||||
<div class="gc-card-item__field">
|
||||
<span class="gc-card-item__label">Annual Premium</span>
|
||||
<span class="gc-card-item__value" style="font-weight: 600;">{{ fmtCurrency(a.annualPremium) }}</span>
|
||||
</div>
|
||||
<div class="gc-card-item__field">
|
||||
<span class="gc-card-item__label">Commission</span>
|
||||
<span class="gc-card-item__value">{{ a.commissionPct }}%</span>
|
||||
</div>
|
||||
<div class="gc-card-item__field">
|
||||
<span class="gc-card-item__label">Status</span>
|
||||
<span class="gc-status-pill" :class="'gc-status--' + statusBadge(a.status).color">{{ statusBadge(a.status).label }}</span>
|
||||
</div>
|
||||
<div class="gc-card-item__field">
|
||||
<span class="gc-card-item__label">Renewal</span>
|
||||
<span :class="renewalClass(a.renewalDate)">{{ fmtDate(a.renewalDate) }}</span>
|
||||
</div>
|
||||
<div class="gc-card-item__field">
|
||||
<span class="gc-card-item__label">Agent</span>
|
||||
<span class="gc-card-item__value">{{ a.agent }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="a.hasUrgentIssues" class="gc-card-item__urgent">
|
||||
<UIcon name="i-heroicons-exclamation-triangle" style="width: 12px; height: 12px;" />
|
||||
Urgent issues
|
||||
</div>
|
||||
</NuxtLink>
|
||||
<div v-if="filtered.length === 0" class="gc-empty" style="grid-column: 1 / -1;">No group accounts match your filters.</div>
|
||||
</div>
|
||||
|
||||
<!-- List View -->
|
||||
<div v-else class="gc-table-wrap">
|
||||
<table class="gc-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="gc-th gc-th--left">Group Name</th>
|
||||
<th class="gc-th gc-th--left">LOB</th>
|
||||
<th class="gc-th gc-th--left gc-hide-mobile">Carrier</th>
|
||||
<th class="gc-th gc-th--right">Members</th>
|
||||
<th class="gc-th gc-th--right">Annual Premium</th>
|
||||
<th class="gc-th gc-th--right gc-hide-tablet">Monthly Premium</th>
|
||||
<th class="gc-th gc-th--right gc-hide-tablet">Comm %</th>
|
||||
<th class="gc-th gc-th--left">Status</th>
|
||||
<th class="gc-th gc-th--left gc-hide-mobile">Renewal</th>
|
||||
<th class="gc-th gc-th--left gc-hide-tablet">Agent</th>
|
||||
<th class="gc-th gc-th--center" title="Issues">
|
||||
<UIcon name="i-heroicons-exclamation-triangle" class="gc-th-icon" />
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="a in filtered" :key="a.id" class="gc-row">
|
||||
<td class="gc-td">
|
||||
<div class="gc-group-cell">
|
||||
<span
|
||||
class="gc-avatar"
|
||||
:style="{ background: initialsColor(a.name) + '12', color: initialsColor(a.name) }"
|
||||
>{{ initials(a.name) }}</span>
|
||||
<div>
|
||||
<NuxtLink :to="`/support/collectivos/${a.id}`" class="gc-group-link">
|
||||
{{ a.name }}
|
||||
</NuxtLink>
|
||||
<span class="gc-ruc">{{ a.ruc }}</span>
|
||||
<template v-else>
|
||||
<div class="gc-table-wrap">
|
||||
<table class="gc-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="gc-th gc-th--left">Applicant</th>
|
||||
<th class="gc-th gc-th--left">Type</th>
|
||||
<th class="gc-th gc-th--left">Details</th>
|
||||
<th class="gc-th gc-th--right">Premium</th>
|
||||
<th class="gc-th gc-th--left">Status</th>
|
||||
<th class="gc-th gc-th--left">Submitted</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="policy in policies" :key="policy.application_id" class="gc-row">
|
||||
<td class="gc-td">
|
||||
<div class="gc-group-cell">
|
||||
<span class="gc-avatar">{{ policyApplicantName(policy)[0] }}</span>
|
||||
<div>
|
||||
<NuxtLink :to="`/policies/app/${policy.application_id}`" class="gc-group-link">
|
||||
{{ policyApplicantName(policy) }}
|
||||
</NuxtLink>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td class="gc-td">
|
||||
<span class="gc-lob-pill" :class="'gc-lob--' + lobColor(a.lob)">{{ a.lob }}</span>
|
||||
</td>
|
||||
<td class="gc-td gc-hide-mobile">{{ a.carrier }}</td>
|
||||
<td class="gc-td gc-td--num">
|
||||
{{ a.totalMembers.toLocaleString() }}
|
||||
<span class="gc-dependents">({{ a.dependentsCount }})</span>
|
||||
</td>
|
||||
<td class="gc-td gc-td--num gc-td--premium">{{ fmtCurrency(a.annualPremium) }}</td>
|
||||
<td class="gc-td gc-td--num gc-hide-tablet">{{ fmtCurrency(a.monthlyPremium) }}</td>
|
||||
<td class="gc-td gc-td--num gc-hide-tablet">{{ a.commissionPct }}%</td>
|
||||
<td class="gc-td">
|
||||
<span class="gc-status-pill" :class="'gc-status--' + statusBadge(a.status).color">
|
||||
{{ statusBadge(a.status).label }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="gc-td gc-hide-mobile" :class="renewalClass(a.renewalDate)">
|
||||
<span :class="renewalDotClass(a.renewalDate)" />
|
||||
{{ fmtDate(a.renewalDate) }}
|
||||
</td>
|
||||
<td class="gc-td gc-hide-tablet">{{ a.agent }}</td>
|
||||
<td class="gc-td gc-td--center">
|
||||
<span
|
||||
v-if="a.hasUrgentIssues"
|
||||
class="gc-dot gc-dot--red"
|
||||
:title="`Urgent issues on ${a.name}`"
|
||||
/>
|
||||
<span
|
||||
v-else-if="a.pendingTasks > 0"
|
||||
class="gc-dot gc-dot--amber"
|
||||
:title="`${a.pendingTasks} pending task${a.pendingTasks !== 1 ? 's' : ''} on ${a.name}`"
|
||||
/>
|
||||
<span v-else class="gc-dot gc-dot--clear" />
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="filtered.length === 0">
|
||||
<td colspan="11" class="gc-empty">No group accounts match your filters.</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- 6. Bottom Summary Bar -->
|
||||
<div class="gc-bottom-bar">
|
||||
<div class="gc-bottom-inner">
|
||||
<span class="gc-bottom-item">
|
||||
<UIcon name="i-heroicons-table-cells" class="gc-bottom-icon" />
|
||||
{{ filtered.length }} group{{ filtered.length !== 1 ? 's' : '' }} shown
|
||||
</span>
|
||||
<span class="gc-bottom-sep" />
|
||||
<span class="gc-bottom-item">
|
||||
Annual premium: <strong>{{ fmtCurrency(filteredTotalAnnual) }}</strong>
|
||||
</span>
|
||||
<span class="gc-bottom-sep" />
|
||||
<span class="gc-bottom-item">
|
||||
Members + dependents: <strong>{{ (filteredTotalMembers + filteredTotalDependents).toLocaleString() }}</strong>
|
||||
</span>
|
||||
</td>
|
||||
<td class="gc-td">
|
||||
<span class="gc-lob-pill gc-lob--neutral capitalize">{{ policy.policy_type }}</span>
|
||||
</td>
|
||||
<td class="gc-td">{{ policyDetailsSummary(policy) }}</td>
|
||||
<td class="gc-td gc-td--num gc-td--premium">{{ fmtCurrency(policy.premium || 0) }}</td>
|
||||
<td class="gc-td">
|
||||
<span class="gc-status-pill" :class="`gc-status--${statusColor(policy.status)}`">
|
||||
{{ statusLabel(policy.status) }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="gc-td">{{ fmtDate(policy.submitted_at) }}</td>
|
||||
</tr>
|
||||
<tr v-if="policies.length === 0">
|
||||
<td colspan="6" class="gc-empty">No policies found</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 7. Cross-links -->
|
||||
<div class="gc-crosslinks">
|
||||
<NuxtLink to="/support/collectivos" class="gc-crosslink">
|
||||
Go to Operations
|
||||
<UIcon name="i-heroicons-arrow-right" class="gc-crosslink-icon" />
|
||||
</NuxtLink>
|
||||
<NuxtLink to="/policies" class="gc-crosslink">
|
||||
View All Policies
|
||||
<UIcon name="i-heroicons-arrow-right" class="gc-crosslink-icon" />
|
||||
</NuxtLink>
|
||||
</div>
|
||||
<div class="gc-bottom-bar">
|
||||
<div class="gc-bottom-inner">
|
||||
<span class="gc-bottom-item">
|
||||
<UIcon name="i-heroicons-table-cells" class="gc-bottom-icon" />
|
||||
{{ policies.length }} polic{{ policies.length !== 1 ? 'ies' : 'y' }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* ── gc- prefix: group cartera scoped styles ── */
|
||||
|
||||
.gc-page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -469,8 +162,6 @@ function initialsColor(name: string) {
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
/* ── Header ── */
|
||||
|
||||
.gc-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -490,7 +181,7 @@ function initialsColor(name: string) {
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
color: #01696f;
|
||||
background: rgba(1,105,111,0.08);
|
||||
background: rgba(1,105, 111, 0.08);
|
||||
padding: 2px 9px;
|
||||
border-radius: 10px;
|
||||
}
|
||||
@@ -522,136 +213,12 @@ function initialsColor(name: string) {
|
||||
height: 14px;
|
||||
}
|
||||
|
||||
/* ── View toggle ── */
|
||||
|
||||
.gc-view-toggle {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
padding: 3px;
|
||||
border-radius: 10px;
|
||||
background: rgba(0, 0, 0, 0.04);
|
||||
margin-left: auto;
|
||||
}
|
||||
.gc-view-toggle-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 32px;
|
||||
height: 28px;
|
||||
border-radius: 7px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: #8a8a86;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
.gc-view-toggle-btn:hover { color: var(--text-primary); }
|
||||
.gc-view-toggle-btn--active {
|
||||
background: #fff;
|
||||
color: var(--text-primary);
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
|
||||
/* ── Card grid ── */
|
||||
|
||||
.gc-card-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 14px;
|
||||
}
|
||||
@media (max-width: 1023px) { .gc-card-grid { grid-template-columns: repeat(2, 1fr); } }
|
||||
@media (max-width: 639px) { .gc-card-grid { grid-template-columns: 1fr; } }
|
||||
|
||||
.gc-card-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
padding: 16px;
|
||||
background: #fff;
|
||||
border: 1px solid rgba(0, 0, 0, 0.06);
|
||||
.gc-card {
|
||||
background: #ffffff;
|
||||
border-radius: 12px;
|
||||
border: 1px solid rgba(0, 0, 0, 0.06);
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.03);
|
||||
text-decoration: none;
|
||||
transition: all 0.15s ease;
|
||||
cursor: pointer;
|
||||
}
|
||||
.gc-card-item:hover {
|
||||
box-shadow: 0 3px 12px rgba(0, 0, 0, 0.07);
|
||||
border-color: rgba(1, 105, 111, 0.15);
|
||||
}
|
||||
.gc-card-item__top {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
.gc-card-item__name {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
margin: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.gc-card-item__body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
padding-top: 8px;
|
||||
border-top: 1px solid rgba(0, 0, 0, 0.04);
|
||||
}
|
||||
.gc-card-item__field {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
.gc-card-item__label {
|
||||
font-size: 12px;
|
||||
color: #8a8a86;
|
||||
}
|
||||
.gc-card-item__value {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
.gc-card-item__urgent {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: #dc2626;
|
||||
padding: 4px 8px;
|
||||
background: rgba(239, 68, 68, 0.06);
|
||||
border-radius: 6px;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
/* ── Filters ── */
|
||||
|
||||
.gc-filters {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.gc-filter-search {
|
||||
flex: 1 1 220px;
|
||||
min-width: 180px;
|
||||
}
|
||||
|
||||
.gc-filter-select {
|
||||
width: 100%;
|
||||
max-width: 180px;
|
||||
}
|
||||
|
||||
@media (max-width: 639px) {
|
||||
.gc-filter-select { max-width: 100%; }
|
||||
}
|
||||
|
||||
/* ── Table ── */
|
||||
|
||||
.gc-table-wrap {
|
||||
background: #fff;
|
||||
@@ -681,13 +248,6 @@ function initialsColor(name: string) {
|
||||
|
||||
.gc-th--left { text-align: left; }
|
||||
.gc-th--right { text-align: right; }
|
||||
.gc-th--center { text-align: center; }
|
||||
|
||||
.gc-th-icon {
|
||||
width: 13px;
|
||||
height: 13px;
|
||||
color: #8a8a86;
|
||||
}
|
||||
|
||||
.gc-row {
|
||||
transition: all 150ms ease;
|
||||
@@ -716,12 +276,6 @@ function initialsColor(name: string) {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.gc-td--center {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* ── Group name cell with avatar ── */
|
||||
|
||||
.gc-group-cell {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -740,6 +294,8 @@ function initialsColor(name: string) {
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.02em;
|
||||
line-height: 1;
|
||||
background: rgba(1, 105, 111, 0.08);
|
||||
color: #01696f;
|
||||
}
|
||||
|
||||
.gc-group-link {
|
||||
@@ -756,21 +312,6 @@ function initialsColor(name: string) {
|
||||
text-underline-offset: 2px;
|
||||
}
|
||||
|
||||
.gc-ruc {
|
||||
display: block;
|
||||
font-size: 11px;
|
||||
color: #8a8a86;
|
||||
margin-top: 1px;
|
||||
}
|
||||
|
||||
.gc-dependents {
|
||||
color: #8a8a86;
|
||||
font-size: 11px;
|
||||
margin-left: 2px;
|
||||
}
|
||||
|
||||
/* ── Custom pill badges (LOB) ── */
|
||||
|
||||
.gc-lob-pill {
|
||||
display: inline-block;
|
||||
font-size: 9px;
|
||||
@@ -781,28 +322,11 @@ function initialsColor(name: string) {
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.gc-lob--success {
|
||||
color: #16a34a;
|
||||
background: rgba(22, 163, 74, 0.1);
|
||||
}
|
||||
|
||||
.gc-lob--info {
|
||||
color: #3b82f6;
|
||||
background: rgba(59, 130, 246, 0.1);
|
||||
}
|
||||
|
||||
.gc-lob--warning {
|
||||
color: #ca8a04;
|
||||
background: rgba(202, 138, 4, 0.1);
|
||||
}
|
||||
|
||||
.gc-lob--neutral {
|
||||
color: #8a8a86;
|
||||
background: rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
|
||||
/* ── Custom pill badges (Status) ── */
|
||||
|
||||
.gc-status-pill {
|
||||
display: inline-block;
|
||||
font-size: 9px;
|
||||
@@ -838,43 +362,6 @@ function initialsColor(name: string) {
|
||||
background: rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
|
||||
/* ── Renewal color-coding ── */
|
||||
|
||||
.gc-renewal--overdue { color: #dc2626; font-weight: 600; }
|
||||
.gc-renewal--urgent { color: #ea580c; font-weight: 600; }
|
||||
.gc-renewal--soon { color: #ca8a04; }
|
||||
|
||||
/* ── Renewal date dots ── */
|
||||
|
||||
.gc-rdot {
|
||||
display: inline-block;
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
margin-right: 5px;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.gc-rdot--red { background: #dc2626; }
|
||||
.gc-rdot--orange { background: #ea580c; }
|
||||
.gc-rdot--amber { background: #f59e0b; }
|
||||
|
||||
/* ── Issues dot ── */
|
||||
|
||||
.gc-dot {
|
||||
display: inline-block;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.gc-dot--red { background: #dc2626; }
|
||||
.gc-dot--amber { background: #f59e0b; }
|
||||
.gc-dot--clear { background: transparent; }
|
||||
|
||||
/* ── Empty state ── */
|
||||
|
||||
.gc-empty {
|
||||
padding: 40px 14px;
|
||||
text-align: center;
|
||||
@@ -882,18 +369,6 @@ function initialsColor(name: string) {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
/* ── Responsive hide classes ── */
|
||||
|
||||
@media (max-width: 767px) {
|
||||
.gc-hide-mobile { display: none; }
|
||||
}
|
||||
|
||||
@media (max-width: 1023px) {
|
||||
.gc-hide-tablet { display: none; }
|
||||
}
|
||||
|
||||
/* ── Bottom Summary Bar ── */
|
||||
|
||||
.gc-bottom-bar {
|
||||
background: #ffffff;
|
||||
border: 1px solid rgba(0, 0, 0, 0.06);
|
||||
@@ -929,37 +404,22 @@ function initialsColor(name: string) {
|
||||
color: #8a8a86;
|
||||
}
|
||||
|
||||
.gc-bottom-sep {
|
||||
width: 3px;
|
||||
height: 3px;
|
||||
border-radius: 50%;
|
||||
background: #d4d4d0;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* ── Cross-links ── */
|
||||
|
||||
.gc-crosslinks {
|
||||
.pol-skeleton-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.gc-crosslink {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: #01696f;
|
||||
text-decoration: none;
|
||||
transition: all 150ms ease;
|
||||
gap: 16px;
|
||||
padding: 10px 0;
|
||||
border-bottom: 1px solid rgba(0, 0, 0, 0.03);
|
||||
}
|
||||
|
||||
.gc-crosslink:hover { text-decoration: underline; }
|
||||
|
||||
.gc-crosslink-icon {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
.pol-skeleton-row:last-child { border-bottom: none; }
|
||||
.pol-skeleton {
|
||||
height: 12px;
|
||||
border-radius: 4px;
|
||||
background: rgba(0, 0, 0, 0.05);
|
||||
animation: pol-pulse 1.5s ease-in-out infinite;
|
||||
}
|
||||
@keyframes pol-pulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.4; }
|
||||
}
|
||||
</style>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -19,11 +19,11 @@ const selectedCustomer = ref<any>(null)
|
||||
|
||||
const { data: customersData, pending: customersPending } = useCustomer('/customers', {
|
||||
query: computed(() => ({
|
||||
'page[number]': customerPage.value,
|
||||
'page[size]': 12,
|
||||
'page_size': 12,
|
||||
'page': customerPage.value,
|
||||
...(debouncedCustomerSearch.value && {
|
||||
'filters[0][field]': 'search',
|
||||
'filters[0][op]': '==',
|
||||
'filters[0][op]': '==',
|
||||
'filters[0][value]': debouncedCustomerSearch.value
|
||||
})
|
||||
}))
|
||||
|
||||
@@ -4,40 +4,53 @@ const providerId = route.params.provider_id as string
|
||||
const toast = useToast()
|
||||
const { $providers } = useNuxtApp()
|
||||
|
||||
const { emails, roles, label } = useProviderContactEmails(providerId)
|
||||
|
||||
const { data, pending, error, refresh } = useProviders(`/providers/${providerId}`)
|
||||
const provider = computed(() => data.value?.data)
|
||||
|
||||
const emails = ref<Record<string, string>>({
|
||||
quotes: '',
|
||||
claims: '',
|
||||
renewals: '',
|
||||
billing: '',
|
||||
support: ''
|
||||
})
|
||||
|
||||
const roles = ['quotes', 'claims', 'renewals', 'billing', 'support']
|
||||
|
||||
const label = computed(() => {
|
||||
if (!provider.value) return ''
|
||||
return provider.value.name || 'Unknown'
|
||||
})
|
||||
|
||||
// templates and default_templates come directly from provider
|
||||
const templates = computed(() => provider.value?.templates ?? {})
|
||||
const defaultTemplates = computed(() => provider.value?.default_templates ?? {})
|
||||
const templates = computed(() => provider.value?.templates ?? {})
|
||||
const defaultTemplates = computed(() => provider.value?.default_templates ?? {})
|
||||
|
||||
// ── Template upload ──────────────────────────────────────────────────────────
|
||||
const isUploadOpen = ref(false)
|
||||
const uploadFile = ref<File | null>(null)
|
||||
const isUploadOpen = ref(false)
|
||||
const uploadFile = ref<File | null>(null)
|
||||
const uploadPolicyType = ref('car')
|
||||
const uploadClientType = ref('natural')
|
||||
const uploading = ref(false)
|
||||
const uploading = ref(false)
|
||||
|
||||
async function handleUpload() {
|
||||
if (!uploadFile.value) return
|
||||
uploading.value = true
|
||||
try {
|
||||
const formData = new FormData()
|
||||
formData.append('file', uploadFile.value)
|
||||
formData.append('file', uploadFile.value)
|
||||
formData.append('policy_type', uploadPolicyType.value)
|
||||
formData.append('client_type', uploadClientType.value)
|
||||
|
||||
await $providers(`/providers/${providerId}/templates`, {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
body: formData
|
||||
})
|
||||
|
||||
toast.add({ title: 'Template uploaded', color: 'green' })
|
||||
isUploadOpen.value = false
|
||||
uploadFile.value = null
|
||||
await refresh() // single refresh — gets updated templates + defaults together
|
||||
uploadFile.value = null
|
||||
await refresh()
|
||||
} catch (e: any) {
|
||||
toast.add({ title: 'Upload failed', description: e?.data?.error ?? e.message, color: 'red' })
|
||||
} finally {
|
||||
@@ -54,7 +67,7 @@ async function setDefault(templateId: string, policyType: string, clientType: st
|
||||
try {
|
||||
await $providers(`/providers/${providerId}/templates/${templateId}/set-default`, {
|
||||
method: 'POST',
|
||||
body: { policy_type: policyType, client_type: clientType }
|
||||
body: { policy_type: policyType, client_type: clientType }
|
||||
})
|
||||
toast.add({ title: 'Default template updated', color: 'green' })
|
||||
await refresh()
|
||||
@@ -68,7 +81,7 @@ async function toggleTemplate(templateId: string, active: boolean, policyType: s
|
||||
try {
|
||||
await $providers(`/providers/${providerId}/templates/${templateId}/${path}`, {
|
||||
method: 'POST',
|
||||
body: { policy_type: policyType, client_type: clientType }
|
||||
body: { policy_type: policyType, client_type: client_type }
|
||||
})
|
||||
toast.add({ title: `Template ${active ? 'deactivated' : 'activated'}`, color: 'green' })
|
||||
await refresh()
|
||||
@@ -89,13 +102,13 @@ async function toggleProvider() {
|
||||
}
|
||||
|
||||
const policyTypeItems = [
|
||||
{ label: 'Car', value: 'car' },
|
||||
{ label: 'Car', value: 'car' },
|
||||
{ label: 'Life', value: 'life' },
|
||||
{ label: 'Fire', value: 'fire' }
|
||||
]
|
||||
|
||||
const clientTypeItems = [
|
||||
{ label: 'Natural', value: 'natural' },
|
||||
{ label: 'Natural', value: 'natural' },
|
||||
{ label: 'Jurídico', value: 'juridico' }
|
||||
]
|
||||
|
||||
@@ -119,7 +132,6 @@ const clientTypeColor = (ct: string) =>
|
||||
</div>
|
||||
|
||||
<template v-else-if="provider">
|
||||
<!-- Header -->
|
||||
<div class="flex justify-between items-start">
|
||||
<div class="space-y-1">
|
||||
<div class="flex items-center gap-2">
|
||||
@@ -143,7 +155,6 @@ const clientTypeColor = (ct: string) =>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Info -->
|
||||
<UCard>
|
||||
<template #header>
|
||||
<p class="font-semibold text-[var(--text-primary)] flex items-center gap-2">
|
||||
@@ -177,7 +188,6 @@ const clientTypeColor = (ct: string) =>
|
||||
</div>
|
||||
</UCard>
|
||||
|
||||
<!-- Templates — grouped by policy_type → client_type -->
|
||||
<UCard>
|
||||
<template #header>
|
||||
<div class="flex justify-between items-center">
|
||||
@@ -195,14 +205,12 @@ const clientTypeColor = (ct: string) =>
|
||||
<p class="text-sm">No templates yet. Upload a provider PDF form.</p>
|
||||
</div>
|
||||
|
||||
<!-- policy_type level -->
|
||||
<div v-else class="space-y-8">
|
||||
<div v-for="(clientMap, policyType) in templates" :key="policyType">
|
||||
<p class="text-xs font-bold text-gray-400 uppercase tracking-widest mb-4">
|
||||
{{ String(policyType) }}
|
||||
</p>
|
||||
|
||||
<!-- client_type level -->
|
||||
<div class="space-y-6 pl-2">
|
||||
<div v-for="(tmplList, clientType) in clientMap" :key="clientType">
|
||||
<div class="flex items-center gap-2 mb-3">
|
||||
@@ -260,7 +268,6 @@ const clientTypeColor = (ct: string) =>
|
||||
</UCard>
|
||||
</template>
|
||||
|
||||
<!-- Upload Slideover -->
|
||||
<USlideover v-model:open="isUploadOpen" side="right">
|
||||
<template #content>
|
||||
<div class="flex flex-col h-full">
|
||||
|
||||
@@ -1,479 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { emptyAutoQuoteDraft } from '~/composables/useAutoQuoteDraft'
|
||||
import type { AutoQuoteIntakePayload, AutoQuoteMode, AutoQuoteSegment } from '~/types/auto-quote-intake'
|
||||
import { useCustomerSelection } from '~/composables/useCustomerSelection'
|
||||
import { usePolicyApi } from '~/composables/usePolicyApi'
|
||||
|
||||
/** Client-only: many Nuxt UI fields on this screen can stall hydration / main thread if SSR + client fight */
|
||||
definePageMeta({ ssr: false })
|
||||
|
||||
usePageTitle('Quotes · Auto')
|
||||
|
||||
const STEP_ORDER = ['setup', 'solicit', 'acceptance'] as const
|
||||
type StepId = (typeof STEP_ORDER)[number]
|
||||
|
||||
const STEP_LABELS: Record<StepId, string> = {
|
||||
setup: 'Quote setup',
|
||||
solicit: 'Quotes to solicit',
|
||||
acceptance: 'Acceptance'
|
||||
}
|
||||
|
||||
const step = ref<StepId>('setup')
|
||||
/** Highest step index the user has reached (for stepper — no reactive watch loops) */
|
||||
const maxStepIndex = ref(0)
|
||||
const intakeBusy = ref(false)
|
||||
|
||||
const draft = reactive(emptyAutoQuoteDraft())
|
||||
|
||||
const toast = useToast()
|
||||
const { quoteRequestEmailEnabled } = useQuoteRequestEmailEnabled()
|
||||
|
||||
// Use customer selection composable
|
||||
const {
|
||||
insured,
|
||||
buyer,
|
||||
isInsuredValid,
|
||||
isBuyerValid,
|
||||
validationErrors
|
||||
} = useCustomerSelection()
|
||||
|
||||
// Use policy API composable
|
||||
const { submitPolicyQuote } = usePolicyApi()
|
||||
|
||||
const modeCards: { id: AutoQuoteMode; title: string; hint: string; icon: string }[] = [
|
||||
{
|
||||
id: 'single',
|
||||
title: 'Single quote',
|
||||
hint: 'One package — we'll email carriers' quoting inboxes on file.',
|
||||
icon: 'i-heroicons-document-text'
|
||||
},
|
||||
{
|
||||
id: 'comparative_pdf',
|
||||
title: 'Comparative quote',
|
||||
hint: 'Same vehicle facts; prep plan comparisons and enter premiums when emails arrive.',
|
||||
icon: 'i-heroicons-document-duplicate'
|
||||
}
|
||||
]
|
||||
|
||||
const segmentCards: { id: AutoQuoteSegment; title: string; hint: string; icon: string }[] = [
|
||||
{
|
||||
id: 'individual',
|
||||
title: 'Individual',
|
||||
hint: 'Personal auto.',
|
||||
icon: 'i-heroicons-user'
|
||||
},
|
||||
{
|
||||
id: 'corporate',
|
||||
title: 'Corporate',
|
||||
hint: 'Business or group.',
|
||||
icon: 'i-heroicons-building-office-2'
|
||||
},
|
||||
{
|
||||
id: 'fleet',
|
||||
title: 'Fleet',
|
||||
hint: 'Fleet program.',
|
||||
icon: 'i-heroicons-truck'
|
||||
}
|
||||
]
|
||||
|
||||
function canProceedFromSetup() {
|
||||
if (!draft.quoteMode) {
|
||||
toast.add({ title: 'Choose a quote type', description: 'Single or comparative.', color: 'warning' })
|
||||
return false
|
||||
}
|
||||
if (!draft.segment) {
|
||||
toast.add({ title: 'Choose policy type', description: 'Individual, corporate, or fleet.', color: 'warning' })
|
||||
return false
|
||||
}
|
||||
if (!isInsuredValid.value) {
|
||||
toast.add({
|
||||
title: 'Complete insured information',
|
||||
description: `Missing: ${validationErrors.value.insured.join(', ')}`,
|
||||
color: 'warning'
|
||||
})
|
||||
return false
|
||||
}
|
||||
if (!isBuyerValid.value) {
|
||||
toast.add({
|
||||
title: 'Complete buyer information',
|
||||
description: `Missing: ${validationErrors.value.buyer.join(', ')}`,
|
||||
color: 'warning'
|
||||
})
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
function canProceedFromSolicit() {
|
||||
if (draft.solicit.carrierIds.length === 0 || draft.solicit.planIds.length === 0) {
|
||||
toast.add({
|
||||
title: 'Choose carriers and plans',
|
||||
description: 'Select at least one insurance company and one coverage package.',
|
||||
color: 'warning'
|
||||
})
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
function goToStep(target: StepId) {
|
||||
const ti = STEP_ORDER.indexOf(target)
|
||||
if (ti > maxStepIndex.value) return
|
||||
step.value = target
|
||||
}
|
||||
|
||||
function onStepPillClick(stepIndex: number, target: StepId) {
|
||||
if (stepIndex > maxStepIndex.value) return
|
||||
goToStep(target)
|
||||
}
|
||||
|
||||
function goPrev() {
|
||||
const i = STEP_ORDER.indexOf(step.value)
|
||||
if (i <= 0) return
|
||||
step.value = STEP_ORDER[i - 1]!
|
||||
}
|
||||
|
||||
function goNext() {
|
||||
const i = STEP_ORDER.indexOf(step.value)
|
||||
if (step.value === 'setup' && !canProceedFromSetup()) return
|
||||
if (step.value === 'solicit' && !canProceedFromSolicit()) return
|
||||
if (i >= STEP_ORDER.length - 1) return
|
||||
const next = STEP_ORDER[i + 1]!
|
||||
step.value = next
|
||||
maxStepIndex.value = Math.max(maxStepIndex.value, i + 1)
|
||||
}
|
||||
|
||||
function buildPayload(): AutoQuoteIntakePayload {
|
||||
return {
|
||||
policy_type: 'car',
|
||||
insured: insured.value,
|
||||
buyer: buyer.value,
|
||||
policy_details: { ...draft.vehicle },
|
||||
selected_providers: draft.solicit.carrierIds.map(id => ({
|
||||
provider_id: id,
|
||||
email: getProviderEmail(id)
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
function getProviderEmail(providerId: string): string {
|
||||
// This would come from the providers API
|
||||
// For now, return a placeholder
|
||||
return `quotes@${providerId}.com`
|
||||
}
|
||||
|
||||
async function finalize() {
|
||||
if (!draft.quoteMode || !draft.segment) return
|
||||
if (intakeBusy.value) return
|
||||
intakeBusy.value = true
|
||||
try {
|
||||
const payload = buildPayload()
|
||||
const emailOn = quoteRequestEmailEnabled.value
|
||||
|
||||
if (payload.quoteMode === 'comparative_pdf') {
|
||||
toast.add({
|
||||
title: emailOn ? 'Quote requests queued' : 'Comparative run saved',
|
||||
description: emailOn
|
||||
? 'Opening the comparative sheet. Provider emails follow your Settings → Quote requests toggle.'
|
||||
: 'Emails to providers are disabled — comparative layout saved for manual or table pricing.',
|
||||
color: 'success'
|
||||
})
|
||||
await nextTick()
|
||||
await navigateTo({
|
||||
path: '/quotes/compare',
|
||||
query: { from: 'auto', segment: payload.segment }
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Submit to policy API
|
||||
const data = await submitPolicyQuote(payload)
|
||||
|
||||
toast.add({
|
||||
title: emailOn ? 'Quote requests recorded' : 'Quote run saved (no emails)',
|
||||
description: emailOn
|
||||
? 'Requests can be sent to carrier quoting addresses on file when your integration is on.'
|
||||
: 'Outbound provider email is off in Settings — this request stays in-app for tables, APIs, or AI.',
|
||||
color: 'success'
|
||||
})
|
||||
|
||||
// Navigate to policy detail page
|
||||
await navigateTo(`/policies/${data.application_id}`)
|
||||
} finally {
|
||||
intakeBusy.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const step = ref<StepId>('setup')
|
||||
/** Highest step index the user has reached (for stepper — no reactive watch loops) */
|
||||
const maxStepIndex = ref(0)
|
||||
const intakeBusy = ref(false)
|
||||
|
||||
const draft = reactive(emptyAutoQuoteDraft())
|
||||
|
||||
const toast = useToast()
|
||||
const { quoteRequestEmailEnabled } = useQuoteRequestEmailEnabled()
|
||||
|
||||
const modeCards: { id: AutoQuoteMode; title: string; hint: string; icon: string }[] = [
|
||||
{
|
||||
id: 'single',
|
||||
title: 'Single quote',
|
||||
hint: 'One package — we’ll email carriers’ quoting inboxes on file.',
|
||||
icon: 'i-heroicons-document-text'
|
||||
},
|
||||
{
|
||||
id: 'comparative_pdf',
|
||||
title: 'Comparative quote',
|
||||
hint: 'Same vehicle facts; prep plan comparisons and enter premiums when emails arrive.',
|
||||
icon: 'i-heroicons-document-duplicate'
|
||||
}
|
||||
]
|
||||
|
||||
const segmentCards: { id: AutoQuoteSegment; title: string; hint: string; icon: string }[] = [
|
||||
{
|
||||
id: 'individual',
|
||||
title: 'Individual',
|
||||
hint: 'Personal auto.',
|
||||
icon: 'i-heroicons-user'
|
||||
},
|
||||
{
|
||||
id: 'corporate',
|
||||
title: 'Corporate',
|
||||
hint: 'Business or group.',
|
||||
icon: 'i-heroicons-building-office-2'
|
||||
},
|
||||
{
|
||||
id: 'fleet',
|
||||
title: 'Fleet',
|
||||
hint: 'Fleet program.',
|
||||
icon: 'i-heroicons-truck'
|
||||
}
|
||||
]
|
||||
|
||||
function canProceedFromCustomer() {
|
||||
const c = draft.client
|
||||
if (!c.fullName.trim() || !c.email.trim()) {
|
||||
toast.add({
|
||||
title: 'Add legal name and email',
|
||||
description: 'We need them for carrier notifications.',
|
||||
color: 'warning'
|
||||
})
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
function canProceedFromSetup() {
|
||||
if (!draft.quoteMode) {
|
||||
toast.add({ title: 'Choose a quote type', description: 'Single or comparative.', color: 'warning' })
|
||||
return false
|
||||
}
|
||||
if (!draft.segment) {
|
||||
toast.add({ title: 'Choose policy type', description: 'Individual, corporate, or fleet.', color: 'warning' })
|
||||
return false
|
||||
}
|
||||
if (!canProceedFromCustomer()) return false
|
||||
if (
|
||||
(draft.segment === 'corporate' || draft.segment === 'fleet') &&
|
||||
!draft.client.organizationName?.trim()
|
||||
) {
|
||||
toast.add({
|
||||
title: 'Add organization',
|
||||
description: 'Required for corporate and fleet policies.',
|
||||
color: 'warning'
|
||||
})
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
function canProceedFromSolicit() {
|
||||
if (draft.solicit.carrierIds.length === 0 || draft.solicit.planIds.length === 0) {
|
||||
toast.add({
|
||||
title: 'Choose carriers and plans',
|
||||
description: 'Select at least one insurance company and one coverage package.',
|
||||
color: 'warning'
|
||||
})
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
function goToStep(target: StepId) {
|
||||
const ti = STEP_ORDER.indexOf(target)
|
||||
if (ti > maxStepIndex.value) return
|
||||
step.value = target
|
||||
}
|
||||
|
||||
function onStepPillClick(stepIndex: number, target: StepId) {
|
||||
if (stepIndex > maxStepIndex.value) return
|
||||
goToStep(target)
|
||||
}
|
||||
|
||||
function goPrev() {
|
||||
const i = STEP_ORDER.indexOf(step.value)
|
||||
if (i <= 0) return
|
||||
step.value = STEP_ORDER[i - 1]!
|
||||
}
|
||||
|
||||
function goNext() {
|
||||
const i = STEP_ORDER.indexOf(step.value)
|
||||
if (step.value === 'setup' && !canProceedFromSetup()) return
|
||||
if (step.value === 'solicit' && !canProceedFromSolicit()) return
|
||||
if (i >= STEP_ORDER.length - 1) return
|
||||
const next = STEP_ORDER[i + 1]!
|
||||
step.value = next
|
||||
maxStepIndex.value = Math.max(maxStepIndex.value, i + 1)
|
||||
}
|
||||
|
||||
function buildPayload(): AutoQuoteIntakePayload {
|
||||
return {
|
||||
quoteMode: draft.quoteMode!,
|
||||
segment: draft.segment!,
|
||||
client: { ...draft.client },
|
||||
vehicle: { ...draft.vehicle },
|
||||
solicit: {
|
||||
carrierIds: [...draft.solicit.carrierIds],
|
||||
planIds: [...draft.solicit.planIds]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function finalize() {
|
||||
if (!draft.quoteMode || !draft.segment) return
|
||||
if (intakeBusy.value) return
|
||||
intakeBusy.value = true
|
||||
try {
|
||||
const payload = buildPayload()
|
||||
const emailOn = quoteRequestEmailEnabled.value
|
||||
|
||||
if (payload.quoteMode === 'comparative_pdf') {
|
||||
toast.add({
|
||||
title: emailOn ? 'Quote requests queued' : 'Comparative run saved',
|
||||
description: emailOn
|
||||
? 'Opening the comparative sheet. Provider emails follow your Settings → Quote requests toggle.'
|
||||
: 'Emails to providers are disabled — comparative layout saved for manual or table pricing.',
|
||||
color: 'success'
|
||||
})
|
||||
await nextTick()
|
||||
await navigateTo({
|
||||
path: '/quotes/compare',
|
||||
query: { from: 'auto', segment: payload.segment }
|
||||
})
|
||||
return
|
||||
}
|
||||
toast.add({
|
||||
title: emailOn ? 'Quote requests recorded' : 'Quote run saved (no emails)',
|
||||
description: emailOn
|
||||
? 'Requests can be sent to carrier quoting addresses on file when your integration is on.'
|
||||
: 'Outbound provider email is off in Settings — this request stays in-app for tables, APIs, or AI.',
|
||||
color: 'success'
|
||||
})
|
||||
} finally {
|
||||
intakeBusy.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="mx-auto max-w-4xl space-y-6 pb-12">
|
||||
<NuxtLink to="/quotes" class="inline-flex">
|
||||
<UButton color="neutral" variant="ghost" size="sm" icon="i-heroicons-arrow-left">Back to quotes</UButton>
|
||||
</NuxtLink>
|
||||
|
||||
<div class="max-w-2xl">
|
||||
<h1 class="mt-1 text-2xl font-semibold tracking-tight text-[var(--text-primary)]">Auto quoting</h1>
|
||||
<p class="mt-1 text-[13px] text-[var(--text-muted)]">
|
||||
Set up the risk, choose who to solicit, then accept — three steps.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="flex flex-wrap items-center gap-x-1 gap-y-2 text-[11px] font-medium text-[var(--text-muted)] sm:text-xs"
|
||||
role="navigation"
|
||||
aria-label="Steps"
|
||||
>
|
||||
<template v-for="(s, idx) in STEP_ORDER" :key="s">
|
||||
<UIcon v-if="idx > 0" name="i-heroicons-chevron-right" class="h-3 w-3 shrink-0 opacity-40" aria-hidden="true" />
|
||||
<button
|
||||
type="button"
|
||||
class="min-w-0 rounded-full px-2 py-1 text-left transition sm:px-2.5"
|
||||
:class="
|
||||
step === s
|
||||
? 'bg-[var(--brand-soft)] text-[var(--brand)]'
|
||||
: idx <= maxStepIndex
|
||||
? 'cursor-pointer bg-[var(--sidebar-border)]/60 hover:bg-[var(--brand-soft)]/80 hover:text-[var(--brand)]'
|
||||
: 'cursor-default bg-[var(--sidebar-border)]/60 opacity-50'
|
||||
"
|
||||
:aria-current="step === s ? 'step' : undefined"
|
||||
@click.prevent.stop="onStepPillClick(idx, s)"
|
||||
>
|
||||
<span class="hidden sm:inline">{{ idx + 1 }}. {{ STEP_LABELS[s] }}</span>
|
||||
<span class="sm:hidden">{{ idx + 1 }}</span>
|
||||
</button>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<UCard :ui="{ body: { padding: 'p-5 sm:p-6' } }">
|
||||
<template #header>
|
||||
<div>
|
||||
<p class="text-xs font-semibold uppercase tracking-wide text-[var(--text-muted)]">
|
||||
Step {{ STEP_ORDER.indexOf(step) + 1 }} of {{ STEP_ORDER.length }}
|
||||
</p>
|
||||
<h2 class="mt-0.5 text-lg font-semibold text-[var(--text-primary)]">{{ STEP_LABELS[step] }}</h2>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<QuotesAutoSetupStep
|
||||
v-if="step === 'setup'"
|
||||
:draft="draft"
|
||||
:mode-cards="modeCards"
|
||||
:segment-cards="segmentCards"
|
||||
/>
|
||||
|
||||
<QuotesAutoSolicitQuotesStep
|
||||
v-else-if="step === 'solicit' && draft.quoteMode"
|
||||
:draft="draft"
|
||||
:quote-mode="draft.quoteMode"
|
||||
/>
|
||||
|
||||
<QuotesAutoAcceptanceStep
|
||||
v-else-if="step === 'acceptance' && draft.quoteMode && draft.segment"
|
||||
:draft="draft"
|
||||
:quote-mode="draft.quoteMode"
|
||||
:segment="draft.segment"
|
||||
/>
|
||||
</UCard>
|
||||
|
||||
<div class="flex flex-wrap items-center justify-between gap-3">
|
||||
<UButton
|
||||
v-if="step !== 'setup'"
|
||||
type="button"
|
||||
color="neutral"
|
||||
variant="soft"
|
||||
@click="goPrev"
|
||||
>
|
||||
Back
|
||||
</UButton>
|
||||
<NuxtLink v-else to="/quotes" class="inline-flex">
|
||||
<UButton color="neutral" variant="ghost" size="sm">Cancel</UButton>
|
||||
</NuxtLink>
|
||||
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<UButton v-if="step !== 'acceptance'" type="button" color="primary" @click="goNext">
|
||||
Continue
|
||||
</UButton>
|
||||
<UButton
|
||||
v-else
|
||||
type="button"
|
||||
color="primary"
|
||||
:loading="intakeBusy"
|
||||
:disabled="intakeBusy"
|
||||
@click="finalize"
|
||||
>
|
||||
{{ quoteRequestEmailEnabled ? 'Send quote requests' : 'Save quote run' }}
|
||||
</UButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,169 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
definePageMeta({ ssr: false })
|
||||
usePageTitle('Quotes · Custom')
|
||||
|
||||
const toast = useToast()
|
||||
|
||||
const form = reactive({
|
||||
clientName: '',
|
||||
clientEmail: '',
|
||||
clientPhone: '',
|
||||
lineOfBusiness: '',
|
||||
policyType: '',
|
||||
carrier: '',
|
||||
startDate: '',
|
||||
endDate: '',
|
||||
sumInsured: null as number | null,
|
||||
premium: null as number | null,
|
||||
commissionPct: null as number | null,
|
||||
deductible: null as number | null,
|
||||
notes: ''
|
||||
})
|
||||
|
||||
const commissionAmount = computed(() => {
|
||||
if (form.premium && form.commissionPct) return ((form.premium * form.commissionPct) / 100).toFixed(2)
|
||||
return '0.00'
|
||||
})
|
||||
|
||||
const linesOfBusiness = ['Auto', 'Health', 'Life', 'Property', 'Liability', 'Marine', 'Surety', 'Other']
|
||||
|
||||
const inputCls = 'w-full rounded-xl border border-[var(--sidebar-border)] bg-[var(--surface)] px-3 py-2 text-sm text-[var(--text-primary)] placeholder:text-[var(--text-muted)]'
|
||||
const labelCls = 'block text-sm font-semibold text-[var(--text-muted)] mb-1.5'
|
||||
|
||||
async function save() {
|
||||
if (!form.clientName.trim() || !form.lineOfBusiness) {
|
||||
toast.add({ title: 'Missing fields', description: 'Client name and line of business are required.', color: 'warning' })
|
||||
return
|
||||
}
|
||||
toast.add({ title: 'Quote saved', description: 'Your custom quote has been recorded.', color: 'success' })
|
||||
await navigateTo('/quotes')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="mx-auto max-w-4xl space-y-6 pb-12">
|
||||
<NuxtLink to="/quotes" class="inline-flex">
|
||||
<UButton color="neutral" variant="ghost" size="sm" icon="i-heroicons-arrow-left">Back to quotes</UButton>
|
||||
</NuxtLink>
|
||||
|
||||
<div>
|
||||
<h1 class="mt-1 text-2xl font-semibold tracking-tight text-[var(--text-primary)]">Custom quote entry</h1>
|
||||
<p class="mt-1 text-[13px] text-[var(--text-muted)]">
|
||||
Manually enter a quote for any line of business — quick single-page form.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<UCard :ui="{ body: { padding: 'p-5 sm:p-6' } }">
|
||||
<template #header>
|
||||
<div>
|
||||
<p class="text-xs font-medium text-[var(--text-muted)]">Client</p>
|
||||
<h2 class="mt-0.5 text-lg font-semibold text-[var(--text-primary)]">Client information</h2>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="grid gap-4 sm:grid-cols-3">
|
||||
<div>
|
||||
<label :class="labelCls">Client name *</label>
|
||||
<input v-model="form.clientName" :class="inputCls" placeholder="Full name" />
|
||||
</div>
|
||||
<div>
|
||||
<label :class="labelCls">Email</label>
|
||||
<input v-model="form.clientEmail" type="email" :class="inputCls" placeholder="client@example.com" />
|
||||
</div>
|
||||
<div>
|
||||
<label :class="labelCls">Phone</label>
|
||||
<input v-model="form.clientPhone" type="tel" :class="inputCls" placeholder="+1 (555) 000-0000" />
|
||||
</div>
|
||||
</div>
|
||||
</UCard>
|
||||
|
||||
<UCard :ui="{ body: { padding: 'p-5 sm:p-6' } }">
|
||||
<template #header>
|
||||
<div>
|
||||
<p class="text-xs font-medium text-[var(--text-muted)]">Policy</p>
|
||||
<h2 class="mt-0.5 text-lg font-semibold text-[var(--text-primary)]">Policy details</h2>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="space-y-4">
|
||||
<div class="grid gap-4 sm:grid-cols-2">
|
||||
<div>
|
||||
<label :class="labelCls">Line of business *</label>
|
||||
<select v-model="form.lineOfBusiness" :class="inputCls">
|
||||
<option value="" disabled>Select line</option>
|
||||
<option v-for="l in linesOfBusiness" :key="l" :value="l">{{ l }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label :class="labelCls">Policy type / description</label>
|
||||
<input v-model="form.policyType" :class="inputCls" placeholder="e.g. Comprehensive auto" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-4 sm:grid-cols-3">
|
||||
<div>
|
||||
<label :class="labelCls">Carrier</label>
|
||||
<input v-model="form.carrier" :class="inputCls" placeholder="Carrier name" />
|
||||
</div>
|
||||
<div>
|
||||
<label :class="labelCls">Start date</label>
|
||||
<input v-model="form.startDate" type="date" :class="inputCls" />
|
||||
</div>
|
||||
<div>
|
||||
<label :class="labelCls">End date</label>
|
||||
<input v-model="form.endDate" type="date" :class="inputCls" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-4 sm:grid-cols-2">
|
||||
<div>
|
||||
<label :class="labelCls">Sum insured / limit</label>
|
||||
<input v-model.number="form.sumInsured" type="number" :class="inputCls" placeholder="0.00" min="0" step="0.01" />
|
||||
</div>
|
||||
<div>
|
||||
<label :class="labelCls">Deductible</label>
|
||||
<input v-model.number="form.deductible" type="number" :class="inputCls" placeholder="0.00" min="0" step="0.01" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-4 sm:grid-cols-3">
|
||||
<div>
|
||||
<label :class="labelCls">Premium</label>
|
||||
<input v-model.number="form.premium" type="number" :class="inputCls" placeholder="0.00" min="0" step="0.01" />
|
||||
</div>
|
||||
<div>
|
||||
<label :class="labelCls">Commission %</label>
|
||||
<input v-model.number="form.commissionPct" type="number" :class="inputCls" placeholder="0" min="0" max="100" step="0.1" />
|
||||
</div>
|
||||
<div>
|
||||
<label :class="labelCls">Commission amount</label>
|
||||
<div :class="[inputCls, 'flex items-center bg-[var(--brand-soft)]/30 font-medium']">
|
||||
{{ commissionAmount }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</UCard>
|
||||
|
||||
<UCard :ui="{ body: { padding: 'p-5 sm:p-6' } }">
|
||||
<template #header>
|
||||
<div>
|
||||
<p class="text-xs font-medium text-[var(--text-muted)]">Notes</p>
|
||||
<h2 class="mt-0.5 text-lg font-semibold text-[var(--text-primary)]">Special conditions</h2>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div>
|
||||
<label :class="labelCls">Special conditions / notes</label>
|
||||
<textarea v-model="form.notes" :class="inputCls" rows="4" placeholder="Any additional terms, conditions, or notes..." />
|
||||
</div>
|
||||
</UCard>
|
||||
|
||||
<div class="flex items-center justify-between">
|
||||
<NuxtLink to="/quotes">
|
||||
<UButton color="neutral" variant="ghost" size="sm">Cancel</UButton>
|
||||
</NuxtLink>
|
||||
<UButton color="primary" @click="save">Save quote</UButton>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user