Files
policy-ui/app/pages/quotes/index.vue
Jordan Weingarten 67482f6629 WIP jordan
2026-04-16 11:11:44 -05:00

1268 lines
46 KiB
Vue

<script setup lang="ts">
import {
MOCK_PIPELINE_QUOTES,
QUOTE_LOB_OPTIONS,
UNIFIED_PIPELINE_STAGES,
type MockPipelineQuote,
type QuoteOverviewLob,
type QuotePipelineScopeFilter,
type UnifiedStageId
} from '~/data/quotes-overview.mock'
import { PIPELINE_STAGES, type PipelineStage } from '~/composables/useSalesPipeline'
definePageMeta({ ssr: false })
usePageTitle('Quotes · Mission Control')
/* ── Top-level view: overview vs analytics ── */
type TopView = 'overview' | 'analytics'
const topView = ref<TopView>('overview')
/* ── Overview sub-view: tracker (3 buckets) vs pipeline (5 readable columns) ── */
type OverviewMode = 'tracker' | 'pipeline'
const overviewMode = ref<OverviewMode>('tracker')
/* ── Filters ── */
const pipelineScope = ref<QuotePipelineScopeFilter>('global')
const lobFilter = ref<QuoteOverviewLob | 'all'>('all')
const search = ref('')
const lobSelectItems = computed(() =>
QUOTE_LOB_OPTIONS.map((o) => ({ label: o.label, value: o.value }))
)
const scopeTabs = [
{ value: 'global' as const, label: 'Global', icon: 'i-heroicons-globe-alt' },
{ value: 'corporate' as const, label: 'Corporate', icon: 'i-heroicons-building-office-2' },
{ value: 'personal' as const, label: 'Personal', icon: 'i-heroicons-user' }
]
const filteredQuotes = computed(() => {
let rows = [...MOCK_PIPELINE_QUOTES]
if (pipelineScope.value !== 'global') {
rows = rows.filter((q) => q.party === pipelineScope.value)
}
if (lobFilter.value !== 'all') {
rows = rows.filter((q) => q.lob === lobFilter.value)
}
const t = search.value.trim().toLowerCase()
if (t) {
rows = rows.filter(
(q) =>
q.customerLabel.toLowerCase().includes(t) ||
q.id.toLowerCase().includes(t) ||
q.owner.toLowerCase().includes(t)
)
}
return rows
})
/* ── KPIs ── */
const kpis = computed(() => {
const q = filteredQuotes.value
const awaiting = q.filter((x) => !x.customerInformed).length
const formsOpen = q.filter((x) => x.formsDone < x.formsTotal).length
const avgFormPct = q.length > 0 ? Math.round(q.reduce((s, x) => s + formsPct(x), 0) / q.length) : 0
return { total: q.length, awaiting, formsOpen, avgFormPct }
})
/* ── Tracker: 3 buckets ── */
type TrackerBucket = 'waiting_quotes' | 'customer_review' | 'form_filling'
const TRACKER_BUCKETS: { id: TrackerBucket; label: string; hint: string; icon: string; color: string; stages: UnifiedStageId[] }[] = [
{ id: 'waiting_quotes', label: 'Waiting for quotes', hint: 'Gathering info & waiting on carriers', icon: 'i-heroicons-clock', color: '#f59e0b', stages: ['customer', 'get_quotes', 'waiting_carriers'] },
{ id: 'customer_review', label: 'Customer reviewing', hint: 'Quotes in — client deciding', icon: 'i-heroicons-eye', color: '#3b82f6', stages: ['present_quotes', 'waiting_client'] },
{ id: 'form_filling', label: 'Accepted — forms', hint: 'Customer said yes — paperwork time', icon: 'i-heroicons-document-check', color: '#10b981', stages: ['solicitud', 'emission'] },
]
const quotesByBucket = computed(() => {
const map: Record<TrackerBucket, MockPipelineQuote[]> = {
waiting_quotes: [], customer_review: [], form_filling: [],
}
for (const q of filteredQuotes.value) {
for (const bucket of TRACKER_BUCKETS) {
if (bucket.stages.includes(q.unifiedStage)) {
map[bucket.id].push(q)
break
}
}
}
return map
})
/* ── Pipeline: 5 readable columns (collapsed waiting stages) ── */
type PipelineCol = 'customer' | 'quoting' | 'presenting' | 'solicitud' | 'emission'
const PIPELINE_COLS: { id: PipelineCol; label: string; hint: string; stages: UnifiedStageId[]; color: string }[] = [
{ id: 'customer', label: 'Customer', hint: 'Intake & qualify', stages: ['customer'], color: '#8a8a86' },
{ id: 'quoting', label: 'Quoting', hint: 'Get quotes + awaiting carriers', stages: ['get_quotes', 'waiting_carriers'], color: '#01696f' },
{ id: 'presenting', label: 'Presenting', hint: 'Present + awaiting client', stages: ['present_quotes', 'waiting_client'], color: '#8b5cf6' },
{ id: 'solicitud', label: 'Solicitud', hint: 'Forms & binding', stages: ['solicitud'], color: '#d97706' },
{ id: 'emission', label: 'Emission', hint: 'Policy issued', stages: ['emission'], color: '#10b981' },
]
const quotesByCol = computed(() => {
const map: Record<PipelineCol, MockPipelineQuote[]> = {
customer: [], quoting: [], presenting: [], solicitud: [], emission: [],
}
for (const q of filteredQuotes.value) {
for (const col of PIPELINE_COLS) {
if (col.stages.includes(q.unifiedStage)) {
map[col.id].push(q)
break
}
}
}
return map
})
/* ── Analytics: mock data from mission control ── */
interface AgentStats {
name: string
activeDeals: number
quotesSent: number
conversionRate: number
avgResponseTime: string
pipelineValue: number
}
const agents: AgentStats[] = [
{ name: 'Ana Ramírez', 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ía Fernández', activeDeals: 6, quotesSent: 4, conversionRate: 42, avgResponseTime: '2.4d', pipelineValue: 4_700 },
]
interface MockDeal {
id: string; customerName: string; productLine: string; currentStage: PipelineStage
agent: string; daysAtStage: number; premium: number; formCompletion: number
}
const mockDeals: MockDeal[] = [
{ id: 'd01', customerName: 'María Elena Pérez', productLine: 'Auto', currentStage: 'customer', agent: 'Ana Ramírez', daysAtStage: 1, premium: 1200, formCompletion: 45 },
{ id: 'd02', customerName: 'Carlos Mendoza', productLine: 'Life', currentStage: 'customer', agent: 'Lucía Fernández', daysAtStage: 3, premium: 3200, formCompletion: 20 },
{ id: 'd03', customerName: 'Laura Castillo', productLine: 'Health', currentStage: 'get_quotes', agent: 'Ana Ramírez', daysAtStage: 2, premium: 2800, formCompletion: 60 },
{ id: 'd04', customerName: 'Sofía Rojas Delgado', productLine: 'Auto', currentStage: 'get_quotes', agent: 'Lucía Fernández', daysAtStage: 4, premium: 1500, formCompletion: 50 },
{ id: 'd05', customerName: 'Andrés Vargas', productLine: 'General Risk', currentStage: 'get_quotes', agent: 'Marco Villanueva', daysAtStage: 1, premium: 8500, formCompletion: 75 },
{ id: 'd06', customerName: 'Patricia Herrera', productLine: 'Auto', currentStage: 'waiting_carriers', agent: 'Ana Ramírez', daysAtStage: 6, premium: 1100, formCompletion: 100 },
{ id: 'd07', customerName: 'Fernando López', productLine: 'Life', currentStage: 'waiting_carriers', agent: 'Lucía Fernández', daysAtStage: 8, premium: 4200, formCompletion: 100 },
{ id: 'd08', customerName: 'Roberto Jiménez Mora', productLine: 'Health', currentStage: 'waiting_carriers', agent: 'Marco Villanueva', daysAtStage: 3, premium: 3100, formCompletion: 100 },
{ id: 'd09', customerName: 'Gabriela Torres', productLine: 'Auto', currentStage: 'present_quotes', agent: 'Ana Ramírez', daysAtStage: 2, premium: 1400, formCompletion: 100 },
{ id: 'd10', customerName: 'Diego Salazar', productLine: 'Life', currentStage: 'present_quotes', agent: 'Marco Villanueva', daysAtStage: 1, premium: 5600, formCompletion: 100 },
{ id: 'd11', customerName: 'Isabel Moreno', productLine: 'General Risk', currentStage: 'waiting_client', agent: 'Ana Ramírez', daysAtStage: 7, premium: 2200, formCompletion: 100 },
{ id: 'd12', customerName: 'Alejandro Rios', productLine: 'Auto', currentStage: 'waiting_client', agent: 'Lucía Fernández', daysAtStage: 12, premium: 900, formCompletion: 100 },
{ id: 'd13', customerName: 'Valentina Cruz', productLine: 'Health', currentStage: 'solicitud', agent: 'Ana Ramírez', daysAtStage: 3, premium: 2700, formCompletion: 68 },
{ id: 'd14', customerName: 'Jorge Navarro', productLine: 'Life', currentStage: 'solicitud', agent: 'Marco Villanueva', daysAtStage: 2, premium: 6800, formCompletion: 85 },
{ id: 'd15', customerName: 'Camila Fuentes', productLine: 'Auto', currentStage: 'emission', agent: 'Ana Ramírez', daysAtStage: 1, premium: 1600, formCompletion: 92 },
{ id: 'd16', customerName: 'Raúl Espinoza', productLine: 'General Risk', currentStage: 'emission', agent: 'Marco Villanueva', daysAtStage: 2, premium: 7200, formCompletion: 100 },
{ id: 'd17', customerName: 'Mónica Delgado', productLine: 'Life', currentStage: 'waiting_carriers', agent: 'Ana Ramírez', daysAtStage: 11, premium: 3800, formCompletion: 100 },
{ id: 'd18', customerName: 'Enrique Paredes', productLine: 'Health', currentStage: 'customer', agent: 'Lucía Fernández', daysAtStage: 6, premium: 2100, formCompletion: 30 },
]
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 maxStageCount = computed(() =>
Math.max(...PIPELINE_STAGES.map(s => stageCounts.value[s.id]), 1),
)
const stageColors: Record<PipelineStage, string> = {
customer: '#01696f',
get_quotes: '#0d8a8f',
waiting_carriers: '#f59e0b',
present_quotes: '#3b82f6',
waiting_client: '#f59e0b',
solicitud: '#8b5cf6',
emission: '#10b981',
}
const stuckDeals = computed(() =>
mockDeals.filter(d => d.daysAtStage > 5).sort((a, b) => b.daysAtStage - a.daysAtStage),
)
const activeStageTab = ref<PipelineStage>('customer')
const stageDeals = computed(() =>
mockDeals.filter(d => d.currentStage === activeStageTab.value),
)
/* ── Helpers ── */
function lobBadgeColor(lob: QuoteOverviewLob) {
switch (lob) { case 'auto': return 'primary'; case 'health': return 'success'; case 'life': return 'info'; case 'general_risk': return 'warning'; default: return 'neutral' }
}
function lobLabel(lob: QuoteOverviewLob) { return QUOTE_LOB_OPTIONS.find((o) => o.value === lob)?.label ?? lob }
function lobIcon(lob: QuoteOverviewLob) {
const icons: Record<QuoteOverviewLob, string> = { auto: 'i-heroicons-truck', health: 'i-heroicons-heart', life: 'i-heroicons-shield-check', general_risk: 'i-heroicons-building-office-2', custom: 'i-heroicons-puzzle-piece' }
return icons[lob]
}
function lobHubPath(lob: QuoteOverviewLob) {
const paths: Record<QuoteOverviewLob, string> = { auto: '/quotes/auto', health: '/quotes/health', life: '/quotes/life', general_risk: '/quotes/general-risk', custom: '/quotes/custom' }
return paths[lob]
}
function formsPct(q: MockPipelineQuote) { return q.formsTotal <= 0 ? 0 : Math.round((100 * q.formsDone) / q.formsTotal) }
function fmtCurrency(v: number) { return '$' + v.toLocaleString('en-US') }
function stageLabelFor(id: PipelineStage) { return PIPELINE_STAGES.find(s => s.id === id)?.label ?? id }
function severityColor(days: number) { return days > 10 ? '#ef4444' : days > 5 ? '#f59e0b' : '#6b7280' }
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
}
/* ── SVG ring for KPI ── */
const ringCirc = 2 * Math.PI * 38
const avgFormRing = computed(() => {
const pct = kpis.value.avgFormPct
return { dasharray: `${ringCirc}`, dashoffset: `${ringCirc - (ringCirc * pct) / 100}` }
})
const showStickyQuoteCta = ref(false)
onMounted(() => {
const onScroll = () => { showStickyQuoteCta.value = window.scrollY > 160 }
window.addEventListener('scroll', onScroll, { passive: true })
onUnmounted(() => window.removeEventListener('scroll', onScroll))
})
</script>
<template>
<div class="qmc-page">
<!-- Header -->
<div class="qmc-header">
<div>
<h1 class="qmc-title">Mission Control</h1>
<p class="qmc-subtitle">Pipeline overview, quoting tracker, and team analytics all in one place.</p>
</div>
<div class="qmc-header-actions">
<NuxtLink to="/quotes/new">
<UButton icon="i-heroicons-plus" color="primary">New quote</UButton>
</NuxtLink>
<NuxtLink to="/quotes/compare">
<UButton icon="i-heroicons-squares-plus" color="neutral" variant="soft">Comparative</UButton>
</NuxtLink>
</div>
</div>
<!-- TOP-LEVEL TOGGLE -->
<div class="qmc-view-toggle">
<button
type="button"
class="qmc-vtab"
:class="{ 'qmc-vtab--active': topView === 'overview' }"
@click="topView = 'overview'"
>
<UIcon name="i-heroicons-view-columns" class="qmc-vtab-icon" />
Quotes Overview
</button>
<button
type="button"
class="qmc-vtab"
:class="{ 'qmc-vtab--active': topView === 'analytics' }"
@click="topView = 'analytics'"
>
<UIcon name="i-heroicons-chart-bar" class="qmc-vtab-icon" />
Analytics
</button>
</div>
<!--
OVERVIEW TAB
-->
<template v-if="topView === 'overview'">
<!-- KPI Cards (modern) -->
<div class="qmc-kpi-grid">
<!-- Ring card -->
<div class="qmc-kpi-hero">
<div class="qmc-ring-wrap">
<svg viewBox="0 0 88 88" class="qmc-ring-svg">
<circle cx="44" cy="44" r="38" fill="none" stroke="rgba(255,255,255,0.15)" stroke-width="5" />
<circle
cx="44" cy="44" r="38" fill="none"
stroke="#5eead4" stroke-width="5" stroke-linecap="round"
:stroke-dasharray="avgFormRing.dasharray"
:stroke-dashoffset="avgFormRing.dashoffset"
transform="rotate(-90 44 44)"
class="qmc-ring-progress"
/>
</svg>
<span class="qmc-ring-label">{{ kpis.avgFormPct }}%</span>
</div>
<div class="qmc-kpi-hero-text">
<span class="qmc-kpi-title">Avg form completion</span>
<span class="qmc-kpi-sub">Across {{ kpis.total }} active quotes</span>
</div>
</div>
<!-- Stat cards -->
<div class="qmc-kpi-card">
<UIcon name="i-heroicons-document-text" class="qmc-kpi-icon" />
<span class="qmc-kpi-num">{{ kpis.total }}</span>
<span class="qmc-kpi-title">In-flight quotes</span>
</div>
<div class="qmc-kpi-card qmc-kpi-card--amber">
<UIcon name="i-heroicons-bell-alert" class="qmc-kpi-icon" />
<span class="qmc-kpi-num">{{ kpis.awaiting }}</span>
<span class="qmc-kpi-title">Need notification</span>
</div>
<div class="qmc-kpi-card">
<UIcon name="i-heroicons-clipboard-document-list" class="qmc-kpi-icon" />
<span class="qmc-kpi-num">{{ kpis.formsOpen }}</span>
<span class="qmc-kpi-title">Forms incomplete</span>
</div>
</div>
<!-- Scope + filter row -->
<div class="qmc-filter-bar">
<div class="qmc-scope-pills">
<button
v-for="tab in scopeTabs"
:key="tab.value"
type="button"
class="qmc-scope-pill"
:class="{ 'qmc-scope-pill--active': pipelineScope === tab.value }"
@click="pipelineScope = tab.value"
>
<UIcon :name="tab.icon" style="width: 13px; height: 13px;" />
{{ tab.label }}
</button>
</div>
<div class="qmc-filter-inputs">
<USelect
v-model="lobFilter"
:items="lobSelectItems"
value-key="value"
label-key="label"
class="w-full min-w-[10rem] sm:max-w-[14rem]"
/>
<UInput
v-model="search"
icon="i-heroicons-magnifying-glass"
placeholder="Search..."
class="w-full flex-1"
/>
</div>
</div>
<!-- Overview sub-toggle -->
<div class="qmc-sub-toggle-row">
<div class="qmc-sub-toggle">
<button
type="button"
class="qmc-stab"
:class="{ 'qmc-stab--active': overviewMode === 'tracker' }"
@click="overviewMode = 'tracker'"
>
Quote tracker
</button>
<button
type="button"
class="qmc-stab"
:class="{ 'qmc-stab--active': overviewMode === 'pipeline' }"
@click="overviewMode = 'pipeline'"
>
Full pipeline
</button>
</div>
<span class="qmc-sub-hint">
{{ overviewMode === 'tracker' ? '3 lifecycle stages' : '5 pipeline stages' }}
</span>
</div>
<!-- TRACKER (3-bucket) -->
<div v-if="overviewMode === 'tracker'" class="qmc-tracker-grid">
<div
v-for="bucket in TRACKER_BUCKETS"
:key="bucket.id"
class="qmc-bucket"
>
<div class="qmc-bucket-head" :style="`--bc: ${bucket.color}`">
<div class="qmc-bucket-icon" :style="`background: ${bucket.color}14; color: ${bucket.color}`">
<UIcon :name="bucket.icon" style="width: 18px; height: 18px;" />
</div>
<div class="qmc-bucket-info">
<div class="qmc-bucket-row">
<span class="qmc-bucket-label">{{ bucket.label }}</span>
<span class="qmc-bucket-count" :style="`background: ${bucket.color}14; color: ${bucket.color}`">
{{ quotesByBucket[bucket.id].length }}
</span>
</div>
<span class="qmc-bucket-hint">{{ bucket.hint }}</span>
</div>
</div>
<div class="qmc-bucket-cards">
<div
v-for="q in quotesByBucket[bucket.id]"
:key="q.id"
class="qmc-qcard"
>
<div class="qmc-qcard-top">
<div class="qmc-qcard-name-wrap">
<p class="qmc-qcard-name">{{ q.customerLabel }}</p>
<p class="qmc-qcard-meta">{{ q.pathLabel }} · {{ q.owner }}</p>
</div>
<UBadge :color="lobBadgeColor(q.lob)" variant="soft" size="xs">{{ lobLabel(q.lob) }}</UBadge>
</div>
<div class="qmc-qcard-bar-row">
<span class="qmc-qcard-bar-label">{{ q.formsDone }}/{{ q.formsTotal }}</span>
<div class="qmc-qcard-bar-track">
<div class="qmc-qcard-bar-fill" :style="{ width: `${formsPct(q)}%`, background: bucket.color }" />
</div>
</div>
<div class="qmc-qcard-tags">
<UBadge :color="q.customerInformed ? 'success' : 'warning'" variant="soft" size="xs">
{{ q.customerInformed ? 'Informed' : 'Nudge' }}
</UBadge>
</div>
</div>
<p v-if="quotesByBucket[bucket.id].length === 0" class="qmc-empty-col">No quotes here</p>
</div>
</div>
</div>
<!-- PIPELINE (5 columns) -->
<div v-if="overviewMode === 'pipeline'" class="qmc-pipeline-scroll">
<div class="qmc-pipeline-grid">
<div
v-for="col in PIPELINE_COLS"
:key="col.id"
class="qmc-pcol"
>
<div class="qmc-pcol-head">
<div class="qmc-pcol-dot" :style="`background: ${col.color}`" />
<span class="qmc-pcol-label">{{ col.label }}</span>
<span class="qmc-pcol-count">{{ quotesByCol[col.id].length }}</span>
</div>
<p class="qmc-pcol-hint">{{ col.hint }}</p>
<div class="qmc-pcol-cards">
<div
v-for="q in quotesByCol[col.id]"
:key="q.id"
class="qmc-qcard"
>
<div class="qmc-qcard-top">
<div class="qmc-qcard-name-wrap">
<p class="qmc-qcard-name">{{ q.customerLabel }}</p>
<p class="qmc-qcard-meta">{{ q.pathLabel }} · {{ q.owner }}</p>
</div>
<UBadge :color="lobBadgeColor(q.lob)" variant="soft" size="xs">{{ lobLabel(q.lob) }}</UBadge>
</div>
<div class="qmc-qcard-bar-row">
<span class="qmc-qcard-bar-label">{{ q.formsDone }}/{{ q.formsTotal }}</span>
<div class="qmc-qcard-bar-track">
<div class="qmc-qcard-bar-fill" :style="{ width: `${formsPct(q)}%`, background: col.color }" />
</div>
</div>
<div class="qmc-qcard-tags">
<UBadge :color="q.customerInformed ? 'success' : 'warning'" variant="soft" size="xs">
{{ q.customerInformed ? 'Informed' : 'Nudge' }}
</UBadge>
</div>
</div>
<p v-if="quotesByCol[col.id].length === 0" class="qmc-empty-col">No quotes</p>
</div>
</div>
</div>
</div>
<!-- LOB quick links -->
<div class="qmc-section">
<p class="qmc-section-eyebrow">Lines of business</p>
<div class="qmc-lob-grid">
<NuxtLink
v-for="lob in ['auto', 'health', 'life', 'general_risk', 'custom'] as const"
:key="lob"
:to="lobHubPath(lob)"
class="qmc-lob-card group"
>
<div class="qmc-lob-icon">
<UIcon :name="lobIcon(lob)" style="width: 18px; height: 18px;" />
</div>
<span class="qmc-lob-name">{{ lobLabel(lob) }}</span>
<UIcon name="i-heroicons-chevron-right" class="qmc-lob-arrow" />
</NuxtLink>
</div>
</div>
</template>
<!--
ANALYTICS TAB (from mission control)
-->
<template v-if="topView === 'analytics'">
<!-- Analytics KPI strip (modern cards) -->
<div class="qmc-analytics-kpis">
<div class="qmc-akpi">
<span class="qmc-akpi-label">Active Deals</span>
<span class="qmc-akpi-value">{{ mockDeals.length }}</span>
</div>
<div class="qmc-akpi">
<span class="qmc-akpi-label">Quotes This Month</span>
<span class="qmc-akpi-value">24</span>
</div>
<div class="qmc-akpi">
<span class="qmc-akpi-label">Avg Days in Pipeline</span>
<span class="qmc-akpi-value">8.3</span>
</div>
<div class="qmc-akpi">
<span class="qmc-akpi-label">Conversion</span>
<span class="qmc-akpi-value qmc-akpi-value--brand">62%</span>
</div>
<div class="qmc-akpi">
<span class="qmc-akpi-label">Pipeline Premium</span>
<span class="qmc-akpi-value">{{ fmtCurrency(totalPipelinePremium) }}</span>
</div>
</div>
<!-- Funnel chart -->
<div class="qmc-card qmc-section">
<h2 class="qmc-section-title">Pipeline funnel</h2>
<div class="qmc-funnel">
<div
v-for="stage in PIPELINE_STAGES"
:key="stage.id"
class="qmc-funnel-row"
>
<span class="qmc-funnel-label" :class="{ 'qmc-funnel-label--waiting': stage.isWaiting }">
{{ stage.label }}
<span v-if="stage.isWaiting" class="qmc-funnel-wait-dot" />
</span>
<div class="qmc-funnel-bar-track">
<div
class="qmc-funnel-bar"
:style="{
width: Math.max((stageCounts[stage.id] / maxStageCount) * 100, 4) + '%',
background: `linear-gradient(90deg, ${stageColors[stage.id]}, ${stageColors[stage.id]}cc)`,
}"
/>
</div>
<span class="qmc-funnel-count">{{ stageCounts[stage.id] }}</span>
</div>
</div>
</div>
<!-- Agent performance -->
<div class="qmc-card qmc-section">
<h2 class="qmc-section-title">Agent performance</h2>
<div class="qmc-agent-grid">
<div v-for="a in agents" :key="a.name" class="qmc-agent-card">
<div class="qmc-agent-head">
<div class="qmc-agent-avatar">{{ a.name.split(' ').map(w => w[0]).join('') }}</div>
<div>
<p class="qmc-agent-name">{{ a.name }}</p>
<p class="qmc-agent-sub">{{ a.activeDeals }} deals · {{ a.quotesSent }} quotes</p>
</div>
</div>
<div class="qmc-agent-stats">
<div class="qmc-agent-stat">
<span class="qmc-agent-stat-label">Conversion</span>
<span
class="qmc-agent-stat-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>
</div>
<div class="qmc-agent-stat">
<span class="qmc-agent-stat-label">Avg response</span>
<span class="qmc-agent-stat-val">{{ a.avgResponseTime }}</span>
</div>
<div class="qmc-agent-stat">
<span class="qmc-agent-stat-label">Pipeline value</span>
<span class="qmc-agent-stat-val">{{ fmtCurrency(a.pipelineValue) }}</span>
</div>
</div>
</div>
</div>
</div>
<!-- Bottleneck analysis -->
<div class="qmc-card qmc-section">
<h2 class="qmc-section-title">Bottleneck analysis</h2>
<p class="qmc-section-sub">Deals stuck at a stage for more than 5 days</p>
<div v-if="stuckDeals.length === 0" class="qmc-empty-state">No bottlenecks detected.</div>
<div v-else class="qmc-stuck-list">
<div
v-for="deal in stuckDeals"
:key="deal.id"
class="qmc-stuck-row"
:style="{ borderLeftColor: severityColor(deal.daysAtStage) }"
>
<div class="qmc-stuck-info">
<span class="qmc-stuck-name">{{ deal.customerName }}</span>
<span class="qmc-stuck-meta">
<span class="qmc-stuck-days" :style="{ color: severityColor(deal.daysAtStage) }">{{ deal.daysAtStage }}d</span>
at {{ stageLabelFor(deal.currentStage) }}
</span>
</div>
<span class="qmc-stuck-agent">{{ deal.agent }}</span>
<button class="qmc-btn-nudge" @click="onNudge(deal)">Nudge</button>
</div>
</div>
</div>
<!-- Stage breakdown -->
<div class="qmc-card qmc-section">
<h2 class="qmc-section-title">Stage breakdown</h2>
<div class="qmc-stage-tabs">
<button
v-for="stage in PIPELINE_STAGES"
:key="stage.id"
class="qmc-stab"
:class="{ 'qmc-stab--active': activeStageTab === stage.id }"
@click="activeStageTab = stage.id"
>
{{ stage.label }}
</button>
</div>
<div v-if="stageDeals.length === 0" class="qmc-empty-state">
<UIcon name="i-heroicons-inbox" style="width: 20px; height: 20px; color: #c0c0bc;" />
<p>No deals at this stage</p>
<p class="text-[10px] opacity-60">Deals will appear here as they move through the pipeline.</p>
</div>
<div v-else class="qmc-stage-list">
<div v-for="deal in stageDeals" :key="deal.id" class="qmc-stage-row">
<div class="qmc-stage-row-main">
<span class="qmc-stage-row-name">{{ deal.customerName }}</span>
<span class="qmc-stage-row-line">{{ deal.productLine }}</span>
</div>
<span class="qmc-stage-row-days" :style="{ color: deal.daysAtStage > 5 ? severityColor(deal.daysAtStage) : 'inherit' }">
{{ deal.daysAtStage }}d
</span>
<span class="qmc-stage-row-agent">{{ deal.agent }}</span>
<span class="qmc-stage-row-prem">{{ fmtCurrency(deal.premium) }}</span>
<div class="qmc-stage-row-bar">
<div class="qmc-stage-bar-track">
<div class="qmc-stage-bar-fill" :style="{ width: deal.formCompletion + '%' }" />
</div>
<span class="qmc-stage-bar-pct">{{ deal.formCompletion }}%</span>
</div>
</div>
</div>
</div>
</template>
<!-- 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>
<!-- Sticky "New quote" CTA -->
<Transition
enter-active-class="transition duration-200 ease-out"
enter-from-class="opacity-0 translate-y-2"
enter-to-class="opacity-100 translate-y-0"
leave-active-class="transition duration-150 ease-in"
leave-from-class="opacity-100 translate-y-0"
leave-to-class="opacity-0 translate-y-2"
>
<div v-if="showStickyQuoteCta" class="fixed bottom-6 right-6 z-40">
<NuxtLink to="/quotes/new">
<UButton icon="i-heroicons-plus" color="primary" size="lg" class="shadow-lg">New quote</UButton>
</NuxtLink>
</div>
</Transition>
</div>
</template>
<style scoped>
/* ══════════════════════════════════════════════════════
QUOTES MISSION CONTROL — unified page
══════════════════════════════════════════════════════ */
.qmc-page {
display: flex;
flex-direction: column;
gap: 20px;
padding-bottom: 3rem;
}
/* ── Header ── */
.qmc-header {
display: flex;
align-items: flex-end;
justify-content: space-between;
gap: 16px;
flex-wrap: wrap;
}
.qmc-title {
font-size: 24px;
font-weight: 650;
letter-spacing: -0.015em;
color: var(--text-primary);
margin-top: 4px;
}
.qmc-subtitle {
font-size: 14px;
color: #8a8a86;
margin-top: 4px;
max-width: 36rem;
line-height: 1.5;
}
.qmc-header-actions {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
/* ── Top-level view toggle ── */
.qmc-view-toggle {
display: inline-flex;
gap: 2px;
padding: 3px;
border-radius: 10px;
background: rgba(0,0,0,0.04);
}
.qmc-vtab {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 7px 16px;
border-radius: 8px;
border: none;
font-size: 13px;
font-weight: 500;
color: #6b6b68;
background: transparent;
cursor: pointer;
transition: all 150ms ease;
}
.qmc-vtab:hover { color: var(--text-primary); }
.qmc-vtab--active {
background: #fff;
color: var(--text-primary);
box-shadow: 0 1px 3px rgba(0,0,0,0.08);
}
.qmc-vtab-icon { width: 15px; height: 15px; }
/* ── Modern KPI grid ── */
.qmc-kpi-grid {
display: grid;
grid-template-columns: 1.4fr repeat(3, 1fr);
gap: 14px;
}
.qmc-kpi-hero {
display: flex;
align-items: center;
gap: 16px;
padding: 20px;
border-radius: 12px;
background: linear-gradient(135deg, #0a2e30 0%, #01696f 100%);
color: #fff;
}
.qmc-ring-wrap {
position: relative;
width: 72px;
height: 72px;
flex-shrink: 0;
}
.qmc-ring-svg { width: 100%; height: 100%; }
.qmc-ring-progress { transition: stroke-dashoffset 0.6s ease; }
.qmc-ring-label {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
font-size: 18px;
font-weight: 800;
color: #5eead4;
text-shadow: 0 1px 2px rgba(0,0,0,0.2);
}
.qmc-kpi-hero-text { display: flex; flex-direction: column; gap: 2px; }
.qmc-kpi-hero-text .qmc-kpi-title { font-size: 13px; font-weight: 600; color: rgba(255,255,255,0.95); }
.qmc-kpi-hero-text .qmc-kpi-sub { font-size: 11px; color: rgba(255,255,255,0.6); }
.qmc-kpi-card {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 6px;
padding: 16px 12px;
border-radius: 12px;
background: #fff;
border: 1px solid rgba(0,0,0,0.06);
box-shadow: 0 1px 3px rgba(0,0,0,0.03);
}
.qmc-kpi-icon { width: 20px; height: 20px; color: #01696f; opacity: 0.6; }
.qmc-kpi-card--amber .qmc-kpi-icon { color: #d97706; }
.qmc-kpi-num { font-size: 28px; font-weight: 700; color: var(--text-primary); line-height: 1; }
.qmc-kpi-title { font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.04em; color: #8a8a86; text-align: center; }
.qmc-kpi-sub { font-size: 11px; color: #8a8a86; }
/* ── Filter bar ── */
.qmc-filter-bar {
display: flex;
align-items: center;
gap: 12px;
flex-wrap: wrap;
}
.qmc-scope-pills {
display: inline-flex;
gap: 4px;
}
.qmc-scope-pill {
display: inline-flex;
align-items: center;
gap: 5px;
padding: 5px 12px;
border-radius: 9999px;
border: 1px solid rgba(0,0,0,0.08);
font-size: 12px;
font-weight: 500;
color: var(--text-muted);
background: #fff;
cursor: pointer;
transition: all 150ms ease;
}
.qmc-scope-pill:hover { border-color: rgba(0,0,0,0.15); color: var(--text-primary); }
.qmc-scope-pill--active {
background: rgba(1,105,111,0.06);
border-color: rgba(1,105,111,0.2);
color: #01696f;
}
.qmc-filter-inputs {
display: flex;
gap: 8px;
flex: 1;
min-width: 0;
}
/* ── Sub-toggle (tracker / pipeline) ── */
.qmc-sub-toggle-row { display: flex; align-items: center; gap: 10px; }
.qmc-sub-toggle {
display: inline-flex;
gap: 1px;
padding: 2px;
border-radius: 8px;
background: rgba(0,0,0,0.04);
}
.qmc-stab {
padding: 5px 12px;
border-radius: 8px;
font-size: 12px;
font-weight: 500;
border: none;
background: transparent;
color: #6b6b68;
cursor: pointer;
white-space: nowrap;
transition: all 150ms ease;
}
.qmc-stab--active {
background: #fff;
color: var(--text-primary);
box-shadow: 0 1px 3px rgba(0,0,0,0.08);
}
.qmc-sub-hint { font-size: 11px; color: #8a8a86; }
/* ── Tracker grid (3 buckets) ── */
.qmc-tracker-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 16px;
}
.qmc-bucket {
display: flex;
flex-direction: column;
border-radius: 12px;
background: #fff;
border: 1px solid rgba(0,0,0,0.06);
box-shadow: 0 1px 3px rgba(0,0,0,0.03);
overflow: hidden;
}
.qmc-bucket-head {
display: flex;
align-items: center;
gap: 12px;
padding: 16px;
border-bottom: 1px solid rgba(0,0,0,0.04);
}
.qmc-bucket-icon {
display: flex;
align-items: center;
justify-content: center;
width: 36px;
height: 36px;
border-radius: 10px;
flex-shrink: 0;
}
.qmc-bucket-info { min-width: 0; flex: 1; }
.qmc-bucket-row { display: flex; align-items: center; gap: 8px; }
.qmc-bucket-label { font-size: 14px; font-weight: 600; color: var(--text-primary); }
.qmc-bucket-count {
display: inline-flex;
align-items: center;
justify-content: center;
height: 20px;
min-width: 20px;
padding: 0 6px;
border-radius: 9999px;
font-size: 11px;
font-weight: 600;
}
.qmc-bucket-hint { font-size: 11px; color: #8a8a86; margin-top: 1px; }
.qmc-bucket-cards {
display: flex;
flex-direction: column;
gap: 8px;
padding: 12px;
flex: 1;
}
/* ── Quote card (shared between tracker & pipeline) ── */
.qmc-qcard {
padding: 12px;
border-radius: 10px;
border: 1px solid rgba(0,0,0,0.06);
background: var(--page-bg, #fafafa);
transition: all 150ms ease;
}
.qmc-qcard:hover {
border-color: rgba(1,105,111,0.2);
box-shadow: 0 2px 8px rgba(0,0,0,0.04);
}
.qmc-qcard-top { display: flex; align-items: flex-start; justify-content: space-between; gap: 8px; }
.qmc-qcard-name-wrap { min-width: 0; }
.qmc-qcard-name {
font-size: 13px;
font-weight: 600;
color: var(--text-primary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.qmc-qcard-meta { font-size: 11px; color: #8a8a86; margin-top: 1px; }
.qmc-qcard-bar-row { display: flex; align-items: center; gap: 6px; margin-top: 10px; }
.qmc-qcard-bar-label { font-size: 10px; font-weight: 600; color: #8a8a86; min-width: 28px; }
.qmc-qcard-bar-track {
flex: 1;
height: 4px;
background: rgba(0,0,0,0.06);
border-radius: 2px;
overflow: hidden;
}
.qmc-qcard-bar-fill {
height: 100%;
border-radius: 2px;
transition: width 0.3s ease;
}
.qmc-qcard-tags { margin-top: 8px; display: flex; flex-wrap: wrap; gap: 4px; }
.qmc-empty-col {
padding: 32px 0;
text-align: center;
font-size: 11px;
color: #8a8a86;
border: 1px dashed rgba(0,0,0,0.08);
border-radius: 8px;
}
/* ── Pipeline grid (5 columns) ── */
.qmc-pipeline-scroll { overflow-x: auto; margin: 0 -4px; padding: 0 4px 8px; }
.qmc-pipeline-grid {
display: grid;
grid-template-columns: repeat(5, 1fr);
gap: 12px;
min-width: 60rem;
}
.qmc-pcol {
display: flex;
flex-direction: column;
border-radius: 12px;
background: var(--page-bg, #fafafa);
border: 1px solid rgba(0,0,0,0.06);
padding: 12px;
}
.qmc-pcol-head { display: flex; align-items: center; gap: 6px; }
.qmc-pcol-dot { width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0; }
.qmc-pcol-label { font-size: 13px; font-weight: 600; color: var(--text-primary); }
.qmc-pcol-count {
font-size: 11px;
font-weight: 600;
color: #8a8a86;
margin-left: auto;
}
.qmc-pcol-hint { font-size: 10px; color: #8a8a86; margin-top: 2px; margin-bottom: 10px; }
.qmc-pcol-cards { display: flex; flex-direction: column; gap: 8px; flex: 1; }
/* ── Section / card (analytics) ── */
.qmc-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);
}
.qmc-section { padding: 20px 24px; }
.qmc-section-title { font-size: 14px; font-weight: 700; color: var(--text-primary); margin-bottom: 16px; }
.qmc-section-sub { font-size: 13px; color: #8a8a86; margin-top: -10px; margin-bottom: 14px; }
.qmc-section-eyebrow {
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.04em;
color: #8a8a86;
margin-bottom: 12px;
}
/* ── LOB grid ── */
.qmc-lob-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
gap: 10px;
}
.qmc-lob-card {
display: flex;
align-items: center;
gap: 10px;
padding: 12px 14px;
border-radius: 10px;
border: 1px solid rgba(0,0,0,0.06);
background: #fff;
transition: all 150ms ease;
}
.qmc-lob-card:hover {
border-color: rgba(1,105,111,0.25);
box-shadow: 0 2px 8px rgba(0,0,0,0.04);
}
.qmc-lob-icon {
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
border-radius: 8px;
background: rgba(1,105,111,0.06);
color: #01696f;
flex-shrink: 0;
}
.qmc-lob-name { font-size: 13px; font-weight: 500; color: var(--text-primary); flex: 1; }
.qmc-lob-arrow { width: 14px; height: 14px; color: #8a8a86; opacity: 0; transition: opacity 150ms ease; }
.qmc-lob-card:hover .qmc-lob-arrow { opacity: 1; }
/* ── Analytics KPI strip ── */
.qmc-analytics-kpis {
display: grid;
grid-template-columns: repeat(5, 1fr);
gap: 12px;
}
.qmc-akpi {
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
padding: 16px 12px;
border-radius: 12px;
background: #fff;
border: 1px solid rgba(0,0,0,0.06);
box-shadow: 0 1px 3px rgba(0,0,0,0.03);
}
.qmc-akpi-label {
font-size: 10px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.04em;
color: #8a8a86;
text-align: center;
}
.qmc-akpi-value { font-size: 22px; font-weight: 700; color: var(--text-primary); }
.qmc-akpi-value--brand { color: #01696f; }
/* ── Funnel ── */
.qmc-funnel { display: flex; flex-direction: column; gap: 8px; }
.qmc-funnel-row {
display: grid;
grid-template-columns: 150px 1fr 36px;
align-items: center;
gap: 10px;
}
.qmc-funnel-label {
font-size: 13px;
color: var(--text-primary);
white-space: nowrap;
display: flex;
align-items: center;
gap: 5px;
}
.qmc-funnel-label--waiting { color: #8a8a86; font-style: italic; }
.qmc-funnel-wait-dot { width: 5px; height: 5px; border-radius: 50%; background: #f59e0b; flex-shrink: 0; }
.qmc-funnel-bar-track {
height: 22px;
background: rgba(0,0,0,0.03);
border-radius: 6px;
overflow: hidden;
}
.qmc-funnel-bar {
height: 100%;
border-radius: 6px;
transition: width 0.4s ease;
min-width: 4px;
}
.qmc-funnel-count { font-size: 14px; font-weight: 600; text-align: right; color: var(--text-primary); }
/* ── Agent performance ── */
.qmc-agent-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); gap: 12px; }
.qmc-agent-card {
padding: 16px;
border-radius: 12px;
border: 1px solid rgba(0,0,0,0.06);
background: var(--page-bg, #fafafa);
}
.qmc-agent-head { display: flex; align-items: center; gap: 10px; margin-bottom: 12px; }
.qmc-agent-avatar {
width: 36px;
height: 36px;
border-radius: 50%;
background: linear-gradient(135deg, #01696f, #0d8a8f);
color: #fff;
display: flex;
align-items: center;
justify-content: center;
font-size: 12px;
font-weight: 600;
flex-shrink: 0;
}
.qmc-agent-name { font-size: 13px; font-weight: 600; color: var(--text-primary); }
.qmc-agent-sub { font-size: 11px; color: #8a8a86; }
.qmc-agent-stats { display: flex; flex-direction: column; gap: 6px; }
.qmc-agent-stat { display: flex; align-items: center; justify-content: space-between; }
.qmc-agent-stat-label { font-size: 11px; color: #8a8a86; }
.qmc-agent-stat-badge {
display: inline-flex;
padding: 2px 8px;
border-radius: 6px;
font-size: 12px;
font-weight: 600;
}
.qmc-agent-stat-val { font-size: 12px; font-weight: 600; color: var(--text-primary); }
/* ── Stuck deals ── */
.qmc-stuck-list { display: flex; flex-direction: column; gap: 6px; }
.qmc-stuck-row {
display: flex;
align-items: center;
gap: 16px;
padding: 10px 14px;
border-radius: 8px;
border: 1px solid rgba(0,0,0,0.04);
border-left: 3px solid;
}
.qmc-stuck-info { flex: 1; display: flex; flex-direction: column; gap: 2px; }
.qmc-stuck-name { font-size: 13px; font-weight: 500; color: var(--text-primary); }
.qmc-stuck-meta { font-size: 12px; color: #8a8a86; }
.qmc-stuck-days { font-weight: 600; }
.qmc-stuck-agent { font-size: 12px; color: #8a8a86; min-width: 120px; }
.qmc-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;
transition: background 0.15s;
}
.qmc-btn-nudge:hover { background: rgba(1,105,111,0.06); }
/* ── Stage tabs ── */
.qmc-stage-tabs {
display: inline-flex;
background: rgba(0,0,0,0.04);
padding: 3px;
border-radius: 10px;
gap: 2px;
margin-bottom: 16px;
flex-wrap: wrap;
}
/* ── Stage breakdown list ── */
.qmc-stage-list { display: flex; flex-direction: column; gap: 4px; }
.qmc-stage-row {
display: grid;
grid-template-columns: 1fr 40px 120px 80px 140px;
align-items: center;
gap: 8px;
padding: 8px 0;
border-bottom: 1px solid rgba(0,0,0,0.04);
font-size: 13px;
}
.qmc-stage-row:last-child { border-bottom: none; }
.qmc-stage-row-main { display: flex; flex-direction: column; }
.qmc-stage-row-name { font-weight: 500; color: var(--text-primary); }
.qmc-stage-row-line { font-size: 11px; color: #8a8a86; }
.qmc-stage-row-days { font-weight: 600; text-align: center; }
.qmc-stage-row-agent { font-size: 12px; color: #8a8a86; }
.qmc-stage-row-prem { font-size: 12px; font-weight: 500; color: var(--text-primary); text-align: right; }
.qmc-stage-row-bar { display: flex; align-items: center; gap: 6px; }
.qmc-stage-bar-track {
flex: 1;
height: 5px;
background: rgba(0,0,0,0.06);
border-radius: 3px;
overflow: hidden;
}
.qmc-stage-bar-fill {
height: 100%;
background: #01696f;
border-radius: 3px;
transition: width 0.3s ease;
}
.qmc-stage-bar-pct { font-size: 11px; color: #8a8a86; min-width: 28px; }
/* ── Empty ── */
.qmc-empty-state { font-size: 13px; color: #8a8a86; padding: 24px 0; text-align: center; }
/* ── Responsive ── */
@media (max-width: 1024px) {
.qmc-kpi-grid { grid-template-columns: 1fr 1fr; }
.qmc-tracker-grid { grid-template-columns: 1fr; }
.qmc-analytics-kpis { grid-template-columns: repeat(3, 1fr); }
}
@media (max-width: 640px) {
.qmc-kpi-grid { grid-template-columns: 1fr; }
.qmc-analytics-kpis { grid-template-columns: repeat(2, 1fr); }
.qmc-pipeline-grid { min-width: 50rem; }
.qmc-stage-row { grid-template-columns: 1fr; gap: 4px; }
}
</style>