admin firs step

This commit is contained in:
Roo
2026-03-23 21:43:40 +00:00
parent 309a72cc0b
commit cddcd34ba9
47 changed files with 22698 additions and 19 deletions

View 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>