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,662 @@
<script setup lang="ts">
import { PIPELINE_STAGES, type PipelineStage } from '~/composables/useSalesPipeline'
definePageMeta({ ssr: false })
usePageTitle('Mission Control \u00b7 Quotes')
/* ── Mock agents ── */
interface AgentStats {
name: string
activeDeals: number
quotesSent: number
conversionRate: number
avgResponseTime: string
pipelineValue: number
}
const agents: AgentStats[] = [
{ name: 'Ana Ram\u00edrez', activeDeals: 8, quotesSent: 14, conversionRate: 71, avgResponseTime: '1.2d', pipelineValue: 18_400 },
{ name: 'Marco Villanueva', activeDeals: 4, quotesSent: 6, conversionRate: 83, avgResponseTime: '0.8d', pipelineValue: 22_100 },
{ name: 'Luc\u00eda Fern\u00e1ndez', activeDeals: 6, quotesSent: 4, conversionRate: 42, avgResponseTime: '2.4d', pipelineValue: 4_700 },
]
/* ── Mock deals spread across pipeline ── */
interface MockDeal {
id: string
customerName: string
productLine: string
currentStage: PipelineStage
agent: string
daysAtStage: number
premium: number
formCompletion: number
createdDaysAgo: number
}
const mockDeals: MockDeal[] = [
{ id: 'd01', customerName: 'Mar\u00eda Elena P\u00e9rez', productLine: 'Auto', currentStage: 'customer', agent: 'Ana Ram\u00edrez', daysAtStage: 1, premium: 1200, formCompletion: 45, createdDaysAgo: 1 },
{ id: 'd02', customerName: 'Carlos Mendoza', productLine: 'Life', currentStage: 'customer', agent: 'Luc\u00eda Fern\u00e1ndez', daysAtStage: 3, premium: 3200, formCompletion: 20, createdDaysAgo: 3 },
{ id: 'd03', customerName: 'Laura Castillo', productLine: 'Health', currentStage: 'get_quotes', agent: 'Ana Ram\u00edrez', daysAtStage: 2, premium: 2800, formCompletion: 60, createdDaysAgo: 5 },
{ id: 'd04', customerName: 'Sof\u00eda Rojas Delgado', productLine: 'Auto', currentStage: 'get_quotes', agent: 'Luc\u00eda Fern\u00e1ndez', daysAtStage: 4, premium: 1500, formCompletion: 50, createdDaysAgo: 7 },
{ id: 'd05', customerName: 'Andr\u00e9s Vargas', productLine: 'General Risk', currentStage: 'get_quotes', agent: 'Marco Villanueva', daysAtStage: 1, premium: 8500, formCompletion: 75, createdDaysAgo: 4 },
{ id: 'd06', customerName: 'Patricia Herrera', productLine: 'Auto', currentStage: 'waiting_carriers', agent: 'Ana Ram\u00edrez', daysAtStage: 6, premium: 1100, formCompletion: 100, createdDaysAgo: 10 },
{ id: 'd07', customerName: 'Fernando L\u00f3pez', productLine: 'Life', currentStage: 'waiting_carriers', agent: 'Luc\u00eda Fern\u00e1ndez', daysAtStage: 8, premium: 4200, formCompletion: 100, createdDaysAgo: 12 },
{ id: 'd08', customerName: 'Roberto Jim\u00e9nez Mora', productLine: 'Health', currentStage: 'waiting_carriers', agent: 'Marco Villanueva', daysAtStage: 3, premium: 3100, formCompletion: 100, createdDaysAgo: 8 },
{ id: 'd09', customerName: 'Gabriela Torres', productLine: 'Auto', currentStage: 'present_quotes', agent: 'Ana Ram\u00edrez', daysAtStage: 2, premium: 1400, formCompletion: 100, createdDaysAgo: 14 },
{ id: 'd10', customerName: 'Diego Salazar', productLine: 'Life', currentStage: 'present_quotes', agent: 'Marco Villanueva', daysAtStage: 1, premium: 5600, formCompletion: 100, createdDaysAgo: 9 },
{ id: 'd11', customerName: 'Isabel Moreno', productLine: 'General Risk', currentStage: 'waiting_client', agent: 'Ana Ram\u00edrez', daysAtStage: 7, premium: 2200, formCompletion: 100, createdDaysAgo: 16 },
{ id: 'd12', customerName: 'Alejandro Rios', productLine: 'Auto', currentStage: 'waiting_client', agent: 'Luc\u00eda Fern\u00e1ndez', daysAtStage: 12, premium: 900, formCompletion: 100, createdDaysAgo: 20 },
{ id: 'd13', customerName: 'Valentina Cruz', productLine: 'Health', currentStage: 'solicitud', agent: 'Ana Ram\u00edrez', daysAtStage: 3, premium: 2700, formCompletion: 68, createdDaysAgo: 18 },
{ id: 'd14', customerName: 'Jorge Navarro', productLine: 'Life', currentStage: 'solicitud', agent: 'Marco Villanueva', daysAtStage: 2, premium: 6800, formCompletion: 85, createdDaysAgo: 15 },
{ id: 'd15', customerName: 'Camila Fuentes', productLine: 'Auto', currentStage: 'emission', agent: 'Ana Ram\u00edrez', daysAtStage: 1, premium: 1600, formCompletion: 92, createdDaysAgo: 22 },
{ id: 'd16', customerName: 'Ra\u00fal Espinoza', productLine: 'General Risk', currentStage: 'emission', agent: 'Marco Villanueva', daysAtStage: 2, premium: 7200, formCompletion: 100, createdDaysAgo: 25 },
{ id: 'd17', customerName: 'M\u00f3nica Delgado', productLine: 'Life', currentStage: 'waiting_carriers', agent: 'Ana Ram\u00edrez', daysAtStage: 11, premium: 3800, formCompletion: 100, createdDaysAgo: 15 },
{ id: 'd18', customerName: 'Enrique Paredes', productLine: 'Health', currentStage: 'customer', agent: 'Luc\u00eda Fern\u00e1ndez', daysAtStage: 6, premium: 2100, formCompletion: 30, createdDaysAgo: 6 },
]
/* ── KPI computations ── */
const totalActiveDeals = computed(() => mockDeals.length)
const totalPipelinePremium = computed(() =>
mockDeals.reduce((sum, d) => sum + d.premium, 0),
)
const stageCounts = computed(() => {
const counts: Record<PipelineStage, number> = {} as any
for (const s of PIPELINE_STAGES) counts[s.id] = 0
for (const d of mockDeals) counts[d.currentStage]++
return counts
})
const bottleneckStage = computed(() => {
let max = 0
let stage = PIPELINE_STAGES[0]
for (const s of PIPELINE_STAGES) {
if (stageCounts.value[s.id] > max) {
max = stageCounts.value[s.id]
stage = s
}
}
return stage.label
})
const maxStageCount = computed(() =>
Math.max(...PIPELINE_STAGES.map(s => stageCounts.value[s.id]), 1),
)
/* ── Funnel colors ── */
const stageColors: Record<PipelineStage, string> = {
customer: '#01696f',
get_quotes: '#0d8a8f',
waiting_carriers: '#f59e0b',
present_quotes: '#3b82f6',
waiting_client: '#f59e0b',
solicitud: '#8b5cf6',
emission: '#10b981',
}
/* ── Bottleneck deals (stuck > 5 days) ── */
const stuckDeals = computed(() =>
mockDeals
.filter(d => d.daysAtStage > 5)
.sort((a, b) => b.daysAtStage - a.daysAtStage),
)
function severityColor(days: number) {
if (days > 10) return '#ef4444'
if (days > 5) return '#f59e0b'
return '#6b7280'
}
function severityBg(days: number) {
if (days > 10) return 'rgba(239,68,68,0.08)'
if (days > 5) return 'rgba(245,158,11,0.08)'
return 'transparent'
}
/* ── Stage breakdown tab ── */
const activeStageTab = ref<PipelineStage>('customer')
const stageDeals = computed(() =>
mockDeals.filter(d => d.currentStage === activeStageTab.value),
)
function stageLabelFor(id: PipelineStage) {
return PIPELINE_STAGES.find(s => s.id === id)?.label ?? id
}
function formatCurrency(v: number) {
return '$' + v.toLocaleString('en-US')
}
const toast = useToast()
const nudgeConfirmDeal = ref<MockDeal | null>(null)
function onNudge(deal: MockDeal) {
nudgeConfirmDeal.value = deal
}
function confirmNudge() {
if (nudgeConfirmDeal.value) {
toast.add({ title: `Nudge sent to ${nudgeConfirmDeal.value.customerName}`, color: 'success' })
}
nudgeConfirmDeal.value = null
}
function cancelNudge() {
nudgeConfirmDeal.value = null
}
</script>
<template>
<div class="mc-page">
<!-- Header -->
<div class="mc-header">
<h1 class="mc-title">Mission Control</h1>
<p class="mc-subtitle">Pipeline analytics &amp; agent performance</p>
</div>
<!-- KPI Strip -->
<div class="mc-kpi-strip">
<div class="mc-card mc-kpi-card">
<span class="mc-kpi-label">Total Active Deals</span>
<span class="mc-kpi-value">{{ totalActiveDeals }}</span>
</div>
<div class="mc-card mc-kpi-card">
<span class="mc-kpi-label">Quotes This Month</span>
<span class="mc-kpi-value">24</span>
</div>
<div class="mc-card mc-kpi-card">
<span class="mc-kpi-label">Avg Days in Pipeline</span>
<span class="mc-kpi-value">8.3</span>
</div>
<div class="mc-card mc-kpi-card">
<span class="mc-kpi-label">Conversion Rate</span>
<span class="mc-kpi-value">62%</span>
</div>
<div class="mc-card mc-kpi-card">
<span class="mc-kpi-label">Pipeline Premium</span>
<span class="mc-kpi-value">{{ formatCurrency(totalPipelinePremium) }}</span>
</div>
<div class="mc-card mc-kpi-card">
<span class="mc-kpi-label">Bottleneck Stage</span>
<span class="mc-kpi-value mc-kpi-value--bottleneck">{{ bottleneckStage }}</span>
</div>
</div>
<!-- Pipeline Funnel -->
<div class="mc-card mc-section">
<h2 class="mc-section-title">Pipeline Funnel</h2>
<div class="mc-funnel">
<div
v-for="stage in PIPELINE_STAGES"
:key="stage.id"
class="mc-funnel-row"
>
<span class="mc-funnel-label">{{ stage.label }}</span>
<div class="mc-funnel-bar-track">
<div
class="mc-funnel-bar"
:style="{
width: Math.max((stageCounts[stage.id] / maxStageCount) * 100, 4) + '%',
background: stageColors[stage.id],
}"
/>
</div>
<span class="mc-funnel-count">{{ stageCounts[stage.id] }}</span>
<span class="mc-funnel-pct">
{{ totalActiveDeals ? Math.round((stageCounts[stage.id] / totalActiveDeals) * 100) : 0 }}%
</span>
</div>
</div>
</div>
<!-- Agent Performance -->
<div class="mc-card mc-section">
<h2 class="mc-section-title">Agent Performance</h2>
<div class="mc-table-wrap">
<table class="mc-table">
<thead>
<tr>
<th>Agent</th>
<th>Active Deals</th>
<th>Quotes Sent</th>
<th>Conversion</th>
<th>Avg Response</th>
<th>Pipeline Value</th>
</tr>
</thead>
<tbody>
<tr v-for="a in agents" :key="a.name">
<td class="mc-agent-name">{{ a.name }}</td>
<td>{{ a.activeDeals }}</td>
<td>{{ a.quotesSent }}</td>
<td>
<span
class="mc-badge"
:style="{ background: a.conversionRate >= 70 ? 'rgba(16,185,129,0.1)' : a.conversionRate >= 50 ? 'rgba(245,158,11,0.1)' : 'rgba(239,68,68,0.1)', color: a.conversionRate >= 70 ? '#059669' : a.conversionRate >= 50 ? '#d97706' : '#dc2626' }"
>
{{ a.conversionRate }}%
</span>
</td>
<td>{{ a.avgResponseTime }}</td>
<td>{{ formatCurrency(a.pipelineValue) }}</td>
</tr>
</tbody>
</table>
</div>
</div>
<!-- Bottleneck Analysis -->
<div class="mc-card mc-section">
<h2 class="mc-section-title">Bottleneck Analysis</h2>
<p class="mc-section-desc">Deals stuck at a stage for more than 5 days</p>
<div v-if="stuckDeals.length === 0" class="mc-empty">No bottlenecks detected.</div>
<div v-else class="mc-stuck-list">
<div
v-for="deal in stuckDeals"
:key="deal.id"
class="mc-stuck-row"
:style="{ background: severityBg(deal.daysAtStage) }"
>
<div class="mc-stuck-info">
<span class="mc-stuck-name">{{ deal.customerName }}</span>
<span class="mc-stuck-meta">
<span
class="mc-stuck-days"
:style="{ color: severityColor(deal.daysAtStage) }"
>
{{ deal.daysAtStage }}d
</span>
at {{ stageLabelFor(deal.currentStage) }}
</span>
</div>
<span class="mc-stuck-agent">{{ deal.agent }}</span>
<button class="mc-btn-nudge" @click="onNudge(deal)">Nudge</button>
</div>
</div>
</div>
<!-- Stage Breakdown -->
<div class="mc-card mc-section">
<h2 class="mc-section-title">Stage Breakdown</h2>
<div class="mc-tab-bar">
<button
v-for="stage in PIPELINE_STAGES"
:key="stage.id"
class="mc-tab"
:class="{ 'mc-tab--active': activeStageTab === stage.id }"
@click="activeStageTab = stage.id"
>
{{ stage.label }}
</button>
</div>
<div v-if="stageDeals.length === 0" class="mc-empty">
<UIcon name="i-heroicons-inbox" style="width: 20px; height: 20px; color: #c0c0bc;" />
<p>No deals at this stage</p>
</div>
<div v-else class="mc-table-wrap">
<table class="mc-table">
<thead>
<tr>
<th>Customer</th>
<th>Product Line</th>
<th>Days at Stage</th>
<th>Agent</th>
<th>Premium</th>
<th>Form Completion</th>
</tr>
</thead>
<tbody>
<tr v-for="deal in stageDeals" :key="deal.id">
<td class="mc-agent-name">{{ deal.customerName }}</td>
<td>{{ deal.productLine }}</td>
<td>
<span :style="{ color: deal.daysAtStage > 5 ? severityColor(deal.daysAtStage) : 'inherit', fontWeight: deal.daysAtStage > 5 ? 600 : 400 }">
{{ deal.daysAtStage }}d
</span>
</td>
<td>{{ deal.agent }}</td>
<td>{{ formatCurrency(deal.premium) }}</td>
<td>
<div class="mc-progress-wrap">
<div class="mc-progress-track">
<div
class="mc-progress-fill"
:style="{ width: deal.formCompletion + '%' }"
/>
</div>
<span class="mc-progress-label">{{ deal.formCompletion }}%</span>
</div>
</td>
</tr>
</tbody>
</table>
</div>
</div>
<!-- Nudge confirmation modal -->
<Teleport to="body">
<Transition
enter-active-class="transition duration-150 ease-out"
enter-from-class="opacity-0"
enter-to-class="opacity-100"
leave-active-class="transition duration-100 ease-in"
leave-from-class="opacity-100"
leave-to-class="opacity-0"
>
<div v-if="nudgeConfirmDeal" class="fixed inset-0 z-50 flex items-center justify-center bg-black/30" @click.self="cancelNudge">
<div class="w-full max-w-sm rounded-xl border border-[var(--card-border)] bg-white p-6 shadow-xl">
<div class="flex items-start gap-3">
<div class="flex h-9 w-9 shrink-0 items-center justify-center rounded-lg" style="background: rgba(1,105,111,0.08); color: #01696f;">
<UIcon name="i-heroicons-bell-alert" style="width: 18px; height: 18px;" />
</div>
<div class="min-w-0">
<p class="text-[14px] font-semibold text-[var(--text-primary)]">Send nudge?</p>
<p class="mt-1 text-[13px] text-[var(--text-muted)]">
This will send a follow-up reminder to <span class="font-semibold text-[var(--text-primary)]">{{ nudgeConfirmDeal.customerName }}</span> about their pending deal.
</p>
</div>
</div>
<div class="mt-5 flex justify-end gap-2">
<UButton color="neutral" variant="soft" size="sm" @click="cancelNudge">Cancel</UButton>
<UButton color="primary" size="sm" @click="confirmNudge">Send nudge</UButton>
</div>
</div>
</div>
</Transition>
</Teleport>
</div>
</template>
<style scoped>
/* ── Page ── */
.mc-page {
display: flex;
flex-direction: column;
gap: 20px;
max-width: 1120px;
}
/* ── Header ── */
.mc-header {
margin-bottom: 4px;
}
.mc-title {
font-size: 24px;
font-weight: 650;
letter-spacing: -0.015em;
color: var(--text-primary);
}
.mc-subtitle {
font-size: 14px;
color: #8a8a86;
margin-top: 2px;
}
/* ── Card ── */
.mc-card {
background: #fff;
border: 1px solid rgba(0, 0, 0, 0.06);
border-radius: 12px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.03);
}
/* ── KPI strip ── */
.mc-kpi-strip {
display: grid;
grid-template-columns: repeat(6, 1fr);
gap: 12px;
}
.mc-kpi-card {
display: flex;
flex-direction: column;
align-items: center;
padding: 16px 12px;
gap: 6px;
}
.mc-kpi-label {
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.04em;
color: #8a8a86;
text-align: center;
}
.mc-kpi-value {
font-size: 22px;
font-weight: 650;
color: var(--text-primary);
}
.mc-kpi-value--bottleneck {
font-size: 14px;
font-weight: 600;
color: #01696f;
}
/* ── Section ── */
.mc-section {
padding: 20px 24px;
}
.mc-section-title {
font-size: 14px;
font-weight: 700;
color: var(--text-primary);
margin-bottom: 14px;
}
.mc-section-desc {
font-size: 13px;
color: #8a8a86;
margin-top: -8px;
margin-bottom: 14px;
}
/* ── Funnel ── */
.mc-funnel {
display: flex;
flex-direction: column;
gap: 8px;
}
.mc-funnel-row {
display: grid;
grid-template-columns: 140px 1fr 36px 44px;
align-items: center;
gap: 10px;
}
.mc-funnel-label {
font-size: 13px;
color: var(--text-primary);
white-space: nowrap;
}
.mc-funnel-bar-track {
height: 22px;
background: rgba(0, 0, 0, 0.03);
border-radius: 6px;
overflow: hidden;
}
.mc-funnel-bar {
height: 100%;
border-radius: 6px;
transition: width 0.4s ease;
min-width: 4px;
}
.mc-funnel-count {
font-size: 14px;
font-weight: 600;
text-align: right;
color: var(--text-primary);
}
.mc-funnel-pct {
font-size: 12px;
color: #8a8a86;
text-align: right;
}
/* ── Table ── */
.mc-table-wrap {
overflow-x: auto;
}
.mc-table {
width: 100%;
border-collapse: collapse;
font-size: 13px;
}
.mc-table th {
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.04em;
color: #8a8a86;
font-weight: 500;
text-align: left;
padding: 0 12px 10px;
border-bottom: 1px solid rgba(0, 0, 0, 0.06);
}
.mc-table td {
padding: 10px 12px;
color: var(--text-primary);
border-bottom: 1px solid rgba(0, 0, 0, 0.04);
}
.mc-table tbody tr:last-child td {
border-bottom: none;
}
.mc-agent-name {
font-weight: 500;
}
/* ── Badge ── */
.mc-badge {
display: inline-flex;
align-items: center;
padding: 2px 8px;
border-radius: 6px;
font-size: 12px;
font-weight: 600;
}
/* ── Stuck list ── */
.mc-stuck-list {
display: flex;
flex-direction: column;
gap: 6px;
}
.mc-stuck-row {
display: flex;
align-items: center;
gap: 16px;
padding: 10px 14px;
border-radius: 8px;
border: 1px solid rgba(0, 0, 0, 0.04);
}
.mc-stuck-info {
flex: 1;
display: flex;
flex-direction: column;
gap: 2px;
}
.mc-stuck-name {
font-size: 13px;
font-weight: 500;
color: var(--text-primary);
}
.mc-stuck-meta {
font-size: 12px;
color: #8a8a86;
}
.mc-stuck-days {
font-weight: 600;
}
.mc-stuck-agent {
font-size: 12px;
color: #8a8a86;
min-width: 120px;
}
.mc-btn-nudge {
font-size: 12px;
font-weight: 500;
padding: 4px 14px;
border-radius: 6px;
border: 1px solid rgba(0, 0, 0, 0.1);
background: #fff;
color: #01696f;
cursor: pointer;
white-space: nowrap;
transition: background 0.15s;
}
.mc-btn-nudge:hover {
background: rgba(1, 105, 111, 0.06);
}
/* ── Tab bar ── */
.mc-tab-bar {
display: inline-flex;
background: rgba(0, 0, 0, 0.04);
padding: 3px;
border-radius: 10px;
gap: 2px;
margin-bottom: 16px;
flex-wrap: wrap;
}
.mc-tab {
font-size: 12px;
font-weight: 500;
padding: 5px 12px;
border-radius: 8px;
border: none;
background: transparent;
color: #6b6b68;
cursor: pointer;
white-space: nowrap;
transition: all 0.15s;
}
.mc-tab--active {
background: #fff;
color: var(--text-primary);
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.08);
}
/* ── Progress bar ── */
.mc-progress-wrap {
display: flex;
align-items: center;
gap: 8px;
}
.mc-progress-track {
flex: 1;
height: 4px;
background: rgba(0, 0, 0, 0.06);
border-radius: 2px;
overflow: hidden;
min-width: 60px;
}
.mc-progress-fill {
height: 100%;
background: #01696f;
border-radius: 2px;
transition: width 0.3s ease;
}
.mc-progress-label {
font-size: 12px;
color: #8a8a86;
min-width: 32px;
}
/* ── Empty state ── */
.mc-empty {
font-size: 13px;
color: #8a8a86;
padding: 20px 0;
text-align: center;
}
/* ── Responsive ── */
@media (max-width: 900px) {
.mc-kpi-strip {
grid-template-columns: repeat(3, 1fr);
}
}
@media (max-width: 600px) {
.mc-kpi-strip {
grid-template-columns: repeat(2, 1fr);
}
}
</style>