feat: add interactive Chart.js trend chart and improve KPI card layout

- Replace static SVG trend chart with Chart.js bar+line mixed chart

- Click-triggered custom tooltip popup with per-day detail overlay

- KPI cards: flex column layout, compact K/M currency formatting

- Custom legend above chart for consistent spacing

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
This commit is contained in:
2026-04-29 23:36:18 +08:00
parent e54ebd072a
commit f98ad7953f
4 changed files with 2777 additions and 64 deletions

1868
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -10,9 +10,14 @@
"preview": "vite preview --host 127.0.0.1"
},
"dependencies": {
"@primevue/themes": "^4.5.4",
"@vitejs/plugin-vue": "^5.2.4",
"@vueuse/motion": "^3.0.3",
"chart.js": "^4.5.1",
"primeicons": "^7.0.0",
"primevue": "^4.5.5",
"vite": "^5.4.19",
"vue": "^3.5.13"
"vue": "^3.5.13",
"vue-chartjs": "^5.3.3"
}
}

View File

@@ -0,0 +1,201 @@
<template>
<div class="trend-chart">
<div class="chart-legend">
<span><i style="background:#10b981"></i>申请量</span>
<span><i style="background:#3b82f6"></i>审批完成量</span>
<span><i style="background:#8b5cf6"></i>平均审批时长小时</span>
</div>
<div class="chart-body">
<Bar :data="chartData" :options="chartOptions" />
</div>
</div>
</template>
<script setup>
import { computed } from 'vue'
import { Bar } from 'vue-chartjs'
import {
Chart as ChartJS,
CategoryScale,
LinearScale,
BarElement,
PointElement,
LineElement,
Filler,
Tooltip,
Legend
} from 'chart.js'
ChartJS.register(CategoryScale, LinearScale, BarElement, PointElement, LineElement, Filler, Tooltip, Legend)
const props = defineProps({
labels: { type: Array, required: true },
applications: { type: Array, required: true },
approved: { type: Array, required: true },
avgHours: { type: Array, required: true }
})
const chartData = computed(() => ({
labels: props.labels,
datasets: [
{
label: '申请量(单)',
data: props.applications,
backgroundColor: '#10b981',
borderRadius: 4,
barPercentage: 0.6,
categoryPercentage: 0.5,
order: 2
},
{
label: '审批完成量(单)',
data: props.approved,
backgroundColor: '#3b82f6',
borderRadius: 4,
barPercentage: 0.6,
categoryPercentage: 0.5,
order: 2
},
{
label: '平均审批时长(小时)',
data: props.avgHours,
borderColor: '#8b5cf6',
backgroundColor: 'transparent',
borderWidth: 2,
pointBackgroundColor: '#ffffff',
pointBorderColor: '#8b5cf6',
pointBorderWidth: 2,
pointRadius: 3,
pointHoverRadius: 5,
type: 'line',
yAxisID: 'y1',
order: 1
}
]
}))
const chartOptions = {
responsive: true,
maintainAspectRatio: false,
interaction: {
mode: 'index',
intersect: false
},
events: ['click', 'mousemove', 'mouseout'],
plugins: {
legend: {
display: false
},
tooltip: {
enabled: false,
external: externalTooltip
}
},
scales: {
x: {
grid: { display: false },
ticks: {
color: '#64748b',
font: { size: 11 }
}
},
y: {
beginAtZero: true,
max: 250,
grid: { color: '#f1f5f9' },
ticks: {
color: '#64748b',
font: { size: 11 },
stepSize: 50
}
},
y1: {
position: 'right',
beginAtZero: true,
max: 15,
grid: { display: false },
ticks: {
color: '#64748b',
font: { size: 11 },
stepSize: 3
}
}
}
}
function externalTooltip(context) {
const { chart, tooltip } = context
let el = chart.canvas.parentNode.querySelector('.chart-tooltip')
if (!el) {
el = document.createElement('div')
el.classList.add('chart-tooltip')
el.style.cssText =
'position:absolute;background:#fff;border:1px solid #e2e8f0;border-radius:8px;padding:12px 16px;pointer-events:none;transition:opacity .15s,transform .15s;font-family:Inter,system-ui,sans-serif;font-size:13px;box-shadow:0 4px 12px rgba(0,0,0,.08);z-index:10;opacity:0;transform:translateY(4px)'
chart.canvas.parentNode.appendChild(el)
}
if (tooltip.opacity === 0) {
el.style.opacity = '0'
return
}
if (tooltip.body) {
const titleLines = tooltip.title || []
const bodyLines = tooltip.body.map((b) => b.lines)
const colors = tooltip.labelColors
const dot = (color, text) =>
`<div style="display:flex;align-items:center;gap:6px;margin-top:4px"><span style="width:8px;height:8px;border-radius:50%;background:${color};flex-shrink:0"></span><span style="color:#64748b">${text}</span></div>`
el.innerHTML =
`<div style="font-weight:600;color:#1e293b;margin-bottom:4px">${titleLines.join('')}</div>` +
bodyLines
.map((lines, i) =>
lines.map((line) => dot(colors[i]?.backgroundColor || colors[i]?.borderColor || '#999', line))
)
.join('')
}
const { offsetLeft, offsetTop } = chart.canvas
const left = offsetLeft + tooltip.caretX
const top = offsetTop + tooltip.caretY
el.style.opacity = '1'
el.style.transform = 'translateY(0)'
el.style.left = `${left}px`
el.style.top = `${top - el.offsetHeight - 12}px`
el.style.transform = `translate(-50%, 0)`
}
</script>
<style scoped>
.trend-chart {
height: 280px;
display: flex;
flex-direction: column;
}
.chart-legend {
display: flex;
align-items: center;
gap: 16px;
color: #475569;
font-size: 12px;
margin-bottom: 12px;
}
.chart-legend i {
display: inline-block;
width: 8px;
height: 8px;
border-radius: 2px;
margin-right: 4px;
vertical-align: middle;
}
.chart-body {
flex: 1;
min-height: 0;
}
</style>

