admin firs step
This commit is contained in:
497
frontend/admin/components/SalespersonTile.vue
Normal file
497
frontend/admin/components/SalespersonTile.vue
Normal file
@@ -0,0 +1,497 @@
|
||||
<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>
|
||||
Reference in New Issue
Block a user