252 lines
12 KiB
Vue
252 lines
12 KiB
Vue
<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>
|