Files
portal/app/resources/js/components/dashboard/DashboardKpiRow.vue
T
Дмитрий 3cedf28f33
Accessibility (Pa11y live) / a11y (push) Has been cancelled
SAST — Semgrep / Semgrep SAST scan (push) Has been cancelled
feat/ui: подсказки вопросик по карте — дашборд, мастер проекта, биллинг
Дашборд: вопросик у KPI Получено/Конверсия/Активные, у воронки и у прогноза хватит на N дней; pp на п.п.
Мастер проекта: подпись лимита заявок в день + вопросик у выбора источника Сайт/Звонок/СМС.
Биллинг: вопросик у Цены за лид 7 ступеней.
Тест FunnelChart 8/8, type-check чистый, затронутые спеки 58/60.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 13:41:27 +03:00

195 lines
5.8 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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>