0540441436
На странице Канбан кнопка создания сделки в правом верхнем углу не нужна — убрана. Оставшаяся «Обновить» прижимается к правому краю прежним space-between заголовка, отступ — обычный pa-6 контейнера. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
342 lines
12 KiB
Vue
342 lines
12 KiB
Vue
<script setup lang="ts">
|
||
/**
|
||
* Канбан — альтернативный вид сделок (по статусам). 5 колонок (lead_statuses).
|
||
*
|
||
* Источник дизайна: liderra_v8_handoff/concepts/v8_kanban.html.
|
||
* DnD реализован через vuedraggable@4 (обёртка SortableJS) — карточки можно
|
||
* перетаскивать между колонками. При drop:
|
||
* - событие 'added' в целевой колонке → optimistic update statusSlug +
|
||
* POST /api/deals/transition (через dealsApi). На failure — revert:
|
||
* карточка возвращается в исходную колонку + toast «Не удалось переместить».
|
||
* Без auth.user.tenant_id — local-only mode (API не зовётся).
|
||
* - событие 'removed' в исходной колонке → ничего не делаем (обработано в added).
|
||
* - событие 'moved' внутри одной колонки → только смена порядка (statusSlug
|
||
* не меняется; на API будущем — PATCH /api/deals/{id} {sort_order}).
|
||
*
|
||
* Не входит в этот коммит:
|
||
* - PATCH /api/deals/{id} {sort_order} при moved (intra-column reorder) — backend.
|
||
* - Filters (Проект/Менеджер) — общий filter-bar с DealsView.
|
||
* - DealDetailDrawer на click по карточке (event @open-deal).
|
||
*/
|
||
import { computed, onMounted, reactive, ref } from 'vue';
|
||
import { LEAD_STATUSES } from '../composables/leadStatuses';
|
||
import { type MockDeal } from '../composables/mockDeals';
|
||
import { mapApiDeal } from '../composables/dealsApiMapper';
|
||
import { usePolling } from '../composables/usePolling';
|
||
import * as dealsApi from '../api/deals';
|
||
import KanbanColumn from '../components/kanban/KanbanColumn.vue';
|
||
// NB: DealDetailDrawer и NewDealDialog оставлены sync — Vitest падает с
|
||
// EnvironmentTeardownError при async-загрузке chunk'ов после teardown jsdom
|
||
// (KanbanView.spec.ts). Sprint 2 Phase B / O-perf-06 split применён к DealsView.
|
||
import DealDetailDrawer from '../components/deals/DealDetailDrawer.vue';
|
||
import NewDealDialog from '../components/deals/NewDealDialog.vue';
|
||
import { useAuthStore } from '../stores/auth';
|
||
import { useLeadStatusesStore } from '../stores/leadStatuses';
|
||
|
||
const auth = useAuthStore();
|
||
const leadStatusesStore = useLeadStatusesStore();
|
||
const leadStatuses = computed(() => leadStatusesStore.statuses);
|
||
|
||
interface DraggableChangeEvent {
|
||
added?: { element: MockDeal; newIndex: number };
|
||
removed?: { element: MockDeal; oldIndex: number };
|
||
moved?: { element: MockDeal; oldIndex: number; newIndex: number };
|
||
}
|
||
|
||
// Reactive Record<slug, MockDeal[]> — отдельный массив для каждой колонки
|
||
// (vuedraggable v-model требует независимые arrays).
|
||
const dealsByStatus = reactive<Record<string, MockDeal[]>>(
|
||
LEAD_STATUSES.reduce<Record<string, MockDeal[]>>((acc, s) => {
|
||
acc[s.slug] = [];
|
||
return acc;
|
||
}, {}),
|
||
);
|
||
|
||
/**
|
||
* 18.05.2026 ux — inline status picker в drawer (DealDetailHero).
|
||
* При смене статуса через drawer — переносим карточку между колонками
|
||
* Канбана (vuedraggable arrays) + API call + rollback.
|
||
*/
|
||
async function onDrawerStatusChanged(slug: string): Promise<void> {
|
||
if (!selectedDeal.value) return;
|
||
const deal = selectedDeal.value;
|
||
const prev = deal.statusSlug;
|
||
if (prev === slug) return;
|
||
const next = slug as MockDeal['statusSlug'];
|
||
|
||
// Optimistic: переносим карточку между колонками.
|
||
const fromCol = dealsByStatus[prev];
|
||
const toCol = dealsByStatus[next];
|
||
if (fromCol && toCol) {
|
||
const idx = fromCol.findIndex((d) => d.id === deal.id);
|
||
if (idx >= 0) fromCol.splice(idx, 1);
|
||
deal.statusSlug = next;
|
||
toCol.unshift(deal);
|
||
} else {
|
||
deal.statusSlug = next;
|
||
}
|
||
|
||
if (!auth.user?.tenant_id) return;
|
||
try {
|
||
await dealsApi.transitionDeals({ tenant_id: auth.user.tenant_id, ids: [deal.id], status: next });
|
||
transitionToastColor.value = 'success';
|
||
transitionToastText.value = 'Статус обновлён.';
|
||
transitionToastOpen.value = true;
|
||
} catch {
|
||
// Rollback: вернуть карточку обратно.
|
||
deal.statusSlug = prev;
|
||
if (fromCol && toCol) {
|
||
const idx = toCol.findIndex((d) => d.id === deal.id);
|
||
if (idx >= 0) toCol.splice(idx, 1);
|
||
if (!fromCol.find((d) => d.id === deal.id)) fromCol.push(deal);
|
||
}
|
||
transitionToastColor.value = 'warning';
|
||
transitionToastText.value = 'Не удалось изменить статус — возвращено как было.';
|
||
transitionToastOpen.value = true;
|
||
}
|
||
}
|
||
|
||
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);
|
||
}
|
||
transitionToastColor.value = 'warning';
|
||
transitionToastText.value = `Не удалось переместить сделку #${dealItem.id} — восстановлен исходный статус.`;
|
||
transitionToastOpen.value = true;
|
||
}
|
||
}
|
||
|
||
const drawerOpen = ref(false);
|
||
const selectedDeal = ref<MockDeal | null>(null);
|
||
|
||
function onOpenDeal(id: number) {
|
||
// Найти deal среди всех reactive-массивов dealsByStatus.
|
||
for (const slug of Object.keys(dealsByStatus)) {
|
||
const found = dealsByStatus[slug].find((d) => d.id === id);
|
||
if (found) {
|
||
selectedDeal.value = found;
|
||
drawerOpen.value = true;
|
||
return;
|
||
}
|
||
}
|
||
}
|
||
|
||
const totalDeals = ref(0);
|
||
const fetchError = ref(false);
|
||
|
||
const newDealOpen = ref(false);
|
||
|
||
// Sprint 1 C4: revert-on-fail toast при DnD-fail + success-toast смены статуса (UI-аудит).
|
||
const transitionToastOpen = ref(false);
|
||
const transitionToastText = ref('');
|
||
const transitionToastColor = ref<'warning' | 'success'>('warning');
|
||
|
||
function onDealCreated(deal: MockDeal) {
|
||
if (!dealsByStatus[deal.statusSlug]) dealsByStatus[deal.statusSlug] = [];
|
||
dealsByStatus[deal.statusSlug].unshift(deal);
|
||
totalDeals.value++;
|
||
}
|
||
|
||
async function loadDeals() {
|
||
if (!auth.user?.tenant_id) return;
|
||
fetchError.value = false;
|
||
try {
|
||
const { deals, total } = await dealsApi.listDeals({ tenantId: auth.user.tenant_id, limit: 500 });
|
||
const mapped = deals.map((d) => mapApiDeal(d));
|
||
// Очищаем все колонки и распределяем заново.
|
||
for (const slug of Object.keys(dealsByStatus)) {
|
||
dealsByStatus[slug].splice(0, dealsByStatus[slug].length);
|
||
}
|
||
for (const deal of mapped) {
|
||
if (!dealsByStatus[deal.statusSlug]) dealsByStatus[deal.statusSlug] = [];
|
||
dealsByStatus[deal.statusSlug].push(deal);
|
||
}
|
||
totalDeals.value = total;
|
||
} catch {
|
||
fetchError.value = true; // state остаётся пустым — показываем error-alert
|
||
}
|
||
}
|
||
|
||
onMounted(() => {
|
||
void leadStatusesStore.load();
|
||
void loadDeals();
|
||
});
|
||
|
||
usePolling(loadDeals);
|
||
|
||
defineExpose({
|
||
dealsByStatus,
|
||
totalDeals,
|
||
newDealOpen,
|
||
onDealCreated,
|
||
fetchError,
|
||
loadDeals,
|
||
onColumnChange,
|
||
transitionToastOpen,
|
||
transitionToastText,
|
||
});
|
||
</script>
|
||
|
||
<template>
|
||
<v-container fluid class="kanban pa-6">
|
||
<header class="page-head">
|
||
<div>
|
||
<h1 class="text-h4 mb-2 page-title">Канбан</h1>
|
||
<div class="page-stats text-body-2 text-medium-emphasis">
|
||
<span
|
||
><span class="num">{{ leadStatuses.length }}</span> статусов</span
|
||
>
|
||
<span class="sep">·</span>
|
||
<span
|
||
><span class="num">{{ totalDeals }}</span> сделок</span
|
||
>
|
||
<span class="sep">·</span>
|
||
<span class="text-caption text-medium-emphasis"> Перетаскивание между колонками </span>
|
||
</div>
|
||
</div>
|
||
<div class="d-flex ga-2">
|
||
<v-btn variant="outlined" prepend-icon="mdi-refresh" data-testid="reload-btn" @click="loadDeals">
|
||
Обновить
|
||
</v-btn>
|
||
</div>
|
||
</header>
|
||
|
||
<v-alert
|
||
v-if="fetchError"
|
||
type="warning"
|
||
variant="tonal"
|
||
density="compact"
|
||
closable
|
||
class="mt-3"
|
||
data-testid="fetch-error-alert"
|
||
>
|
||
Не удалось загрузить сделки. Попробуйте обновить.
|
||
</v-alert>
|
||
|
||
<div class="kanban-board mt-4" tabindex="0" role="region" aria-label="Канбан-доска воронки продаж">
|
||
<KanbanColumn
|
||
v-for="status in leadStatuses"
|
||
:key="status.slug"
|
||
:status="status"
|
||
:deals="dealsByStatus[status.slug] || []"
|
||
@update:deals="(list) => (dealsByStatus[status.slug] = list)"
|
||
@change="(e) => onColumnChange(status.slug, e)"
|
||
@open-deal="onOpenDeal"
|
||
/>
|
||
</div>
|
||
|
||
<DealDetailDrawer
|
||
v-model:open="drawerOpen"
|
||
:deal="selectedDeal"
|
||
:tenant-id="auth.user?.tenant_id"
|
||
@status-changed="onDrawerStatusChanged"
|
||
/>
|
||
|
||
<NewDealDialog v-model="newDealOpen" :tenant-id="auth.user?.tenant_id" @created="onDealCreated" />
|
||
|
||
<v-snackbar
|
||
v-model="transitionToastOpen"
|
||
:timeout="4000"
|
||
:color="transitionToastColor"
|
||
location="bottom right"
|
||
data-testid="kanban-transition-toast"
|
||
>
|
||
{{ transitionToastText }}
|
||
</v-snackbar>
|
||
</v-container>
|
||
</template>
|
||
|
||
<style scoped>
|
||
.kanban {
|
||
max-width: 100%;
|
||
height: 100%;
|
||
display: flex;
|
||
flex-direction: column;
|
||
}
|
||
|
||
.page-head {
|
||
display: flex;
|
||
align-items: flex-start;
|
||
justify-content: space-between;
|
||
flex-wrap: wrap;
|
||
gap: 16px;
|
||
}
|
||
|
||
.page-title {
|
||
font-variation-settings: 'opsz' 28;
|
||
letter-spacing: -0.018em;
|
||
}
|
||
|
||
.page-stats {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
gap: 6px;
|
||
align-items: center;
|
||
}
|
||
.page-stats .sep {
|
||
/* WCAG2AA 4.5:1 fix (was #92907b → 2.92:1 on ivory; #6b6356 → 5.33:1). */
|
||
color: #6b6356;
|
||
}
|
||
|
||
.num {
|
||
font-family: 'JetBrains Mono', ui-monospace, monospace;
|
||
font-feature-settings: 'tnum';
|
||
font-weight: 500;
|
||
}
|
||
|
||
.kanban-board {
|
||
display: flex;
|
||
gap: 12px;
|
||
overflow-x: auto;
|
||
overflow-y: hidden;
|
||
flex: 1 1 auto;
|
||
min-height: 600px;
|
||
padding-bottom: 12px;
|
||
}
|
||
|
||
.kanban-board::-webkit-scrollbar {
|
||
height: 8px;
|
||
}
|
||
.kanban-board::-webkit-scrollbar-track {
|
||
background: transparent;
|
||
}
|
||
.kanban-board::-webkit-scrollbar-thumb {
|
||
background: #d9d5cd;
|
||
border-radius: 4px;
|
||
}
|
||
.kanban-board::-webkit-scrollbar-thumb:hover {
|
||
background: #92907b;
|
||
}
|
||
</style>
|