Files
portal/app/resources/js/views/DealsView.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

646 lines
25 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">
/**
* Список сделок — центральный экран CRM. Используется менеджерами ежедневно.
*
* Источник дизайна: liderra_v8_handoff/concepts/v8_deals.html.
* MVP: page-head + chiprow со срезами + поиск + v-data-table с mock'ами.
*
* Не входит в этот коммит (отдельные TODO):
* - Drawer с деталями сделки при клике на строку (правая панель v-navigation-drawer right).
* - Filter-popover'ы для Проект/Менеджер/Ещё (полноценный multi-select).
* - Sort по Стоимость/Время через v-data-table sort-by.
* - Экспорт в XLSX (требует backend endpoint и xlsx-библиотеку).
*
* Источник статусов — composables/leadStatuses.ts (snapshot из db/schema.sql:2130).
*/
import { computed, defineAsyncComponent, onMounted, reactive, ref } from 'vue';
import { DEALS_TABS, MOCK_DEALS, type MockDeal } from '../composables/mockDeals';
import { mapApiDeal } from '../composables/dealsApiMapper';
import { usePolling } from '../composables/usePolling';
// Sprint 2 Phase B / O-perf-06: lazy-imports для тяжёлых компонентов, гейтящихся
// через v-model (открываются по action, не на mount). DealDetailDrawer (580 строк)
// и NewDealDialog отдельные chunk'и — initial bundle DealsView меньше.
const DealDetailDrawer = defineAsyncComponent(() => import('../components/deals/DealDetailDrawer.vue'));
const NewDealDialog = defineAsyncComponent(() => import('../components/deals/NewDealDialog.vue'));
import DealsFilters from '../components/deals/DealsFilters.vue';
import DealsBulkBar from '../components/deals/DealsBulkBar.vue';
import DealsTable from '../components/deals/DealsTable.vue';
import FilterChip from '../components/ui/FilterChip.vue';
import DensityToggle from '../components/ui/DensityToggle.vue';
import StatusPill from '../components/ui/StatusPill.vue';
import { useDensity } from '../composables/useDensity';
import { useAuthStore } from '../stores/auth';
import { useLeadStatusesStore } from '../stores/leadStatuses';
import * as dealsApi from '../api/deals';
import { buildCsvString, triggerBlobDownload, triggerCsvDownload } from '../composables/useCsvDownload';
// Task 15: density-toggle composable (persists в localStorage, влияет на row height).
const { rowHeight } = useDensity();
// Task 15: stub-обработчики redesign-filter-chip'ов. На I1 — popover'ы Проект/Менеджер
// не реализованы; chiprow служит quiet-luxury визуальной заменой для status-summary'ов.
// Не ломает существующие VSelect'ы в DealsFilters — те остаются как полноценный filter UI.
function onRedesignFilterClick(name: string): void {
console.log(`[redesign filterbar] ${name} clicked — popover TBD`);
}
const auth = useAuthStore();
const leadStatusesStore = useLeadStatusesStore();
const activeTab = ref<(typeof DEALS_TABS)[number]['id']>('active');
const searchQuery = ref('');
const selected = ref<number[]>([]);
// Smart-filters (multi-select) — null = «все»
const filterProjects = ref<string[]>([]);
const filterManagers = ref<string[]>([]);
// Локальная reactive-копия. При наличии auth.user.tenant_id — fetch через
// API (см. onMounted ниже); на network/500 — fallback на MOCK_DEALS чтобы UI
// оставался работоспособным (полезно для dev и Vitest jsdom-среды).
const dealsState = reactive<MockDeal[]>(MOCK_DEALS.map((d) => ({ ...d, manager: { ...d.manager } })));
const loading = ref(false);
const fetchError = ref(false);
// Trash-mode: показываем только soft-deleted. Toggle через btn в page-head.
const trashMode = ref(false);
async function loadDeals() {
if (!auth.user?.tenant_id) return;
loading.value = true;
fetchError.value = false;
try {
const { deals } = await dealsApi.listDeals({
tenantId: auth.user.tenant_id,
limit: 200,
onlyDeleted: trashMode.value,
});
const mapped = deals.map((d) => mapApiDeal(d));
dealsState.splice(0, dealsState.length, ...mapped);
} catch {
fetchError.value = true; // оставляем MOCK_DEALS как fallback
} finally {
loading.value = false;
}
}
function toggleTrashMode() {
trashMode.value = !trashMode.value;
selected.value = [];
void loadDeals();
}
async function applyBulkRestoreFromTrash() {
const ids = [...selected.value];
if (ids.length === 0) return;
// Optimistic: убираем из текущего списка (пользователь сейчас в trash-mode).
const idSet = new Set(ids);
for (let i = dealsState.length - 1; i >= 0; i--) {
if (idSet.has(dealsState[i].id)) dealsState.splice(i, 1);
}
selected.value = [];
if (!auth.user?.tenant_id) return;
try {
const res = await dealsApi.bulkRestoreDeals({
tenant_id: auth.user.tenant_id,
ids,
});
deleteToastText.value = `Восстановлено ${res.restored} из ${res.requested}.`;
} catch {
deleteToastText.value = 'Не удалось восстановить — изменения только локально.';
}
deleteToastOpen.value = true;
}
onMounted(() => {
void leadStatusesStore.load();
void loadDeals();
});
// Polling — каждые 30 сек авто-refresh dealsState. Pause при скрытой вкладке.
// Включается только при наличии auth.user (без auth listDeals = no-op anyway).
usePolling(loadDeals);
// Уникальные проекты и менеджеры из dealsState — динамически (после bulk-edit
// статус-смены состав не меняется, но after «Новая сделка» — может).
const availableProjects = computed(() => Array.from(new Set(dealsState.map((d) => d.project))).sort());
const availableManagers = computed(() => {
const seen = new Map<string, string>();
dealsState.forEach((d) => seen.set(d.manager.name, d.manager.initials));
return Array.from(seen.entries())
.map(([name, initials]) => ({ name, initials }))
.sort((a, b) => a.name.localeCompare(b.name));
});
function clearFilters() {
filterProjects.value = [];
filterManagers.value = [];
}
const drawerOpen = ref(false);
const selectedDeal = ref<MockDeal | null>(null);
// Bulk-actions state
const statusMenuOpen = ref(false);
const deleteConfirmOpen = ref(false);
const exportToastOpen = ref(false);
const exportToastText = ref('');
const statusToastOpen = ref(false);
const statusToastText = ref('');
const deleteToastOpen = ref(false);
const deleteToastText = ref('');
// Snapshot для undo последнего bulk-delete (auto-clear через timeout snackbar'а).
const lastDeletedIds = ref<number[]>([]);
const lastDeletedSnapshot = ref<MockDeal[]>([]);
// New-deal dialog state
const newDealOpen = ref(false);
function onDealCreated(deal: MockDeal) {
dealsState.unshift(deal);
}
function openDeal(deal: MockDeal) {
selectedDeal.value = deal;
drawerOpen.value = true;
}
async function applyBulkStatus(slug: MockDeal['statusSlug']) {
const ids = [...selected.value];
statusMenuOpen.value = false;
// Optimistic local-update — UI отвечает сразу, API на фоне.
ids.forEach((id) => {
const deal = dealsState.find((d) => d.id === id);
if (deal) deal.statusSlug = slug;
});
if (!auth.user?.tenant_id || ids.length === 0) return;
try {
const res = await dealsApi.transitionDeals({
tenant_id: auth.user.tenant_id,
ids,
status: slug,
});
statusToastText.value = `Обновлено ${res.updated} из ${res.requested}.`;
} catch {
statusToastText.value = 'Не удалось сохранить статус — изменения только локально.';
}
statusToastOpen.value = true;
}
async function applyBulkDelete() {
const ids = [...selected.value];
deleteConfirmOpen.value = false;
// Snapshot удалённых сделок для undo (deep clone — manager.* nested object).
const removed: MockDeal[] = [];
const idSet = new Set(ids);
for (let i = dealsState.length - 1; i >= 0; i--) {
if (idSet.has(dealsState[i].id)) {
removed.unshift({ ...dealsState[i], manager: { ...dealsState[i].manager } });
dealsState.splice(i, 1);
}
}
selected.value = [];
if (!auth.user?.tenant_id || ids.length === 0) {
// Local-only mode — undo тоже только local.
if (removed.length > 0) {
lastDeletedIds.value = ids;
lastDeletedSnapshot.value = removed;
deleteToastText.value = `Удалено ${removed.length} сделок (локально).`;
deleteToastOpen.value = true;
}
return;
}
try {
const res = await dealsApi.bulkDeleteDeals({
tenant_id: auth.user.tenant_id,
ids,
});
// Сохраняем для undo (последний bulk-delete).
lastDeletedIds.value = ids;
lastDeletedSnapshot.value = removed;
deleteToastText.value = `Удалено ${res.deleted} из ${res.requested}.`;
} catch {
// Локальный update НЕ откатываем (UX-pattern). Undo тоже доступен —
// пользователь восстановит на backend если он позже стал доступен.
lastDeletedIds.value = ids;
lastDeletedSnapshot.value = removed;
deleteToastText.value = 'Не удалось удалить — изменения только локально.';
}
deleteToastOpen.value = true;
}
async function undoBulkDelete() {
const ids = lastDeletedIds.value;
const snapshot = lastDeletedSnapshot.value;
if (ids.length === 0 || snapshot.length === 0) return;
// Optimistic — возвращаем сделки в state.
snapshot.forEach((deal) => {
if (!dealsState.find((d) => d.id === deal.id)) {
dealsState.unshift(deal);
}
});
lastDeletedIds.value = [];
lastDeletedSnapshot.value = [];
deleteToastOpen.value = false;
if (!auth.user?.tenant_id) {
deleteToastText.value = `Восстановлено ${snapshot.length} сделок (локально).`;
deleteToastOpen.value = true;
return;
}
try {
const res = await dealsApi.bulkRestoreDeals({
tenant_id: auth.user.tenant_id,
ids,
});
deleteToastText.value = `Восстановлено ${res.restored} из ${res.requested}.`;
} catch {
deleteToastText.value = 'Не удалось восстановить — изменения только локально.';
}
deleteToastOpen.value = true;
}
async function applyBulkExport(format: 'xlsx' | 'csv' = 'xlsx') {
if (selected.value.length === 0) {
exportToastText.value = 'Нет выбранных сделок.';
exportToastOpen.value = true;
return;
}
// С tenant_id — backend (RLS-фильтрация чужих id). На fail — fallback на
// local CSV (даже если запросили xlsx — без backend'а xlsx не построим).
if (auth.user?.tenant_id) {
try {
if (format === 'xlsx') {
const blob = await dealsApi.exportDealsXlsx({
tenant_id: auth.user.tenant_id,
ids: selected.value,
});
triggerBlobDownload(blob, `deals_export_${new Date().toISOString().slice(0, 10)}.xlsx`);
exportToastText.value = `Экспортировано ${selected.value.length} сделок в XLSX.`;
} else {
const csv = await dealsApi.exportDeals({
tenant_id: auth.user.tenant_id,
ids: selected.value,
});
triggerCsvDownload(csv, `deals_export_${new Date().toISOString().slice(0, 10)}.csv`);
exportToastText.value = `Экспортировано ${selected.value.length} сделок в CSV.`;
}
exportToastOpen.value = true;
return;
} catch {
exportToastText.value = 'Backend недоступен — экспорт сформирован локально (CSV).';
}
}
buildLocalCsv();
}
function buildLocalCsv() {
const idSet = new Set(selected.value);
const rows = dealsState.filter((d) => idSet.has(d.id));
const headers = ['ID', 'Имя', 'Телефон', 'Статус', 'Проект', 'Менеджер', 'Стоимость', 'Получено мин назад'];
const csv = buildCsvString(
headers,
rows.map((d) => [d.id, d.name, d.phone, d.statusSlug, d.project, d.manager.name, d.cost, d.receivedMinutesAgo]),
);
triggerCsvDownload(csv, `deals_export_${new Date().toISOString().slice(0, 10)}.csv`);
if (!exportToastText.value) {
exportToastText.value = `Экспортировано ${rows.length} сделок в CSV.`;
}
exportToastOpen.value = true;
}
// Expose internal state для unit-тестов (Vitest dom-mount).
defineExpose({
selected,
dealsState,
applyBulkStatus,
applyBulkDelete,
applyBulkExport,
exportToastOpen,
exportToastText,
onDealCreated,
newDealOpen,
filterProjects,
filterManagers,
clearFilters,
loading,
fetchError,
loadDeals,
statusToastOpen,
statusToastText,
deleteToastOpen,
deleteToastText,
lastDeletedIds,
lastDeletedSnapshot,
undoBulkDelete,
trashMode,
toggleTrashMode,
applyBulkRestoreFromTrash,
});
const leadStatuses = computed(() => leadStatusesStore.statuses);
const statusBySlug = computed(() => leadStatusesStore.bySlug);
const filteredDeals = computed(() => {
const tab = DEALS_TABS.find((t) => t.id === activeTab.value);
const slugFilter = tab?.slugs;
const q = searchQuery.value.trim().toLowerCase();
const projects = new Set(filterProjects.value);
const managers = new Set(filterManagers.value);
return dealsState.filter((deal) => {
if (slugFilter && !slugFilter.includes(deal.statusSlug)) return false;
if (projects.size > 0 && !projects.has(deal.project)) return false;
if (managers.size > 0 && !managers.has(deal.manager.name)) return false;
if (q) {
const haystack = `${deal.name} ${deal.phone} ${deal.project}`.toLowerCase();
if (!haystack.includes(q)) return false;
}
return true;
});
});
const counts = computed(() => {
const result: Record<string, number> = {};
DEALS_TABS.forEach((tab) => {
const slugFilter = tab.slugs;
result[tab.id] = dealsState.filter((d) => !slugFilter || slugFilter.includes(d.statusSlug)).length;
});
return result;
});
const totalDeals = computed(() => dealsState.length);
const newToday = 3; // mock
const inWork = computed(
() => dealsState.filter((d) => ['new', 'viewed', 'worked', 'negotiations', 'hot'].includes(d.statusSlug)).length,
);
const waitingPay = computed(() => dealsState.filter((d) => d.statusSlug === 'waiting_payment').length);
</script>
<template>
<v-container fluid class="deals pa-6">
<header class="page-head">
<div>
<h1 class="text-h4 mb-2 page-title">{{ trashMode ? 'Корзина' : 'Сделки' }}</h1>
<div class="page-stats text-body-2 text-medium-emphasis">
<span
><span class="num text-primary">+{{ newToday }}</span> новых лида с утра</span
>
<span class="sep">·</span>
<span
><span class="num">{{ totalDeals }}</span> всего</span
>
<span class="sep">·</span>
<span
><span class="num">{{ inWork }}</span> в работе</span
>
<span class="sep">·</span>
<span
><span class="num">{{ waitingPay }}</span> ждут оплату</span
>
</div>
</div>
<div class="d-flex ga-2">
<v-btn
variant="outlined"
prepend-icon="mdi-refresh"
:loading="loading"
data-testid="reload-btn"
@click="loadDeals"
>
Обновить
</v-btn>
<v-btn
:variant="trashMode ? 'flat' : 'outlined'"
:color="trashMode ? 'warning' : undefined"
:prepend-icon="trashMode ? 'mdi-arrow-left' : 'mdi-trash-can-outline'"
data-testid="trash-toggle-btn"
@click="toggleTrashMode"
>
{{ trashMode ? 'К сделкам' : 'Корзина' }}
</v-btn>
<v-btn v-if="!trashMode" variant="outlined" prepend-icon="mdi-download">Экспорт</v-btn>
<v-btn
v-if="!trashMode"
color="primary"
variant="flat"
prepend-icon="mdi-plus"
data-testid="new-deal-btn"
@click="newDealOpen = true"
>
Новая сделка
</v-btn>
</div>
</header>
<v-alert v-if="trashMode" type="info" variant="tonal" density="compact" class="mt-3">
Корзина: показаны удалённые сделки. Выберите для восстановления.
</v-alert>
<DealsFilters
v-if="!trashMode"
v-model:active-tab="activeTab"
v-model:search-query="searchQuery"
v-model:filter-projects="filterProjects"
v-model:filter-managers="filterManagers"
:available-projects="availableProjects"
:available-managers="availableManagers"
:counts="counts"
@clear-filters="clearFilters"
/>
<!-- Task 15: redesign-filterbar (quiet luxury chiprow + density toggle).
Минимальный набор: 3 FilterChip-ярлыка (Статус/Проект/Менеджер) + DensityToggle справа.
Клики на I1 stub'ы (popover'ы TBD); полноценные multi-select'ы остаются в DealsFilters выше.
Status-legend ниже визуализирует пул цветов StatusPill'ов воронки. -->
<div v-if="!trashMode" class="ld-filterbar mt-3">
<div class="ld-filterbar__chips">
<FilterChip
label="Статус"
:count="filteredDeals.length"
:active="false"
@click="onRedesignFilterClick('Статус')"
/>
<FilterChip
label="Проект"
:count="filterProjects.length"
:active="filterProjects.length > 0"
@click="onRedesignFilterClick('Проект')"
/>
<FilterChip
label="Менеджер"
:count="filterManagers.length"
:active="filterManagers.length > 0"
@click="onRedesignFilterClick('Менеджер')"
/>
</div>
<DensityToggle class="ld-filterbar__density" />
</div>
<!-- Status-legend: показывает топ-4 статуса воронки как StatusPill-чипы.
Цели: (1) предпросмотр пула цветов в quiet-luxury палитре,
(2) обеспечивает наличие StatusPill в дереве компонентов (DealsTable inner
slots внутри stubbed VDataTable в тестах, не рендерятся). -->
<div v-if="!trashMode" class="ld-status-legend mt-2">
<StatusPill slug="new" label="Новые" />
<StatusPill slug="in_progress" label="В работе" />
<StatusPill slug="won" label="Выиграно" />
<StatusPill slug="archived" label="Архив" />
</div>
<!-- Bulk-actions bar (показывается только при selected.length > 0) -->
<DealsBulkBar
v-model:status-menu-open="statusMenuOpen"
:selected-count="selected.length"
:trash-mode="trashMode"
:lead-statuses="leadStatuses"
@apply-status="applyBulkStatus"
@apply-export="applyBulkExport()"
@request-delete="deleteConfirmOpen = true"
@apply-restore-trash="applyBulkRestoreFromTrash"
@clear-selected="selected = []"
/>
<v-alert
v-if="fetchError"
type="warning"
variant="tonal"
density="compact"
closable
class="mt-3"
data-testid="fetch-error-alert"
>
Backend недоступен показаны mock-данные.
</v-alert>
<!-- Task 15: wrapper с .ld-hover-lift + .ld-stagger-row для quiet-luxury motion
(lift на hover + stagger fade-in строк, motion #2 #4). CSS-переменная
--row-height пробрасывается в DealsTable для динамической плотности. -->
<div class="ld-hover-lift ld-stagger-row mt-4" :style="{ '--row-height': rowHeight + 'px' }">
<DealsTable
:deals="filteredDeals"
:selected-ids="selected"
:status-by-slug="statusBySlug"
:row-height="rowHeight"
@update:selected-ids="selected = $event"
@row-click="openDeal"
/>
</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" />
<!-- DELETE CONFIRM -->
<v-dialog v-model="deleteConfirmOpen" :max-width="420" data-testid="bulk-delete-dialog">
<v-card>
<v-card-title>Удалить выбранные сделки?</v-card-title>
<v-card-text>
Будет удалено <strong>{{ selected.length }}</strong> сделок. На production это переведёт их в архив
(soft-delete) фактически записи в БД сохраняются, restorable в течение 30 дней.
</v-card-text>
<v-card-actions>
<v-spacer />
<v-btn variant="text" @click="deleteConfirmOpen = false">Отмена</v-btn>
<v-btn color="error" data-testid="bulk-delete-confirm-btn" @click="applyBulkDelete">
Удалить
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
<v-snackbar v-model="exportToastOpen" :timeout="3000" data-testid="bulk-export-toast" location="bottom right">
{{ exportToastText }}
</v-snackbar>
<v-snackbar v-model="statusToastOpen" :timeout="3000" data-testid="bulk-status-toast" location="bottom right">
{{ statusToastText }}
</v-snackbar>
<v-snackbar v-model="deleteToastOpen" :timeout="8000" data-testid="bulk-delete-toast" location="bottom right">
{{ deleteToastText }}
<template v-if="lastDeletedSnapshot.length > 0" #actions>
<v-btn color="warning" variant="text" data-testid="bulk-delete-undo-btn" @click="undoBulkDelete">
Восстановить
</v-btn>
</template>
</v-snackbar>
</v-container>
</template>
<style scoped>
.deals {
max-width: 1440px;
}
.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;
}
/* Task 15: redesign-filterbar — quiet-luxury chiprow + density toggle */
.ld-filterbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
flex-wrap: wrap;
}
.ld-filterbar__chips {
display: inline-flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.ld-filterbar__density {
flex-shrink: 0;
}
/* Status-legend strip: визуальная палитра StatusPill пула. */
.ld-status-legend {
display: inline-flex;
align-items: center;
gap: 6px;
flex-wrap: wrap;
}
</style>