WIP jordan
This commit is contained in:
14
app/pages/onboarding/active-leads/new.vue
Normal file
14
app/pages/onboarding/active-leads/new.vue
Normal file
@@ -0,0 +1,14 @@
|
||||
<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>
|
||||
364
app/pages/onboarding/emissions/index.vue
Normal file
364
app/pages/onboarding/emissions/index.vue
Normal file
@@ -0,0 +1,364 @@
|
||||
<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>
|
||||
251
app/pages/onboarding/index.vue
Normal file
251
app/pages/onboarding/index.vue
Normal file
@@ -0,0 +1,251 @@
|
||||
<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>
|
||||
727
app/pages/onboarding/policy-upload/new.vue
Normal file
727
app/pages/onboarding/policy-upload/new.vue
Normal file
@@ -0,0 +1,727 @@
|
||||
<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>
|
||||
14
app/pages/onboarding/potential-leads/new.vue
Normal file
14
app/pages/onboarding/potential-leads/new.vue
Normal file
@@ -0,0 +1,14 @@
|
||||
<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>
|
||||
311
app/pages/onboarding/solicitud.vue
Normal file
311
app/pages/onboarding/solicitud.vue
Normal file
@@ -0,0 +1,311 @@
|
||||
<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>
|
||||
Reference in New Issue
Block a user