View File

@@ -1,94 +1,733 @@
<template>
<section class="view">
<div class="metric-strip">
<section class="dashboard">
<div class="kpi-grid">
<article
v-for="(metric, index) in metrics"
v-for="metric in kpiMetrics"
:key="metric.label"
v-motion
class="metric"
:initial="{ opacity: 0, y: 16 }"
:enter="{ opacity: 1, y: 0, transition: { delay: index * 0.07, duration: 0.36 } }"
:style="{ '--accent': metric.color }"
class="kpi-card panel"
:style="{ '--accent': metric.accent }"
>
<div class="metric-top">
<span>{{ metric.label }}</span>
<b :class="metric.tone">{{ metric.delta }}</b>
<div class="kpi-top">
<div>
<p>{{ metric.label }}</p>
<strong>{{ metric.displayValue }}</strong>
</div>
<div class="kpi-icon">
<i :class="metric.icon"></i>
</div>
</div>
<div class="kpi-bottom" :class="metric.trend">
<span>
<i :class="metric.trend === 'down' ? 'pi pi-arrow-down' : 'pi pi-arrow-up'"></i>
{{ metric.changeText }}
</span>
<small>{{ metric.delta }}</small>
</div>
<strong>{{ metric.value }}</strong>
<small>{{ metric.note }}</small>
</article>
</div>
<div class="overview-grid">
<article class="panel spend-panel">
<PanelHead eyebrow="Category spend" title="费用类型月度支出" note="用企业报销常见科目展示本月费用压力。" />
<div class="bar-chart" role="img" aria-label="费用类型月度支出柱状图">
<div v-for="item in spendByCategory" :key="item.name" class="bar-row">
<span>{{ item.name }}</span>
<div class="bar-track"><div class="bar-fill" :style="{ width: item.width, background: item.color }"></div></div>
<strong>{{ item.value }}</strong>
</div>
<div class="content-grid top-grid">
<article class="panel dashboard-card trend-panel">
<div class="card-head">
<h3>报销申请与审批趋势 <i class="pi pi-info-circle"></i></h3>
<select v-model="activeTrendRange" class="card-select" aria-label="趋势时间范围">
<option v-for="range in trendRanges" :key="range">{{ range }}</option>
</select>
</div>
<TrendChart
:labels="activeTrend.labels"
:applications="activeTrend.applications"
:approved="activeTrend.approved"
:avg-hours="activeTrend.avgHours"
/>
</article>
<article class="panel">
<PanelHead eyebrow="Compliance mix" title="审核结论占比" note="把自动通过、需补件和高风险分层展示。" />
<div class="donut-layout">
<div class="donut" aria-label="审核结论环形图"><strong>68%</strong><span>可自动处理</span></div>
<div class="legend">
<div v-for="item in auditMix" :key="item.name" class="legend-row">
<i :style="{ background: item.color }"></i><span>{{ item.name }}</span><strong>{{ item.value }}</strong>
<article class="panel dashboard-card donut-panel">
<div class="card-head">
<h3>费用结构 <i class="pi pi-info-circle"></i></h3>
</div>
<div class="donut-wrap">
<div class="donut" :style="{ background: spendDonutBackground }">
<div class="donut-center">
<strong>{{ spendTotalLabel }}</strong>
<span>待处理金额</span>
</div>
</div>
<div class="donut-legend">
<div v-for="item in spendLegend" :key="item.name" class="legend-row">
<span><i :style="{ background: item.color }"></i>{{ item.name }}</span>
</div>
</div>
</div>
<p class="panel-note">* 百分比为占待处理金额比例</p>
</article>
<article class="panel dashboard-card donut-panel">
<div class="card-head">
<h3>风险异常分布 <i class="pi pi-info-circle"></i></h3>
</div>
<div class="donut-wrap">
<div class="donut" :style="{ background: riskDonutBackground }">
<div class="donut-center">
<strong>{{ riskTotal }}</strong>
<span>异常预警单</span>
</div>
</div>
<div class="donut-legend">
<div v-for="item in riskLegend" :key="item.name" class="legend-row">
<span><i :style="{ background: item.color }"></i>{{ item.name }}</span>
</div>
</div>
</div>
<p class="panel-note">* 近30天数据</p>
</article>
</div>
<RequestTable :requests="filteredRequests" @ask="emit('ask', $event)" @approve="emit('approve', $event)" @reject="emit('reject', $event)" />
<div class="content-grid bottom-grid">
<article class="panel dashboard-card rank-panel">
<div class="card-head">
<h3>部门报销排行待处理金额 <i class="pi pi-info-circle"></i></h3>
<select v-model="activeDepartmentRange" class="card-select" aria-label="部门排行时间范围">
<option v-for="range in departmentRangeOptions" :key="range">{{ range }}</option>
</select>
</div>
<div class="rank-list">
<div v-for="item in rankedDepartments" :key="item.name" class="rank-row">
<span class="rank-index">{{ item.rank }}</span>
<span class="rank-name">{{ item.shortName }}</span>
<div class="rank-track">
<div class="rank-fill" :style="{ width: item.width, background: item.color }"></div>
</div>
<strong class="rank-value">{{ item.amountLabel }}</strong>
</div>
</div>
</article>
<article class="panel dashboard-card bottleneck-panel">
<div class="card-head">
<h3>审批瓶颈平均处理时长 <i class="pi pi-info-circle"></i></h3>
</div>
<div class="bottleneck-list">
<div v-for="item in bottlenecks" :key="item.name" class="bottleneck-row">
<div class="reviewer">
<div class="reviewer-avatar">{{ item.avatar }}</div>
<div>
<strong>{{ item.name }}</strong>
<span>{{ item.role }}</span>
</div>
</div>
<div class="reviewer-stats">
<strong>{{ item.duration }}</strong>
<span class="status-tag" :class="item.tone">{{ item.status }}</span>
</div>
</div>
</div>
<button type="button" class="text-link">查看全部 <i class="pi pi-angle-right"></i></button>
</article>
<article class="panel dashboard-card budget-panel">
<div class="card-head">
<h3>预算执行率本月 <i class="pi pi-info-circle"></i></h3>
</div>
<div class="budget-gauge" :style="{ '--budget-ratio': `${budgetSummary.ratio}` }">
<div class="budget-gauge-ring"></div>
<div class="budget-gauge-center">
<strong>{{ budgetSummary.ratio }}%</strong>
<span>已执行</span>
</div>
</div>
<div class="budget-summary">
<div>
<span>预算总额</span>
<strong>{{ budgetSummary.total }}</strong>
</div>
<div>
<span>已执行</span>
<strong>{{ budgetSummary.used }}</strong>
</div>
<div>
<span>剩余可用</span>
<strong>{{ budgetSummary.left }}</strong>
</div>
</div>
<button type="button" class="text-link">查看详情 <i class="pi pi-angle-right"></i></button>
</article>
</div>
</section>
</template>
<script setup>
import PanelHead from '../components/shared/PanelHead.vue'
import RequestTable from '../components/business/RequestTable.vue'
import { metrics, spendByCategory, auditMix } from '../data/metrics.js'
import { computed, ref } from 'vue'
import {
metricBlueprints,
trendRanges,
trendSeries,
spendByCategory,
exceptionMix,
departmentRangeOptions,
bottlenecks,
budgetSummary
} from '../data/metrics.js'
import TrendChart from '../components/charts/TrendChart.vue'
defineProps({
filteredRequests: { type: Array, required: true }
})
const emit = defineEmits(['ask', 'approve', 'reject'])
const activeTrendRange = ref(trendRanges[0])
const activeDepartmentRange = ref(departmentRangeOptions[0])
const demoTotals = {
pendingCount: 128,
pendingAmount: 361600,
avgSla: 6.8,
autoPassRate: 78,
riskCount: 14,
slaRate: 96
}
const demoDepartments = [
{ name: '销售部', amount: 182000, color: '#10b981' },
{ name: '研发中心', amount: 146000, color: '#3b82f6' },
{ name: '市场部', amount: 96000, color: '#f59e0b' },
{ name: '运营部', amount: 68600, color: '#8b5cf6' },
{ name: '行政部', amount: 48300, color: '#3b82f6' }
]
const formatCompact = (value) => {
if (value >= 1_000_000) return `¥${(value / 1_000_000).toFixed(1)}M`
if (value >= 1_000) return `¥${(value / 1_000).toFixed(1)}K`
return `¥${value}`
}
const formatCurrency = (value) => formatCompact(value)
const kpiMetrics = computed(() => metricBlueprints.map((metric) => {
const rawValue = demoTotals[metric.key]
const displayValue = metric.key === 'pendingAmount'
? formatCurrency(rawValue)
: metric.unit && !String(rawValue).endsWith(metric.unit)
? `${rawValue} ${metric.unit}`
: `${rawValue}`
return {
...metric,
displayValue,
changeText: metric.change
}
}))
const activeTrend = computed(() => trendSeries[activeTrendRange.value])
const spendTotal = computed(() => spendByCategory.reduce((sum, item) => sum + item.value, 0))
const spendTotalLabel = computed(() => formatCurrency(demoTotals.pendingAmount))
const buildDonutBackground = (items, total) => {
let current = 0
const stops = items.map((item) => {
const start = current
current += (item.value / total) * 100
return `${item.color} ${start}% ${current}%`
}).join(',')
return `radial-gradient(circle, #fff 0 60%, transparent 61%), conic-gradient(${stops})`
}
const spendDonutBackground = computed(() => buildDonutBackground(spendByCategory, spendTotal.value))
const riskTotal = computed(() => exceptionMix.reduce((sum, item) => sum + item.value, 0))
const riskDonutBackground = computed(() => buildDonutBackground(exceptionMix, riskTotal.value))
const spendLegend = computed(() => spendByCategory.map((item) => ({
...item,
display: `${Math.round((item.value / spendTotal.value) * 100)}%`
})))
const riskLegend = computed(() => exceptionMix.map((item) => ({
...item,
display: `${item.value}`
})))
const rankedDepartments = computed(() => {
const rows = demoDepartments
const max = Math.max(...rows.map((item) => item.amount), 1)
return rows.slice(0, 5).map((item, index) => ({
...item,
rank: index + 1,
shortName: item.name,
amountLabel: formatCurrency(item.amount),
width: `${Math.max((item.amount / max) * 100, 18)}%`,
color: item.color
}))
})
</script>
<style scoped>
.view { display: grid; gap: 22px; animation: fadeUp 220ms var(--ease) both; }
.metric-strip { display: grid; grid-template-columns: repeat(4, minmax(190px, 1fr)); gap: 16px; }
.metric {
min-height: 128px; padding: 18px; border-top: 3px solid var(--accent);
border: 1px solid var(--line); border-radius: var(--radius); background: var(--surface);
box-shadow: 0 1px 2px rgba(16,24,40,.04);
transition: transform 220ms var(--ease), box-shadow 220ms var(--ease), border-color 220ms var(--ease);
.dashboard {
display: grid;
gap: 24px;
animation: fadeUp 260ms var(--ease) both;
}
.kpi-grid {
display: grid;
grid-template-columns: repeat(6, minmax(0, 1fr));
gap: 24px;
}
.kpi-card {
padding: 20px;
display: flex;
flex-direction: column;
gap: 0;
}
.kpi-top {
flex: 1;
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 10px;
}
.kpi-top > div {
min-width: 0;
overflow: hidden;
}
.kpi-top p {
margin: 0 0 6px;
color: #64748b;
font-size: 13px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.kpi-top strong {
display: block;
color: #1e293b;
font-size: clamp(20px, 2vw, 26px);
line-height: 1.2;
font-weight: 700;
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
.kpi-icon {
width: 38px;
height: 38px;
display: grid;
place-items: center;
border-radius: 10px;
background: color-mix(in srgb, var(--accent) 10%, white);
color: var(--accent);
font-size: 16px;
flex: 0 0 auto;
}
.kpi-bottom {
display: flex;
flex-direction: column;
gap: 1px;
margin-top: 14px;
padding-top: 12px;
border-top: 1px solid #f1f5f9;
}
.kpi-bottom span {
display: inline-flex;
align-items: center;
gap: 3px;
font-size: 13px;
font-weight: 500;
}
.kpi-bottom.up span {
color: #dc2626;
}
.kpi-bottom.down span {
color: #16a34a;
}
.kpi-bottom small {
color: #94a3b8;
font-size: 12px;
}
.content-grid {
display: grid;
grid-template-columns: repeat(12, minmax(0, 1fr));
gap: 24px;
}
.dashboard-card {
padding: 20px;
}
.trend-panel,
.rank-panel {
grid-column: span 6;
}
.donut-panel,
.bottleneck-panel,
.budget-panel {
grid-column: span 3;
}
.card-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
margin-bottom: 16px;
}
.card-head h3 {
color: #1e293b;
font-size: 16px;
font-weight: 600;
line-height: 1.35;
}
.card-head .pi {
color: #94a3b8;
font-size: 12px;
vertical-align: 1px;
}
.card-select {
height: 30px;
min-width: 82px;
padding: 0 8px;
border: 1px solid #cbd5e1;
border-radius: 4px;
background: #fff;
color: #334155;
font-size: 14px;
}
.donut-wrap {
min-height: 192px;
display: grid;
grid-template-columns: minmax(116px, 1fr) minmax(96px, .8fr);
align-items: center;
gap: 10px;
}
.donut {
width: min(148px, 100%);
aspect-ratio: 1;
display: grid;
place-items: center;
border-radius: 50%;
justify-self: center;
}
.donut-center {
width: 84px;
aspect-ratio: 1;
display: grid;
place-items: center;
align-content: center;
border-radius: 50%;
background: #fff;
text-align: center;
}
.donut-center strong {
color: #1e293b;
font-size: 16px;
line-height: 1;
font-weight: 700;
}
.donut-center span {
margin-top: 6px;
color: #64748b;
font-size: 11px;
}
.donut-legend {
display: grid;
gap: 14px;
align-content: center;
}
.legend-row span {
display: inline-flex;
align-items: center;
gap: 8px;
color: #334155;
font-size: 12px;
}
.legend-row i {
width: 10px;
height: 10px;
border-radius: 999px;
flex: 0 0 auto;
}
.panel-note {
margin-top: 8px;
color: #64748b;
font-size: 12px;
text-align: center;
}
.rank-list,
.bottleneck-list {
display: grid;
gap: 16px;
}
.rank-row {
display: grid;
grid-template-columns: 24px 72px minmax(0, 1fr) auto;
align-items: center;
gap: 12px;
}
.rank-index {
width: 24px;
height: 24px;
display: grid;
place-items: center;
border-radius: 999px;
background: #94a3b8;
color: #fff;
font-size: 12px;
font-weight: 500;
}
.rank-row:first-child .rank-index {
background: #f59e0b;
}
.rank-name {
color: #475569;
font-size: 14px;
}
.rank-value {
color: #334155;
font-size: 14px;
font-weight: 500;
}
.rank-track {
height: 24px;
overflow: hidden;
border-radius: 999px;
background: #f1f5f9;
}
.rank-fill {
height: 100%;
border-radius: inherit;
}
.bottleneck-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.reviewer {
display: flex;
align-items: center;
gap: 12px;
min-width: 0;
}
.reviewer-avatar {
width: 32px;
height: 32px;
display: grid;
place-items: center;
border-radius: 999px;
background: #e2f6ef;
color: #047857;
font-size: 13px;
font-weight: 700;
flex: 0 0 auto;
}
.reviewer strong,
.reviewer-stats strong {
display: block;
color: #1e293b;
font-size: 14px;
font-weight: 500;
}
.reviewer span,
.reviewer-stats span {
display: block;
margin-top: 4px;
color: #64748b;
font-size: 12px;
}
.reviewer-stats {
text-align: right;
}
.status-tag {
display: inline-flex;
align-items: center;
padding: 2px 8px;
border-radius: 4px;
font-size: 12px;
}
.status-tag.danger {
background: rgba(239,68,68,.10);
color: #ef4444;
}
.status-tag.warning {
background: rgba(245,158,11,.10);
color: #f59e0b;
}
.status-tag.success {
background: rgba(16,185,129,.10);
color: #16a34a;
}
.text-link {
width: 100%;
margin-top: 16px;
display: inline-flex;
align-items: center;
justify-content: space-between;
padding: 16px 0 0;
border: 0;
border-top: 1px solid #f1f5f9;
background: transparent;
color: #10b981;
font-size: 14px;
}
.budget-gauge {
position: relative;
height: 128px;
display: grid;
place-items: center;
}
.budget-gauge-ring {
width: 172px;
height: 92px;
border-radius: 172px 172px 0 0;
background: conic-gradient(from 180deg, #10b981 0 calc(var(--budget-ratio) * 1.8deg), #e2e8f0 calc(var(--budget-ratio) * 1.8deg) 180deg);
-webkit-mask: radial-gradient(circle at 50% 100%, transparent 0 52px, #000 53px);
mask: radial-gradient(circle at 50% 100%, transparent 0 52px, #000 53px);
}
.budget-gauge-center {
position: absolute;
inset: auto 0 10px;
display: grid;
place-items: center;
}
.budget-gauge-center strong {
color: #10b981;
font-size: 24px;
line-height: 1;
}
.budget-gauge-center span {
margin-top: 4px;
color: #64748b;
font-size: 12px;
}
.budget-summary {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 8px;
margin-top: 16px;
text-align: center;
}
.budget-summary span {
display: block;
color: #64748b;
font-size: 12px;
}
.budget-summary strong {
display: block;
margin-top: 6px;
color: #1e293b;
font-size: 13px;
font-weight: 500;
}
@media (max-width: 1320px) {
.kpi-grid {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.trend-panel,
.rank-panel {
grid-column: span 12;
}
.donut-panel,
.bottleneck-panel,
.budget-panel {
grid-column: span 6;
}
}
@media (max-width: 860px) {
.kpi-grid,
.content-grid {
grid-template-columns: 1fr;
}
.trend-panel,
.rank-panel,
.donut-panel,
.bottleneck-panel,
.budget-panel {
grid-column: span 1;
}
.card-head {
align-items: flex-start;
flex-direction: column;
}
.donut-wrap {
grid-template-columns: 1fr;
}
.rank-row {
grid-template-columns: 24px 64px minmax(0, 1fr);
}
.rank-value {
grid-column: 2 / -1;
}
.budget-summary {
grid-template-columns: 1fr;
}
}
.metric:hover { transform: translateY(-2px); border-color: rgba(51,92,255,.22); box-shadow: 0 16px 42px rgba(16,24,40,.08); }
.metric-top { display: flex; justify-content: space-between; gap: 10px; color: var(--muted); font-size: 12px; font-weight: 800; text-transform: uppercase; }
.metric-top b { padding: 4px 8px; border-radius: 999px; background: var(--success-soft); color: var(--success); font-size: 12px; }
.metric-top b.warn { background: var(--warning-soft); color: var(--warning); }
.metric-top b.bad { background: var(--danger-soft); color: var(--danger); }
.metric strong { display: block; margin-top: 16px; color: var(--ink); font-size: 30px; line-height: 1; font-variant-numeric: tabular-nums; }
.metric small { display: block; margin-top: 10px; color: var(--muted); }
.overview-grid { display: grid; grid-template-columns: minmax(0, 1.35fr) minmax(360px, .65fr); gap: 22px; }
.spend-panel { padding: 20px; }
.bar-chart { display: grid; gap: 14px; }
.bar-row { display: grid; grid-template-columns: 94px minmax(0, 1fr) 70px; align-items: center; gap: 14px; font-weight: 700; }
.bar-track { height: 36px; overflow: hidden; border-radius: 7px; background: #f2f4f7; }
.bar-fill { height: 100%; border-radius: inherit; animation: grow 520ms var(--ease) both; }
.bar-row strong { color: var(--ink); text-align: right; font-variant-numeric: tabular-nums; }
.donut-layout { display: grid; grid-template-columns: 190px minmax(0, 1fr); align-items: center; gap: 20px; }
.donut { width: 174px; aspect-ratio: 1; display: grid; place-items: center; border-radius: 50%; background: radial-gradient(circle,#fff 0 52%,transparent 53%), conic-gradient(#335cff 0 42%,#0e9384 42% 68%,#f79009 68% 86%,#d92d20 86% 100%); }
.donut strong { display: block; color: var(--ink); font-size: 28px; text-align: center; }
.donut span { color: var(--muted); font-size: 12px; }
.legend { display: grid; gap: 10px; }
.legend-row { display: grid; grid-template-columns: 12px 1fr auto; align-items: center; gap: 10px; padding-bottom: 10px; border-bottom: 1px solid var(--line); }
.legend-row i { width: 12px; height: 12px; border-radius: 999px; }
.legend-row strong { color: var(--ink); }
</style>