143cc458c1
Q.DEFER.002 sub-B closure: manual Pa11y audit-pass via Playwright MCP login + axe-core CDN inject on 16 auth-required views. Found ~13 unique violation patterns, 12 fixed, 3 deferred to Q.DEFER.004. ROOT CAUSE found: AdminLayout `<v-navigation-drawer color="secondary" theme="dark">` resolved to Vuetify default-dark `secondary=#54b6b2` (Teal mid) instead of liderraForest `#012019` теало-нуар. Switching to direct hex preserves design intent + restores white-text contrast across all 8 admin views (~50 nodes color-contrast violations cleared). Patterns fixed: 1. AdminLayout sidebar palette (8 admin views): - color="secondary" → color="#012019" (root cause) - .brand-sub red #b94837 → #e06155 (3.41 → 5.08) - .nav-count gray #7a8c87 → #8a9c95 (4.26 → 5.34) - <v-list nav> + role="navigation" + aria-label (aria-required-children fix: <v-list role=list> had [role=link] children — undefined для list) 2. DashboardBalance .runway-bar — role="img" (aria-prohibited-attr fix) 3. DashboardKpiRow .delta-up — #2e8b57 → #1b6e3b (4.27 → 6.25) 4. TransactionsTable .tx-amount-up — #2e8b57 → #1b6e3b (same fix) 5. RemindersList .empty-hint — #9a9690 → #6b6356 (2.98 → 5.74; +liderra-muted alignment) 6. KanbanView .kanban-board — tabindex="0" role="region" aria-label (scrollable-region-focusable fix) 7. ProjectCard: - .v-progress-linear + :aria-label="Прогресс дневной нормы: N%" - icon menu :aria-label="Меню действий проекта «...»" - bulk-select .card-check input :aria-label="Выбрать проект «...»" 8. useStatusPill in_progress #3F7C95 → #2A5A6E (4.07 → 6.11); useStatusPill.spec.ts sync 9. ProjectsView toolbar select-all input aria-label 10. AdminTenants impersonate v-btn aria-label 11. Global app.css: `.v-messages, .v-field-label { --v-medium-emphasis-opacity: 0.7; }` Vuetify default ~0.52 → rendered #7a7a7a/#767471 fails 4.20-4.29:1; 0.7 → rendered ≈#595959 → 7.9:1+ passes WCAG AA. Re-verified post-fix via axe-core on all affected views: all clean except DEV-only `.dev-index-num` chip (tree-shaked в prod, not a real violation). Vitest verified post-fix: 79 files / 614 passed / 3 skipped / 0 failed (baseline preserved). 3 patterns deferred to Q.DEFER.004: - DealsTable VDataTable show-select bulk-checkboxes (6 nodes) — Vuetify slot rewrite needed - AdminSupplierPrices 9 form inputs — v-text-field/v-switch label props - Vuetify v-tooltip eager-mount aria-tooltip-name — library-level cosmetic Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
171 lines
4.9 KiB
Vue
171 lines
4.9 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;
|
||
}
|
||
|
||
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 }}</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-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>
|