bc24420ad4
Регрессия full: prettier --check на 5 файлах, тронутых Sprint 5B (T2/T3/T4). Whitespace-only, 0 изменений поведения — Vitest 67/67 на затронутых спеках. Pre-existing prettier-дрейф 28 НЕ-5B файлов оставлен (вне scope спринта). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
842 lines
34 KiB
Vue
842 lines
34 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, watch } from 'vue';
|
||
import { useRoute } from 'vue-router';
|
||
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();
|
||
|
||
// Sprint 1 C2: popovers для Проект/Менеджер chip'ов. Draft-state накапливает
|
||
// выбор внутри v-menu; копируется из filterProjects/filterManagers при открытии
|
||
// (watch на menu open=true); переносится обратно в filterProjects/Managers на
|
||
// «Применить» button. Status chip — read-only (P2 backlog Sprint 5).
|
||
const projectMenuOpen = ref(false);
|
||
const managerMenuOpen = ref(false);
|
||
const projectMenuDraft = ref<string[]>([]);
|
||
const managerMenuDraft = ref<string[]>([]);
|
||
|
||
// При открытии меню — копируем текущий filter в draft (snapshot-on-open).
|
||
// При закрытии без apply — draft остаётся, но не влияет на filterProjects
|
||
// (apply нужен явно).
|
||
watch(projectMenuOpen, (isOpen) => {
|
||
if (isOpen) projectMenuDraft.value = [...filterProjects.value];
|
||
});
|
||
watch(managerMenuOpen, (isOpen) => {
|
||
if (isOpen) managerMenuDraft.value = [...filterManagers.value];
|
||
});
|
||
|
||
function onRedesignFilterClick(name: string): void {
|
||
// Status chip — read-only summary (P2 backlog Sprint 5).
|
||
// Project/Manager managed by v-menu activator (no manual click handler needed).
|
||
if (name === 'Статус') {
|
||
// no-op — placeholder для будущей реализации
|
||
}
|
||
}
|
||
|
||
function applyProjectFilter(): void {
|
||
filterProjects.value = [...projectMenuDraft.value];
|
||
projectMenuOpen.value = false;
|
||
}
|
||
|
||
function applyManagerFilter(): void {
|
||
filterManagers.value = [...managerMenuDraft.value];
|
||
managerMenuOpen.value = false;
|
||
}
|
||
|
||
function clearProjectDraft(): void {
|
||
projectMenuDraft.value = [];
|
||
}
|
||
|
||
function clearManagerDraft(): void {
|
||
managerMenuDraft.value = [];
|
||
}
|
||
|
||
function toggleProjectDraft(proj: string): void {
|
||
projectMenuDraft.value = projectMenuDraft.value.includes(proj)
|
||
? projectMenuDraft.value.filter((p) => p !== proj)
|
||
: [...projectMenuDraft.value, proj];
|
||
}
|
||
|
||
function toggleManagerDraft(name: string): void {
|
||
managerMenuDraft.value = managerMenuDraft.value.includes(name)
|
||
? managerMenuDraft.value.filter((m) => m !== name)
|
||
: [...managerMenuDraft.value, name];
|
||
}
|
||
|
||
const route = useRoute();
|
||
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(async () => {
|
||
void leadStatusesStore.load();
|
||
await loadDeals();
|
||
openDealFromQuery();
|
||
});
|
||
|
||
watch(
|
||
() => route.query.openId,
|
||
() => openDealFromQuery(),
|
||
);
|
||
|
||
// 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;
|
||
}
|
||
|
||
/** Audit C8/F3: deep-link — открыть drawer сделки по ?openId= из URL. */
|
||
function openDealFromQuery(): void {
|
||
const raw = route.query.openId;
|
||
const id = Number(Array.isArray(raw) ? raw[0] : raw);
|
||
if (!Number.isInteger(id) || id <= 0) return;
|
||
if (selectedDeal.value?.id === id) return;
|
||
const deal = dealsState.find((d) => d.id === id);
|
||
if (deal) openDeal(deal);
|
||
}
|
||
|
||
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;
|
||
}
|
||
await exportDealIds([...selected.value], format);
|
||
}
|
||
|
||
// Audit C3: экспорт всех отфильтрованных сделок — кнопка «Экспорт» в page-head.
|
||
async function exportAllFiltered(format: 'xlsx' | 'csv' = 'xlsx') {
|
||
const ids = filteredDeals.value.map((d) => d.id);
|
||
if (ids.length === 0) {
|
||
exportToastText.value = 'Список пуст — нечего экспортировать.';
|
||
exportToastOpen.value = true;
|
||
return;
|
||
}
|
||
await exportDealIds(ids, format);
|
||
}
|
||
|
||
/**
|
||
* Общий экспорт по списку id. С tenant_id — backend (RLS-фильтрация чужих id).
|
||
* На fail / без tenant — fallback на локальный CSV.
|
||
*/
|
||
async function exportDealIds(ids: number[], format: 'xlsx' | 'csv') {
|
||
exportToastText.value = '';
|
||
if (auth.user?.tenant_id) {
|
||
try {
|
||
if (format === 'xlsx') {
|
||
const blob = await dealsApi.exportDealsXlsx({
|
||
tenant_id: auth.user.tenant_id,
|
||
ids,
|
||
});
|
||
triggerBlobDownload(blob, `deals_export_${new Date().toISOString().slice(0, 10)}.xlsx`);
|
||
exportToastText.value = `Экспортировано ${ids.length} сделок в XLSX.`;
|
||
} else {
|
||
const csv = await dealsApi.exportDeals({
|
||
tenant_id: auth.user.tenant_id,
|
||
ids,
|
||
});
|
||
triggerCsvDownload(csv, `deals_export_${new Date().toISOString().slice(0, 10)}.csv`);
|
||
exportToastText.value = `Экспортировано ${ids.length} сделок в CSV.`;
|
||
}
|
||
exportToastOpen.value = true;
|
||
return;
|
||
} catch {
|
||
exportToastText.value = 'Backend недоступен — экспорт сформирован локально (CSV).';
|
||
}
|
||
}
|
||
|
||
buildLocalCsv(ids);
|
||
}
|
||
|
||
function buildLocalCsv(ids: number[]) {
|
||
const idSet = new Set(ids);
|
||
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,
|
||
exportAllFiltered,
|
||
exportToastOpen,
|
||
exportToastText,
|
||
onDealCreated,
|
||
newDealOpen,
|
||
filterProjects,
|
||
filterManagers,
|
||
clearFilters,
|
||
loading,
|
||
fetchError,
|
||
loadDeals,
|
||
statusToastOpen,
|
||
statusToastText,
|
||
deleteToastOpen,
|
||
deleteToastText,
|
||
lastDeletedIds,
|
||
lastDeletedSnapshot,
|
||
undoBulkDelete,
|
||
trashMode,
|
||
toggleTrashMode,
|
||
applyBulkRestoreFromTrash,
|
||
projectMenuOpen,
|
||
managerMenuOpen,
|
||
projectMenuDraft,
|
||
managerMenuDraft,
|
||
applyProjectFilter,
|
||
applyManagerFilter,
|
||
clearProjectDraft,
|
||
clearManagerDraft,
|
||
toggleProjectDraft,
|
||
toggleManagerDraft,
|
||
drawerOpen,
|
||
selectedDeal,
|
||
});
|
||
|
||
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"
|
||
data-testid="export-all-btn"
|
||
@click="exportAllFiltered()"
|
||
>
|
||
Экспорт
|
||
</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"
|
||
/>
|
||
|
||
<!-- Sprint 1 C2: redesign-filterbar с popover'ами для Проект/Менеджер.
|
||
Status chip остаётся read-only (P2 backlog Sprint 5).
|
||
Полноценные multi-select'ы в DealsFilters выше сохранены. -->
|
||
<div v-if="!trashMode" class="ld-filterbar mt-3">
|
||
<div class="ld-filterbar__chips">
|
||
<FilterChip
|
||
label="Статус"
|
||
:count="filteredDeals.length"
|
||
:active="false"
|
||
@click="onRedesignFilterClick('Статус')"
|
||
/>
|
||
<v-menu v-model="projectMenuOpen" :close-on-content-click="false" location="bottom start">
|
||
<template #activator="{ props: activatorProps }">
|
||
<span v-bind="activatorProps">
|
||
<FilterChip
|
||
label="Проект"
|
||
:count="filterProjects.length"
|
||
:active="filterProjects.length > 0"
|
||
/>
|
||
</span>
|
||
</template>
|
||
<v-card min-width="260" max-width="320" data-testid="project-menu-card">
|
||
<v-card-text class="pa-2">
|
||
<v-list density="compact" class="pa-0">
|
||
<v-list-item v-if="availableProjects.length === 0" class="text-medium-emphasis">
|
||
<v-list-item-title>Нет проектов в текущем списке</v-list-item-title>
|
||
</v-list-item>
|
||
<v-list-item
|
||
v-for="proj in availableProjects"
|
||
:key="proj"
|
||
class="py-1"
|
||
@click="toggleProjectDraft(proj)"
|
||
>
|
||
<template #prepend>
|
||
<v-checkbox-btn :model-value="projectMenuDraft.includes(proj)" />
|
||
</template>
|
||
<v-list-item-title>{{ proj }}</v-list-item-title>
|
||
</v-list-item>
|
||
</v-list>
|
||
</v-card-text>
|
||
<v-card-actions class="px-3 pb-2">
|
||
<v-btn
|
||
variant="text"
|
||
size="small"
|
||
data-testid="project-menu-clear"
|
||
@click="clearProjectDraft"
|
||
>
|
||
Очистить
|
||
</v-btn>
|
||
<v-spacer />
|
||
<v-btn
|
||
color="primary"
|
||
variant="flat"
|
||
size="small"
|
||
data-testid="project-menu-apply"
|
||
@click="applyProjectFilter"
|
||
>
|
||
Применить
|
||
</v-btn>
|
||
</v-card-actions>
|
||
</v-card>
|
||
</v-menu>
|
||
<v-menu v-model="managerMenuOpen" :close-on-content-click="false" location="bottom start">
|
||
<template #activator="{ props: activatorProps }">
|
||
<span v-bind="activatorProps">
|
||
<FilterChip
|
||
label="Менеджер"
|
||
:count="filterManagers.length"
|
||
:active="filterManagers.length > 0"
|
||
/>
|
||
</span>
|
||
</template>
|
||
<v-card min-width="260" max-width="320" data-testid="manager-menu-card">
|
||
<v-card-text class="pa-2">
|
||
<v-list density="compact" class="pa-0">
|
||
<v-list-item v-if="availableManagers.length === 0" class="text-medium-emphasis">
|
||
<v-list-item-title>Нет менеджеров в текущем списке</v-list-item-title>
|
||
</v-list-item>
|
||
<v-list-item
|
||
v-for="mgr in availableManagers"
|
||
:key="mgr.name"
|
||
class="py-1"
|
||
@click="toggleManagerDraft(mgr.name)"
|
||
>
|
||
<template #prepend>
|
||
<v-checkbox-btn :model-value="managerMenuDraft.includes(mgr.name)" />
|
||
</template>
|
||
<v-list-item-title>{{ mgr.name }}</v-list-item-title>
|
||
<v-list-item-subtitle class="text-caption">{{ mgr.initials }}</v-list-item-subtitle>
|
||
</v-list-item>
|
||
</v-list>
|
||
</v-card-text>
|
||
<v-card-actions class="px-3 pb-2">
|
||
<v-btn
|
||
variant="text"
|
||
size="small"
|
||
data-testid="manager-menu-clear"
|
||
@click="clearManagerDraft"
|
||
>
|
||
Очистить
|
||
</v-btn>
|
||
<v-spacer />
|
||
<v-btn
|
||
color="primary"
|
||
variant="flat"
|
||
size="small"
|
||
data-testid="manager-menu-apply"
|
||
@click="applyManagerFilter"
|
||
>
|
||
Применить
|
||
</v-btn>
|
||
</v-card-actions>
|
||
</v-card>
|
||
</v-menu>
|
||
</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>
|