3cedf28f33
Дашборд: вопросик у KPI Получено/Конверсия/Активные, у воронки и у прогноза хватит на N дней; pp на п.п. Мастер проекта: подпись лимита заявок в день + вопросик у выбора источника Сайт/Звонок/СМС. Биллинг: вопросик у Цены за лид 7 ступеней. Тест FunnelChart 8/8, type-check чистый, затронутые спеки 58/60. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
195 lines
5.8 KiB
Vue
195 lines
5.8 KiB
Vue
<script setup lang="ts">
|
||
/**
|
||
* DashboardKpiRow — 3 KPI-карты (получено лидов / конверсия / активные проекты).
|
||
* Numerics через JetBrains Mono с tabular-nums + count-up анимация (motion #1).
|
||
*
|
||
* Sprint 4 Phase B/3 — split DashboardView (audit O-refactor-04 закрытие).
|
||
* Task 14 (Quiet Luxury) — добавлены ld-kpi__value/ld-kpi__label классы и
|
||
* count-up через useCountUp композабл. Respects prefers-reduced-motion.
|
||
*/
|
||
import { onMounted, ref, watch, type Ref } from 'vue';
|
||
import { useCountUp } from '../../composables/useCountUp';
|
||
|
||
export interface Kpi {
|
||
label: string;
|
||
value: string;
|
||
unit?: string;
|
||
delta?: { dir: 'up' | 'down' | 'neutral'; text: string };
|
||
sub: string;
|
||
/** UX-аудит 25.06: пояснение для новичка по «?» рядом с подписью. */
|
||
hint?: string;
|
||
}
|
||
|
||
const props = defineProps<{
|
||
kpis: Kpi[];
|
||
}>();
|
||
|
||
/**
|
||
* Парсит KPI value-строку в число. Поддерживает:
|
||
* - целые ('247', '8')
|
||
* - дробные ('18.4')
|
||
* - с пробелами как тысячными ('14 250')
|
||
*/
|
||
function parseNumeric(raw: string): { value: number; precision: number } {
|
||
const cleaned = raw.replace(/\s+/g, '').replace(',', '.');
|
||
const value = parseFloat(cleaned);
|
||
if (Number.isNaN(value)) return { value: 0, precision: 0 };
|
||
const dotIdx = cleaned.indexOf('.');
|
||
const precision = dotIdx === -1 ? 0 : cleaned.length - dotIdx - 1;
|
||
return { value, precision };
|
||
}
|
||
|
||
/**
|
||
* Форматирует число обратно с пробелами как тысячными
|
||
* (чтобы '14 250' выводилось так же, а не '14250').
|
||
*/
|
||
function formatNumber(value: number, precision: number): string {
|
||
const fixed = precision === 0 ? Math.round(value).toString() : value.toFixed(precision);
|
||
const [intPart, decPart] = fixed.split('.');
|
||
const withSpaces = intPart.replace(/\B(?=(\d{3})+(?!\d))/g, ' ');
|
||
return decPart === undefined ? withSpaces : `${withSpaces}.${decPart}`;
|
||
}
|
||
|
||
interface AnimationSlot {
|
||
target: Ref<number>;
|
||
display: Ref<number>;
|
||
start: () => void;
|
||
precision: number;
|
||
}
|
||
|
||
const slots: AnimationSlot[] = [];
|
||
|
||
function rebuildSlots(): void {
|
||
slots.length = 0;
|
||
for (const kpi of props.kpis) {
|
||
const { value, precision } = parseNumeric(kpi.value);
|
||
const target = ref(value);
|
||
const { display, start } = useCountUp(target, { duration: 600, precision });
|
||
slots.push({ target, display, start, precision });
|
||
}
|
||
}
|
||
|
||
rebuildSlots();
|
||
|
||
// Если props.kpis сменился (новый range / refetch) — пересобираем слоты
|
||
// и перезапускаем анимацию.
|
||
watch(
|
||
() => props.kpis,
|
||
() => {
|
||
rebuildSlots();
|
||
slots.forEach((s) => s.start());
|
||
},
|
||
{ deep: true },
|
||
);
|
||
|
||
onMounted(() => {
|
||
slots.forEach((s) => s.start());
|
||
});
|
||
|
||
function displayFor(idx: number): string {
|
||
const slot = slots[idx];
|
||
if (!slot) return '';
|
||
return formatNumber(slot.display.value, slot.precision);
|
||
}
|
||
</script>
|
||
|
||
<template>
|
||
<v-col v-for="(kpi, idx) in kpis" :key="kpi.label" cols="12" sm="6" md="3">
|
||
<v-card variant="outlined" class="kpi-card pa-4">
|
||
<div class="kpi-label ld-kpi__label ld-label text-body-2 text-medium-emphasis">
|
||
{{ kpi.label }}
|
||
<v-tooltip v-if="kpi.hint" :text="kpi.hint" location="top" max-width="260">
|
||
<template #activator="{ props: tip }">
|
||
<v-icon
|
||
v-bind="tip"
|
||
size="14"
|
||
class="kpi-hint ml-1"
|
||
icon="mdi-help-circle-outline"
|
||
:aria-label="kpi.hint"
|
||
tabindex="0"
|
||
/>
|
||
</template>
|
||
</v-tooltip>
|
||
</div>
|
||
<div class="kpi-value ld-kpi__value ld-mono">
|
||
{{ displayFor(idx) }}
|
||
<span v-if="kpi.unit" class="kpi-unit">{{ kpi.unit }}</span>
|
||
</div>
|
||
<div class="kpi-foot text-caption text-medium-emphasis mt-2">
|
||
<span v-if="kpi.delta?.dir === 'up'" class="delta-up">
|
||
<v-icon size="14" color="success">mdi-arrow-up</v-icon>
|
||
{{ kpi.delta.text }}
|
||
</span>
|
||
<span v-else-if="kpi.delta?.dir === 'down'" class="delta-down">
|
||
<v-icon size="14" color="error">mdi-arrow-down</v-icon>
|
||
{{ kpi.delta.text }}
|
||
</span>
|
||
<span v-else-if="kpi.delta" class="delta-neutral">{{ kpi.delta.text }}</span>
|
||
<span class="ml-1">{{ kpi.sub }}</span>
|
||
</div>
|
||
</v-card>
|
||
</v-col>
|
||
</template>
|
||
|
||
<style scoped>
|
||
.kpi-card {
|
||
background: #fff;
|
||
border-color: #d9d5cd !important;
|
||
transition: border-color 0.15s;
|
||
}
|
||
.kpi-card:hover {
|
||
border-color: #66635c !important;
|
||
}
|
||
|
||
.kpi-label {
|
||
font-size: 13px;
|
||
margin-bottom: 8px;
|
||
}
|
||
.kpi-hint {
|
||
color: #9b9484;
|
||
cursor: help;
|
||
vertical-align: middle;
|
||
}
|
||
.kpi-hint:hover {
|
||
color: #0f6e56;
|
||
}
|
||
|
||
.kpi-value {
|
||
font-family: 'JetBrains Mono', ui-monospace, monospace;
|
||
font-feature-settings: 'tnum';
|
||
font-size: 32px;
|
||
font-weight: 600;
|
||
line-height: 1.1;
|
||
color: #081319;
|
||
}
|
||
|
||
.kpi-unit {
|
||
font-size: 18px;
|
||
color: #66635c;
|
||
font-weight: 500;
|
||
margin-left: 2px;
|
||
}
|
||
|
||
.kpi-foot {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 4px;
|
||
flex-wrap: wrap;
|
||
}
|
||
|
||
.delta-up,
|
||
.delta-down,
|
||
.delta-neutral {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 2px;
|
||
font-weight: 500;
|
||
}
|
||
.delta-up {
|
||
color: #1b6e3b;
|
||
}
|
||
.delta-down {
|
||
color: #b83a3a;
|
||
}
|
||
</style>
|