admin_users_personels
This commit is contained in:
366
frontend_admin/components/charts/UserTrendChart.vue
Normal file
366
frontend_admin/components/charts/UserTrendChart.vue
Normal file
@@ -0,0 +1,366 @@
|
||||
<template>
|
||||
<div class="bg-slate-800 rounded-xl border border-slate-700 p-5">
|
||||
<!-- Header -->
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<div>
|
||||
<h3 class="text-sm font-semibold text-white">{{ $t('users.trend_title') }}</h3>
|
||||
<p class="text-xs text-slate-400 mt-0.5">{{ $t('users.trend_subtitle') }}</p>
|
||||
</div>
|
||||
<!-- Period selector -->
|
||||
<select
|
||||
v-model="selectedPeriod"
|
||||
class="px-2.5 py-1.5 text-xs bg-slate-700 border border-slate-600 rounded-lg text-slate-300 focus:outline-none focus:ring-2 focus:ring-indigo-500/50 transition"
|
||||
@change="updateChart"
|
||||
>
|
||||
<option value="7">{{ $t('users.trend_7d') }}</option>
|
||||
<option value="14">{{ $t('users.trend_14d') }}</option>
|
||||
<option value="30">{{ $t('users.trend_30d') }}</option>
|
||||
<option value="90">{{ $t('users.trend_90d') }}</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Loading State -->
|
||||
<div v-if="loading" class="flex items-center justify-center py-12">
|
||||
<svg class="w-6 h-6 text-indigo-400 animate-spin" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" />
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<!-- Empty State -->
|
||||
<div v-else-if="filteredData.length === 0" class="flex flex-col items-center justify-center py-12 text-center">
|
||||
<svg class="w-10 h-10 text-slate-600 mb-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" />
|
||||
</svg>
|
||||
<p class="text-slate-500 text-xs">{{ $t('users.trend_no_data') }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Chart Area -->
|
||||
<div v-else class="relative">
|
||||
<!-- SVG Bar Chart -->
|
||||
<svg
|
||||
:viewBox="`0 0 ${svgWidth} ${svgHeight}`"
|
||||
class="w-full h-auto overflow-visible"
|
||||
preserveAspectRatio="xMidYMid meet"
|
||||
>
|
||||
<!-- Grid lines -->
|
||||
<line
|
||||
v-for="(y, yi) in gridLines"
|
||||
:key="'grid-' + yi"
|
||||
:x1="padding.left"
|
||||
:y1="y"
|
||||
:x2="svgWidth - padding.right"
|
||||
:y2="y"
|
||||
stroke="currentColor"
|
||||
class="text-slate-700/50"
|
||||
stroke-width="1"
|
||||
stroke-dasharray="4,4"
|
||||
/>
|
||||
<!-- Y-axis labels -->
|
||||
<text
|
||||
v-for="(label, li) in yLabels"
|
||||
:key="'ylabel-' + li"
|
||||
:x="padding.left - 8"
|
||||
:y="label.y + 4"
|
||||
text-anchor="end"
|
||||
class="fill-slate-500"
|
||||
font-size="10"
|
||||
>
|
||||
{{ label.text }}
|
||||
</text>
|
||||
|
||||
<!-- Bars -->
|
||||
<g
|
||||
v-for="(item, idx) in visibleBars"
|
||||
:key="'bar-' + idx"
|
||||
class="chart-bar-group"
|
||||
@mouseenter="hoveredBar = idx"
|
||||
@mouseleave="hoveredBar = null"
|
||||
>
|
||||
<rect
|
||||
:x="item.x"
|
||||
:y="item.y"
|
||||
:width="barWidth"
|
||||
:height="item.height"
|
||||
:rx="3"
|
||||
:ry="3"
|
||||
:fill="item.color"
|
||||
:opacity="hoveredBar === idx ? 0.9 : 0.7"
|
||||
class="transition-all duration-200 cursor-pointer"
|
||||
/>
|
||||
<!-- Tooltip -->
|
||||
<g v-if="hoveredBar === idx">
|
||||
<rect
|
||||
:x="item.tooltipX"
|
||||
:y="item.tooltipY - 28"
|
||||
:width="item.tooltipWidth"
|
||||
height="22"
|
||||
rx="4"
|
||||
fill="#1e293b"
|
||||
stroke="#475569"
|
||||
stroke-width="1"
|
||||
/>
|
||||
<text
|
||||
:x="item.tooltipX + item.tooltipWidth / 2"
|
||||
:y="item.tooltipY - 13"
|
||||
text-anchor="middle"
|
||||
fill="#e2e8f0"
|
||||
font-size="11"
|
||||
font-weight="600"
|
||||
>
|
||||
{{ item.tooltipText }}
|
||||
</text>
|
||||
</g>
|
||||
</g>
|
||||
|
||||
<!-- X-axis labels (show every Nth label to avoid crowding) -->
|
||||
<text
|
||||
v-for="(item, xi) in xLabels"
|
||||
:key="'xlabel-' + xi"
|
||||
:x="item.x + barWidth / 2"
|
||||
:y="svgHeight - padding.bottom + 16"
|
||||
text-anchor="middle"
|
||||
class="fill-slate-500"
|
||||
font-size="9"
|
||||
>
|
||||
{{ item.label }}
|
||||
</text>
|
||||
</svg>
|
||||
|
||||
<!-- Summary Stats Below Chart -->
|
||||
<div class="flex items-center justify-between mt-4 pt-3 border-t border-slate-700/50">
|
||||
<div class="flex items-center gap-4 text-xs">
|
||||
<div class="flex items-center gap-1.5">
|
||||
<span class="w-2.5 h-2.5 rounded-sm bg-indigo-500/70"></span>
|
||||
<span class="text-slate-400">{{ $t('users.trend_daily') }}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="text-slate-400">{{ $t('users.trend_avg') }}:</span>
|
||||
<span class="text-white font-semibold ml-1">{{ averageCount }}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="text-slate-400">{{ $t('users.trend_total') }}:</span>
|
||||
<span class="text-white font-semibold ml-1">{{ totalCount }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="trendDirection !== 'stable'" class="flex items-center gap-1 text-xs">
|
||||
<svg
|
||||
v-if="trendDirection === 'up'"
|
||||
class="w-3.5 h-3.5 text-emerald-400"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 7h8m0 0v8m0-8l-8 8-4-4-6 6" />
|
||||
</svg>
|
||||
<svg
|
||||
v-else
|
||||
class="w-3.5 h-3.5 text-red-400"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 17h8m0 0V9m0 8l-8-8-4 4-6-6" />
|
||||
</svg>
|
||||
<span :class="trendDirection === 'up' ? 'text-emerald-400' : 'text-red-400'" class="font-medium">
|
||||
{{ trendPercent }}%
|
||||
</span>
|
||||
<span class="text-slate-500">{{ $t('users.trend_vs_prev') }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, watch } from 'vue'
|
||||
|
||||
interface TrendDataPoint {
|
||||
date: string
|
||||
count: number
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
data: TrendDataPoint[]
|
||||
loading?: boolean
|
||||
}>()
|
||||
|
||||
// ── State ──────────────────────────────────────────────────────────
|
||||
|
||||
const selectedPeriod = ref('30')
|
||||
const hoveredBar = ref<number | null>(null)
|
||||
|
||||
// ── Chart Dimensions ────────────────────────────────────────────────
|
||||
|
||||
const svgWidth = 800
|
||||
const svgHeight = 220
|
||||
const padding = { top: 10, right: 16, bottom: 30, left: 36 }
|
||||
|
||||
// ── Computed ───────────────────────────────────────────────────────
|
||||
|
||||
const filteredData = computed(() => {
|
||||
const days = parseInt(selectedPeriod.value, 10)
|
||||
if (!props.data || props.data.length === 0) return []
|
||||
// Sort by date ascending
|
||||
const sorted = [...props.data].sort(
|
||||
(a, b) => new Date(a.date).getTime() - new Date(b.date).getTime()
|
||||
)
|
||||
const cutoff = new Date()
|
||||
cutoff.setDate(cutoff.getDate() - days)
|
||||
return sorted.filter((d) => new Date(d.date) >= cutoff)
|
||||
})
|
||||
|
||||
const maxCount = computed(() => {
|
||||
if (filteredData.value.length === 0) return 1
|
||||
return Math.max(...filteredData.value.map((d) => d.count), 1)
|
||||
})
|
||||
|
||||
const chartHeight = svgHeight - padding.top - padding.bottom
|
||||
|
||||
// Grid lines (5 horizontal lines)
|
||||
const gridLines = computed(() => {
|
||||
const lines = []
|
||||
for (let i = 0; i <= 4; i++) {
|
||||
lines.push(padding.top + (chartHeight / 4) * i)
|
||||
}
|
||||
return lines
|
||||
})
|
||||
|
||||
const yLabels = computed(() => {
|
||||
const labels = []
|
||||
for (let i = 0; i <= 4; i++) {
|
||||
const y = padding.top + (chartHeight / 4) * i
|
||||
const value = Math.round(maxCount.value - (maxCount.value / 4) * i)
|
||||
labels.push({ y, text: value.toString() })
|
||||
}
|
||||
return labels
|
||||
})
|
||||
|
||||
const barWidth = computed(() => {
|
||||
const count = filteredData.value.length
|
||||
if (count === 0) return 0
|
||||
const availableWidth = svgWidth - padding.left - padding.right
|
||||
const maxBarWidth = 32
|
||||
const calculatedWidth = (availableWidth / count) * 0.7
|
||||
return Math.min(calculatedWidth, maxBarWidth)
|
||||
})
|
||||
|
||||
const visibleBars = computed(() => {
|
||||
const count = filteredData.value.length
|
||||
if (count === 0) return []
|
||||
|
||||
const availableWidth = svgWidth - padding.left - padding.right
|
||||
const gap = count > 1 ? (availableWidth - barWidth.value * count) / (count - 1) : 0
|
||||
|
||||
return filteredData.value.map((item, idx) => {
|
||||
const x = padding.left + idx * (barWidth.value + gap)
|
||||
const barHeight = maxCount.value > 0 ? (item.count / maxCount.value) * chartHeight : 0
|
||||
const y = svgHeight - padding.bottom - barHeight
|
||||
|
||||
// Color gradient based on value
|
||||
const ratio = maxCount.value > 0 ? item.count / maxCount.value : 0
|
||||
let color: string
|
||||
if (ratio > 0.8) color = '#6366f1' // indigo-500
|
||||
else if (ratio > 0.5) color = '#818cf8' // indigo-400
|
||||
else if (ratio > 0.3) color = '#a5b4fc' // indigo-300
|
||||
else color = '#c7d2fe' // indigo-200
|
||||
|
||||
// Tooltip
|
||||
const tooltipText = `${item.count} ${formatDate(item.date)}`
|
||||
const tooltipWidth = tooltipText.length * 7 + 16
|
||||
let tooltipX = x + barWidth.value / 2 - tooltipWidth / 2
|
||||
if (tooltipX < padding.left) tooltipX = padding.left
|
||||
if (tooltipX + tooltipWidth > svgWidth - padding.right) tooltipX = svgWidth - padding.right - tooltipWidth
|
||||
const tooltipY = y - 4
|
||||
|
||||
return {
|
||||
x,
|
||||
y,
|
||||
height: barHeight,
|
||||
color,
|
||||
tooltipX,
|
||||
tooltipY,
|
||||
tooltipWidth,
|
||||
tooltipText,
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
// X-axis labels (show every Nth)
|
||||
const xLabels = computed(() => {
|
||||
const count = filteredData.value.length
|
||||
if (count === 0) return []
|
||||
|
||||
const availableWidth = svgWidth - padding.left - padding.right
|
||||
const gap = count > 1 ? (availableWidth - barWidth.value * count) / (count - 1) : 0
|
||||
|
||||
// Determine label interval based on data density
|
||||
let interval = 1
|
||||
if (count > 14) interval = 2
|
||||
if (count > 30) interval = 5
|
||||
if (count > 60) interval = 10
|
||||
|
||||
return filteredData.value
|
||||
.map((item, idx) => {
|
||||
const x = padding.left + idx * (barWidth.value + gap)
|
||||
const d = new Date(item.date)
|
||||
const label = `${d.getMonth() + 1}/${d.getDate()}`
|
||||
return { x, label, idx }
|
||||
})
|
||||
.filter((_, i) => i % interval === 0 || i === count - 1)
|
||||
})
|
||||
|
||||
// Summary stats
|
||||
const averageCount = computed(() => {
|
||||
if (filteredData.value.length === 0) return 0
|
||||
const sum = filteredData.value.reduce((acc, d) => acc + d.count, 0)
|
||||
return Math.round(sum / filteredData.value.length)
|
||||
})
|
||||
|
||||
const totalCount = computed(() => {
|
||||
return filteredData.value.reduce((acc, d) => acc + d.count, 0)
|
||||
})
|
||||
|
||||
const trendDirection = computed(() => {
|
||||
const data = filteredData.value
|
||||
if (data.length < 4) return 'stable'
|
||||
const half = Math.floor(data.length / 2)
|
||||
const firstHalf = data.slice(0, half).reduce((acc, d) => acc + d.count, 0) / half
|
||||
const secondHalf = data.slice(half).reduce((acc, d) => acc + d.count, 0) / (data.length - half)
|
||||
if (secondHalf > firstHalf * 1.05) return 'up'
|
||||
if (secondHalf < firstHalf * 0.95) return 'down'
|
||||
return 'stable'
|
||||
})
|
||||
|
||||
const trendPercent = computed(() => {
|
||||
const data = filteredData.value
|
||||
if (data.length < 4) return 0
|
||||
const half = Math.floor(data.length / 2)
|
||||
const firstHalf = data.slice(0, half).reduce((acc, d) => acc + d.count, 0) / half
|
||||
if (firstHalf === 0) return 100
|
||||
const secondHalf = data.slice(half).reduce((acc, d) => acc + d.count, 0) / (data.length - half)
|
||||
return Math.round(Math.abs((secondHalf - firstHalf) / firstHalf) * 100)
|
||||
})
|
||||
|
||||
// ── Helpers ────────────────────────────────────────────────────────
|
||||
|
||||
function formatDate(dateStr: string): string {
|
||||
try {
|
||||
const d = new Date(dateStr)
|
||||
return `${d.getMonth() + 1}/${d.getDate()}`
|
||||
} catch {
|
||||
return dateStr
|
||||
}
|
||||
}
|
||||
|
||||
function updateChart() {
|
||||
// Trigger reactivity by clearing and resetting hover
|
||||
hoveredBar.value = null
|
||||
}
|
||||
|
||||
// ── Watch for data changes ─────────────────────────────────────────
|
||||
|
||||
watch(() => props.data, () => {
|
||||
updateChart()
|
||||
})
|
||||
</script>
|
||||
Reference in New Issue
Block a user