Files
portal/app/resources/js/components/deals/DealsTable.vue
T
Дмитрий d9fc3d92e4 fix(a11y): Q.DEFER.004 sub-A — DealsTable show-select bulk-checkbox aria-label
VDataTable show-select prop генерировал unlabeled checkbox per row + select-all
header — axe-core критичная label violation (6 nodes на demo seed).

Override через Vuetify 3.12 typed slots:
- header.data-table-select → aria-label='Выбрать все сделки'
- item.data-table-select → aria-label='Выбрать сделку «{{name}}»' (per row)

Test coverage: tests/Frontend/DealsTable.spec.ts (2 specs).
2026-05-13 00:28:39 +03:00

185 lines
7.1 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">
/**
* Таблица сделок (Sprint 3 Phase C — extraction из DealsView).
*
* Логически замкнутый блок: v-data-table со всеми типизированными слотами
* (Vuetify 3.12 VDataTableSlots, Sprint 2 Phase B / O-stack-05).
*
* Контракт:
* props:
* - deals: MockDeal[] — отфильтрованный список (computed в родителе).
* - selectedIds: number[] — v-model:selected (двунаправленно).
* - statusBySlug: Map<string, LeadStatus> — для status-chip color/label.
* emits:
* - update:selectedIds — sync v-model selected с родителем.
* - row-click(deal) — раскрыть drawer.
*/
import type { MockDeal } from '../../composables/mockDeals';
import type { LeadStatus } from '../../composables/leadStatuses';
import StatusPill from '../ui/StatusPill.vue';
withDefaults(
defineProps<{
deals: MockDeal[];
selectedIds: number[];
statusBySlug: Map<string, LeadStatus>;
// Task 15: row height from density toggle (44 comfortable / 36 compact).
rowHeight?: number;
}>(),
{ rowHeight: 44 },
);
const emit = defineEmits<{
'update:selectedIds': [value: number[]];
'row-click': [deal: MockDeal];
}>();
function onSelectedUpdate(value: number[]) {
emit('update:selectedIds', value);
}
function formatRelative(minutes: number): string {
if (minutes < 60) return `${minutes} мин назад`;
if (minutes < 60 * 24) return `${Math.floor(minutes / 60)} ч назад`;
return `${Math.floor(minutes / (60 * 24))} д назад`;
}
function formatCost(cost: number): string {
return new Intl.NumberFormat('ru-RU').format(cost) + ' ₽';
}
</script>
<template>
<v-card variant="outlined" class="deals-table-card">
<v-data-table
:model-value="selectedIds"
:items="deals"
:headers="[
{ title: 'Лид', key: 'name', sortable: true },
{ title: 'Статус', key: 'statusSlug', sortable: false },
{ title: 'Проект', key: 'project', sortable: false },
{ title: 'Менеджер', key: 'manager', sortable: false },
{ title: 'Стоимость', key: 'cost', align: 'end', sortable: true },
{ title: 'Время', key: 'receivedMinutesAgo', align: 'end', sortable: true },
]"
show-select
item-value="id"
items-per-page="-1"
hide-default-footer
hover
:density="rowHeight && rowHeight < 40 ? 'compact' : 'comfortable'"
:row-props="() => ({ class: 'ld-hover-lift ld-stagger-row', style: { height: rowHeight + 'px' } })"
@update:model-value="onSelectedUpdate"
@click:row="(_e: Event, { item }: { item: MockDeal }) => emit('row-click', item)"
>
<!--
Vuetify 3.12 типизированные слоты VDataTable (Sprint 2 Phase B / O-stack-05).
`:items="deals"` (MockDeal[]) → Vuetify через VDataTableSlots<ItemType<T>>
выводит `item` как `MockDeal` автоматически. Дополнительная inline-аннотация
`{ item }: { item: MockDeal }` фиксирует этот контракт явно — IDE и vue-tsc
проверяют доступ к полям статически.
-->
<template #[`item.name`]="{ item }: { item: MockDeal }">
<div class="cell-deal">
<v-avatar size="32" color="primary" class="mr-3">
<span class="text-caption font-weight-medium">{{
item.name
.split(' ')
.map((p: string) => p[0])
.join('')
.slice(0, 2)
}}</span>
</v-avatar>
<div>
<div class="deal-name">{{ item.name }}</div>
<div class="deal-phone text-caption text-medium-emphasis ld-mono-s">{{ item.phone }}</div>
</div>
</div>
</template>
<template #[`item.statusSlug`]="{ item }: { item: MockDeal }">
<!-- Task 15: StatusPill заменяет v-chip + ручной dot. Label fallback на slug
если nameRu отсутствует (leadStatuses store ещё не загружен). -->
<StatusPill
:slug="item.statusSlug"
:label="statusBySlug.get(item.statusSlug)?.nameRu ?? item.statusSlug"
/>
</template>
<template #[`item.manager`]="{ item }: { item: MockDeal }">
<div class="cell-manager">
<v-avatar size="22" color="secondary" class="mr-2">
<span class="text-caption">{{ item.manager.initials }}</span>
</v-avatar>
{{ item.manager.name }}
</div>
</template>
<template #[`item.cost`]="{ item }: { item: MockDeal }">
<span class="num ld-mono">{{ formatCost(item.cost) }}</span>
</template>
<template #[`item.receivedMinutesAgo`]="{ item }: { item: MockDeal }">
<span class="num ld-mono-s text-medium-emphasis">{{ formatRelative(item.receivedMinutesAgo) }}</span>
</template>
<template #[`header.data-table-select`]="{ allSelected, selectAll, someSelected }">
<v-checkbox-btn
:model-value="allSelected"
:indeterminate="someSelected && !allSelected"
aria-label="Выбрать все сделки"
@update:model-value="selectAll"
/>
</template>
<template #[`item.data-table-select`]="{ isSelected, toggleSelect, internalItem, item }">
<v-checkbox-btn
:model-value="isSelected(internalItem)"
:aria-label="`Выбрать сделку «${(item as MockDeal).name}»`"
@update:model-value="(v: boolean | null) => toggleSelect(internalItem)"
/>
</template>
</v-data-table>
<div v-if="deals.length === 0" class="empty-state pa-8 text-center text-medium-emphasis">
Нет сделок по выбранным фильтрам
</div>
</v-card>
</template>
<style scoped>
.deals-table-card {
background: #fff;
}
.num {
font-family: 'JetBrains Mono', ui-monospace, monospace;
font-feature-settings: 'tnum';
font-weight: 500;
}
.cell-deal {
display: flex;
align-items: center;
padding: 6px 0;
}
.deal-name {
font-weight: 500;
color: #081319;
}
.cell-manager {
display: flex;
align-items: center;
}
.status-dot {
display: inline-block;
width: 6px;
height: 6px;
border-radius: 50%;
margin-right: 6px;
}
</style>