feat(gui-v2): StatCard (PrimeVue Card KPI tile, replaces AppKpiCard) + story
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
22
apps/app/src/components-v2/shared/StatCard.stories.ts
Normal file
22
apps/app/src/components-v2/shared/StatCard.stories.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import type { Meta, StoryObj } from '@storybook/vue3-vite'
|
||||
import StatCard from '@/components-v2/shared/StatCard.vue'
|
||||
|
||||
const meta: Meta<typeof StatCard> = {
|
||||
title: 'Shared/StatCard',
|
||||
component: StatCard,
|
||||
tags: ['autodocs'],
|
||||
argTypes: {
|
||||
icon: { control: 'text' },
|
||||
label: { control: 'text' },
|
||||
value: { control: 'text' },
|
||||
trend: { control: 'text' },
|
||||
trendDir: { control: 'inline-radio', options: ['', 'up', 'down'] },
|
||||
},
|
||||
}
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof StatCard>
|
||||
|
||||
export const Plain: Story = { args: { icon: 'tabler-users', label: 'Vrijwilligers', value: 128 } }
|
||||
export const TrendUp: Story = { args: { icon: 'tabler-calendar-event', label: 'Diensten', value: 42, trend: '+12% vs vorige week', trendDir: 'up' } }
|
||||
export const TrendDown: Story = { args: { icon: 'tabler-alert-triangle', label: 'No-shows', value: 3, trend: '-2 vs vorige week', trendDir: 'down' } }
|
||||
64
apps/app/src/components-v2/shared/StatCard.vue
Normal file
64
apps/app/src/components-v2/shared/StatCard.vue
Normal file
@@ -0,0 +1,64 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* StatCard — KPI tile. crewli-starter port (replaces v1 AppKpiCard).
|
||||
* CSS translation (main.css .stat-card 1019–1039 → Tailwind + Aura tokens):
|
||||
* .stat-card .lbl → text-[11.5px] font-semibold uppercase tracking-[0.06em] text-[var(--p-text-muted-color)]
|
||||
* .stat-card .val → text-[28px] font-bold tracking-[-0.01em] tabular-nums
|
||||
* .trend.up/.down → text-[var(--p-green-600)] / text-[var(--p-red-600)]
|
||||
*/
|
||||
import Card from 'primevue/card'
|
||||
import Icon from '@/components/Icon.vue'
|
||||
|
||||
defineProps<{
|
||||
icon: string
|
||||
label: string
|
||||
value: string | number
|
||||
trend?: string
|
||||
trendDir?: 'up' | 'down'
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Card
|
||||
:pt="{
|
||||
root: 'border border-[var(--p-content-border-color)] rounded-[var(--p-border-radius-lg)] shadow-none',
|
||||
body: 'p-[18px]',
|
||||
content: 'flex flex-col gap-1.5',
|
||||
}"
|
||||
>
|
||||
<template #content>
|
||||
<div class="flex items-center gap-2 text-[11.5px] font-semibold uppercase tracking-[0.06em] text-[var(--p-text-muted-color)]">
|
||||
<Icon
|
||||
:name="icon"
|
||||
:size="16"
|
||||
class="text-[var(--p-primary-color)]"
|
||||
/>
|
||||
{{ label }}
|
||||
</div>
|
||||
<div class="text-[28px] font-bold tracking-[-0.01em] tabular-nums">
|
||||
{{ value }}
|
||||
</div>
|
||||
<div
|
||||
v-if="trend"
|
||||
:data-trend="trendDir"
|
||||
class="flex items-center gap-1 text-xs text-[var(--p-text-muted-color)]"
|
||||
:class="{
|
||||
'text-[var(--p-green-600)]!': trendDir === 'up',
|
||||
'text-[var(--p-red-600)]!': trendDir === 'down',
|
||||
}"
|
||||
>
|
||||
<Icon
|
||||
v-if="trendDir === 'up'"
|
||||
name="tabler-trending-up"
|
||||
:size="14"
|
||||
/>
|
||||
<Icon
|
||||
v-if="trendDir === 'down'"
|
||||
name="tabler-trending-down"
|
||||
:size="14"
|
||||
/>
|
||||
{{ trend }}
|
||||
</div>
|
||||
</template>
|
||||
</Card>
|
||||
</template>
|
||||
29
apps/app/src/components-v2/shared/__tests__/StatCard.spec.ts
Normal file
29
apps/app/src/components-v2/shared/__tests__/StatCard.spec.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { defineComponent } from 'vue'
|
||||
import StatCard from '@/components-v2/shared/StatCard.vue'
|
||||
|
||||
const CardStub = defineComponent({ name: 'CardStub', template: '<div class="card-stub"><slot name="content" /></div>' })
|
||||
const IconStub = defineComponent({ name: 'Icon', props: ['name', 'size'], template: '<i class="icon-stub" :data-icon="name" />' })
|
||||
|
||||
const mountCard = (props: { icon: string; label: string; value: string | number; trend?: string; trendDir?: 'up' | 'down' }) =>
|
||||
mount(StatCard, { props, global: { stubs: { Card: CardStub, Icon: IconStub } } })
|
||||
|
||||
describe('StatCard', () => {
|
||||
it('renders label, value and the leading icon', () => {
|
||||
const w = mountCard({ icon: 'tabler-users', label: 'Vrijwilligers', value: 128 })
|
||||
|
||||
expect(w.text()).toContain('Vrijwilligers')
|
||||
expect(w.text()).toContain('128')
|
||||
expect(w.get('.icon-stub').attributes('data-icon')).toBe('tabler-users')
|
||||
})
|
||||
|
||||
it('shows the trend row with direction class only when trend is set', () => {
|
||||
expect(mountCard({ icon: 'tabler-users', label: 'X', value: 1 }).find('[data-trend]').exists()).toBe(false)
|
||||
|
||||
const up = mountCard({ icon: 'tabler-users', label: 'X', value: 1, trend: '+12%', trendDir: 'up' })
|
||||
|
||||
expect(up.get('[data-trend]').attributes('data-trend')).toBe('up')
|
||||
expect(up.text()).toContain('+12%')
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user