feat(deals): DealsTable — lead-registry columns (Телефон/Источник/Город/Статус/Напоминание/Комментарий/Поставлен)
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user