561096cc84
Претензия владельца 12.07.2026: «видео мало и они обрубленные». Причина «обрубленности» найдена живым прогоном: раннер НЕ переключал экраны — шаг про «Проекты» показывался поверх «Биллинга». Фикс GuidedTour: при переходе на шаг с другим route зовём router.push (TDD-тест «шаг на другом экране»). Каталог: 6 → 15 экскурсий, все минимум по 3 шага (тест-сторож в TourCatalog.spec): start (4 шага), create-project (6), top-up-balance (6), tariffs (4), balance-pause (4), deals (4), kanban (3), konkurentnoe-pole (6), change-source (4), security (4), notifications (3), import (4), api (3), dashboard (4), support (3). Якоря data-tour расставлены по портале (3 агента): биллинг (кошелёк, запас, ступени, вкладки, сумма, способ оплаты), проекты (фильтры, карточка, меню, тип источника, лимит, регионы, дни), сделки (фильтры, таблица, экспорт), канбан, импорт, дашборд, настройки (вкладки, 2FA, матрица уведомлений), Поле (собрать, вручную, дубли, вкладки, карточка, легенда, изучение), помощь (FAQ, форма). Каждая из 17 статей инструкции получила свою экскурсию (frontmatter tour:). Vitest 1216/1216, Pest бот 46/46, живая проверка в браузере: экскурсия сама переходит между экранами и подсвечивает цели. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
492 lines
16 KiB
Vue
492 lines
16 KiB
Vue
<script setup lang="ts">
|
|
/**
|
|
* Страница «Сделки» — реестр лидов, поставленных crm.bp (редизайн 2026-05-17).
|
|
*
|
|
* Лиды поступают ТОЛЬКО от поставщика — ручного создания и корзины нет.
|
|
* Фильтрация (телефон/Статус/Проект + диапазон дат поставки) и пагинация —
|
|
* server-side через GET /api/deals. Клик по строке открывает master-detail
|
|
* панель справа (список сжимается, панель не перекрывает таблицу).
|
|
*
|
|
* Спека: docs/superpowers/specs/2026-05-17-deals-page-redesign-design.md
|
|
*/
|
|
import { computed, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue';
|
|
import { useRoute } from 'vue-router';
|
|
import type { MockDeal } from '../composables/mockDeals';
|
|
import { mapApiDeal } from '../composables/dealsApiMapper';
|
|
import { stripChannelPrefix } from '../composables/projectName';
|
|
import { usePolling } from '../composables/usePolling';
|
|
import RuDateField from '../components/common/RuDateField.vue';
|
|
import DealsFilters from '../components/deals/DealsFilters.vue';
|
|
import DealsBulkBar from '../components/deals/DealsBulkBar.vue';
|
|
import DealsTable from '../components/deals/DealsTable.vue';
|
|
import DealDetailDrawer from '../components/deals/DealDetailDrawer.vue';
|
|
import { useAuthStore } from '../stores/auth';
|
|
import { useLeadStatusesStore } from '../stores/leadStatuses';
|
|
import * as dealsApi from '../api/deals';
|
|
import { triggerBlobDownload, triggerCsvDownload } from '../composables/useCsvDownload';
|
|
|
|
const route = useRoute();
|
|
const auth = useAuthStore();
|
|
const leadStatusesStore = useLeadStatusesStore();
|
|
|
|
// --- Фильтры (single-select) + диапазон дат поставки ---
|
|
const searchPhone = ref('');
|
|
const filterStatus = ref<string | null>(null);
|
|
const filterProject = ref<number | null>(null);
|
|
const filterCity = ref<string | null>(null);
|
|
const receivedFrom = ref(''); // 'YYYY-MM-DD'
|
|
const receivedTo = ref('');
|
|
|
|
// --- Пагинация ---
|
|
const PER_PAGE_OPTIONS = [10, 20, 50];
|
|
const perPage = ref(20);
|
|
const page = ref(1);
|
|
|
|
// --- Данные текущей страницы ---
|
|
const dealsState = reactive<MockDeal[]>([]);
|
|
const total = ref(0);
|
|
const loading = ref(false);
|
|
const fetchError = ref(false);
|
|
const availableProjects = ref<dealsApi.ApiProject[]>([]);
|
|
// Список для фильтра «Проект» — без префикса B1_/B2_/B3_ (display-only;
|
|
// id сохраняем, фильтрация идёт по id, не по name).
|
|
const availableProjectsForFilter = computed(() =>
|
|
availableProjects.value.map((p) => ({ ...p, name: stripChannelPrefix(p.name) })),
|
|
);
|
|
|
|
const leadStatuses = computed(() => leadStatusesStore.statuses);
|
|
const statusBySlug = computed(() => leadStatusesStore.bySlug);
|
|
const availableCities = computed(() =>
|
|
Array.from(new Set(dealsState.map((d) => d.city).filter((c): c is string => !!c))).sort(),
|
|
);
|
|
const pageCount = computed(() => Math.max(1, Math.ceil(total.value / perPage.value)));
|
|
|
|
// --- Master-detail панель ---
|
|
const selectedDeal = ref<MockDeal | null>(null);
|
|
const panelOpen = ref(false);
|
|
|
|
// --- Bulk-смена статуса ---
|
|
const selected = ref<number[]>([]);
|
|
const statusMenuOpen = ref(false);
|
|
|
|
// --- Тосты ---
|
|
const exportToastOpen = ref(false);
|
|
const exportToastText = ref('');
|
|
const statusToastOpen = ref(false);
|
|
const statusToastText = ref('');
|
|
|
|
async function loadDeals() {
|
|
if (!auth.user?.tenant_id) return;
|
|
loading.value = true;
|
|
fetchError.value = false;
|
|
try {
|
|
const res = await dealsApi.listDeals({
|
|
tenantId: auth.user.tenant_id,
|
|
statusIn: filterStatus.value ? [filterStatus.value] : undefined,
|
|
projectId: filterProject.value ?? undefined,
|
|
search: searchPhone.value.trim() || undefined,
|
|
receivedFrom: receivedFrom.value || undefined,
|
|
receivedTo: receivedTo.value || undefined,
|
|
limit: perPage.value,
|
|
offset: (page.value - 1) * perPage.value,
|
|
});
|
|
total.value = res.total;
|
|
dealsState.splice(0, dealsState.length, ...res.deals.map((d) => mapApiDeal(d)));
|
|
} catch {
|
|
fetchError.value = true;
|
|
dealsState.splice(0, dealsState.length);
|
|
total.value = 0;
|
|
} finally {
|
|
loading.value = false;
|
|
}
|
|
}
|
|
|
|
async function loadProjects() {
|
|
if (!auth.user?.tenant_id) return;
|
|
try {
|
|
availableProjects.value = await dealsApi.listProjects(auth.user.tenant_id);
|
|
} catch {
|
|
availableProjects.value = [];
|
|
}
|
|
}
|
|
|
|
// Фильтры (кроме поиска) и perPage → стр.1 + ОДНА перезагрузка.
|
|
// page!==1 → меняем page, перезагрузку делает watch(page); page===1 → грузим напрямую.
|
|
watch([filterStatus, filterProject, receivedFrom, receivedTo, perPage], () => {
|
|
if (page.value !== 1) {
|
|
page.value = 1;
|
|
} else {
|
|
void loadDeals();
|
|
}
|
|
});
|
|
watch(page, () => void loadDeals());
|
|
|
|
// Selected-driven drawer visibility (18.05.2026 ux-request):
|
|
// 0 selected → drawer по row-click; 1 selected → авто-открыт для этой сделки;
|
|
// ≥2 selected → закрыт (показывается bulk-полоса).
|
|
watch(selected, (ids) => {
|
|
if (ids.length === 1) {
|
|
const deal = dealsState.find((d) => d.id === ids[0]);
|
|
if (deal) {
|
|
selectedDeal.value = deal;
|
|
panelOpen.value = true;
|
|
}
|
|
} else if (ids.length >= 2) {
|
|
panelOpen.value = false;
|
|
}
|
|
});
|
|
|
|
// Поиск по телефону — debounce 350 мс.
|
|
let searchTimer: ReturnType<typeof setTimeout> | undefined;
|
|
watch(searchPhone, () => {
|
|
if (searchTimer) clearTimeout(searchTimer);
|
|
searchTimer = setTimeout(() => {
|
|
if (page.value !== 1) {
|
|
page.value = 1;
|
|
} else {
|
|
void loadDeals();
|
|
}
|
|
}, 350);
|
|
});
|
|
|
|
onBeforeUnmount(() => {
|
|
if (searchTimer) clearTimeout(searchTimer);
|
|
});
|
|
|
|
function clearFilters() {
|
|
searchPhone.value = '';
|
|
filterStatus.value = null;
|
|
filterProject.value = null;
|
|
filterCity.value = null;
|
|
}
|
|
|
|
/**
|
|
* 18.05.2026 ux — inline status picker в drawer (DealDetailHero).
|
|
* Optimistic UI: меняем statusSlug в dealsState ДО API, rollback при ошибке.
|
|
*/
|
|
async function onDrawerStatusChanged(slug: string): Promise<void> {
|
|
if (!auth.user?.tenant_id || !selectedDeal.value) return;
|
|
const id = selectedDeal.value.id;
|
|
const target = dealsState.find((d) => d.id === id);
|
|
if (!target) return;
|
|
const prev = target.statusSlug;
|
|
if (prev === slug) return;
|
|
target.statusSlug = slug as MockDeal['statusSlug'];
|
|
try {
|
|
await dealsApi.updateDeal(id, { tenant_id: auth.user.tenant_id, status: slug });
|
|
statusToastText.value = 'Статус обновлён.';
|
|
} catch {
|
|
target.statusSlug = prev;
|
|
statusToastText.value = 'Не удалось сохранить статус.';
|
|
}
|
|
statusToastOpen.value = true;
|
|
}
|
|
|
|
async function applyBulkStatus(slug: MockDeal['statusSlug']) {
|
|
const ids = [...selected.value];
|
|
statusMenuOpen.value = false;
|
|
ids.forEach((id) => {
|
|
const d = dealsState.find((x) => x.id === id);
|
|
if (d) d.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;
|
|
selected.value = [];
|
|
}
|
|
|
|
async function exportByRange(format: 'xlsx' | 'csv') {
|
|
if (!auth.user?.tenant_id) {
|
|
exportToastText.value = 'Нет данных для экспорта.';
|
|
exportToastOpen.value = true;
|
|
return;
|
|
}
|
|
try {
|
|
const result = await dealsApi.exportDealsByRange({
|
|
tenant_id: auth.user.tenant_id,
|
|
received_from: receivedFrom.value || undefined,
|
|
received_to: receivedTo.value || undefined,
|
|
format,
|
|
});
|
|
const stamp = new Date().toISOString().slice(0, 10);
|
|
if (format === 'xlsx') {
|
|
triggerBlobDownload(result as Blob, `deals_export_${stamp}.xlsx`);
|
|
exportToastText.value = 'Экспорт XLSX сформирован.';
|
|
} else {
|
|
triggerCsvDownload(result as string, `deals_export_${stamp}.csv`);
|
|
exportToastText.value = 'Экспорт CSV сформирован.';
|
|
}
|
|
} catch {
|
|
exportToastText.value = 'Не удалось сформировать экспорт.';
|
|
}
|
|
exportToastOpen.value = true;
|
|
}
|
|
|
|
function openPanel(deal: MockDeal) {
|
|
if (panelOpen.value && selectedDeal.value?.id === deal.id) {
|
|
closePanel();
|
|
return;
|
|
}
|
|
selectedDeal.value = deal;
|
|
panelOpen.value = true;
|
|
}
|
|
|
|
function closePanel() {
|
|
panelOpen.value = false;
|
|
}
|
|
|
|
/** Deep-link /deals?openId= — открыть панель сделки с текущей страницы (audit C8/F3). */
|
|
function openDealFromQuery(): void {
|
|
const raw = route.query.openId;
|
|
const id = Number(Array.isArray(raw) ? raw[0] : raw);
|
|
if (!Number.isInteger(id) || id <= 0) return;
|
|
const deal = dealsState.find((d) => d.id === id);
|
|
if (deal) openPanel(deal);
|
|
}
|
|
|
|
onMounted(async () => {
|
|
void leadStatusesStore.load();
|
|
void loadProjects();
|
|
await loadDeals();
|
|
openDealFromQuery();
|
|
});
|
|
|
|
watch(
|
|
() => route.query.openId,
|
|
() => openDealFromQuery(),
|
|
);
|
|
|
|
// Polling — авто-refresh текущей страницы (pause при скрытой вкладке).
|
|
usePolling(loadDeals);
|
|
|
|
defineExpose({
|
|
searchPhone,
|
|
filterStatus,
|
|
filterProject,
|
|
filterCity,
|
|
receivedFrom,
|
|
receivedTo,
|
|
perPage,
|
|
page,
|
|
pageCount,
|
|
dealsState,
|
|
total,
|
|
loading,
|
|
fetchError,
|
|
availableProjects,
|
|
selected,
|
|
selectedDeal,
|
|
panelOpen,
|
|
statusMenuOpen,
|
|
loadDeals,
|
|
clearFilters,
|
|
applyBulkStatus,
|
|
exportByRange,
|
|
openPanel,
|
|
closePanel,
|
|
exportToastOpen,
|
|
exportToastText,
|
|
statusToastOpen,
|
|
statusToastText,
|
|
});
|
|
</script>
|
|
|
|
<template>
|
|
<v-container fluid class="deals 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 class="num">{{ total }}</span> в выборке
|
|
</div>
|
|
</div>
|
|
<v-btn
|
|
variant="outlined"
|
|
prepend-icon="mdi-refresh"
|
|
:loading="loading"
|
|
data-testid="reload-btn"
|
|
@click="loadDeals"
|
|
>
|
|
Обновить
|
|
</v-btn>
|
|
</header>
|
|
|
|
<!-- Панель экспорта: диапазон дат поставки + Excel/CSV -->
|
|
<v-card variant="outlined" class="export-panel mt-4" data-tour="deals-export">
|
|
<div class="export-panel-inner">
|
|
<span class="export-label text-body-2">Диапазон поставки:</span>
|
|
<RuDateField v-model="receivedFrom" label="от" class="date-input" data-testid="export-from" />
|
|
<RuDateField v-model="receivedTo" label="до" class="date-input" data-testid="export-to" />
|
|
<v-spacer />
|
|
<v-btn
|
|
variant="outlined"
|
|
prepend-icon="mdi-file-excel-outline"
|
|
data-testid="export-xlsx-btn"
|
|
@click="exportByRange('xlsx')"
|
|
>
|
|
Экспорт в Excel
|
|
</v-btn>
|
|
<v-btn
|
|
variant="outlined"
|
|
prepend-icon="mdi-file-delimited-outline"
|
|
data-testid="export-csv-btn"
|
|
@click="exportByRange('csv')"
|
|
>
|
|
Экспорт в CSV
|
|
</v-btn>
|
|
</div>
|
|
</v-card>
|
|
|
|
<DealsFilters
|
|
v-model:search-phone="searchPhone"
|
|
v-model:filter-status="filterStatus"
|
|
v-model:filter-project="filterProject"
|
|
v-model:filter-city="filterCity"
|
|
:lead-statuses="leadStatuses"
|
|
:available-projects="availableProjectsForFilter"
|
|
:available-cities="availableCities"
|
|
class="mt-4"
|
|
@clear-filters="clearFilters"
|
|
/>
|
|
|
|
<!-- Селектор числа строк — между фильтрами и таблицей -->
|
|
<div class="perpage mt-3">
|
|
<span class="text-body-2 text-medium-emphasis">Показывать по:</span>
|
|
<v-btn-toggle
|
|
v-model="perPage"
|
|
mandatory
|
|
density="comfortable"
|
|
variant="outlined"
|
|
color="primary"
|
|
data-testid="perpage-toggle"
|
|
>
|
|
<v-btn v-for="n in PER_PAGE_OPTIONS" :key="n" :value="n" size="small">{{ n }}</v-btn>
|
|
</v-btn-toggle>
|
|
</div>
|
|
|
|
<DealsBulkBar
|
|
v-if="selected.length >= 2"
|
|
v-model:status-menu-open="statusMenuOpen"
|
|
:selected-count="selected.length"
|
|
:lead-statuses="leadStatuses"
|
|
@apply-status="applyBulkStatus"
|
|
@clear-selected="selected = []"
|
|
/>
|
|
|
|
<v-alert
|
|
v-if="fetchError"
|
|
type="warning"
|
|
variant="tonal"
|
|
density="compact"
|
|
closable
|
|
class="mt-3"
|
|
data-testid="fetch-error-alert"
|
|
>
|
|
Не удалось загрузить сделки. Попробуйте обновить.
|
|
</v-alert>
|
|
|
|
<!-- master-detail: список сжимается, панель встаёт рядом (не overlay) -->
|
|
<div class="deals-body mt-4">
|
|
<div class="deals-list">
|
|
<DealsTable
|
|
:deals="dealsState"
|
|
:selected-ids="selected"
|
|
:status-by-slug="statusBySlug"
|
|
:active-deal-id="panelOpen ? (selectedDeal?.id ?? null) : null"
|
|
@update:selected-ids="selected = $event"
|
|
@row-click="openPanel"
|
|
/>
|
|
</div>
|
|
<DealDetailDrawer
|
|
inline
|
|
:open="panelOpen"
|
|
:deal="selectedDeal"
|
|
:tenant-id="auth.user?.tenant_id"
|
|
@update:open="(v: boolean) => (panelOpen = v)"
|
|
@status-changed="onDrawerStatusChanged"
|
|
/>
|
|
</div>
|
|
|
|
<!-- Футер: пагинация -->
|
|
<div v-if="pageCount > 1" class="tfoot mt-4">
|
|
<v-pagination
|
|
v-model="page"
|
|
:length="pageCount"
|
|
:total-visible="7"
|
|
density="comfortable"
|
|
data-testid="deals-pagination"
|
|
/>
|
|
</div>
|
|
|
|
<v-snackbar v-model="exportToastOpen" :timeout="3000" data-testid="export-toast" location="bottom right">
|
|
{{ exportToastText }}
|
|
</v-snackbar>
|
|
<v-snackbar v-model="statusToastOpen" :timeout="3000" data-testid="status-toast" location="bottom right">
|
|
{{ statusToastText }}
|
|
</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;
|
|
}
|
|
.num {
|
|
font-family: 'JetBrains Mono', ui-monospace, monospace;
|
|
font-feature-settings: 'tnum';
|
|
font-weight: 500;
|
|
}
|
|
.export-panel {
|
|
background: #fff;
|
|
}
|
|
.export-panel-inner {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 12px;
|
|
flex-wrap: wrap;
|
|
padding: 12px 16px;
|
|
}
|
|
.export-label {
|
|
color: #6b6356;
|
|
flex-shrink: 0;
|
|
}
|
|
.date-input {
|
|
max-width: 170px;
|
|
}
|
|
.perpage {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 12px;
|
|
}
|
|
.deals-body {
|
|
display: flex;
|
|
align-items: flex-start;
|
|
gap: 16px;
|
|
}
|
|
.deals-list {
|
|
flex: 1 1 auto;
|
|
min-width: 0;
|
|
}
|
|
.tfoot {
|
|
display: flex;
|
|
justify-content: center;
|
|
}
|
|
</style>
|