admin firs step
This commit is contained in:
635
frontend/admin/components/AiLogsTile.vue
Normal file
635
frontend/admin/components/AiLogsTile.vue
Normal file
@@ -0,0 +1,635 @@
|
||||
<template>
|
||||
<v-card
|
||||
color="indigo-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-robot" class="mr-2"></v-icon>
|
||||
<span class="text-subtitle-1 font-weight-bold">AI Pipeline Monitor</span>
|
||||
</div>
|
||||
<div class="d-flex align-center">
|
||||
<v-chip size="small" color="green" class="mr-2">
|
||||
<v-icon icon="mdi-pulse" size="small" class="mr-1"></v-icon>
|
||||
Live
|
||||
</v-chip>
|
||||
<v-chip size="small" :color="connectionStatusColor">
|
||||
<v-icon :icon="connectionStatusIcon" size="small" class="mr-1"></v-icon>
|
||||
{{ connectionStatusText }}
|
||||
</v-chip>
|
||||
</div>
|
||||
</v-card-title>
|
||||
|
||||
<v-card-text class="flex-grow-1 pa-0">
|
||||
<!-- Connection Status Bar -->
|
||||
<div class="px-4 pt-2 pb-1" :class="connectionStatusBarClass">
|
||||
<div class="d-flex align-center justify-space-between">
|
||||
<div class="text-caption">
|
||||
<v-icon :icon="connectionStatusIcon" size="small" class="mr-1"></v-icon>
|
||||
{{ connectionStatusMessage }}
|
||||
</div>
|
||||
<div class="text-caption">
|
||||
Polling: {{ pollingInterval / 1000 }}s
|
||||
<v-btn
|
||||
icon="mdi-refresh"
|
||||
size="x-small"
|
||||
variant="text"
|
||||
class="ml-1"
|
||||
@click="forceRefresh"
|
||||
:loading="isRefreshing"
|
||||
></v-btn>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Robot Status Dashboard -->
|
||||
<div class="pa-4">
|
||||
<div class="text-subtitle-2 font-weight-medium mb-2">Robot Status Dashboard</div>
|
||||
|
||||
<!-- Geographical Filter -->
|
||||
<div class="mb-3">
|
||||
<v-chip-group v-model="selectedRegion" column>
|
||||
<v-chip size="small" value="all">All Regions</v-chip>
|
||||
<v-chip size="small" value="GB">UK (GB)</v-chip>
|
||||
<v-chip size="small" value="EU">Europe</v-chip>
|
||||
<v-chip size="small" value="US">North America</v-chip>
|
||||
<v-chip size="small" value="OC">Oceania</v-chip>
|
||||
</v-chip-group>
|
||||
</div>
|
||||
|
||||
<!-- Robot Status Cards -->
|
||||
<v-row dense class="mb-4">
|
||||
<v-col v-for="robot in filteredRobots" :key="robot.id" cols="12" sm="6">
|
||||
<v-card variant="outlined" class="pa-2">
|
||||
<div class="d-flex align-center justify-space-between">
|
||||
<div>
|
||||
<div class="d-flex align-center">
|
||||
<v-icon :icon="robot.icon" size="small" :color="robot.statusColor" class="mr-2"></v-icon>
|
||||
<span class="text-caption font-weight-medium">{{ robot.name }}</span>
|
||||
</div>
|
||||
<div class="text-caption text-grey mt-1">{{ robot.description }}</div>
|
||||
</div>
|
||||
<div class="text-right">
|
||||
<div class="text-caption" :class="`text-${robot.statusColor}`">{{ robot.status }}</div>
|
||||
<div class="text-caption text-grey">{{ robot.region }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Progress Bar -->
|
||||
<v-progress-linear
|
||||
v-if="robot.progress !== undefined"
|
||||
:model-value="robot.progress"
|
||||
height="6"
|
||||
:color="robot.progressColor"
|
||||
class="mt-2"
|
||||
></v-progress-linear>
|
||||
|
||||
<!-- Stats -->
|
||||
<div class="d-flex justify-space-between mt-2">
|
||||
<div class="text-caption">
|
||||
<v-icon icon="mdi-check-circle" size="x-small" color="success" class="mr-1"></v-icon>
|
||||
{{ robot.successRate }}%
|
||||
</div>
|
||||
<div class="text-caption">
|
||||
<v-icon icon="mdi-alert-circle" size="x-small" color="error" class="mr-1"></v-icon>
|
||||
{{ robot.failureRate }}%
|
||||
</div>
|
||||
<div class="text-caption">
|
||||
<v-icon icon="mdi-clock-outline" size="x-small" color="warning" class="mr-1"></v-icon>
|
||||
{{ robot.avgTime }}s
|
||||
</div>
|
||||
</div>
|
||||
</v-card>
|
||||
</v-col>
|
||||
</v-row>
|
||||
|
||||
<!-- Overall Pipeline Stats -->
|
||||
<v-card variant="outlined" class="pa-3 mb-4">
|
||||
<div class="text-subtitle-2 font-weight-medium mb-2">Pipeline Overview</div>
|
||||
<v-row dense>
|
||||
<v-col cols="6" sm="3">
|
||||
<div class="text-center">
|
||||
<div class="text-h5 font-weight-bold text-primary">{{ pipelineStats.totalProcessed }}</div>
|
||||
<div class="text-caption text-grey">Total Processed</div>
|
||||
</div>
|
||||
</v-col>
|
||||
<v-col cols="6" sm="3">
|
||||
<div class="text-center">
|
||||
<div class="text-h5 font-weight-bold text-success">{{ pipelineStats.successRate }}%</div>
|
||||
<div class="text-caption text-grey">Success Rate</div>
|
||||
</div>
|
||||
</v-col>
|
||||
<v-col cols="6" sm="3">
|
||||
<div class="text-center">
|
||||
<div class="text-h5 font-weight-bold text-warning">{{ pipelineStats.avgProcessingTime }}s</div>
|
||||
<div class="text-caption text-grey">Avg Time</div>
|
||||
</div>
|
||||
</v-col>
|
||||
<v-col cols="6" sm="3">
|
||||
<div class="text-center">
|
||||
<div class="text-h5 font-weight-bold" :class="pipelineStats.queueSize > 100 ? 'text-error' : 'text-info'">{{ pipelineStats.queueSize }}</div>
|
||||
<div class="text-caption text-grey">Queue Size</div>
|
||||
</div>
|
||||
</v-col>
|
||||
</v-row>
|
||||
</v-card>
|
||||
|
||||
<!-- Recent Activity -->
|
||||
<div class="text-subtitle-2 font-weight-medium mb-2">Recent Activity</div>
|
||||
<div class="log-entries-container pa-2" ref="logContainer" style="height: 150px;">
|
||||
<!-- Loading State -->
|
||||
<div v-if="isLoading && logs.length === 0" class="text-center py-4">
|
||||
<v-progress-circular indeterminate color="primary" size="20"></v-progress-circular>
|
||||
<div class="text-caption mt-1">Loading AI logs...</div>
|
||||
</div>
|
||||
|
||||
<!-- Empty State -->
|
||||
<div v-else-if="logs.length === 0" class="text-center py-4">
|
||||
<v-icon icon="mdi-robot-off" size="32" color="grey-lighten-1"></v-icon>
|
||||
<div class="text-body-2 mt-1">No AI activity</div>
|
||||
</div>
|
||||
|
||||
<!-- Log Entries -->
|
||||
<div v-else class="log-entries">
|
||||
<div
|
||||
v-for="(log, index) in visibleLogs"
|
||||
:key="log.id"
|
||||
class="log-entry mb-2 pa-2"
|
||||
:class="{ 'new-entry': log.isNew }"
|
||||
>
|
||||
<div class="d-flex align-center">
|
||||
<v-icon
|
||||
:color="getLogColor(log.type)"
|
||||
:icon="getLogIcon(log.type)"
|
||||
size="small"
|
||||
class="mr-2"
|
||||
></v-icon>
|
||||
<div class="flex-grow-1">
|
||||
<div class="text-caption">{{ log.message }}</div>
|
||||
<div class="d-flex align-center mt-1">
|
||||
<v-chip size="x-small" :color="getRobotColor(log.robot)" class="mr-1">
|
||||
{{ log.robot }}
|
||||
</v-chip>
|
||||
<span class="text-caption text-grey">{{ formatTime(log.timestamp) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</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-robot" size="small" class="mr-1"></v-icon>
|
||||
{{ activeRobots }} active • {{ filteredRobots.length }} filtered
|
||||
</div>
|
||||
|
||||
<div class="d-flex">
|
||||
<v-btn
|
||||
size="x-small"
|
||||
variant="text"
|
||||
@click="toggleAutoScroll"
|
||||
:color="autoScroll ? 'primary' : 'grey'"
|
||||
>
|
||||
<v-icon :icon="autoScroll ? 'mdi-pin' : 'mdi-pin-off'" size="small" class="mr-1"></v-icon>
|
||||
{{ autoScroll ? 'Auto-scroll' : 'Manual' }}
|
||||
</v-btn>
|
||||
</div>
|
||||
</div>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, onUnmounted, nextTick } from 'vue'
|
||||
|
||||
// Types
|
||||
interface AiLogEntry {
|
||||
id: string
|
||||
timestamp: Date
|
||||
message: string
|
||||
type: 'info' | 'success' | 'warning' | 'error' | 'gold'
|
||||
robot: string
|
||||
vehicleId?: string
|
||||
status?: string
|
||||
details?: string
|
||||
isNew?: boolean
|
||||
}
|
||||
|
||||
interface RobotStatus {
|
||||
id: string
|
||||
name: string
|
||||
description: string
|
||||
region: string
|
||||
status: 'running' | 'idle' | 'error' | 'paused'
|
||||
statusColor: string
|
||||
icon: string
|
||||
progress?: number
|
||||
progressColor: string
|
||||
successRate: number
|
||||
failureRate: number
|
||||
avgTime: number
|
||||
lastActivity: Date
|
||||
}
|
||||
|
||||
// State
|
||||
const logs = ref<AiLogEntry[]>([])
|
||||
const isLoading = ref(true)
|
||||
const isRefreshing = ref(false)
|
||||
const autoScroll = ref(true)
|
||||
const pollingInterval = ref(5000) // 5 seconds
|
||||
const connectionStatus = ref<'connected' | 'disconnected' | 'error'>('connected')
|
||||
const logContainer = ref<HTMLElement | null>(null)
|
||||
const selectedRegion = ref('all')
|
||||
|
||||
// Robot status data
|
||||
const robots = ref<RobotStatus[]>([
|
||||
{
|
||||
id: 'gb-discovery',
|
||||
name: 'GB Discovery',
|
||||
description: 'UK catalog discovery from MOT CSV',
|
||||
region: 'GB',
|
||||
status: 'running',
|
||||
statusColor: 'success',
|
||||
icon: 'mdi-magnify',
|
||||
progress: 75,
|
||||
progressColor: 'primary',
|
||||
successRate: 92,
|
||||
failureRate: 3,
|
||||
avgTime: 45,
|
||||
lastActivity: new Date()
|
||||
},
|
||||
{
|
||||
id: 'gb-hunter',
|
||||
name: 'GB Hunter',
|
||||
description: 'DVLA API vehicle data fetcher',
|
||||
region: 'GB',
|
||||
status: 'running',
|
||||
statusColor: 'success',
|
||||
icon: 'mdi-target',
|
||||
progress: 60,
|
||||
progressColor: 'indigo',
|
||||
successRate: 88,
|
||||
failureRate: 5,
|
||||
avgTime: 12,
|
||||
lastActivity: new Date()
|
||||
},
|
||||
{
|
||||
id: 'nhtsa-fetcher',
|
||||
name: 'NHTSA Fetcher',
|
||||
description: 'US vehicle specifications',
|
||||
region: 'US',
|
||||
status: 'idle',
|
||||
statusColor: 'warning',
|
||||
icon: 'mdi-database-import',
|
||||
progress: 0,
|
||||
progressColor: 'orange',
|
||||
successRate: 95,
|
||||
failureRate: 2,
|
||||
avgTime: 8,
|
||||
lastActivity: new Date(Date.now() - 3600000) // 1 hour ago
|
||||
},
|
||||
{
|
||||
id: 'system-ocr',
|
||||
name: 'System OCR',
|
||||
description: 'Document processing AI',
|
||||
region: 'all',
|
||||
status: 'running',
|
||||
statusColor: 'success',
|
||||
icon: 'mdi-text-recognition',
|
||||
progress: 90,
|
||||
progressColor: 'green',
|
||||
successRate: 85,
|
||||
failureRate: 8,
|
||||
avgTime: 25,
|
||||
lastActivity: new Date()
|
||||
},
|
||||
{
|
||||
id: 'rdw-enricher',
|
||||
name: 'RDW Enricher',
|
||||
description: 'Dutch vehicle data',
|
||||
region: 'EU',
|
||||
status: 'error',
|
||||
statusColor: 'error',
|
||||
icon: 'mdi-alert-circle',
|
||||
progress: 30,
|
||||
progressColor: 'red',
|
||||
successRate: 78,
|
||||
failureRate: 15,
|
||||
avgTime: 18,
|
||||
lastActivity: new Date(Date.now() - 1800000) // 30 minutes ago
|
||||
},
|
||||
{
|
||||
id: 'alchemist-pro',
|
||||
name: 'Alchemist Pro',
|
||||
description: 'Gold status optimizer',
|
||||
region: 'all',
|
||||
status: 'running',
|
||||
statusColor: 'success',
|
||||
icon: 'mdi-star',
|
||||
progress: 85,
|
||||
progressColor: 'amber',
|
||||
successRate: 96,
|
||||
failureRate: 1,
|
||||
avgTime: 32,
|
||||
lastActivity: new Date()
|
||||
}
|
||||
])
|
||||
|
||||
// Mock data generator
|
||||
const generateMockLog = (): AiLogEntry => {
|
||||
const robotList = robots.value.map(r => r.name)
|
||||
const types: AiLogEntry['type'][] = ['info', 'success', 'warning', 'error', 'gold']
|
||||
const messages = [
|
||||
'Vehicle #4521 changed to Gold Status',
|
||||
'New vehicle discovered in UK catalog',
|
||||
'DVLA API quota limit reached',
|
||||
'OCR processing completed for invoice #789',
|
||||
'Service validation failed - missing coordinates',
|
||||
'Price comparison completed for 15 services',
|
||||
'Vehicle technical data enriched successfully',
|
||||
'Database synchronization in progress',
|
||||
'AI model training completed',
|
||||
'Real-time monitoring activated'
|
||||
]
|
||||
|
||||
const robot = robotList[Math.floor(Math.random() * robotList.length)]
|
||||
const type = types[Math.floor(Math.random() * types.length)]
|
||||
const message = messages[Math.floor(Math.random() * messages.length)]
|
||||
|
||||
return {
|
||||
id: `log_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`,
|
||||
timestamp: new Date(),
|
||||
message,
|
||||
type,
|
||||
robot,
|
||||
vehicleId: type === 'gold' ? `#${Math.floor(Math.random() * 10000)}` : undefined,
|
||||
status: type === 'gold' ? 'GOLD' : type === 'success' ? 'SUCCESS' : type === 'error' ? 'FAILED' : 'PROCESSING',
|
||||
details: type === 'error' ? 'API timeout after 30 seconds' : undefined,
|
||||
isNew: true
|
||||
}
|
||||
}
|
||||
|
||||
// Computed properties
|
||||
const filteredRobots = computed(() => {
|
||||
if (selectedRegion.value === 'all') return robots.value
|
||||
return robots.value.filter(robot => robot.region === selectedRegion.value || robot.region === 'all')
|
||||
})
|
||||
|
||||
const visibleLogs = computed(() => {
|
||||
// Show latest 5 logs
|
||||
return [...logs.value].slice(-5).reverse()
|
||||
})
|
||||
|
||||
const activeRobots = computed(() => {
|
||||
return robots.value.filter(r => r.status === 'running').length
|
||||
})
|
||||
|
||||
const pipelineStats = computed(() => {
|
||||
const totalRobots = robots.value.length
|
||||
const runningRobots = robots.value.filter(r => r.status === 'running').length
|
||||
const totalSuccessRate = robots.value.reduce((sum, r) => sum + r.successRate, 0) / totalRobots
|
||||
const totalAvgTime = robots.value.reduce((sum, r) => sum + r.avgTime, 0) / totalRobots
|
||||
|
||||
return {
|
||||
totalProcessed: Math.floor(Math.random() * 10000) + 5000,
|
||||
successRate: Math.round(totalSuccessRate),
|
||||
avgProcessingTime: Math.round(totalAvgTime),
|
||||
queueSize: Math.floor(Math.random() * 200),
|
||||
runningRobots,
|
||||
totalRobots
|
||||
}
|
||||
})
|
||||
|
||||
const connectionStatusColor = computed(() => {
|
||||
switch (connectionStatus.value) {
|
||||
case 'connected': return 'green'
|
||||
case 'disconnected': return 'orange'
|
||||
case 'error': return 'red'
|
||||
default: return 'grey'
|
||||
}
|
||||
})
|
||||
|
||||
const connectionStatusIcon = computed(() => {
|
||||
switch (connectionStatus.value) {
|
||||
case 'connected': return 'mdi-check-circle'
|
||||
case 'disconnected': return 'mdi-alert-circle'
|
||||
case 'error': return 'mdi-close-circle'
|
||||
default: return 'mdi-help-circle'
|
||||
}
|
||||
})
|
||||
|
||||
const connectionStatusText = computed(() => {
|
||||
switch (connectionStatus.value) {
|
||||
case 'connected': return 'Connected'
|
||||
case 'disconnected': return 'Disconnected'
|
||||
case 'error': return 'Error'
|
||||
default: return 'Unknown'
|
||||
}
|
||||
})
|
||||
|
||||
const connectionStatusMessage = computed(() => {
|
||||
switch (connectionStatus.value) {
|
||||
case 'connected': return 'Connected to AI logs stream'
|
||||
case 'disconnected': return 'Disconnected - using mock data'
|
||||
case 'error': return 'Connection error - check API endpoint'
|
||||
default: return 'Status unknown'
|
||||
}
|
||||
})
|
||||
|
||||
const connectionStatusBarClass = computed(() => {
|
||||
switch (connectionStatus.value) {
|
||||
case 'connected': return 'bg-green-lighten-5'
|
||||
case 'disconnected': return 'bg-orange-lighten-5'
|
||||
case 'error': return 'bg-red-lighten-5'
|
||||
default: return 'bg-grey-lighten-5'
|
||||
}
|
||||
})
|
||||
|
||||
// Helper functions
|
||||
// Helper functions
|
||||
const getLogColor = (type: AiLogEntry['type']) => {
|
||||
switch (type) {
|
||||
case 'info': return 'blue'
|
||||
case 'success': return 'green'
|
||||
case 'warning': return 'orange'
|
||||
case 'error': return 'red'
|
||||
case 'gold': return 'amber'
|
||||
default: return 'grey'
|
||||
}
|
||||
}
|
||||
|
||||
const getLogIcon = (type: AiLogEntry['type']) => {
|
||||
switch (type) {
|
||||
case 'info': return 'mdi-information'
|
||||
case 'success': return 'mdi-check-circle'
|
||||
case 'warning': return 'mdi-alert'
|
||||
case 'error': return 'mdi-alert-circle'
|
||||
case 'gold': return 'mdi-star'
|
||||
default: return 'mdi-help-circle'
|
||||
}
|
||||
}
|
||||
|
||||
const getRobotColor = (robotName: string) => {
|
||||
const robot = robots.value.find(r => r.name === robotName)
|
||||
return robot?.statusColor || 'grey'
|
||||
}
|
||||
|
||||
const getStatusColor = (status: string) => {
|
||||
switch (status.toLowerCase()) {
|
||||
case 'running': return 'success'
|
||||
case 'idle': return 'warning'
|
||||
case 'error': return 'error'
|
||||
case 'paused': return 'grey'
|
||||
default: return 'grey'
|
||||
}
|
||||
}
|
||||
|
||||
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' })
|
||||
}
|
||||
|
||||
// Data fetching and polling
|
||||
const fetchLogs = async () => {
|
||||
if (isRefreshing.value) return
|
||||
|
||||
isRefreshing.value = true
|
||||
try {
|
||||
// Simulate API call
|
||||
await new Promise(resolve => setTimeout(resolve, 500))
|
||||
|
||||
// Add new mock log
|
||||
const newLog = generateMockLog()
|
||||
logs.value.push(newLog)
|
||||
|
||||
// Keep only last 50 logs
|
||||
if (logs.value.length > 50) {
|
||||
logs.value = logs.value.slice(-50)
|
||||
}
|
||||
|
||||
// Mark old logs as not new
|
||||
setTimeout(() => {
|
||||
logs.value.forEach(log => {
|
||||
if (log.isNew && Date.now() - log.timestamp.getTime() > 5000) {
|
||||
log.isNew = false
|
||||
}
|
||||
})
|
||||
}, 5000)
|
||||
|
||||
// Update connection status randomly
|
||||
if (Math.random() > 0.95) {
|
||||
connectionStatus.value = 'disconnected'
|
||||
} else if (Math.random() > 0.98) {
|
||||
connectionStatus.value = 'error'
|
||||
} else {
|
||||
connectionStatus.value = 'connected'
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch AI logs:', error)
|
||||
connectionStatus.value = 'error'
|
||||
} finally {
|
||||
isRefreshing.value = false
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const forceRefresh = () => {
|
||||
fetchLogs()
|
||||
}
|
||||
|
||||
const toggleAutoScroll = () => {
|
||||
autoScroll.value = !autoScroll.value
|
||||
}
|
||||
|
||||
const clearLogs = () => {
|
||||
logs.value = []
|
||||
}
|
||||
|
||||
const scrollToBottom = () => {
|
||||
if (logContainer.value && autoScroll.value) {
|
||||
nextTick(() => {
|
||||
logContainer.value!.scrollTop = logContainer.value!.scrollHeight
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Polling management
|
||||
let pollInterval: number | null = null
|
||||
|
||||
const startPolling = () => {
|
||||
if (pollInterval) clearInterval(pollInterval)
|
||||
pollInterval = setInterval(() => {
|
||||
fetchLogs()
|
||||
scrollToBottom()
|
||||
}, pollingInterval.value) as unknown as number
|
||||
}
|
||||
|
||||
const stopPolling = () => {
|
||||
if (pollInterval) {
|
||||
clearInterval(pollInterval)
|
||||
pollInterval = null
|
||||
}
|
||||
}
|
||||
|
||||
// Lifecycle hooks
|
||||
onMounted(() => {
|
||||
// Initial load
|
||||
fetchLogs()
|
||||
|
||||
// Start polling
|
||||
startPolling()
|
||||
|
||||
// Generate initial logs
|
||||
for (let i = 0; i < 10; i++) {
|
||||
const log = generateMockLog()
|
||||
log.timestamp = new Date(Date.now() - (10 - i) * 60000) // Staggered times
|
||||
log.isNew = false
|
||||
logs.value.push(log)
|
||||
}
|
||||
|
||||
isLoading.value = false
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
stopPolling()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.log-entries-container {
|
||||
overflow-y: auto;
|
||||
border: 1px solid rgba(0, 0, 0, 0.12);
|
||||
border-radius: 4px;
|
||||
background-color: rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
.log-entry {
|
||||
border-left: 3px solid;
|
||||
background-color: rgba(255, 255, 255, 0.02);
|
||||
transition: background-color 0.2s;
|
||||
}
|
||||
|
||||
.log-entry:hover {
|
||||
background-color: rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
.log-entry.new-entry {
|
||||
background-color: rgba(33, 150, 243, 0.1);
|
||||
border-left-color: #2196f3;
|
||||
}
|
||||
|
||||
.h-100 {
|
||||
height: 100%;
|
||||
}
|
||||
</style>
|
||||
474
frontend/admin/components/FinancialTile.vue
Normal file
474
frontend/admin/components/FinancialTile.vue
Normal file
@@ -0,0 +1,474 @@
|
||||
<template>
|
||||
<v-card
|
||||
color="teal-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-chart-line" class="mr-2"></v-icon>
|
||||
<span class="text-subtitle-1 font-weight-bold">Financial Overview</span>
|
||||
</div>
|
||||
<div class="d-flex align-center">
|
||||
<v-chip size="small" color="green" class="mr-2">
|
||||
<v-icon icon="mdi-cash" size="small" class="mr-1"></v-icon>
|
||||
Live
|
||||
</v-chip>
|
||||
<v-menu>
|
||||
<template v-slot:activator="{ props }">
|
||||
<v-btn
|
||||
size="x-small"
|
||||
variant="text"
|
||||
v-bind="props"
|
||||
class="text-caption"
|
||||
>
|
||||
{{ selectedPeriod }}
|
||||
<v-icon icon="mdi-chevron-down" size="small"></v-icon>
|
||||
</v-btn>
|
||||
</template>
|
||||
<v-list density="compact">
|
||||
<v-list-item
|
||||
v-for="period in periodOptions"
|
||||
:key="period.value"
|
||||
@click="selectedPeriod = period.value"
|
||||
>
|
||||
<v-list-item-title>{{ period.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">
|
||||
<!-- Key Financial Metrics -->
|
||||
<v-row dense class="mb-4">
|
||||
<v-col cols="6" sm="3">
|
||||
<v-card variant="outlined" class="pa-2 text-center">
|
||||
<div class="text-h6 font-weight-bold text-primary">{{ formatCurrency(revenue) }}</div>
|
||||
<div class="text-caption text-grey">Revenue</div>
|
||||
<div class="text-caption" :class="revenueGrowth >= 0 ? 'text-success' : 'text-error'">
|
||||
<v-icon :icon="revenueGrowth >= 0 ? 'mdi-arrow-up' : 'mdi-arrow-down'" size="x-small" class="mr-1"></v-icon>
|
||||
{{ Math.abs(revenueGrowth) }}%
|
||||
</div>
|
||||
</v-card>
|
||||
</v-col>
|
||||
<v-col cols="6" sm="3">
|
||||
<v-card variant="outlined" class="pa-2 text-center">
|
||||
<div class="text-h6 font-weight-bold text-error">{{ formatCurrency(expenses) }}</div>
|
||||
<div class="text-caption text-grey">Expenses</div>
|
||||
<div class="text-caption" :class="expenseGrowth <= 0 ? 'text-success' : 'text-error'">
|
||||
<v-icon :icon="expenseGrowth <= 0 ? 'mdi-arrow-down' : 'mdi-arrow-up'" size="x-small" class="mr-1"></v-icon>
|
||||
{{ Math.abs(expenseGrowth) }}%
|
||||
</div>
|
||||
</v-card>
|
||||
</v-col>
|
||||
<v-col cols="6" sm="3">
|
||||
<v-card variant="outlined" class="pa-2 text-center">
|
||||
<div class="text-h6 font-weight-bold text-success">{{ formatCurrency(profit) }}</div>
|
||||
<div class="text-caption text-grey">Profit</div>
|
||||
<div class="text-caption" :class="profitMargin >= 20 ? 'text-success' : profitMargin >= 10 ? 'text-warning' : 'text-error'">
|
||||
{{ profitMargin }}% margin
|
||||
</div>
|
||||
</v-card>
|
||||
</v-col>
|
||||
<v-col cols="6" sm="3">
|
||||
<v-card variant="outlined" class="pa-2 text-center">
|
||||
<div class="text-h6 font-weight-bold text-indigo">{{ formatCurrency(cashFlow) }}</div>
|
||||
<div class="text-caption text-grey">Cash Flow</div>
|
||||
<div class="text-caption" :class="cashFlow >= 0 ? 'text-success' : 'text-error'">
|
||||
{{ cashFlow >= 0 ? 'Positive' : 'Negative' }}
|
||||
</div>
|
||||
</v-card>
|
||||
</v-col>
|
||||
</v-row>
|
||||
|
||||
<!-- Revenue vs Expenses Chart -->
|
||||
<div class="mb-4">
|
||||
<div class="text-subtitle-2 font-weight-medium mb-2">Revenue vs Expenses</div>
|
||||
<div class="chart-container" style="height: 200px;">
|
||||
<canvas ref="revenueExpenseChart"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Expense Breakdown -->
|
||||
<div class="mb-4">
|
||||
<div class="text-subtitle-2 font-weight-medium mb-2">Expense Breakdown</div>
|
||||
<v-row dense>
|
||||
<v-col v-for="category in expenseCategories" :key="category.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">{{ category.name }}</div>
|
||||
<div class="text-caption text-grey">{{ formatCurrency(category.amount) }}</div>
|
||||
</div>
|
||||
<div class="text-right">
|
||||
<div class="text-caption">{{ category.percentage }}%</div>
|
||||
<v-progress-linear
|
||||
:model-value="category.percentage"
|
||||
height="4"
|
||||
:color="category.color"
|
||||
class="mt-1"
|
||||
></v-progress-linear>
|
||||
</div>
|
||||
</div>
|
||||
</v-card>
|
||||
</v-col>
|
||||
</v-row>
|
||||
</div>
|
||||
|
||||
<!-- Regional Performance -->
|
||||
<div>
|
||||
<div class="text-subtitle-2 font-weight-medium mb-2">Regional Performance</div>
|
||||
<v-table density="compact" class="elevation-1">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="text-left">Region</th>
|
||||
<th class="text-right">Revenue</th>
|
||||
<th class="text-right">Growth</th>
|
||||
<th class="text-right">Margin</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="region in regionalPerformance" :key="region.name">
|
||||
<td class="text-left">
|
||||
<v-chip size="x-small" :color="getRegionColor(region.name)" class="mr-1">
|
||||
{{ region.name }}
|
||||
</v-chip>
|
||||
</td>
|
||||
<td class="text-right">{{ formatCurrency(region.revenue) }}</td>
|
||||
<td class="text-right" :class="region.growth >= 0 ? 'text-success' : 'text-error'">
|
||||
{{ region.growth >= 0 ? '+' : '' }}{{ region.growth }}%
|
||||
</td>
|
||||
<td class="text-right" :class="region.margin >= 20 ? 'text-success' : region.margin >= 10 ? 'text-warning' : 'text-error'">
|
||||
{{ region.margin }}%
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</v-table>
|
||||
</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-calendar" size="small" class="mr-1"></v-icon>
|
||||
Last updated: {{ lastUpdated }}
|
||||
</div>
|
||||
|
||||
<div class="d-flex">
|
||||
<v-btn
|
||||
size="x-small"
|
||||
variant="text"
|
||||
@click="refreshData"
|
||||
: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="exportData"
|
||||
class="ml-2"
|
||||
>
|
||||
<v-icon icon="mdi-download" size="small" class="mr-1"></v-icon>
|
||||
Export
|
||||
</v-btn>
|
||||
</div>
|
||||
</div>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, onUnmounted, nextTick, watch } from 'vue'
|
||||
import { Chart, registerables } from 'chart.js'
|
||||
|
||||
// Register Chart.js components
|
||||
Chart.register(...registerables)
|
||||
|
||||
// Types
|
||||
interface ExpenseCategory {
|
||||
name: string
|
||||
amount: number
|
||||
percentage: number
|
||||
color: string
|
||||
}
|
||||
|
||||
interface RegionalPerformance {
|
||||
name: string
|
||||
revenue: number
|
||||
growth: number
|
||||
margin: number
|
||||
}
|
||||
|
||||
// State
|
||||
const selectedPeriod = ref('month')
|
||||
const isRefreshing = ref(false)
|
||||
const revenueExpenseChart = ref<HTMLCanvasElement | null>(null)
|
||||
let chartInstance: Chart | null = null
|
||||
|
||||
// Period options
|
||||
const periodOptions = [
|
||||
{ label: 'Last 7 Days', value: 'week' },
|
||||
{ label: 'Last Month', value: 'month' },
|
||||
{ label: 'Last Quarter', value: 'quarter' },
|
||||
{ label: 'Last Year', value: 'year' }
|
||||
]
|
||||
|
||||
// Mock financial data
|
||||
const revenue = ref(1254300)
|
||||
const expenses = ref(892500)
|
||||
const revenueGrowth = ref(12.5)
|
||||
const expenseGrowth = ref(8.2)
|
||||
const cashFlow = ref(361800)
|
||||
|
||||
// Computed properties
|
||||
const profit = computed(() => revenue.value - expenses.value)
|
||||
const profitMargin = computed(() => {
|
||||
if (revenue.value === 0) return 0
|
||||
return Math.round((profit.value / revenue.value) * 100)
|
||||
})
|
||||
|
||||
const expenseCategories = ref<ExpenseCategory[]>([
|
||||
{ name: 'Personnel', amount: 425000, percentage: 48, color: 'indigo' },
|
||||
{ name: 'Operations', amount: 215000, percentage: 24, color: 'blue' },
|
||||
{ name: 'Marketing', amount: 125000, percentage: 14, color: 'green' },
|
||||
{ name: 'Technology', amount: 85000, percentage: 10, color: 'orange' },
|
||||
{ name: 'Other', amount: 42500, percentage: 5, color: 'grey' }
|
||||
])
|
||||
|
||||
const regionalPerformance = ref<RegionalPerformance[]>([
|
||||
{ name: 'GB', revenue: 450000, growth: 15.2, margin: 22 },
|
||||
{ name: 'EU', revenue: 385000, growth: 8.7, margin: 18 },
|
||||
{ name: 'US', revenue: 275000, growth: 21.5, margin: 25 },
|
||||
{ name: 'OC', revenue: 144300, growth: 5.3, margin: 12 }
|
||||
])
|
||||
|
||||
const lastUpdated = computed(() => {
|
||||
const now = new Date()
|
||||
return now.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
|
||||
})
|
||||
|
||||
// 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 getRegionColor = (region: string) => {
|
||||
switch (region) {
|
||||
case 'GB': return 'blue'
|
||||
case 'EU': return 'green'
|
||||
case 'US': return 'red'
|
||||
case 'OC': return 'orange'
|
||||
default: return 'grey'
|
||||
}
|
||||
}
|
||||
|
||||
// Chart functions
|
||||
const initChart = () => {
|
||||
if (!revenueExpenseChart.value) return
|
||||
|
||||
// Destroy existing chart
|
||||
if (chartInstance) {
|
||||
chartInstance.destroy()
|
||||
}
|
||||
|
||||
const ctx = revenueExpenseChart.value.getContext('2d')
|
||||
if (!ctx) return
|
||||
|
||||
// Generate mock data based on selected period
|
||||
const labels = generateChartLabels()
|
||||
const revenueData = generateRevenueData()
|
||||
const expenseData = generateExpenseData()
|
||||
|
||||
chartInstance = new Chart(ctx, {
|
||||
type: 'line',
|
||||
data: {
|
||||
labels,
|
||||
datasets: [
|
||||
{
|
||||
label: 'Revenue',
|
||||
data: revenueData,
|
||||
borderColor: '#4CAF50',
|
||||
backgroundColor: 'rgba(76, 175, 80, 0.1)',
|
||||
tension: 0.4,
|
||||
fill: true
|
||||
},
|
||||
{
|
||||
label: 'Expenses',
|
||||
data: expenseData,
|
||||
borderColor: '#F44336',
|
||||
backgroundColor: 'rgba(244, 67, 54, 0.1)',
|
||||
tension: 0.4,
|
||||
fill: true
|
||||
}
|
||||
]
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: {
|
||||
legend: {
|
||||
position: 'top',
|
||||
labels: {
|
||||
usePointStyle: true,
|
||||
padding: 10
|
||||
}
|
||||
},
|
||||
tooltip: {
|
||||
mode: 'index',
|
||||
intersect: false,
|
||||
callbacks: {
|
||||
label: (context) => {
|
||||
return `${context.dataset.label}: ${formatCurrency(context.raw as number)}`
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
scales: {
|
||||
x: {
|
||||
grid: {
|
||||
display: false
|
||||
}
|
||||
},
|
||||
y: {
|
||||
beginAtZero: true,
|
||||
ticks: {
|
||||
callback: (value) => formatCurrency(value as number)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const generateChartLabels = () => {
|
||||
switch (selectedPeriod.value) {
|
||||
case 'week':
|
||||
return ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun']
|
||||
case 'month':
|
||||
return ['Week 1', 'Week 2', 'Week 3', 'Week 4']
|
||||
case 'quarter':
|
||||
return ['Jan-Mar', 'Apr-Jun', 'Jul-Sep', 'Oct-Dec']
|
||||
case 'year':
|
||||
return ['Q1', 'Q2', 'Q3', 'Q4']
|
||||
default:
|
||||
return ['Week 1', 'Week 2', 'Week 3', 'Week 4']
|
||||
}
|
||||
}
|
||||
|
||||
const generateRevenueData = () => {
|
||||
const base = 100000
|
||||
const variance = 0.3
|
||||
const count = generateChartLabels().length
|
||||
|
||||
return Array.from({ length: count }, (_, i) => {
|
||||
const growth = 1 + (i * 0.1)
|
||||
const random = 1 + (Math.random() * variance * 2 - variance)
|
||||
return Math.round(base * growth * random)
|
||||
})
|
||||
}
|
||||
|
||||
const generateExpenseData = () => {
|
||||
const base = 70000
|
||||
const variance = 0.2
|
||||
const count = generateChartLabels().length
|
||||
|
||||
return Array.from({ length: count }, (_, i) => {
|
||||
const growth = 1 + (i * 0.05)
|
||||
const random = 1 + (Math.random() * variance * 2 - variance)
|
||||
return Math.round(base * growth * random)
|
||||
})
|
||||
}
|
||||
|
||||
// Actions
|
||||
const refreshData = () => {
|
||||
isRefreshing.value = true
|
||||
|
||||
// Simulate API call
|
||||
setTimeout(() => {
|
||||
// Update with new random data
|
||||
revenue.value = Math.round(1254300 * (1 + Math.random() * 0.1 - 0.05))
|
||||
expenses.value = Math.round(892500 * (1 + Math.random() * 0.1 - 0.05))
|
||||
revenueGrowth.value = parseFloat((Math.random() * 20 - 5).toFixed(1))
|
||||
expenseGrowth.value = parseFloat((Math.random() * 15 - 5).toFixed(1))
|
||||
cashFlow.value = revenue.value - expenses.value
|
||||
|
||||
// Update chart
|
||||
initChart()
|
||||
|
||||
isRefreshing.value = false
|
||||
}, 1000)
|
||||
}
|
||||
|
||||
const exportData = () => {
|
||||
// Simulate export
|
||||
const data = {
|
||||
revenue: revenue.value,
|
||||
expenses: expenses.value,
|
||||
profit: profit.value,
|
||||
profitMargin: profitMargin.value,
|
||||
period: selectedPeriod.value,
|
||||
timestamp: new Date().toISOString()
|
||||
}
|
||||
|
||||
const dataStr = JSON.stringify(data, null, 2)
|
||||
const dataUri = 'data:application/json;charset=utf-8,'+ encodeURIComponent(dataStr)
|
||||
|
||||
const exportFileDefaultName = `financial_report_${new Date().toISOString().split('T')[0]}.json`
|
||||
|
||||
const linkElement = document.createElement('a')
|
||||
linkElement.setAttribute('href', dataUri)
|
||||
linkElement.setAttribute('download', exportFileDefaultName)
|
||||
linkElement.click()
|
||||
}
|
||||
|
||||
// Lifecycle hooks
|
||||
onMounted(() => {
|
||||
nextTick(() => {
|
||||
initChart()
|
||||
})
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (chartInstance) {
|
||||
chartInstance.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
// Watch for period changes
|
||||
watch(selectedPeriod, () => {
|
||||
initChart()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.chart-container {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.h-100 {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.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>
|
||||
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>
|
||||
202
frontend/admin/components/ServiceMapTile.vue
Normal file
202
frontend/admin/components/ServiceMapTile.vue
Normal file
@@ -0,0 +1,202 @@
|
||||
<template>
|
||||
<TileWrapper
|
||||
title="Geographical Map"
|
||||
subtitle="Service moderation map"
|
||||
icon="map"
|
||||
:loading="loading"
|
||||
>
|
||||
<div class="service-map-tile">
|
||||
<div class="mini-map">
|
||||
<div class="map-placeholder">
|
||||
<div class="map-grid">
|
||||
<div
|
||||
v-for="point in mapPoints"
|
||||
:key="point.id"
|
||||
class="map-point"
|
||||
:class="point.status"
|
||||
:style="{
|
||||
left: `${point.x}%`,
|
||||
top: `${point.y}%`
|
||||
}"
|
||||
:title="point.name"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="tile-stats">
|
||||
<div class="stat">
|
||||
<span class="stat-label">Pending in Scope</span>
|
||||
<span class="stat-value">{{ pendingCount }}</span>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<span class="stat-label">Scope</span>
|
||||
<span class="stat-value scope">{{ scopeLabel }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="tile-actions">
|
||||
<button @click="navigateToMap" class="btn-primary">
|
||||
Open Full Map
|
||||
</button>
|
||||
<button @click="refresh" class="btn-secondary">
|
||||
Refresh
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</TileWrapper>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import TileWrapper from '~/components/TileWrapper.vue'
|
||||
import { useServiceMap } from '~/composables/useServiceMap'
|
||||
|
||||
const router = useRouter()
|
||||
const { pendingServices, scopeLabel } = useServiceMap()
|
||||
const loading = ref(false)
|
||||
|
||||
const pendingCount = computed(() => pendingServices.value.length)
|
||||
|
||||
// Generate random points for the mini map visualization
|
||||
const mapPoints = computed(() => {
|
||||
return pendingServices.value.slice(0, 8).map((service, index) => ({
|
||||
id: service.id,
|
||||
name: service.name,
|
||||
status: service.status,
|
||||
x: 10 + (index % 4) * 25 + Math.random() * 10,
|
||||
y: 10 + Math.floor(index / 4) * 30 + Math.random() * 10
|
||||
}))
|
||||
})
|
||||
|
||||
const navigateToMap = () => {
|
||||
router.push('/moderation-map')
|
||||
}
|
||||
|
||||
const refresh = () => {
|
||||
loading.value = true
|
||||
// Simulate API call
|
||||
setTimeout(() => {
|
||||
loading.value = false
|
||||
}, 1000)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.service-map-tile {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.mini-map {
|
||||
flex: 1;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.map-placeholder {
|
||||
width: 100%;
|
||||
height: 150px;
|
||||
background: linear-gradient(135deg, #e3f2fd 0%, #bbdefb 100%);
|
||||
border-radius: 8px;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
border: 1px solid #90caf9;
|
||||
}
|
||||
|
||||
.map-grid {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.map-point {
|
||||
position: absolute;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-radius: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
border: 2px solid white;
|
||||
box-shadow: 0 2px 4px rgba(0,0,0,0.2);
|
||||
}
|
||||
|
||||
.map-point.pending {
|
||||
background-color: #ffc107;
|
||||
}
|
||||
|
||||
.map-point.approved {
|
||||
background-color: #28a745;
|
||||
}
|
||||
|
||||
.tile-stats {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.stat {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: 0.85rem;
|
||||
color: #666;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: 1.5rem;
|
||||
font-weight: bold;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.stat-value.scope {
|
||||
font-size: 1rem;
|
||||
color: #4a90e2;
|
||||
background: #e3f2fd;
|
||||
padding: 4px 8px;
|
||||
border-radius: 12px;
|
||||
max-width: 100%;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.tile-actions {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
flex: 2;
|
||||
background-color: #4a90e2;
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 10px;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
font-weight: bold;
|
||||
transition: background-color 0.2s;
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
background-color: #3a7bc8;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
flex: 1;
|
||||
background-color: #f8f9fa;
|
||||
color: #495057;
|
||||
border: 1px solid #dee2e6;
|
||||
padding: 10px;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s;
|
||||
}
|
||||
|
||||
.btn-secondary:hover {
|
||||
background-color: #e9ecef;
|
||||
}
|
||||
</style>
|
||||
590
frontend/admin/components/SystemHealthTile.vue
Normal file
590
frontend/admin/components/SystemHealthTile.vue
Normal file
@@ -0,0 +1,590 @@
|
||||
<template>
|
||||
<v-card
|
||||
color="blue-grey-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-server" class="mr-2"></v-icon>
|
||||
<span class="text-subtitle-1 font-weight-bold">System Health</span>
|
||||
</div>
|
||||
<div class="d-flex align-center">
|
||||
<v-chip size="small" :color="overallStatusColor" class="mr-2">
|
||||
<v-icon :icon="overallStatusIcon" size="small" class="mr-1"></v-icon>
|
||||
{{ overallStatusText }}
|
||||
</v-chip>
|
||||
<v-menu>
|
||||
<template v-slot:activator="{ props }">
|
||||
<v-btn
|
||||
size="x-small"
|
||||
variant="text"
|
||||
v-bind="props"
|
||||
class="text-caption"
|
||||
>
|
||||
{{ selectedEnvironment }}
|
||||
<v-icon icon="mdi-chevron-down" size="small"></v-icon>
|
||||
</v-btn>
|
||||
</template>
|
||||
<v-list density="compact">
|
||||
<v-list-item
|
||||
v-for="env in environmentOptions"
|
||||
:key="env.value"
|
||||
@click="selectedEnvironment = env.value"
|
||||
>
|
||||
<v-list-item-title>{{ env.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">
|
||||
<!-- System Status Overview -->
|
||||
<div class="mb-4">
|
||||
<div class="text-subtitle-2 font-weight-medium mb-2">System Status</div>
|
||||
<v-row dense>
|
||||
<v-col v-for="component in systemComponents" :key="component.name" cols="6" sm="3">
|
||||
<v-card variant="outlined" class="pa-2">
|
||||
<div class="d-flex align-center justify-space-between">
|
||||
<div>
|
||||
<div class="d-flex align-center">
|
||||
<v-icon :icon="component.icon" size="small" :color="component.statusColor" class="mr-2"></v-icon>
|
||||
<span class="text-caption font-weight-medium">{{ component.name }}</span>
|
||||
</div>
|
||||
<div class="text-caption text-grey mt-1">{{ component.description }}</div>
|
||||
</div>
|
||||
<div class="text-right">
|
||||
<div class="text-caption" :class="`text-${component.statusColor}`">{{ component.status }}</div>
|
||||
<div class="text-caption text-grey">{{ component.uptime }}%</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Response Time Indicator -->
|
||||
<div v-if="component.responseTime" class="mt-2">
|
||||
<div class="d-flex justify-space-between">
|
||||
<span class="text-caption text-grey">Response</span>
|
||||
<span class="text-caption" :class="getResponseTimeColor(component.responseTime)">{{ component.responseTime }}ms</span>
|
||||
</div>
|
||||
<v-progress-linear
|
||||
:model-value="Math.min(component.responseTime / 10, 100)"
|
||||
height="4"
|
||||
:color="getResponseTimeColor(component.responseTime)"
|
||||
class="mt-1"
|
||||
></v-progress-linear>
|
||||
</div>
|
||||
</v-card>
|
||||
</v-col>
|
||||
</v-row>
|
||||
</div>
|
||||
|
||||
<!-- API Response Times Chart -->
|
||||
<div class="mb-4">
|
||||
<div class="text-subtitle-2 font-weight-medium mb-2">API Response Times (Last 24h)</div>
|
||||
<div class="chart-container" style="height: 150px;">
|
||||
<canvas ref="responseTimeChart"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Database Metrics -->
|
||||
<div class="mb-4">
|
||||
<div class="text-subtitle-2 font-weight-medium mb-2">Database Metrics</div>
|
||||
<v-row dense>
|
||||
<v-col cols="6" sm="3">
|
||||
<v-card variant="outlined" class="pa-2 text-center">
|
||||
<div class="text-h6 font-weight-bold" :class="databaseMetrics.connections > 80 ? 'text-error' : 'text-success'">{{ databaseMetrics.connections }}</div>
|
||||
<div class="text-caption text-grey">Connections</div>
|
||||
<div class="text-caption">{{ databaseMetrics.activeConnections }} active</div>
|
||||
</v-card>
|
||||
</v-col>
|
||||
<v-col cols="6" sm="3">
|
||||
<v-card variant="outlined" class="pa-2 text-center">
|
||||
<div class="text-h6 font-weight-bold" :class="databaseMetrics.queryTime > 500 ? 'text-error' : databaseMetrics.queryTime > 200 ? 'text-warning' : 'text-success'">{{ databaseMetrics.queryTime }}ms</div>
|
||||
<div class="text-caption text-grey">Avg Query Time</div>
|
||||
<div class="text-caption">{{ databaseMetrics.queriesPerSecond }} qps</div>
|
||||
</v-card>
|
||||
</v-col>
|
||||
<v-col cols="6" sm="3">
|
||||
<v-card variant="outlined" class="pa-2 text-center">
|
||||
<div class="text-h6 font-weight-bold" :class="databaseMetrics.cacheHitRate < 80 ? 'text-error' : databaseMetrics.cacheHitRate < 90 ? 'text-warning' : 'text-success'">{{ databaseMetrics.cacheHitRate }}%</div>
|
||||
<div class="text-caption text-grey">Cache Hit Rate</div>
|
||||
<div class="text-caption">{{ formatBytes(databaseMetrics.cacheSize) }} cache</div>
|
||||
</v-card>
|
||||
</v-col>
|
||||
<v-col cols="6" sm="3">
|
||||
<v-card variant="outlined" class="pa-2 text-center">
|
||||
<div class="text-h6 font-weight-bold" :class="databaseMetrics.replicationLag > 1000 ? 'text-error' : databaseMetrics.replicationLag > 500 ? 'text-warning' : 'text-success'">{{ databaseMetrics.replicationLag }}ms</div>
|
||||
<div class="text-caption text-grey">Replication Lag</div>
|
||||
<div class="text-caption">{{ databaseMetrics.replicationStatus }}</div>
|
||||
</v-card>
|
||||
</v-col>
|
||||
</v-row>
|
||||
</div>
|
||||
|
||||
<!-- Server Resources -->
|
||||
<div>
|
||||
<div class="text-subtitle-2 font-weight-medium mb-2">Server Resources</div>
|
||||
<v-row dense>
|
||||
<v-col cols="12" sm="6">
|
||||
<v-card variant="outlined" class="pa-3">
|
||||
<div class="d-flex justify-space-between align-center mb-2">
|
||||
<span class="text-caption font-weight-medium">CPU Usage</span>
|
||||
<span class="text-caption" :class="serverResources.cpu > 80 ? 'text-error' : serverResources.cpu > 60 ? 'text-warning' : 'text-success'">{{ serverResources.cpu }}%</span>
|
||||
</div>
|
||||
<v-progress-linear
|
||||
:model-value="serverResources.cpu"
|
||||
height="8"
|
||||
:color="serverResources.cpu > 80 ? 'error' : serverResources.cpu > 60 ? 'warning' : 'success'"
|
||||
rounded
|
||||
></v-progress-linear>
|
||||
<div class="text-caption text-grey mt-1">{{ serverResources.cpuCores }} cores @ {{ serverResources.cpuFrequency }}GHz</div>
|
||||
</v-card>
|
||||
</v-col>
|
||||
<v-col cols="12" sm="6">
|
||||
<v-card variant="outlined" class="pa-3">
|
||||
<div class="d-flex justify-space-between align-center mb-2">
|
||||
<span class="text-caption font-weight-medium">Memory Usage</span>
|
||||
<span class="text-caption" :class="serverResources.memory > 80 ? 'text-error' : serverResources.memory > 60 ? 'text-warning' : 'text-success'">{{ serverResources.memory }}%</span>
|
||||
</div>
|
||||
<v-progress-linear
|
||||
:model-value="serverResources.memory"
|
||||
height="8"
|
||||
:color="serverResources.memory > 80 ? 'error' : serverResources.memory > 60 ? 'warning' : 'success'"
|
||||
rounded
|
||||
></v-progress-linear>
|
||||
<div class="text-caption text-grey mt-1">{{ formatBytes(serverResources.memoryUsed) }} / {{ formatBytes(serverResources.memoryTotal) }}</div>
|
||||
</v-card>
|
||||
</v-col>
|
||||
<v-col cols="12" sm="6">
|
||||
<v-card variant="outlined" class="pa-3">
|
||||
<div class="d-flex justify-space-between align-center mb-2">
|
||||
<span class="text-caption font-weight-medium">Disk I/O</span>
|
||||
<span class="text-caption" :class="serverResources.diskIO > 80 ? 'text-error' : serverResources.diskIO > 60 ? 'text-warning' : 'text-success'">{{ serverResources.diskIO }}%</span>
|
||||
</div>
|
||||
<v-progress-linear
|
||||
:model-value="serverResources.diskIO"
|
||||
height="8"
|
||||
:color="serverResources.diskIO > 80 ? 'error' : serverResources.diskIO > 60 ? 'warning' : 'success'"
|
||||
rounded
|
||||
></v-progress-linear>
|
||||
<div class="text-caption text-grey mt-1">{{ formatBytes(serverResources.diskRead) }}/s read, {{ formatBytes(serverResources.diskWrite) }}/s write</div>
|
||||
</v-card>
|
||||
</v-col>
|
||||
<v-col cols="12" sm="6">
|
||||
<v-card variant="outlined" class="pa-3">
|
||||
<div class="d-flex justify-space-between align-center mb-2">
|
||||
<span class="text-caption font-weight-medium">Network</span>
|
||||
<span class="text-caption" :class="serverResources.network > 80 ? 'text-error' : serverResources.network > 60 ? 'text-warning' : 'text-success'">{{ serverResources.network }}%</span>
|
||||
</div>
|
||||
<v-progress-linear
|
||||
:model-value="serverResources.network"
|
||||
height="8"
|
||||
:color="serverResources.network > 80 ? 'error' : serverResources.network > 60 ? 'warning' : 'success'"
|
||||
rounded
|
||||
></v-progress-linear>
|
||||
<div class="text-caption text-grey mt-1">{{ formatBytes(serverResources.networkIn) }}/s in, {{ formatBytes(serverResources.networkOut) }}/s out</div>
|
||||
</v-card>
|
||||
</v-col>
|
||||
</v-row>
|
||||
</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-clock" size="small" class="mr-1"></v-icon>
|
||||
Last check: {{ lastCheckTime }}
|
||||
<v-chip size="x-small" color="green" class="ml-2">
|
||||
Auto-refresh: {{ refreshInterval }}s
|
||||
</v-chip>
|
||||
</div>
|
||||
|
||||
<div class="d-flex">
|
||||
<v-btn
|
||||
size="x-small"
|
||||
variant="text"
|
||||
@click="refreshHealth"
|
||||
: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="toggleAutoRefresh"
|
||||
:color="autoRefresh ? 'primary' : 'grey'"
|
||||
class="ml-2"
|
||||
>
|
||||
<v-icon :icon="autoRefresh ? 'mdi-pause' : 'mdi-play'" size="small" class="mr-1"></v-icon>
|
||||
{{ autoRefresh ? 'Pause' : 'Resume' }}
|
||||
</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 SystemComponent {
|
||||
name: string
|
||||
description: string
|
||||
status: 'healthy' | 'degraded' | 'down'
|
||||
statusColor: string
|
||||
icon: string
|
||||
uptime: number
|
||||
responseTime?: number
|
||||
}
|
||||
|
||||
interface DatabaseMetrics {
|
||||
connections: number
|
||||
activeConnections: number
|
||||
queryTime: number
|
||||
queriesPerSecond: number
|
||||
cacheHitRate: number
|
||||
cacheSize: number
|
||||
replicationLag: number
|
||||
replicationStatus: string
|
||||
}
|
||||
|
||||
interface ServerResources {
|
||||
cpu: number
|
||||
cpuCores: number
|
||||
cpuFrequency: number
|
||||
memory: number
|
||||
memoryUsed: number
|
||||
memoryTotal: number
|
||||
diskIO: number
|
||||
diskRead: number
|
||||
diskWrite: number
|
||||
network: number
|
||||
networkIn: number
|
||||
networkOut: number
|
||||
}
|
||||
|
||||
// State
|
||||
const selectedEnvironment = ref('production')
|
||||
const isRefreshing = ref(false)
|
||||
const autoRefresh = ref(true)
|
||||
const refreshInterval = ref(30)
|
||||
const responseTimeChart = ref<HTMLCanvasElement | null>(null)
|
||||
let chartInstance: Chart | null = null
|
||||
let refreshTimer: number | null = null
|
||||
|
||||
// Environment options
|
||||
const environmentOptions = [
|
||||
{ label: 'Production', value: 'production' },
|
||||
{ label: 'Staging', value: 'staging' },
|
||||
{ label: 'Development', value: 'development' },
|
||||
{ label: 'Testing', value: 'testing' }
|
||||
]
|
||||
|
||||
// System components data
|
||||
const systemComponents = ref<SystemComponent[]>([
|
||||
{ name: 'API Gateway', description: 'Main API endpoint', status: 'healthy', statusColor: 'success', icon: 'mdi-api', uptime: 99.9, responseTime: 45 },
|
||||
{ name: 'Database', description: 'PostgreSQL cluster', status: 'healthy', statusColor: 'success', icon: 'mdi-database', uptime: 99.95, responseTime: 120 },
|
||||
{ name: 'Cache', description: 'Redis cache layer', status: 'healthy', statusColor: 'success', icon: 'mdi-memory', uptime: 99.8, responseTime: 8 },
|
||||
{ name: 'Message Queue', description: 'RabbitMQ broker', status: 'degraded', statusColor: 'warning', icon: 'mdi-message-processing', uptime: 98.5, responseTime: 250 },
|
||||
{ name: 'File Storage', description: 'S3-compatible storage', status: 'healthy', statusColor: 'success', icon: 'mdi-file-cloud', uptime: 99.7, responseTime: 180 },
|
||||
{ name: 'Authentication', description: 'OAuth2/JWT service', status: 'healthy', statusColor: 'success', icon: 'mdi-shield-account', uptime: 99.9, responseTime: 65 },
|
||||
{ name: 'Monitoring', description: 'Prometheus/Grafana', status: 'healthy', statusColor: 'success', icon: 'mdi-chart-line', uptime: 99.8, responseTime: 95 },
|
||||
{ name: 'Load Balancer', description: 'Nginx reverse proxy', status: 'healthy', statusColor: 'success', icon: 'mdi-load-balancer', uptime: 99.99, responseTime: 12 }
|
||||
])
|
||||
|
||||
const databaseMetrics = ref<DatabaseMetrics>({
|
||||
connections: 64,
|
||||
activeConnections: 42,
|
||||
queryTime: 85,
|
||||
queriesPerSecond: 1250,
|
||||
cacheHitRate: 92,
|
||||
cacheSize: 2147483648, // 2GB
|
||||
replicationLag: 45,
|
||||
replicationStatus: 'Synced'
|
||||
})
|
||||
|
||||
const serverResources = ref<ServerResources>({
|
||||
cpu: 42,
|
||||
cpuCores: 8,
|
||||
cpuFrequency: 3.2,
|
||||
memory: 68,
|
||||
memoryUsed: 1090519040, // ~1GB
|
||||
memoryTotal: 17179869184, // 16GB
|
||||
diskIO: 28,
|
||||
diskRead: 5242880, // 5MB/s
|
||||
diskWrite: 1048576, // 1MB/s
|
||||
network: 45,
|
||||
networkIn: 2097152, // 2MB/s
|
||||
networkOut: 1048576 // 1MB/s
|
||||
})
|
||||
|
||||
// Computed properties
|
||||
const overallStatus = computed(() => {
|
||||
const healthyCount = systemComponents.value.filter(c => c.status === 'healthy').length
|
||||
const totalCount = systemComponents.value.length
|
||||
|
||||
if (healthyCount === totalCount) return 'healthy'
|
||||
if (healthyCount >= totalCount * 0.8) return 'degraded'
|
||||
return 'critical'
|
||||
})
|
||||
|
||||
const overallStatusColor = computed(() => {
|
||||
switch (overallStatus.value) {
|
||||
case 'healthy': return 'green'
|
||||
case 'degraded': return 'orange'
|
||||
case 'critical': return 'red'
|
||||
default: return 'grey'
|
||||
}
|
||||
})
|
||||
|
||||
const overallStatusIcon = computed(() => {
|
||||
switch (overallStatus.value) {
|
||||
case 'healthy': return 'mdi-check-circle'
|
||||
case 'degraded': return 'mdi-alert-circle'
|
||||
case 'critical': return 'mdi-close-circle'
|
||||
default: return 'mdi-help-circle'
|
||||
}
|
||||
})
|
||||
|
||||
const overallStatusText = computed(() => {
|
||||
switch (overallStatus.value) {
|
||||
case 'healthy': return 'All Systems Normal'
|
||||
case 'degraded': return 'Minor Issues'
|
||||
case case 'critical': return 'Critical Issues'
|
||||
default: return 'Unknown'
|
||||
}
|
||||
})
|
||||
|
||||
const lastCheckTime = computed(() => {
|
||||
const now = new Date()
|
||||
return now.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' })
|
||||
})
|
||||
|
||||
// Helper functions
|
||||
const getResponseTimeColor = (responseTime: number) => {
|
||||
if (responseTime < 100) return 'success'
|
||||
if (responseTime < 300) return 'warning'
|
||||
return 'error'
|
||||
}
|
||||
|
||||
const formatBytes = (bytes: number) => {
|
||||
if (bytes >= 1073741824) {
|
||||
return `${(bytes / 1073741824).toFixed(1)} GB`
|
||||
} else if (bytes >= 1048576) {
|
||||
return `${(bytes / 1048576).toFixed(1)} MB`
|
||||
} else if (bytes >= 1024) {
|
||||
return `${(bytes / 1024).toFixed(1)} KB`
|
||||
}
|
||||
return `${bytes} B`
|
||||
}
|
||||
|
||||
// Chart functions
|
||||
const initChart = () => {
|
||||
if (!responseTimeChart.value) return
|
||||
|
||||
// Destroy existing chart
|
||||
if (chartInstance) {
|
||||
chartInstance.destroy()
|
||||
}
|
||||
|
||||
const ctx = responseTimeChart.value.getContext('2d')
|
||||
if (!ctx) return
|
||||
|
||||
// Generate mock response time data for last 24 hours
|
||||
const labels = Array.from({ length: 24 }, (_, i) => {
|
||||
const hour = new Date(Date.now() - (23 - i) * 3600000)
|
||||
return hour.getHours().toString().padStart(2, '0') + ':00'
|
||||
})
|
||||
|
||||
const data = labels.map(() => {
|
||||
const base = 50
|
||||
const spike = Math.random() > 0.9 ? 300 : 0
|
||||
const variance = Math.random() * 40
|
||||
return Math.round(base + variance + spike)
|
||||
})
|
||||
|
||||
chartInstance = new Chart(ctx, {
|
||||
type: 'line',
|
||||
data: {
|
||||
labels,
|
||||
datasets: [{
|
||||
label: 'API Response Time (ms)',
|
||||
data,
|
||||
borderColor: '#2196F3',
|
||||
backgroundColor: 'rgba(33, 150, 243, 0.1)',
|
||||
tension: 0.4,
|
||||
fill: true,
|
||||
pointBackgroundColor: (context) => {
|
||||
const value = context.dataset.data[context.dataIndex] as number
|
||||
return value > 200 ? '#F44336' : value > 100 ? '#FF9800' : '#4CAF50'
|
||||
},
|
||||
pointBorderColor: '#FFFFFF',
|
||||
pointBorderWidth: 2
|
||||
}]
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: {
|
||||
legend: {
|
||||
display: false
|
||||
},
|
||||
tooltip: {
|
||||
callbacks: {
|
||||
label: (context) => {
|
||||
return `Response Time: ${context.raw}ms`
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
scales: {
|
||||
x: {
|
||||
grid: {
|
||||
display: false
|
||||
},
|
||||
ticks: {
|
||||
maxRotation: 0,
|
||||
callback: (value, index) => {
|
||||
// Show only every 3rd hour label
|
||||
return index % 3 === 0 ? labels[index] : ''
|
||||
}
|
||||
}
|
||||
},
|
||||
y: {
|
||||
beginAtZero: true,
|
||||
title: {
|
||||
display: true,
|
||||
text: 'Milliseconds (ms)'
|
||||
},
|
||||
ticks: {
|
||||
callback: (value) => `${value}ms`
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Auto-refresh management
|
||||
const startAutoRefresh = () => {
|
||||
if (refreshTimer) clearInterval(refreshTimer)
|
||||
refreshTimer = setInterval(() => {
|
||||
refreshHealth()
|
||||
}, refreshInterval.value * 1000) as unknown as number
|
||||
}
|
||||
|
||||
const stopAutoRefresh = () => {
|
||||
if (refreshTimer) {
|
||||
clearInterval(refreshTimer)
|
||||
refreshTimer = null
|
||||
}
|
||||
}
|
||||
|
||||
const toggleAutoRefresh = () => {
|
||||
autoRefresh.value = !autoRefresh.value
|
||||
if (autoRefresh.value) {
|
||||
startAutoRefresh()
|
||||
} else {
|
||||
stopAutoRefresh()
|
||||
}
|
||||
}
|
||||
|
||||
// Actions
|
||||
const refreshHealth = () => {
|
||||
if (isRefreshing.value) return
|
||||
|
||||
isRefreshing.value = true
|
||||
|
||||
// Simulate API call
|
||||
setTimeout(() => {
|
||||
// Update system components with random variations
|
||||
systemComponents.value.forEach(component => {
|
||||
// Random status changes (rare)
|
||||
if (Math.random() > 0.95) {
|
||||
component.status = Math.random() > 0.7 ? 'degraded' : 'healthy'
|
||||
component.statusColor = component.status === 'healthy' ? 'success' : 'warning'
|
||||
}
|
||||
|
||||
// Update response times
|
||||
if (component.responseTime) {
|
||||
const variation = Math.random() * 40 - 20
|
||||
component.responseTime = Math.max(10, Math.round(component.responseTime + variation))
|
||||
}
|
||||
|
||||
// Update uptime (slight variations)
|
||||
component.uptime = Math.min(99.99, component.uptime + (Math.random() * 0.1 - 0.05))
|
||||
})
|
||||
|
||||
// Update database metrics
|
||||
databaseMetrics.value.connections = Math.round(64 + Math.random() * 20 - 10)
|
||||
databaseMetrics.value.activeConnections = Math.round(databaseMetrics.value.connections * 0.7)
|
||||
databaseMetrics.value.queryTime = Math.round(85 + Math.random() * 30 - 15)
|
||||
databaseMetrics.value.queriesPerSecond = Math.round(1250 + Math.random() * 200 - 100)
|
||||
databaseMetrics.value.cacheHitRate = Math.min(99, Math.round(92 + Math.random() * 4 - 2))
|
||||
databaseMetrics.value.replicationLag = Math.round(45 + Math.random() * 20 - 10)
|
||||
|
||||
// Update server resources
|
||||
serverResources.value.cpu = Math.round(42 + Math.random() * 20 - 10)
|
||||
serverResources.value.memory = Math.round(68 + Math.random() * 10 - 5)
|
||||
serverResources.value.diskIO = Math.round(28 + Math.random() * 15 - 7)
|
||||
serverResources.value.network = Math.round(45 + Math.random() * 20 - 10)
|
||||
|
||||
// Update chart
|
||||
initChart()
|
||||
|
||||
isRefreshing.value = false
|
||||
}, 800)
|
||||
}
|
||||
|
||||
// Lifecycle hooks
|
||||
onMounted(() => {
|
||||
nextTick(() => {
|
||||
initChart()
|
||||
})
|
||||
|
||||
// Start auto-refresh if enabled
|
||||
if (autoRefresh.value) {
|
||||
startAutoRefresh()
|
||||
}
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (chartInstance) {
|
||||
chartInstance.destroy()
|
||||
}
|
||||
|
||||
stopAutoRefresh()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.chart-container {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.h-100 {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.v-progress-linear {
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.v-card {
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.v-card:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
</style>
|
||||
168
frontend/admin/components/TileCard.vue
Normal file
168
frontend/admin/components/TileCard.vue
Normal file
@@ -0,0 +1,168 @@
|
||||
<template>
|
||||
<v-card
|
||||
:color="tileColor"
|
||||
variant="tonal"
|
||||
class="h-100 d-flex flex-column"
|
||||
@click="handleTileClick"
|
||||
>
|
||||
<v-card-title class="d-flex align-center justify-space-between">
|
||||
<div class="d-flex align-center">
|
||||
<v-icon :icon="tileIcon" class="mr-2"></v-icon>
|
||||
<span class="text-subtitle-1 font-weight-bold">{{ tile.title }}</span>
|
||||
</div>
|
||||
<v-chip size="small" :color="accessLevelColor" class="text-caption">
|
||||
{{ accessLevelText }}
|
||||
</v-chip>
|
||||
</v-card-title>
|
||||
|
||||
<v-card-text class="flex-grow-1">
|
||||
<p class="text-body-2">{{ tile.description }}</p>
|
||||
|
||||
<!-- Requirements Badges -->
|
||||
<div class="mt-2">
|
||||
<v-chip
|
||||
v-for="role in tile.requiredRole"
|
||||
:key="role"
|
||||
size="x-small"
|
||||
class="mr-1 mb-1"
|
||||
variant="outlined"
|
||||
>
|
||||
{{ role }}
|
||||
</v-chip>
|
||||
<v-chip
|
||||
v-if="tile.minRank"
|
||||
size="x-small"
|
||||
class="mr-1 mb-1"
|
||||
color="warning"
|
||||
variant="outlined"
|
||||
>
|
||||
Rank {{ tile.minRank }}+
|
||||
</v-chip>
|
||||
</div>
|
||||
|
||||
<!-- Scope Level Indicator -->
|
||||
<div v-if="tile.scopeLevel && tile.scopeLevel.length > 0" class="mt-2">
|
||||
<v-icon icon="mdi-map-marker" size="small" class="mr-1"></v-icon>
|
||||
<span class="text-caption">
|
||||
{{ tile.scopeLevel.join(', ') }}
|
||||
</span>
|
||||
</div>
|
||||
</v-card-text>
|
||||
|
||||
<v-card-actions class="mt-auto">
|
||||
<v-spacer></v-spacer>
|
||||
<v-btn
|
||||
variant="text"
|
||||
size="small"
|
||||
:prepend-icon="actionIcon"
|
||||
@click.stop="handleTileClick"
|
||||
>
|
||||
Open
|
||||
</v-btn>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import type { TilePermission } from '~/composables/useRBAC'
|
||||
|
||||
interface Props {
|
||||
tile: TilePermission
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
|
||||
// Tile color based on ID
|
||||
const tileColor = computed(() => {
|
||||
const colors: Record<string, string> = {
|
||||
'ai-logs': 'indigo',
|
||||
'financial-dashboard': 'green',
|
||||
'salesperson-hub': 'orange',
|
||||
'user-management': 'blue',
|
||||
'service-moderation-map': 'teal',
|
||||
'gamification-control': 'purple',
|
||||
'system-health': 'red'
|
||||
}
|
||||
return colors[props.tile.id] || 'surface'
|
||||
})
|
||||
|
||||
// Tile icon based on ID
|
||||
const tileIcon = computed(() => {
|
||||
const icons: Record<string, string> = {
|
||||
'ai-logs': 'mdi-robot',
|
||||
'financial-dashboard': 'mdi-chart-line',
|
||||
'salesperson-hub': 'mdi-account-tie',
|
||||
'user-management': 'mdi-account-group',
|
||||
'service-moderation-map': 'mdi-map',
|
||||
'gamification-control': 'mdi-trophy',
|
||||
'system-health': 'mdi-heart-pulse'
|
||||
}
|
||||
return icons[props.tile.id] || 'mdi-view-dashboard'
|
||||
})
|
||||
|
||||
// Action icon
|
||||
const actionIcon = computed(() => {
|
||||
const actions: Record<string, string> = {
|
||||
'ai-logs': 'mdi-chart-timeline',
|
||||
'financial-dashboard': 'mdi-finance',
|
||||
'salesperson-hub': 'mdi-chart-bar',
|
||||
'user-management': 'mdi-account-cog',
|
||||
'service-moderation-map': 'mdi-map-search',
|
||||
'gamification-control': 'mdi-cog',
|
||||
'system-health': 'mdi-monitor-dashboard'
|
||||
}
|
||||
return actions[props.tile.id] || 'mdi-open-in-new'
|
||||
})
|
||||
|
||||
// Access level indicator
|
||||
const accessLevelColor = computed(() => {
|
||||
if (props.tile.requiredRole.includes('superadmin')) return 'purple'
|
||||
if (props.tile.requiredRole.includes('admin')) return 'blue'
|
||||
if (props.tile.requiredRole.includes('moderator')) return 'green'
|
||||
return 'orange'
|
||||
})
|
||||
|
||||
const accessLevelText = computed(() => {
|
||||
if (props.tile.requiredRole.includes('superadmin')) return 'Superadmin'
|
||||
if (props.tile.requiredRole.includes('admin')) return 'Admin'
|
||||
if (props.tile.requiredRole.includes('moderator')) return 'Moderator'
|
||||
return 'Sales'
|
||||
})
|
||||
|
||||
// Handle tile click
|
||||
function handleTileClick() {
|
||||
const routes: Record<string, string> = {
|
||||
'ai-logs': '/ai-logs',
|
||||
'financial-dashboard': '/finance',
|
||||
'salesperson-hub': '/sales',
|
||||
'user-management': '/users',
|
||||
'service-moderation-map': '/map',
|
||||
'gamification-control': '/gamification',
|
||||
'system-health': '/system'
|
||||
}
|
||||
|
||||
const route = routes[props.tile.id]
|
||||
if (route) {
|
||||
navigateTo(route)
|
||||
} else {
|
||||
console.warn(`No route defined for tile: ${props.tile.id}`)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.v-card {
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease-in-out;
|
||||
}
|
||||
|
||||
.v-card:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
.h-100 {
|
||||
height: 100%;
|
||||
}
|
||||
</style>
|
||||
174
frontend/admin/components/TileWrapper.vue
Normal file
174
frontend/admin/components/TileWrapper.vue
Normal file
@@ -0,0 +1,174 @@
|
||||
<template>
|
||||
<v-card
|
||||
:color="tileColor"
|
||||
variant="tonal"
|
||||
class="h-100 d-flex flex-column tile-wrapper"
|
||||
:class="{ 'draggable-tile': draggable }"
|
||||
>
|
||||
<!-- Drag Handle -->
|
||||
<div v-if="draggable" class="drag-handle d-flex align-center justify-center pa-2" @mousedown.prevent>
|
||||
<v-icon icon="mdi-drag-vertical" size="small" class="text-disabled"></v-icon>
|
||||
</div>
|
||||
|
||||
<!-- Tile Header -->
|
||||
<v-card-title class="d-flex align-center justify-space-between pa-3 pb-0">
|
||||
<div class="d-flex align-center">
|
||||
<v-icon :icon="tileIcon" class="mr-2"></v-icon>
|
||||
<span class="text-subtitle-1 font-weight-bold">{{ tile.title }}</span>
|
||||
</div>
|
||||
<div class="d-flex align-center">
|
||||
<!-- RBAC Badge -->
|
||||
<v-chip size="small" :color="accessLevelColor" class="text-caption mr-1">
|
||||
{{ accessLevelText }}
|
||||
</v-chip>
|
||||
<!-- Visibility Toggle -->
|
||||
<v-btn
|
||||
v-if="showVisibilityToggle"
|
||||
icon
|
||||
size="x-small"
|
||||
variant="text"
|
||||
@click="toggleVisibility"
|
||||
:title="tile.preference?.visible ? 'Hide tile' : 'Show tile'"
|
||||
>
|
||||
<v-icon :icon="tile.preference?.visible ? 'mdi-eye' : 'mdi-eye-off'"></v-icon>
|
||||
</v-btn>
|
||||
</div>
|
||||
</v-card-title>
|
||||
|
||||
<!-- Tile Content Slot -->
|
||||
<v-card-text class="flex-grow-1 pa-3">
|
||||
<slot>
|
||||
<!-- Default content if no slot provided -->
|
||||
<p class="text-body-2">{{ tile.description }}</p>
|
||||
</slot>
|
||||
</v-card-text>
|
||||
|
||||
<!-- Tile Footer Actions -->
|
||||
<v-card-actions class="mt-auto pa-3 pt-0">
|
||||
<v-spacer></v-spacer>
|
||||
<v-btn
|
||||
v-if="showActionButton"
|
||||
variant="text"
|
||||
size="small"
|
||||
:prepend-icon="actionIcon"
|
||||
@click="handleTileClick"
|
||||
>
|
||||
{{ actionText }}
|
||||
</v-btn>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useTileStore } from '~/stores/tiles'
|
||||
import type { TilePermission } from '~/composables/useRBAC'
|
||||
|
||||
interface Props {
|
||||
tile: TilePermission
|
||||
draggable?: boolean
|
||||
showVisibilityToggle?: boolean
|
||||
showActionButton?: boolean
|
||||
actionIcon?: string
|
||||
actionText?: string
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
draggable: true,
|
||||
showVisibilityToggle: true,
|
||||
showActionButton: true,
|
||||
actionIcon: 'mdi-open-in-new',
|
||||
actionText: 'Open'
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
click: [tile: TilePermission]
|
||||
toggleVisibility: [tileId: string, visible: boolean]
|
||||
}>()
|
||||
|
||||
const tileStore = useTileStore()
|
||||
|
||||
// Tile color based on ID
|
||||
const tileColor = computed(() => {
|
||||
const colors: Record<string, string> = {
|
||||
'ai-logs': 'indigo',
|
||||
'financial-dashboard': 'green',
|
||||
'salesperson-hub': 'orange',
|
||||
'user-management': 'blue',
|
||||
'service-moderation-map': 'teal',
|
||||
'gamification-control': 'purple',
|
||||
'system-health': 'red'
|
||||
}
|
||||
return colors[props.tile.id] || 'surface'
|
||||
})
|
||||
|
||||
// Tile icon based on ID
|
||||
const tileIcon = computed(() => {
|
||||
const icons: Record<string, string> = {
|
||||
'ai-logs': 'mdi-robot',
|
||||
'financial-dashboard': 'mdi-chart-line',
|
||||
'salesperson-hub': 'mdi-account-tie',
|
||||
'user-management': 'mdi-account-group',
|
||||
'service-moderation-map': 'mdi-map',
|
||||
'gamification-control': 'mdi-trophy',
|
||||
'system-health': 'mdi-heart-pulse'
|
||||
}
|
||||
return icons[props.tile.id] || 'mdi-view-dashboard'
|
||||
})
|
||||
|
||||
// Access level indicator
|
||||
const accessLevelColor = computed(() => {
|
||||
if (props.tile.minRank && props.tile.minRank > 5) return 'warning'
|
||||
if (props.tile.requiredRole?.includes('admin')) return 'error'
|
||||
return 'success'
|
||||
})
|
||||
|
||||
const accessLevelText = computed(() => {
|
||||
if (props.tile.minRank) return `Rank ${props.tile.minRank}+`
|
||||
if (props.tile.requiredRole?.length) return props.tile.requiredRole[0]
|
||||
return 'All'
|
||||
})
|
||||
|
||||
// Methods
|
||||
function handleTileClick() {
|
||||
emit('click', props.tile)
|
||||
}
|
||||
|
||||
function toggleVisibility() {
|
||||
const newVisible = !props.tile.preference?.visible
|
||||
tileStore.toggleTileVisibility(props.tile.id)
|
||||
emit('toggleVisibility', props.tile.id, newVisible)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.tile-wrapper {
|
||||
position: relative;
|
||||
transition: box-shadow 0.2s ease-in-out, transform 0.2s ease-in-out;
|
||||
}
|
||||
|
||||
.tile-wrapper:hover {
|
||||
box-shadow: 0 8px 16px rgba(0, 0, 0, 0.1);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.drag-handle {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 24px;
|
||||
background-color: rgba(0, 0, 0, 0.02);
|
||||
border-bottom: 1px solid rgba(0, 0, 0, 0.1);
|
||||
cursor: grab;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.drag-handle:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
.draggable-tile {
|
||||
user-select: none;
|
||||
}
|
||||
</style>
|
||||
189
frontend/admin/components/map/ServiceMap.vue
Normal file
189
frontend/admin/components/map/ServiceMap.vue
Normal file
@@ -0,0 +1,189 @@
|
||||
<template>
|
||||
<div class="service-map-container">
|
||||
<div class="scope-indicator">
|
||||
<span class="badge">Current Scope: {{ scopeLabel }}</span>
|
||||
</div>
|
||||
<div class="map-wrapper">
|
||||
<l-map
|
||||
ref="map"
|
||||
:zoom="zoom"
|
||||
:center="center"
|
||||
@ready="onMapReady"
|
||||
style="height: 600px; width: 100%;"
|
||||
>
|
||||
<l-tile-layer
|
||||
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
|
||||
attribution='© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors'
|
||||
/>
|
||||
<l-marker
|
||||
v-for="service in services"
|
||||
:key="service.id"
|
||||
:lat-lng="[service.lat, service.lng]"
|
||||
@click="openPopup(service)"
|
||||
>
|
||||
<l-icon
|
||||
:icon-url="getMarkerIcon(service.status)"
|
||||
:icon-size="[32, 32]"
|
||||
:icon-anchor="[16, 32]"
|
||||
/>
|
||||
<l-popup v-if="selectedService?.id === service.id">
|
||||
<div class="popup-content">
|
||||
<h3>{{ service.name }}</h3>
|
||||
<p><strong>Status:</strong> <span :class="service.status">{{ service.status }}</span></p>
|
||||
<p><strong>Address:</strong> {{ service.address }}</p>
|
||||
<p><strong>Distance:</strong> {{ service.distance }} km</p>
|
||||
<button @click="approveService(service)" class="btn-approve">Approve</button>
|
||||
</div>
|
||||
</l-popup>
|
||||
</l-marker>
|
||||
</l-map>
|
||||
</div>
|
||||
<div class="legend">
|
||||
<div class="legend-item">
|
||||
<img src="/marker-pending.png" alt="Pending" class="legend-icon" />
|
||||
<span>Pending</span>
|
||||
</div>
|
||||
<div class="legend-item">
|
||||
<img src="/marker-approved.png" alt="Approved" class="legend-icon" />
|
||||
<span>Approved</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { LMap, LTileLayer, LMarker, LPopup, LIcon } from '@vue-leaflet/vue-leaflet'
|
||||
import 'leaflet/dist/leaflet.css'
|
||||
import type { Service } from '~/composables/useServiceMap'
|
||||
|
||||
const props = defineProps<{
|
||||
services?: Service[]
|
||||
scopeLabel?: string
|
||||
}>()
|
||||
|
||||
const map = ref<any>(null)
|
||||
const zoom = ref(11)
|
||||
const center = ref<[number, number]>([47.6333, 19.1333]) // Budapest area
|
||||
const selectedService = ref<Service | null>(null)
|
||||
|
||||
const services = ref<Service[]>(props.services || [])
|
||||
|
||||
const getMarkerIcon = (status: string) => {
|
||||
return status === 'approved' ? '/marker-approved.png' : '/marker-pending.png'
|
||||
}
|
||||
|
||||
const openPopup = (service: Service) => {
|
||||
selectedService.value = service
|
||||
}
|
||||
|
||||
const approveService = (service: Service) => {
|
||||
console.log('Approving service:', service)
|
||||
// TODO: Implement API call
|
||||
service.status = 'approved'
|
||||
selectedService.value = null
|
||||
}
|
||||
|
||||
const onMapReady = () => {
|
||||
console.log('Map is ready')
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
// If no services provided, use mock data
|
||||
if (services.value.length === 0) {
|
||||
// Mock data will be loaded via composable
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.service-map-container {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.scope-indicator {
|
||||
position: absolute;
|
||||
top: 10px;
|
||||
right: 10px;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.badge {
|
||||
background-color: #4a90e2;
|
||||
color: white;
|
||||
padding: 8px 12px;
|
||||
border-radius: 20px;
|
||||
font-weight: bold;
|
||||
box-shadow: 0 2px 4px rgba(0,0,0,0.2);
|
||||
}
|
||||
|
||||
.map-wrapper {
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
border: 1px solid #ddd;
|
||||
}
|
||||
|
||||
.legend {
|
||||
position: absolute;
|
||||
bottom: 20px;
|
||||
left: 20px;
|
||||
background: white;
|
||||
padding: 10px;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.legend-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
.legend-icon {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
.popup-content {
|
||||
min-width: 200px;
|
||||
}
|
||||
|
||||
.popup-content h3 {
|
||||
margin-top: 0;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.popup-content p {
|
||||
margin: 5px 0;
|
||||
}
|
||||
|
||||
.btn-approve {
|
||||
background-color: #28a745;
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 8px 16px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-weight: bold;
|
||||
margin-top: 10px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.btn-approve:hover {
|
||||
background-color: #218838;
|
||||
}
|
||||
|
||||
.pending {
|
||||
color: #ffc107;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.approved {
|
||||
color: #28a745;
|
||||
font-weight: bold;
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user