Files
portal/app/resources/js/views/KanbanView.vue
T
Дмитрий 6387706be6 fix(a11y): .sep dot separator contrast 2.92:1 → 5.33:1 (Pattern B)
A11y rescan Pattern B — scoped CSS `.sep { color: #92907b; }` повторяется
в 8 компонентах (page-stats / page-meta / hero-meta containers с точкой-
разделителем `·`). На ivory page background #f6f3ec даёт contrast
2.92:1, ниже WCAG 2.1 AA 4.5:1 threshold.

Fix: #92907b → #6b6356 — same warm-grey hue, darker tone, gives
5.33:1 contrast. 8 files:

- views/{DealsView,BillingView,KanbanView,ReportsView}.vue
- components/dashboard/DashboardPageHead.vue
- components/deals/DealDetailHero.vue
- components/admin/tenants/TenantsStatsHeader.vue
- components/admin/tenant-detail/TenantDetailHeader.vue

Closes Pa11y «color-contrast» violations на /dashboard /billing /reports
(8 .sep elements total flagged).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 10:07:11 +03:00

244 lines
8.9 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">
/**
* Канбан — альтернативный вид сделок (по статусам). 14 колонок (lead_statuses).
*
* Источник дизайна: liderra_v8_handoff/concepts/v8_kanban.html.
* DnD реализован через vuedraggable@4 (обёртка SortableJS) — карточки можно
* перетаскивать между колонками. При drop:
* - событие 'added' в целевой колонке → меняем `statusSlug` сделки.
* - событие 'removed' в исходной колонке → ничего не делаем (обработано в added).
* - событие 'moved' внутри одной колонки → только смена порядка (statusSlug
* не меняется; на API будущем — PATCH /api/deals/{id} {sort_order}).
*
* Не входит в этот коммит:
* - PATCH /api/deals/{id} {status_slug} при drop — backend.
* - Filters (Проект/Менеджер) — общий filter-bar с DealsView.
* - DealDetailDrawer на click по карточке (event @open-deal).
*/
import { computed, onMounted, reactive, ref } from 'vue';
import { LEAD_STATUSES } from '../composables/leadStatuses';
import { MOCK_DEALS, 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). Deep-clone объектов
// сделок чтобы не мутировать MOCK_DEALS const при DnD.
const dealsByStatus = reactive<Record<string, MockDeal[]>>(
LEAD_STATUSES.reduce<Record<string, MockDeal[]>>((acc, s) => {
acc[s.slug] = MOCK_DEALS.filter((d) => d.statusSlug === s.slug).map((d) => ({ ...d }));
return acc;
}, {}),
);
function onColumnChange(targetSlug: MockDeal['statusSlug'], event: DraggableChangeEvent) {
if (event.added) {
// Карточка переехала в эту колонку → синхронизируем statusSlug.
// На production будет POST /api/deals/{id}/transition с проверкой allowed-переходов.
event.added.element.statusSlug = targetSlug;
}
// 'removed' и 'moved' — обрабатываются автоматически через v-model
// (vuedraggable мутирует array; reactive triggers re-render).
}
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(MOCK_DEALS.length);
const fetchError = ref(false);
const newDealOpen = ref(false);
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; // оставляем MOCK_DEALS как fallback
}
}
onMounted(() => {
void leadStatusesStore.load();
void loadDeals();
});
usePolling(loadDeals);
defineExpose({ dealsByStatus, totalDeals, newDealOpen, onDealCreated, fetchError, loadDeals });
</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>
<v-btn
color="primary"
variant="flat"
prepend-icon="mdi-plus"
data-testid="new-deal-btn"
@click="newDealOpen = true"
>
Новая сделка
</v-btn>
</div>
</header>
<v-alert
v-if="fetchError"
type="warning"
variant="tonal"
density="compact"
closable
class="mt-3"
data-testid="fetch-error-alert"
>
Backend недоступен показаны mock-данные.
</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" />
<NewDealDialog v-model="newDealOpen" :tenant-id="auth.user?.tenant_id" @created="onDealCreated" />
</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>