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:
@@ -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>
|
||||
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user