feat(deals): Kanban — 5-column funnel (comment + test sync)

This commit is contained in:
Дмитрий
2026-05-17 18:19:39 +03:00
parent 79248f33fe
commit f0bb4cf7b6
2 changed files with 19 additions and 19 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
<script setup lang="ts">
/**
* Канбан — альтернативный вид сделок (по статусам). 14 колонок (lead_statuses).
* Канбан — альтернативный вид сделок (по статусам). 5 колонок (lead_statuses).
*
* Источник дизайна: liderra_v8_handoff/concepts/v8_kanban.html.
* DnD реализован через vuedraggable@4 (обёртка SortableJS) — карточки можно
+18 -18
View File
@@ -28,11 +28,11 @@ describe('KanbanView.vue', () => {
expect(wrapper.find('h1').text()).toBe('Канбан');
});
it('рендерит ровно 14 KanbanColumn (по числу lead_statuses)', () => {
it('рендерит ровно 5 KanbanColumn (по числу lead_statuses)', () => {
const wrapper = factory();
const cols = wrapper.findAllComponents({ name: 'KanbanColumn' });
expect(cols).toHaveLength(LEAD_STATUSES.length);
expect(cols).toHaveLength(14);
expect(cols).toHaveLength(5);
});
it('каждая колонка получает соответствующий статус', () => {
@@ -46,7 +46,7 @@ describe('KanbanView.vue', () => {
it('содержит page-stats с числом статусов и сделок', () => {
const wrapper = factory();
const text = wrapper.text();
expect(text).toContain('14');
expect(text).toContain('5');
expect(text).toContain('статусов');
expect(text).toContain('сделок');
});
@@ -110,17 +110,17 @@ describe('KanbanView.vue', () => {
const cols = wrapper.findAllComponents({ name: 'KanbanColumn' });
// Берём сделку из первой колонки (new) и эмулируем «added» в paid-колонке.
const newCol = cols[0]; // new — sortOrder=1
const paidCol = cols.find((c) => c.props('status').slug === 'paid')!;
const wonCol = cols.find((c) => c.props('status').slug === 'won')!;
const dealToMove = (newCol.props('deals') as { id: number; statusSlug: string }[])[0];
// Эмуляция события vuedraggable@change → KanbanView.onColumnChange.
await paidCol.vm.$emit('change', {
await wonCol.vm.$emit('change', {
added: { element: dealToMove, newIndex: 0 },
});
await wrapper.vm.$nextTick();
// statusSlug сделки должен переключиться на 'paid'.
expect(dealToMove.statusSlug).toBe('paid');
// statusSlug сделки должен переключиться на 'won'.
expect(dealToMove.statusSlug).toBe('won');
});
});
@@ -160,7 +160,7 @@ describe('KanbanView DnD persist (Sprint 1 C4)', () => {
const transitionSpy = vi.spyOn(dealsApi, 'transitionDeals').mockResolvedValue({
updated: 1,
requested: 1,
status: 'hot',
status: 'in_progress',
});
const wrapper = mount(KanbanView, {
global: {
@@ -174,14 +174,14 @@ describe('KanbanView DnD persist (Sprint 1 C4)', () => {
const deal = { id: 42, statusSlug: 'new' as const, name: 'X', phone: '+79161234567', project: 'p', manager: { name: 'M', initials: 'M' }, cost: 100, receivedMinutesAgo: 5 };
// eslint-disable-next-line @typescript-eslint/no-explicit-any
await (wrapper.vm as any).onColumnChange('hot', { added: { element: deal, newIndex: 0 } });
await (wrapper.vm as any).onColumnChange('in_progress', { added: { element: deal, newIndex: 0 } });
expect(transitionSpy).toHaveBeenCalledWith({
tenant_id: 7,
ids: [42],
status: 'hot',
status: 'in_progress',
});
expect(deal.statusSlug).toBe('hot');
expect(deal.statusSlug).toBe('in_progress');
});
it('onColumnChange reverts statusSlug + opens toast when API rejects', async () => {
@@ -200,15 +200,15 @@ describe('KanbanView DnD persist (Sprint 1 C4)', () => {
// Имитируем vuedraggable mutation: карточка уже в target column до вызова onColumnChange.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const vm = wrapper.vm as any;
if (!vm.dealsByStatus.hot) vm.dealsByStatus.hot = [];
vm.dealsByStatus.hot.push(deal);
if (!vm.dealsByStatus.in_progress) vm.dealsByStatus.in_progress = [];
vm.dealsByStatus.in_progress.push(deal);
await vm.onColumnChange('hot', { added: { element: deal, newIndex: 0 } });
await vm.onColumnChange('in_progress', { added: { element: deal, newIndex: 0 } });
// statusSlug rolled back
expect(deal.statusSlug).toBe('new');
// Card removed from target column (array-revert branch coverage)
expect(vm.dealsByStatus.hot.findIndex((d: { id: number }) => d.id === 43)).toBe(-1);
expect(vm.dealsByStatus.in_progress.findIndex((d: { id: number }) => d.id === 43)).toBe(-1);
// Card restored to source column
expect(vm.dealsByStatus.new.findIndex((d: { id: number }) => d.id === 43)).toBeGreaterThanOrEqual(0);
// Toast shown
@@ -218,7 +218,7 @@ describe('KanbanView DnD persist (Sprint 1 C4)', () => {
it('onColumnChange skips API call if no auth.user.tenant_id', async () => {
const transitionSpy = vi.spyOn(dealsApi, 'transitionDeals').mockResolvedValue({
updated: 1, requested: 1, status: 'hot',
updated: 1, requested: 1, status: 'in_progress',
});
const wrapper = mount(KanbanView, {
global: {
@@ -232,10 +232,10 @@ describe('KanbanView DnD persist (Sprint 1 C4)', () => {
const deal = { id: 44, statusSlug: 'new' as const, name: 'Z', phone: '+79161234567', project: 'p', manager: { name: 'M', initials: 'M' }, cost: 100, receivedMinutesAgo: 5 };
// eslint-disable-next-line @typescript-eslint/no-explicit-any
await (wrapper.vm as any).onColumnChange('hot', { added: { element: deal, newIndex: 0 } });
await (wrapper.vm as any).onColumnChange('in_progress', { added: { element: deal, newIndex: 0 } });
// Без auth — только optimistic local change, API не зовётся
expect(transitionSpy).not.toHaveBeenCalled();
expect(deal.statusSlug).toBe('hot');
expect(deal.statusSlug).toBe('in_progress');
});
});