monacousa-portal/design-mockups/components/core/StatCard.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>