474 lines
14 KiB
Vue
474 lines
14 KiB
Vue
<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> |