WIP jordan

This commit is contained in:
Jordan Weingarten
2026-04-16 11:11:44 -05:00
parent ff2d7b18b5
commit 67482f6629
163 changed files with 50627 additions and 728 deletions

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

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

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

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

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

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