Files
portal/app/resources/js/components/dashboard/DashboardKpiRow.vue
T
Дмитрий 143cc458c1 fix(a11y): Q.DEFER.002 sub-B — 12 patterns fixed across 16 auth views
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>
2026-05-12 22:09:48 +03:00

171 lines
4.9 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;
}
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>