6387706be6
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>
244 lines
8.9 KiB
Vue
244 lines
8.9 KiB
Vue
<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>
|