From fbe5e9e17db1d1d608e5a56fdf072bf08d8a6030 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=94=D0=BC=D0=B8=D1=82=D1=80=D0=B8=D0=B9?= Date: Fri, 15 May 2026 08:51:21 +0300 Subject: [PATCH] =?UTF-8?q?feat(kanban):=20C4=20=E2=80=94=20persist=20DnD?= =?UTF-8?q?=20status=20changes=20via=20POST=20/api/deals/transition?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Drag-drop между колонками теперь сохраняется в БД через существующий DealBulkActionController@transition endpoint (single-element массив). Optimistic UI update (statusSlug меняется сразу) + revert-on-fail с toast «Не удалось переместить — восстановлен исходный статус». Без auth.user.tenant_id (dev/demo без login) — local-only mode, API не зовётся (graceful degradation). +3 Vitest specs в KanbanView.spec.ts (success / revert / no-auth skip). Pest covered by existing DealTransitionTest. Регрессий 0. Closes audit ID C4 from docs/superpowers/specs/2026-05-15-portal-audit-design.md. Co-Authored-By: Claude Opus 4.7 (1M context) --- app/resources/js/views/KanbanView.vue | 70 +++++++++++++++++++--- app/tests/Frontend/KanbanView.spec.ts | 85 ++++++++++++++++++++++++++- 2 files changed, 146 insertions(+), 9 deletions(-) diff --git a/app/resources/js/views/KanbanView.vue b/app/resources/js/views/KanbanView.vue index 172c42d8..6a3d1022 100644 --- a/app/resources/js/views/KanbanView.vue +++ b/app/resources/js/views/KanbanView.vue @@ -50,14 +50,44 @@ const dealsByStatus = reactive>( }, {}), ); -function onColumnChange(targetSlug: MockDeal['statusSlug'], event: DraggableChangeEvent) { - if (event.added) { - // Карточка переехала в эту колонку → синхронизируем statusSlug. - // На production будет POST /api/deals/{id}/transition с проверкой allowed-переходов. - event.added.element.statusSlug = targetSlug; +async function onColumnChange(targetSlug: MockDeal['statusSlug'], event: DraggableChangeEvent) { + if (!event.added) { + // 'removed' и 'moved' — vuedraggable мутирует array; reactive triggers re-render. + return; + } + + const dealItem = event.added.element; + const previousSlug = dealItem.statusSlug; + + // Optimistic: меняем статус в local state сразу (UX отвечает мгновенно). + dealItem.statusSlug = targetSlug; + + // Без auth — local-only mode (dev/demo без tenant context). API не зовём. + if (!auth.user?.tenant_id) return; + + try { + await dealsApi.transitionDeals({ + tenant_id: auth.user.tenant_id, + ids: [dealItem.id], + status: targetSlug, + }); + // success — статус уже применён, тостить не нужно. + } catch { + // Revert на исходный статус + переместить карточку обратно в исходную колонку. + dealItem.statusSlug = previousSlug; + // Найдём целевую колонку и удалим из неё карточку, потом вернём в исходную. + const targetCol = dealsByStatus[targetSlug]; + if (targetCol) { + const idx = targetCol.findIndex((d) => d.id === dealItem.id); + if (idx >= 0) targetCol.splice(idx, 1); + } + const sourceCol = dealsByStatus[previousSlug]; + if (sourceCol && !sourceCol.find((d) => d.id === dealItem.id)) { + sourceCol.push(dealItem); + } + transitionToastText.value = `Не удалось переместить сделку #${dealItem.id} — восстановлен исходный статус.`; + transitionToastOpen.value = true; } - // 'removed' и 'moved' — обрабатываются автоматически через v-model - // (vuedraggable мутирует array; reactive triggers re-render). } const drawerOpen = ref(false); @@ -80,6 +110,10 @@ const fetchError = ref(false); const newDealOpen = ref(false); +// Sprint 1 C4: revert-on-fail toast при DnD-fail. +const transitionToastOpen = ref(false); +const transitionToastText = ref(''); + function onDealCreated(deal: MockDeal) { if (!dealsByStatus[deal.statusSlug]) dealsByStatus[deal.statusSlug] = []; dealsByStatus[deal.statusSlug].unshift(deal); @@ -113,7 +147,17 @@ onMounted(() => { usePolling(loadDeals); -defineExpose({ dealsByStatus, totalDeals, newDealOpen, onDealCreated, fetchError, loadDeals }); +defineExpose({ + dealsByStatus, + totalDeals, + newDealOpen, + onDealCreated, + fetchError, + loadDeals, + onColumnChange, + transitionToastOpen, + transitionToastText, +}); diff --git a/app/tests/Frontend/KanbanView.spec.ts b/app/tests/Frontend/KanbanView.spec.ts index 887ae64f..3c2706c1 100644 --- a/app/tests/Frontend/KanbanView.spec.ts +++ b/app/tests/Frontend/KanbanView.spec.ts @@ -1,8 +1,10 @@ -import { describe, it, expect } from 'vitest'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; import { mount } from '@vue/test-utils'; import { createVuetify } from 'vuetify'; import { createPinia, setActivePinia } from 'pinia'; import KanbanView from '../../resources/js/views/KanbanView.vue'; +import { useAuthStore } from '../../resources/js/stores/auth'; +import * as dealsApi from '../../resources/js/api/deals'; import { LEAD_STATUSES } from '../../resources/js/composables/leadStatuses'; describe('KanbanView.vue', () => { @@ -111,3 +113,84 @@ describe('KanbanView.vue', () => { expect(dealToMove.statusSlug).toBe('paid'); }); }); + +describe('KanbanView DnD persist (Sprint 1 C4)', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('onColumnChange triggers dealsApi.transitionDeals with [dealId] and target status', async () => { + const transitionSpy = vi.spyOn(dealsApi, 'transitionDeals').mockResolvedValue({ + updated: 1, + requested: 1, + status: 'hot', + }); + const wrapper = mount(KanbanView, { + global: { + plugins: [createPinia(), createVuetify()], + stubs: { KanbanColumn: true, DealDetailDrawer: true, NewDealDialog: true }, + }, + }); + const auth = useAuthStore(); + auth.user = { id: 99, tenant_id: 7, email: 'demo@demo.local' } as never; + await new Promise((r) => setTimeout(r, 30)); + + 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 } }); + + expect(transitionSpy).toHaveBeenCalledWith({ + tenant_id: 7, + ids: [42], + status: 'hot', + }); + expect(deal.statusSlug).toBe('hot'); + }); + + it('onColumnChange reverts statusSlug + opens toast when API rejects', async () => { + vi.spyOn(dealsApi, 'transitionDeals').mockRejectedValue(new Error('500')); + const wrapper = mount(KanbanView, { + global: { + plugins: [createPinia(), createVuetify()], + stubs: { KanbanColumn: true, DealDetailDrawer: true, NewDealDialog: true }, + }, + }); + const auth = useAuthStore(); + auth.user = { id: 99, tenant_id: 7, email: 'demo@demo.local' } as never; + await new Promise((r) => setTimeout(r, 30)); + + const deal = { id: 43, statusSlug: 'new' as const, name: 'Y', 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 } }); + + // После failure — statusSlug откатывается на оригинал + expect(deal.statusSlug).toBe('new'); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + expect((wrapper.vm as any).transitionToastOpen).toBe(true); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + expect((wrapper.vm as any).transitionToastText).toContain('Не удалось'); + }); + + 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', + }); + const wrapper = mount(KanbanView, { + global: { + plugins: [createPinia(), createVuetify()], + stubs: { KanbanColumn: true, DealDetailDrawer: true, NewDealDialog: true }, + }, + }); + const auth = useAuthStore(); + auth.user = null; + await new Promise((r) => setTimeout(r, 30)); + + 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 } }); + + // Без auth — только optimistic local change, API не зовётся + expect(transitionSpy).not.toHaveBeenCalled(); + expect(deal.statusSlug).toBe('hot'); + }); +});