Files
portal/app/resources/js/views/DealsView.vue
T
Дмитрий 1412d3fefd feat(deals/drawer): inline status picker — статус-chip кликабельный, без мутации props
UX-request 18.05.2026 (п.3):
- DealDetailHero: v-chip → v-menu со списком всех статусов из lead_statuses
  store; форма и цвет chip'а не меняются
- DealDetailBody: emit 'status-changed' наверх (без мутации props.deal)
- DealDetailDrawer: forward события наружу
- DealsView: onDrawerStatusChanged → optimistic update dealsState + PATCH
  /api/deals/{id} + rollback
- KanbanView: onDrawerStatusChanged → перенос карточки между колонками
  dealsByStatus + transitionDeals + rollback на ошибку

Vue правило vue/no-mutating-props соблюдено (логика в parent'е, не в Body).

Vitest 5 файлов / 38 passed на затронутых; build 2.29s.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 15:34:07 +03:00

483 lines
17 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 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">
Реестр лидов от crm.bp ·
<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>
<v-text-field
v-model="receivedFrom"
type="date"
label="от"
variant="outlined"
density="compact"
hide-details
class="date-input"
data-testid="export-from"
/>
<v-text-field
v-model="receivedTo"
type="date"
label="до"
variant="outlined"
density="compact"
hide-details
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>