498 lines
16 KiB
Vue
498 lines
16 KiB
Vue
<template>
|
|
<v-card
|
|
color="orange-darken-1"
|
|
variant="tonal"
|
|
class="h-100 d-flex flex-column"
|
|
>
|
|
<v-card-title class="d-flex align-center justify-space-between">
|
|
<div class="d-flex align-center">
|
|
<v-icon icon="mdi-account-group" class="mr-2"></v-icon>
|
|
<span class="text-subtitle-1 font-weight-bold">Sales Pipeline</span>
|
|
</div>
|
|
<div class="d-flex align-center">
|
|
<v-chip size="small" color="green" class="mr-2">
|
|
<v-icon icon="mdi-trending-up" size="small" class="mr-1"></v-icon>
|
|
Active
|
|
</v-chip>
|
|
<v-menu>
|
|
<template v-slot:activator="{ props }">
|
|
<v-btn
|
|
size="x-small"
|
|
variant="text"
|
|
v-bind="props"
|
|
class="text-caption"
|
|
>
|
|
{{ selectedTeam }}
|
|
<v-icon icon="mdi-chevron-down" size="small"></v-icon>
|
|
</v-btn>
|
|
</template>
|
|
<v-list density="compact">
|
|
<v-list-item
|
|
v-for="team in teamOptions"
|
|
:key="team.value"
|
|
@click="selectedTeam = team.value"
|
|
>
|
|
<v-list-item-title>{{ team.label }}</v-list-item-title>
|
|
</v-list-item>
|
|
</v-list>
|
|
</v-menu>
|
|
</div>
|
|
</v-card-title>
|
|
|
|
<v-card-text class="flex-grow-1 pa-0">
|
|
<div class="pa-4">
|
|
<!-- Pipeline Stages -->
|
|
<div class="mb-4">
|
|
<div class="text-subtitle-2 font-weight-medium mb-2">Pipeline Stages</div>
|
|
<v-row dense>
|
|
<v-col v-for="stage in pipelineStages" :key="stage.name" cols="6" sm="3">
|
|
<v-card variant="outlined" class="pa-2">
|
|
<div class="d-flex align-center justify-space-between">
|
|
<div>
|
|
<div class="text-caption font-weight-medium">{{ stage.name }}</div>
|
|
<div class="text-caption text-grey">{{ stage.count }} leads</div>
|
|
</div>
|
|
<div class="text-right">
|
|
<div class="text-caption" :class="`text-${stage.color}`">{{ stage.conversion }}%</div>
|
|
<v-progress-linear
|
|
:model-value="stage.conversion"
|
|
height="4"
|
|
:color="stage.color"
|
|
class="mt-1"
|
|
></v-progress-linear>
|
|
</div>
|
|
</div>
|
|
<div class="text-caption text-grey mt-1">
|
|
Avg: {{ stage.avgDays }} days
|
|
</div>
|
|
</v-card>
|
|
</v-col>
|
|
</v-row>
|
|
</div>
|
|
|
|
<!-- Conversion Funnel Chart -->
|
|
<div class="mb-4">
|
|
<div class="text-subtitle-2 font-weight-medium mb-2">Conversion Funnel</div>
|
|
<div class="chart-container" style="height: 180px;">
|
|
<canvas ref="funnelChart"></canvas>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Top Performers -->
|
|
<div class="mb-4">
|
|
<div class="text-subtitle-2 font-weight-medium mb-2">Top Performers</div>
|
|
<v-table density="compact" class="elevation-1">
|
|
<thead>
|
|
<tr>
|
|
<th class="text-left">Salesperson</th>
|
|
<th class="text-right">Leads</th>
|
|
<th class="text-right">Converted</th>
|
|
<th class="text-right">Rate</th>
|
|
<th class="text-right">Revenue</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
<tr v-for="person in topPerformers" :key="person.name">
|
|
<td class="text-left">
|
|
<div class="d-flex align-center">
|
|
<v-avatar size="24" class="mr-2">
|
|
<v-img :src="person.avatar" :alt="person.name"></v-img>
|
|
</v-avatar>
|
|
<span class="text-caption">{{ person.name }}</span>
|
|
</div>
|
|
</td>
|
|
<td class="text-right">{{ person.leads }}</td>
|
|
<td class="text-right">{{ person.converted }}</td>
|
|
<td class="text-right" :class="person.conversionRate >= 30 ? 'text-success' : person.conversionRate >= 20 ? 'text-warning' : 'text-error'">
|
|
{{ person.conversionRate }}%
|
|
</td>
|
|
<td class="text-right font-weight-medium">{{ formatCurrency(person.revenue) }}</td>
|
|
</tr>
|
|
</tbody>
|
|
</v-table>
|
|
</div>
|
|
|
|
<!-- Recent Activities -->
|
|
<div>
|
|
<div class="text-subtitle-2 font-weight-medium mb-2">Recent Activities</div>
|
|
<div class="activity-list">
|
|
<div v-for="activity in recentActivities" :key="activity.id" class="activity-item mb-2 pa-2">
|
|
<div class="d-flex align-center">
|
|
<v-avatar size="28" class="mr-2">
|
|
<v-img :src="activity.avatar" :alt="activity.salesperson"></v-img>
|
|
</v-avatar>
|
|
<div class="flex-grow-1">
|
|
<div class="text-caption">
|
|
<span class="font-weight-medium">{{ activity.salesperson }}</span>
|
|
{{ activity.action }}
|
|
<span class="font-weight-medium">{{ activity.client }}</span>
|
|
</div>
|
|
<div class="d-flex align-center mt-1">
|
|
<v-chip size="x-small" :color="getStageColor(activity.stage)" class="mr-1">
|
|
{{ activity.stage }}
|
|
</v-chip>
|
|
<span class="text-caption text-grey">{{ formatTime(activity.timestamp) }}</span>
|
|
</div>
|
|
</div>
|
|
<v-icon :icon="getActivityIcon(activity.type)" size="small" :color="getActivityColor(activity.type)"></v-icon>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</v-card-text>
|
|
|
|
<v-card-actions class="pa-3">
|
|
<div class="d-flex justify-space-between align-center w-100">
|
|
<div class="text-caption">
|
|
<v-icon icon="mdi-chart-timeline" size="small" class="mr-1"></v-icon>
|
|
Total Pipeline: {{ formatCurrency(totalPipelineValue) }}
|
|
</div>
|
|
|
|
<div class="d-flex">
|
|
<v-btn
|
|
size="x-small"
|
|
variant="text"
|
|
@click="refreshPipeline"
|
|
:loading="isRefreshing"
|
|
>
|
|
<v-icon icon="mdi-refresh" size="small" class="mr-1"></v-icon>
|
|
Refresh
|
|
</v-btn>
|
|
<v-btn
|
|
size="x-small"
|
|
variant="text"
|
|
@click="addNewLead"
|
|
class="ml-2"
|
|
>
|
|
<v-icon icon="mdi-plus" size="small" class="mr-1"></v-icon>
|
|
New Lead
|
|
</v-btn>
|
|
</div>
|
|
</div>
|
|
</v-card-actions>
|
|
</v-card>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import { ref, computed, onMounted, onUnmounted, nextTick } from 'vue'
|
|
import { Chart, registerables } from 'chart.js'
|
|
|
|
// Register Chart.js components
|
|
Chart.register(...registerables)
|
|
|
|
// Types
|
|
interface PipelineStage {
|
|
name: string
|
|
count: number
|
|
conversion: number
|
|
avgDays: number
|
|
color: string
|
|
}
|
|
|
|
interface SalesPerson {
|
|
name: string
|
|
avatar: string
|
|
leads: number
|
|
converted: number
|
|
conversionRate: number
|
|
revenue: number
|
|
}
|
|
|
|
interface Activity {
|
|
id: string
|
|
salesperson: string
|
|
avatar: string
|
|
action: string
|
|
client: string
|
|
stage: string
|
|
type: 'call' | 'meeting' | 'email' | 'proposal' | 'closed'
|
|
timestamp: Date
|
|
}
|
|
|
|
// State
|
|
const selectedTeam = ref('all')
|
|
const isRefreshing = ref(false)
|
|
const funnelChart = ref<HTMLCanvasElement | null>(null)
|
|
let chartInstance: Chart | null = null
|
|
|
|
// Team options
|
|
const teamOptions = [
|
|
{ label: 'All Teams', value: 'all' },
|
|
{ label: 'Enterprise', value: 'enterprise' },
|
|
{ label: 'SMB', value: 'smb' },
|
|
{ label: 'Government', value: 'government' }
|
|
]
|
|
|
|
// Pipeline data
|
|
const pipelineStages = ref<PipelineStage[]>([
|
|
{ name: 'Prospecting', count: 142, conversion: 65, avgDays: 3, color: 'blue' },
|
|
{ name: 'Qualification', count: 92, conversion: 45, avgDays: 7, color: 'indigo' },
|
|
{ name: 'Proposal', count: 41, conversion: 30, avgDays: 14, color: 'orange' },
|
|
{ name: 'Negotiation', count: 28, conversion: 20, avgDays: 21, color: 'red' },
|
|
{ name: 'Closed Won', count: 12, conversion: 15, avgDays: 30, color: 'green' }
|
|
])
|
|
|
|
const topPerformers = ref<SalesPerson[]>([
|
|
{ name: 'Alex Johnson', avatar: 'https://i.pravatar.cc/150?img=1', leads: 45, converted: 18, conversionRate: 40, revenue: 125000 },
|
|
{ name: 'Maria Garcia', avatar: 'https://i.pravatar.cc/150?img=2', leads: 38, converted: 15, conversionRate: 39, revenue: 112000 },
|
|
{ name: 'David Chen', avatar: 'https://i.pravatar.cc/150?img=3', leads: 42, converted: 16, conversionRate: 38, revenue: 108000 },
|
|
{ name: 'Sarah Williams', avatar: 'https://i.pravatar.cc/150?img=4', leads: 35, converted: 13, conversionRate: 37, revenue: 98000 }
|
|
])
|
|
|
|
const recentActivities = ref<Activity[]>([
|
|
{ id: '1', salesperson: 'Alex Johnson', avatar: 'https://i.pravatar.cc/150?img=1', action: 'sent proposal to', client: 'TechCorp Inc.', stage: 'Proposal', type: 'proposal', timestamp: new Date(Date.now() - 3600000) },
|
|
{ id: '2', salesperson: 'Maria Garcia', avatar: 'https://i.pravatar.cc/150?img=2', action: 'closed deal with', client: 'Global Motors', stage: 'Closed Won', type: 'closed', timestamp: new Date(Date.now() - 7200000) },
|
|
{ id: '3', salesperson: 'David Chen', avatar: 'https://i.pravatar.cc/150?img=3', action: 'scheduled meeting with', client: 'HealthPlus', stage: 'Qualification', type: 'meeting', timestamp: new Date(Date.now() - 10800000) },
|
|
{ id: '4', salesperson: 'Sarah Williams', avatar: 'https://i.pravatar.cc/150?img=4', action: 'called', client: 'EduTech Solutions', stage: 'Prospecting', type: 'call', timestamp: new Date(Date.now() - 14400000) }
|
|
])
|
|
|
|
// Computed properties
|
|
const totalPipelineValue = computed(() => {
|
|
return pipelineStages.value.reduce((total, stage) => {
|
|
// Estimate value based on stage
|
|
const stageValue = stage.count * 5000 // Average deal size
|
|
return total + stageValue
|
|
}, 0)
|
|
})
|
|
|
|
// Helper functions
|
|
const formatCurrency = (amount: number) => {
|
|
if (amount >= 1000000) {
|
|
return `€${(amount / 1000000).toFixed(1)}M`
|
|
} else if (amount >= 1000) {
|
|
return `€${(amount / 1000).toFixed(0)}K`
|
|
}
|
|
return `€${amount.toFixed(0)}`
|
|
}
|
|
|
|
const formatTime = (timestamp: Date) => {
|
|
const now = new Date()
|
|
const diff = now.getTime() - timestamp.getTime()
|
|
|
|
if (diff < 60000) return 'Just now'
|
|
if (diff < 3600000) return `${Math.floor(diff / 60000)}m ago`
|
|
if (diff < 86400000) return `${Math.floor(diff / 3600000)}h ago`
|
|
return timestamp.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
|
|
}
|
|
|
|
const getStageColor = (stage: string) => {
|
|
switch (stage.toLowerCase()) {
|
|
case 'prospecting': return 'blue'
|
|
case 'qualification': return 'indigo'
|
|
case 'proposal': return 'orange'
|
|
case 'negotiation': return 'red'
|
|
case 'closed won': return 'green'
|
|
default: return 'grey'
|
|
}
|
|
}
|
|
|
|
const getActivityIcon = (type: Activity['type']) => {
|
|
switch (type) {
|
|
case 'call': return 'mdi-phone'
|
|
case 'meeting': return 'mdi-calendar'
|
|
case 'email': return 'mdi-email'
|
|
case 'proposal': return 'mdi-file-document'
|
|
case 'closed': return 'mdi-check-circle'
|
|
default: return 'mdi-help-circle'
|
|
}
|
|
}
|
|
|
|
const getActivityColor = (type: Activity['type']) => {
|
|
switch (type) {
|
|
case 'call': return 'blue'
|
|
case 'meeting': return 'indigo'
|
|
case 'email': return 'green'
|
|
case 'proposal': return 'orange'
|
|
case 'closed': return 'success'
|
|
default: return 'grey'
|
|
}
|
|
}
|
|
|
|
// Chart functions
|
|
const initChart = () => {
|
|
if (!funnelChart.value) return
|
|
|
|
// Destroy existing chart
|
|
if (chartInstance) {
|
|
chartInstance.destroy()
|
|
}
|
|
|
|
const ctx = funnelChart.value.getContext('2d')
|
|
if (!ctx) return
|
|
|
|
// Prepare funnel data
|
|
const labels = pipelineStages.value.map(stage => stage.name)
|
|
const data = pipelineStages.value.map(stage => stage.count)
|
|
const backgroundColors = pipelineStages.value.map(stage => {
|
|
switch (stage.color) {
|
|
case 'blue': return '#2196F3'
|
|
case 'indigo': return '#3F51B5'
|
|
case 'orange': return '#FF9800'
|
|
case 'red': return '#F44336'
|
|
case 'green': return '#4CAF50'
|
|
default: return '#9E9E9E'
|
|
}
|
|
})
|
|
|
|
chartInstance = new Chart(ctx, {
|
|
type: 'bar',
|
|
data: {
|
|
labels,
|
|
datasets: [{
|
|
label: 'Leads',
|
|
data,
|
|
backgroundColor: backgroundColors,
|
|
borderColor: backgroundColors.map(color => color.replace('0.8', '1')),
|
|
borderWidth: 1,
|
|
borderRadius: 4
|
|
}]
|
|
},
|
|
options: {
|
|
responsive: true,
|
|
maintainAspectRatio: false,
|
|
indexAxis: 'y', // Horizontal bar chart for funnel
|
|
plugins: {
|
|
legend: {
|
|
display: false
|
|
},
|
|
tooltip: {
|
|
callbacks: {
|
|
label: (context) => {
|
|
const stage = pipelineStages.value[context.dataIndex]
|
|
return `${context.dataset.label}: ${context.raw} (${stage.conversion}% conversion)`
|
|
}
|
|
}
|
|
}
|
|
},
|
|
scales: {
|
|
x: {
|
|
beginAtZero: true,
|
|
grid: {
|
|
display: false
|
|
},
|
|
title: {
|
|
display: true,
|
|
text: 'Number of Leads'
|
|
}
|
|
},
|
|
y: {
|
|
grid: {
|
|
display: false
|
|
}
|
|
}
|
|
}
|
|
}
|
|
})
|
|
}
|
|
|
|
// Actions
|
|
const refreshPipeline = () => {
|
|
isRefreshing.value = true
|
|
|
|
// Simulate API call
|
|
setTimeout(() => {
|
|
// Update with new random data
|
|
pipelineStages.value.forEach(stage => {
|
|
stage.count = Math.round(stage.count * (1 + Math.random() * 0.2 - 0.1))
|
|
stage.conversion = Math.round(stage.conversion * (1 + Math.random() * 0.1 - 0.05))
|
|
})
|
|
|
|
// Update top performers
|
|
topPerformers.value.forEach(person => {
|
|
person.leads = Math.round(person.leads * (1 + Math.random() * 0.1 - 0.05))
|
|
person.converted = Math.round(person.converted * (1 + Math.random() * 0.1 - 0.05))
|
|
person.conversionRate = Math.round((person.converted / person.leads) * 100)
|
|
person.revenue = Math.round(person.revenue * (1 + Math.random() * 0.15 - 0.05))
|
|
})
|
|
|
|
// Add new activity
|
|
const activities = ['called', 'emailed', 'met with', 'sent proposal to', 'closed deal with']
|
|
const clients = ['TechCorp', 'Global Motors', 'HealthPlus', 'EduTech', 'FinancePro', 'AutoGroup']
|
|
const salespeople = topPerformers.value
|
|
|
|
const newActivity: Activity = {
|
|
id: `act_${Date.now()}`,
|
|
salesperson: salespeople[Math.floor(Math.random() * salespeople.length)].name,
|
|
avatar: `https://i.pravatar.cc/150?img=${Math.floor(Math.random() * 10) + 1}`,
|
|
action: activities[Math.floor(Math.random() * activities.length)],
|
|
client: clients[Math.floor(Math.random() * clients.length)],
|
|
stage: pipelineStages.value[Math.floor(Math.random() * pipelineStages.value.length)].name,
|
|
type: ['call', 'meeting', 'email', 'proposal', 'closed'][Math.floor(Math.random() * 5)] as Activity['type'],
|
|
timestamp: new Date()
|
|
}
|
|
|
|
recentActivities.value.unshift(newActivity)
|
|
// Keep only last 5 activities
|
|
if (recentActivities.value.length > 5) {
|
|
recentActivities.value = recentActivities.value.slice(0, 5)
|
|
}
|
|
|
|
// Update chart
|
|
initChart()
|
|
|
|
isRefreshing.value = false
|
|
}, 1000)
|
|
}
|
|
|
|
const addNewLead = () => {
|
|
// Simulate adding new lead
|
|
pipelineStages.value[0].count += 1
|
|
|
|
// Show notification
|
|
console.log('New lead added to pipeline')
|
|
}
|
|
|
|
// Lifecycle hooks
|
|
onMounted(() => {
|
|
nextTick(() => {
|
|
initChart()
|
|
})
|
|
})
|
|
|
|
onUnmounted(() => {
|
|
if (chartInstance) {
|
|
chartInstance.destroy()
|
|
}
|
|
})
|
|
</script>
|
|
|
|
<style scoped>
|
|
.chart-container {
|
|
position: relative;
|
|
width: 100%;
|
|
}
|
|
|
|
.h-100 {
|
|
height: 100%;
|
|
}
|
|
|
|
.activity-list {
|
|
max-height: 150px;
|
|
overflow-y: auto;
|
|
}
|
|
|
|
.activity-item {
|
|
border-left: 3px solid;
|
|
background-color: rgba(255, 255, 255, 0.02);
|
|
transition: background-color 0.2s;
|
|
border-left-color: #FF9800; /* Orange accent */
|
|
}
|
|
|
|
.activity-item:hover {
|
|
background-color: rgba(255, 255, 255, 0.05);
|
|
}
|
|
|
|
.v-table {
|
|
background: transparent;
|
|
}
|
|
|
|
.v-table :deep(thead) th {
|
|
background-color: rgba(0, 0, 0, 0.02);
|
|
font-weight: 600;
|
|
font-size: 0.75rem;
|
|
text-transform: uppercase;
|
|
letter-spacing: 0.5px;
|
|
}
|
|
</style>
|