admin firs step
This commit is contained in:
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>
|
||||
Reference in New Issue
Block a user