feat(deals): DealsTable — lead-registry columns (Телефон/Источник/Город/Статус/Напоминание/Комментарий/Поставлен)

This commit is contained in:
Дмитрий
2026-05-17 15:24:51 +03:00
parent a2bfeafcea
commit ccf194ed8a
2 changed files with 105 additions and 122 deletions
@@ -1,32 +1,21 @@
<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.
* Таблица реестра лидов «Сделки» (редизайн 2026-05-17).
* Колонки: чекбокс · Телефон · Источник · Город · Статус · Напоминание ·
* Комментарий · Поставлен. Напоминание/Комментарий — read-only.
*/
import type { MockDeal } from '../../composables/mockDeals';
import type { LeadStatus } from '../../composables/leadStatuses';
import StatusPill from '../ui/StatusPill.vue';
withDefaults(
const props = withDefaults(
defineProps<{
deals: MockDeal[];
selectedIds: number[];
statusBySlug: Map<string, LeadStatus>;
// Task 15: row height from density toggle (44 comfortable / 36 compact).
rowHeight?: number;
activeDealId?: number | null;
}>(),
{ rowHeight: 44 },
{ activeDealId: null },
);
const emit = defineEmits<{
@@ -34,18 +23,22 @@ const emit = defineEmits<{
'row-click': [deal: MockDeal];
}>();
function onSelectedUpdate(value: number[]) {
emit('update:selectedIds', value);
const SIGNAL_LABELS: Record<string, string> = { call: 'Звонки', site: 'Сайт', sms: 'СМС' };
function signalLabel(t: MockDeal['signalType']): string {
return t ? (SIGNAL_LABELS[t] ?? '') : '';
}
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 formatDateTime(iso: string | null | undefined): string {
if (!iso) return '—';
const d = new Date(iso);
return new Intl.DateTimeFormat('ru-RU', {
day: '2-digit', month: '2-digit', year: 'numeric', hour: '2-digit', minute: '2-digit',
}).format(d);
}
function formatCost(cost: number): string {
return new Intl.NumberFormat('ru-RU').format(cost) + '';
function rowProps(deal: MockDeal): Record<string, unknown> {
return { class: deal.id === props.activeDealId ? 'deals-row-active' : '' };
}
</script>
@@ -55,72 +48,61 @@ function formatCost(cost: number): string {
:model-value="selectedIds"
:items="deals"
:headers="[
{ title: 'Лид', key: 'name', sortable: true },
{ title: 'Телефон', key: 'phone', sortable: true },
{ title: 'Источник', key: 'project', sortable: false },
{ title: 'Город', key: 'city', sortable: false },
{ 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 },
{ title: 'Напоминание', key: 'nextReminderAt', sortable: true },
{ title: 'Комментарий', key: 'comment', sortable: false },
{ title: 'Поставлен', key: 'receivedAt', 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"
:row-props="(p: { item: MockDeal }) => rowProps(p.item)"
@update:model-value="(v: number[]) => emit('update:selectedIds', v)"
@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>
<template #[`item.phone`]="{ item }: { item: MockDeal }">
<span class="num ld-mono">{{ item.phone }}</span>
</template>
<template #[`item.project`]="{ item }: { item: MockDeal }">
<div class="cell-source">
<span class="source-project">{{ item.project }}</span>
<span v-if="signalLabel(item.signalType)" class="source-signal">{{
signalLabel(item.signalType)
}}</span>
</div>
</template>
<template #[`item.city`]="{ item }: { item: MockDeal }">
<span :class="{ 'text-medium-emphasis': !item.city }">{{ item.city || '—' }}</span>
</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 #[`item.nextReminderAt`]="{ item }: { item: MockDeal }">
<span class="num ld-mono-s" :class="{ 'text-medium-emphasis': !item.nextReminderAt }">{{
formatDateTime(item.nextReminderAt)
}}</span>
</template>
<template #[`item.cost`]="{ item }: { item: MockDeal }">
<span class="num ld-mono">{{ formatCost(item.cost) }}</span>
<template #[`item.comment`]="{ item }: { item: MockDeal }">
<span class="cell-comment" :class="{ 'text-medium-emphasis': !item.comment }">{{
item.comment || '—'
}}</span>
</template>
<template #[`item.receivedMinutesAgo`]="{ item }: { item: MockDeal }">
<span class="num ld-mono-s text-medium-emphasis">{{ formatRelative(item.receivedMinutesAgo) }}</span>
<template #[`item.receivedAt`]="{ item }: { item: MockDeal }">
<span class="num ld-mono-s">{{ formatDateTime(item.receivedAt) }}</span>
</template>
<template #[`header.data-table-select`]="{ allSelected, selectAll, someSelected }">
@@ -135,8 +117,8 @@ function formatCost(cost: number): string {
<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)"
:aria-label="`Выбрать сделку «${(item as MockDeal).phone}»`"
@update:model-value="() => toggleSelect(internalItem)"
/>
</template>
</v-data-table>
@@ -151,34 +133,32 @@ function formatCost(cost: number): string {
.deals-table-card {
background: #fff;
}
.num {
font-family: 'JetBrains Mono', ui-monospace, monospace;
font-feature-settings: 'tnum';
font-weight: 500;
}
.cell-deal {
.cell-source {
display: flex;
align-items: center;
padding: 6px 0;
flex-direction: column;
line-height: 1.3;
}
.deal-name {
.source-project {
font-weight: 500;
color: #081319;
}
.cell-manager {
display: flex;
align-items: center;
.source-signal {
font-size: 11px;
color: #6b6356;
}
.status-dot {
.cell-comment {
display: inline-block;
width: 6px;
height: 6px;
border-radius: 50%;
margin-right: 6px;
max-width: 240px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
vertical-align: bottom;
}
:deep(.deals-row-active) {
background: rgba(15, 110, 86, 0.07);
}
</style>
+37 -34
View File
@@ -1,7 +1,6 @@
import { describe, it, expect } from 'vitest';
import { mount } from '@vue/test-utils';
import { createVuetify } from 'vuetify';
import DealsTable from '../../resources/js/components/deals/DealsTable.vue';
import type { MockDeal } from '../../resources/js/composables/mockDeals';
@@ -9,51 +8,55 @@ const vuetify = createVuetify();
const sampleDeals: MockDeal[] = [
{
id: 1,
name: 'Иванов И.',
phone: '+7 (916) 100-00-01',
statusSlug: 'new',
project: 'B1 site',
manager: { initials: 'AD', name: 'Admin' },
cost: 1000,
receivedMinutesAgo: 5,
id: 1, name: '+7 (916) 100-00-01', phone: '+7 (916) 100-00-01', statusSlug: 'new',
project: 'Окна', manager: { initials: 'AD', name: 'Admin' }, cost: 0, receivedMinutesAgo: 5,
signalType: 'call', city: 'Москва', comment: 'звонил', receivedAt: '2026-05-15T09:00:00+00:00',
nextReminderAt: '2026-05-18T07:00:00+00:00',
},
{
id: 2,
name: 'Петров П.',
phone: '+7 (916) 100-00-02',
statusSlug: 'new',
project: 'B1 call',
manager: { initials: 'AD', name: 'Admin' },
cost: 1500,
receivedMinutesAgo: 30,
id: 2, name: '+7 (916) 100-00-02', phone: '+7 (916) 100-00-02', statusSlug: 'new',
project: 'Двери', manager: { initials: 'AD', name: 'Admin' }, cost: 0, receivedMinutesAgo: 30,
signalType: 'site', city: null, comment: null, receivedAt: '2026-05-14T09:00:00+00:00',
nextReminderAt: null,
},
];
describe('DealsTable a11y (Q.DEFER.004 sub-A)', () => {
it('select-all header checkbox has aria-label', () => {
const wrapper = mount(DealsTable, {
describe('DealsTable', () => {
it('рендерит колонки реестра лидов', () => {
const w = mount(DealsTable, {
props: { deals: sampleDeals, selectedIds: [], statusBySlug: new Map() },
global: { plugins: [vuetify] },
});
const headerCheckbox = wrapper.find(
'th .v-selection-control input[type="checkbox"][aria-label="Выбрать все сделки"]',
);
expect(headerCheckbox.exists()).toBe(true);
const headers = w.findAll('thead th').map((h) => h.text());
['Телефон', 'Источник', 'Город', 'Статус', 'Напоминание', 'Комментарий', 'Поставлен'].forEach((label) => {
expect(headers.some((h) => h.includes(label))).toBe(true);
});
});
it('each row checkbox has aria-label referencing deal name', () => {
const wrapper = mount(DealsTable, {
it('город без значения рендерится как «—»', () => {
const w = mount(DealsTable, {
props: { deals: sampleDeals, selectedIds: [], statusBySlug: new Map() },
global: { plugins: [vuetify] },
});
const rowCheckbox1 = wrapper.find(
'tbody tr:nth-of-type(1) input[type="checkbox"][aria-label="Выбрать сделку «Иванов И.»"]',
);
const rowCheckbox2 = wrapper.find(
'tbody tr:nth-of-type(2) input[type="checkbox"][aria-label="Выбрать сделку «Петров П.»"]',
);
expect(rowCheckbox1.exists()).toBe(true);
expect(rowCheckbox2.exists()).toBe(true);
expect(w.text()).toContain('—');
});
it('select-all чекбокс имеет aria-label', () => {
const w = mount(DealsTable, {
props: { deals: sampleDeals, selectedIds: [], statusBySlug: new Map() },
global: { plugins: [vuetify] },
});
expect(
w.find('th .v-selection-control input[type="checkbox"][aria-label="Выбрать все сделки"]').exists(),
).toBe(true);
});
it('клик по строке эмитит row-click с deal', async () => {
const w = mount(DealsTable, {
props: { deals: sampleDeals, selectedIds: [], statusBySlug: new Map() },
global: { plugins: [vuetify] },
});
await w.find('tbody tr').trigger('click');
expect(w.emitted('row-click')?.[0]?.[0]).toMatchObject({ id: 1 });
});
});