Files
service-finder/frontend/admin/components/FinancialTile.vue
2026-03-23 21:43:40 +00:00

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>