73d4c8c14f
U1 остаток: пояснения источника проекта без слова конкурент. U5: баннер списка проектов про результат а не синхронизацию + статус Собирает заявки. Деалы/импорт/логин: убрано имя поставщика и англ Pay-per-lead. Активность сделки: технический supplier_webhook заменён понятной фразой. Co-Authored-By: Claude Opus 4.8 (1M context) <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">
|
|
<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>
|