feat(kanban): C4 — persist DnD status changes via POST /api/deals/transition

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) <noreply@anthropic.com>
This commit is contained in:
Дмитрий
2026-05-15 08:51:21 +03:00
parent c09c52ea76
commit 9068005566
2 changed files with 146 additions and 9 deletions
+62 -8
View File
@@ -50,14 +50,44 @@ const dealsByStatus = reactive<Record<string, MockDeal[]>>(
}, {}),
);
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,
});
</script>
<template>
@@ -176,6 +220,16 @@ defineExpose({ dealsByStatus, totalDeals, newDealOpen, onDealCreated, fetchError
<DealDetailDrawer v-model:open="drawerOpen" :deal="selectedDeal" :tenant-id="auth.user?.tenant_id" />
<NewDealDialog v-model="newDealOpen" :tenant-id="auth.user?.tenant_id" @created="onDealCreated" />
<v-snackbar
v-model="transitionToastOpen"
:timeout="4000"
color="warning"
location="bottom right"
data-testid="kanban-transition-toast"
>
{{ transitionToastText }}
</v-snackbar>
</v-container>
</template>
+84 -1
View File
@@ -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');
});
});