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>
646 lines
25 KiB
Vue
646 lines
25 KiB
Vue
<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>
|