320 lines
7.9 KiB
Vue
320 lines
7.9 KiB
Vue
<template>
|
|
<NeumorphicCard :elevation="elevation" :animated="animated" class="stat-card">
|
|
<div class="stat-card__content">
|
|
<div class="stat-card__header">
|
|
<div class="stat-card__icon-wrapper" :class="`stat-card__icon-wrapper--${variant}`">
|
|
<component :is="icon" v-if="icon" class="stat-card__icon" />
|
|
<div v-else class="stat-card__icon-placeholder">
|
|
<svg fill="currentColor" viewBox="0 0 20 20">
|
|
<path d="M2 11a1 1 0 011-1h2a1 1 0 011 1v5a1 1 0 01-1 1H3a1 1 0 01-1-1v-5zM8 7a1 1 0 011-1h2a1 1 0 011 1v9a1 1 0 01-1 1H9a1 1 0 01-1-1V7zM14 4a1 1 0 011-1h2a1 1 0 011 1v12a1 1 0 01-1 1h-2a1 1 0 01-1-1V4z" />
|
|
</svg>
|
|
</div>
|
|
</div>
|
|
<div class="stat-card__trend" v-if="trend">
|
|
<span :class="['stat-card__trend-badge', trendClass]">
|
|
<svg v-if="trend > 0" class="stat-card__trend-icon" 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="stat-card__trend-icon" 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>
|
|
{{ Math.abs(trend) }}%
|
|
</span>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="stat-card__body">
|
|
<h3 class="stat-card__label">{{ label }}</h3>
|
|
<div class="stat-card__value-wrapper">
|
|
<span v-if="prefix" class="stat-card__prefix">{{ prefix }}</span>
|
|
<span class="stat-card__value">{{ formattedValue }}</span>
|
|
<span v-if="suffix" class="stat-card__suffix">{{ suffix }}</span>
|
|
</div>
|
|
<p v-if="description" class="stat-card__description">{{ description }}</p>
|
|
</div>
|
|
|
|
<div v-if="showProgress" class="stat-card__progress">
|
|
<div class="stat-card__progress-bar">
|
|
<div
|
|
class="stat-card__progress-fill"
|
|
:class="`stat-card__progress-fill--${variant}`"
|
|
:style="{ width: `${progress}%` }"
|
|
></div>
|
|
</div>
|
|
<span class="stat-card__progress-text">{{ progress }}% of target</span>
|
|
</div>
|
|
|
|
<div v-if="$slots.footer" class="stat-card__footer">
|
|
<slot name="footer"></slot>
|
|
</div>
|
|
</div>
|
|
</NeumorphicCard>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import { computed } from 'vue';
|
|
import type { Component } from 'vue';
|
|
import NeumorphicCard from './NeumorphicCard.vue';
|
|
|
|
interface Props {
|
|
label: string;
|
|
value: number | string;
|
|
trend?: number;
|
|
prefix?: string;
|
|
suffix?: string;
|
|
description?: string;
|
|
variant?: 'primary' | 'success' | 'warning' | 'danger' | 'info';
|
|
icon?: Component;
|
|
elevation?: 'none' | 'sm' | 'md' | 'lg' | 'xl';
|
|
animated?: boolean;
|
|
showProgress?: boolean;
|
|
progress?: number;
|
|
format?: 'number' | 'currency' | 'percentage';
|
|
}
|
|
|
|
const props = withDefaults(defineProps<Props>(), {
|
|
variant: 'primary',
|
|
elevation: 'md',
|
|
animated: true,
|
|
showProgress: false,
|
|
progress: 0,
|
|
format: 'number'
|
|
});
|
|
|
|
const formattedValue = computed(() => {
|
|
const val = props.value;
|
|
if (typeof val === 'string') return val;
|
|
|
|
switch (props.format) {
|
|
case 'currency':
|
|
return val.toLocaleString('en-US', { minimumFractionDigits: 0, maximumFractionDigits: 0 });
|
|
case 'percentage':
|
|
return val.toFixed(1);
|
|
default:
|
|
return val.toLocaleString('en-US');
|
|
}
|
|
});
|
|
|
|
const trendClass = computed(() => {
|
|
if (!props.trend) return '';
|
|
return props.trend > 0 ? 'stat-card__trend-badge--up' : 'stat-card__trend-badge--down';
|
|
});
|
|
</script>
|
|
|
|
<style lang="scss" scoped>
|
|
@import '../../styles/neumorphic-system.scss';
|
|
|
|
.stat-card {
|
|
height: 100%;
|
|
|
|
&__content {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: $space-4;
|
|
}
|
|
|
|
&__header {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: flex-start;
|
|
}
|
|
|
|
&__icon-wrapper {
|
|
width: 48px;
|
|
height: 48px;
|
|
border-radius: $radius-lg;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
box-shadow: $shadow-soft-sm;
|
|
transition: all $transition-base;
|
|
|
|
&--primary {
|
|
background: linear-gradient(135deg, rgba($primary-500, 0.1), rgba($primary-600, 0.15));
|
|
color: $primary-600;
|
|
}
|
|
|
|
&--success {
|
|
background: linear-gradient(135deg, rgba($success-500, 0.1), rgba($success-500, 0.15));
|
|
color: $success-500;
|
|
}
|
|
|
|
&--warning {
|
|
background: linear-gradient(135deg, rgba($warning-500, 0.1), rgba($warning-500, 0.15));
|
|
color: $warning-500;
|
|
}
|
|
|
|
&--danger {
|
|
background: linear-gradient(135deg, rgba($error-500, 0.1), rgba($error-500, 0.15));
|
|
color: $error-500;
|
|
}
|
|
|
|
&--info {
|
|
background: linear-gradient(135deg, rgba($info-500, 0.1), rgba($info-500, 0.15));
|
|
color: $info-500;
|
|
}
|
|
}
|
|
|
|
&__icon,
|
|
&__icon-placeholder {
|
|
width: 24px;
|
|
height: 24px;
|
|
}
|
|
|
|
&__trend {
|
|
display: flex;
|
|
align-items: center;
|
|
}
|
|
|
|
&__trend-badge {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
gap: $space-1;
|
|
padding: $space-1 $space-2;
|
|
border-radius: $radius-full;
|
|
font-size: $text-sm;
|
|
font-weight: $font-semibold;
|
|
|
|
&--up {
|
|
background: linear-gradient(135deg, rgba($success-500, 0.1), rgba($success-500, 0.15));
|
|
color: $success-500;
|
|
}
|
|
|
|
&--down {
|
|
background: linear-gradient(135deg, rgba($error-500, 0.1), rgba($error-500, 0.15));
|
|
color: $error-500;
|
|
}
|
|
}
|
|
|
|
&__trend-icon {
|
|
width: 14px;
|
|
height: 14px;
|
|
}
|
|
|
|
&__body {
|
|
flex: 1;
|
|
}
|
|
|
|
&__label {
|
|
font-size: $text-sm;
|
|
font-weight: $font-medium;
|
|
color: $neutral-600;
|
|
margin-bottom: $space-2;
|
|
text-transform: uppercase;
|
|
letter-spacing: 0.025em;
|
|
}
|
|
|
|
&__value-wrapper {
|
|
display: flex;
|
|
align-items: baseline;
|
|
gap: $space-1;
|
|
margin-bottom: $space-2;
|
|
}
|
|
|
|
&__prefix,
|
|
&__suffix {
|
|
font-size: $text-lg;
|
|
color: $neutral-500;
|
|
}
|
|
|
|
&__value {
|
|
font-family: $font-heading;
|
|
font-size: $text-3xl;
|
|
font-weight: $font-bold;
|
|
color: $neutral-800;
|
|
line-height: 1;
|
|
}
|
|
|
|
&__description {
|
|
font-size: $text-sm;
|
|
color: $neutral-600;
|
|
line-height: $leading-normal;
|
|
}
|
|
|
|
&__progress {
|
|
margin-top: auto;
|
|
}
|
|
|
|
&__progress-bar {
|
|
width: 100%;
|
|
height: 8px;
|
|
background: rgba($neutral-300, 0.3);
|
|
border-radius: $radius-full;
|
|
overflow: hidden;
|
|
box-shadow: $shadow-inset-sm;
|
|
margin-bottom: $space-2;
|
|
}
|
|
|
|
&__progress-fill {
|
|
height: 100%;
|
|
border-radius: $radius-full;
|
|
transition: width $transition-slow $ease-out-soft;
|
|
|
|
&--primary {
|
|
background: linear-gradient(90deg, $primary-500, $primary-600);
|
|
}
|
|
|
|
&--success {
|
|
background: linear-gradient(90deg, $success-500, #059669);
|
|
}
|
|
|
|
&--warning {
|
|
background: linear-gradient(90deg, $warning-500, #D97706);
|
|
}
|
|
|
|
&--danger {
|
|
background: linear-gradient(90deg, $error-500, #DC2626);
|
|
}
|
|
|
|
&--info {
|
|
background: linear-gradient(90deg, $info-500, #2563EB);
|
|
}
|
|
}
|
|
|
|
&__progress-text {
|
|
font-size: $text-xs;
|
|
color: $neutral-500;
|
|
}
|
|
|
|
&__footer {
|
|
padding-top: $space-3;
|
|
border-top: 1px solid rgba($neutral-300, 0.3);
|
|
}
|
|
|
|
// Hover effect
|
|
&:hover {
|
|
.stat-card__icon-wrapper {
|
|
transform: scale(1.05);
|
|
box-shadow: $shadow-soft-md;
|
|
}
|
|
}
|
|
|
|
// Dark mode
|
|
@include dark-mode {
|
|
.stat-card__label {
|
|
color: $neutral-400;
|
|
}
|
|
|
|
.stat-card__value {
|
|
color: $neutral-100;
|
|
}
|
|
|
|
.stat-card__description {
|
|
color: $neutral-400;
|
|
}
|
|
|
|
.stat-card__prefix,
|
|
.stat-card__suffix {
|
|
color: $neutral-500;
|
|
}
|
|
|
|
.stat-card__progress-bar {
|
|
background: rgba($neutral-600, 0.3);
|
|
}
|
|
|
|
.stat-card__progress-text {
|
|
color: $neutral-500;
|
|
}
|
|
}
|
|
}
|
|
</style> |