d9fc3d92e4
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).
185 lines
7.1 KiB
Vue
185 lines
7.1 KiB
Vue
<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>
|