Files
portal/app/resources/js/views/KanbanView.vue
T
Дмитрий 0540441436
Accessibility (Pa11y live) / a11y (push) Has been cancelled
SAST — Semgrep / Semgrep SAST scan (push) Has been cancelled
fix(ui): убрать кнопку «Новая сделка» с Канбана — «Обновить» к правому краю
На странице Канбан кнопка создания сделки в правом верхнем углу не нужна —
убрана. Оставшаяся «Обновить» прижимается к правому краю прежним
space-between заголовка, отступ — обычный pa-6 контейнера.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 12:00:53 +03:00

342 lines
12 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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>