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>
454 lines
18 KiB
Vue
454 lines
18 KiB
Vue
<template>
|
||
<v-container fluid class="projects-view" :class="{ 'has-drawer': singleSelectedProject !== null }">
|
||
<div class="d-flex justify-space-between align-center mb-4 ga-3 projects-head">
|
||
<h1 class="text-h4">Проекты</h1>
|
||
<v-btn color="primary" prepend-icon="mdi-plus" data-tour="projects-create" @click="openCreate">Создать проект</v-btn>
|
||
</div>
|
||
|
||
<v-alert
|
||
v-if="showCutoffBanner && !isEmptyAccount"
|
||
data-testid="cutoff-banner"
|
||
type="info"
|
||
variant="tonal"
|
||
border="start"
|
||
class="mb-4"
|
||
>
|
||
<div class="d-flex justify-space-between align-start ga-2">
|
||
<span>
|
||
Изменения по проектам (лимиты, регионы, дни) вступают в силу со следующего дня. Внесённые
|
||
<strong>до 18:00 МСК</strong> — начнут работать уже завтра, после 18:00 — послезавтра.
|
||
</span>
|
||
<v-btn
|
||
data-testid="cutoff-banner-close"
|
||
icon="mdi-close"
|
||
size="x-small"
|
||
variant="text"
|
||
aria-label="Скрыть уведомление"
|
||
@click="dismissCutoffBanner"
|
||
/>
|
||
</div>
|
||
</v-alert>
|
||
|
||
<div v-if="!isEmptyAccount" class="d-flex flex-wrap ga-3 mb-4 filters-bar" data-tour="projects-filters">
|
||
<v-select
|
||
v-model="store.filters.signal_type"
|
||
:items="typeFilters"
|
||
label="Тип"
|
||
clearable
|
||
density="comfortable"
|
||
style="max-width: 180px"
|
||
hide-details
|
||
@update:model-value="store.fetch()"
|
||
/>
|
||
<v-select
|
||
v-model="store.filters.status"
|
||
:items="statusFilters"
|
||
label="Статус"
|
||
clearable
|
||
density="comfortable"
|
||
style="max-width: 180px"
|
||
hide-details
|
||
@update:model-value="store.fetch()"
|
||
/>
|
||
<v-autocomplete
|
||
v-model="store.filters.region"
|
||
:items="regionFilterItems"
|
||
item-title="title"
|
||
item-value="value"
|
||
label="Регион"
|
||
clearable
|
||
density="comfortable"
|
||
style="max-width: 240px"
|
||
hide-details
|
||
data-testid="filter-region"
|
||
@update:model-value="onResetPageAndFetch()"
|
||
/>
|
||
<v-select
|
||
v-model="store.filters.delivery_day"
|
||
:items="dayFilterItems"
|
||
item-title="title"
|
||
item-value="value"
|
||
label="День приёма"
|
||
clearable
|
||
density="comfortable"
|
||
style="max-width: 180px"
|
||
hide-details
|
||
data-testid="filter-day"
|
||
@update:model-value="onResetPageAndFetch()"
|
||
/>
|
||
<v-select
|
||
v-model="store.filters.sort"
|
||
:items="sortItems"
|
||
item-title="title"
|
||
item-value="value"
|
||
label="Сортировать"
|
||
density="comfortable"
|
||
style="max-width: 240px"
|
||
hide-details
|
||
data-testid="filter-sort"
|
||
@update:model-value="onResetPageAndFetch()"
|
||
/>
|
||
<v-text-field
|
||
v-model="store.filters.search"
|
||
label="Поиск"
|
||
prepend-inner-icon="mdi-magnify"
|
||
density="comfortable"
|
||
hide-details
|
||
style="max-width: 240px"
|
||
@input="onSearchDebounced"
|
||
/>
|
||
</div>
|
||
|
||
<!-- #6: Селектор количества на странице (паттерн как в DealsView). -->
|
||
<div v-if="!isEmptyAccount" class="perpage mb-3 d-flex align-center ga-3">
|
||
<span class="text-body-2 text-medium-emphasis">Показывать по:</span>
|
||
<v-btn-toggle
|
||
v-model="store.filters.per_page"
|
||
mandatory
|
||
density="comfortable"
|
||
variant="outlined"
|
||
color="primary"
|
||
data-testid="perpage-toggle"
|
||
@update:model-value="onResetPageAndFetch()"
|
||
>
|
||
<v-btn v-for="n in PER_PAGE_OPTIONS" :key="n" :value="n" size="small">{{ n }}</v-btn>
|
||
</v-btn-toggle>
|
||
</div>
|
||
|
||
<div v-if="!store.loading && store.items.length > 0" class="projects-toolbar d-flex align-center ga-3 mb-3">
|
||
<label class="toolbar-check" data-testid="select-all-toggle">
|
||
<input
|
||
type="checkbox"
|
||
aria-label="Выбрать все проекты по текущим фильтрам"
|
||
:checked="store.selectAllByFilter"
|
||
@change="(e) => onToggleSelectAll((e.target as HTMLInputElement).checked)"
|
||
/>
|
||
<span
|
||
class="toolbar-check__box"
|
||
:class="{ 'toolbar-check__box--partial': !store.selectAllByFilter && store.selectedIds.size > 0 }"
|
||
/>
|
||
</label>
|
||
<span class="text-body-2"
|
||
>Выбрано: {{ store.selectedIds.size }} из {{ store.total }} (по текущим фильтрам)</span
|
||
>
|
||
</div>
|
||
|
||
<div v-if="store.loading" class="text-center py-8">
|
||
<v-progress-circular indeterminate color="primary" />
|
||
</div>
|
||
<div v-else-if="store.items.length === 0" class="text-center py-12 text-medium-emphasis">
|
||
Нет проектов. Создайте первый — кнопка справа сверху.
|
||
</div>
|
||
<div v-else class="projects-grid">
|
||
<ProjectCard
|
||
v-for="(project, projectIndex) in store.items"
|
||
:key="project.id"
|
||
:project="project"
|
||
:selected="store.selectedIds.has(project.id)"
|
||
:data-tour="projectIndex === 0 ? 'projects-card' : undefined"
|
||
@toggle-select="store.toggleSelect"
|
||
@edit="openEdit"
|
||
@toggle-active="onToggleActive"
|
||
@sync-now="(p: Project) => store.syncNow(p.id)"
|
||
@delete="(p: Project) => store.del(p.id)"
|
||
/>
|
||
</div>
|
||
|
||
<!-- #6: Пагинация для случаев когда total > per_page. -->
|
||
<div v-if="pageCount > 1" class="d-flex justify-center mt-4">
|
||
<v-pagination
|
||
v-model="store.filters.page"
|
||
:length="pageCount"
|
||
:total-visible="7"
|
||
density="comfortable"
|
||
data-testid="projects-pagination"
|
||
@update:model-value="store.fetch()"
|
||
/>
|
||
</div>
|
||
|
||
<!-- v-show (не v-if): после bulk-действия store.bulkUpdate сбрасывает выбор → при v-if
|
||
бар размонтировался бы вместе со snackbar'ом и тост о пропущенных (в т.ч. «не хватает
|
||
баланса») никогда не показывался. v-show держит компонент смонтированным; snackbar
|
||
Vuetify телепортируется в body и виден даже при скрытом баре. -->
|
||
<BulkActionsBar v-show="store.selectedIds.size >= 2" />
|
||
|
||
<ProjectDetailsDrawer
|
||
:project="singleSelectedProject"
|
||
@close="onDrawerClose"
|
||
@saved="onDrawerSaved"
|
||
@balance-insufficient="onDrawerBalanceInsufficient"
|
||
/>
|
||
<NewProjectDialog v-model="createOpen" mode="create" @saved="onProjectSaved" />
|
||
<EditProjectDialog v-model="editOpen" :project="editing" @saved="onProjectSaved" />
|
||
<ProjectLimitOverloadDialog
|
||
v-model="balanceDialogOpen"
|
||
:payload="balanceDialogPayload"
|
||
@set-zero="balanceDialogOpen = false"
|
||
/>
|
||
|
||
<v-snackbar
|
||
v-model="savedSnackbarOpen"
|
||
color="success"
|
||
:timeout="appliesFromShown ? 7000 : 3500"
|
||
data-testid="projects-saved-snackbar"
|
||
>
|
||
{{ savedSnackbarMessage }}
|
||
</v-snackbar>
|
||
</v-container>
|
||
</template>
|
||
|
||
<script setup lang="ts">
|
||
import { ref, onMounted, onUnmounted, watch, computed } from 'vue';
|
||
import { useProjectsStore, type Project, type BalancePayload } from '../stores/projectsStore';
|
||
import ProjectCard from '../components/projects/ProjectCard.vue';
|
||
import ProjectDetailsDrawer from '../components/projects/ProjectDetailsDrawer.vue';
|
||
import BulkActionsBar from '../components/projects/BulkActionsBar.vue';
|
||
import NewProjectDialog from './projects/NewProjectDialog.vue';
|
||
import EditProjectDialog from './projects/EditProjectDialog.vue';
|
||
import ProjectLimitOverloadDialog from '../components/projects/ProjectLimitOverloadDialog.vue';
|
||
import { REGIONS_ALPHA } from '../constants/regions';
|
||
import { formatAppliesFromMessage } from '../composables/appliesFromMessage';
|
||
|
||
const store = useProjectsStore();
|
||
const createOpen = ref(false);
|
||
const editOpen = ref(false);
|
||
const editing = ref<Project | null>(null);
|
||
|
||
// Task 14: диалог «не хватает баланса» при попытке возобновить проект.
|
||
const balanceDialogOpen = ref(false);
|
||
const balanceDialogPayload = ref<BalancePayload | null>(null);
|
||
|
||
// Тост «Сохранено» после правки проекта. Если правка задела slepok-чувствительные
|
||
// поля (regions / delivery_days_mask / daily_limit_target / источник), backend
|
||
// возвращает applies_from = N.21:00 МСК — показываем расширенное сообщение.
|
||
const savedSnackbarOpen = ref(false);
|
||
const savedSnackbarMessage = ref('');
|
||
const appliesFromShown = ref(false);
|
||
function showSavedSnackbar(appliesFrom: string | null): void {
|
||
savedSnackbarMessage.value = formatAppliesFromMessage(appliesFrom);
|
||
appliesFromShown.value = appliesFrom !== null;
|
||
savedSnackbarOpen.value = true;
|
||
}
|
||
|
||
// Информационный баннер о сроке внесения изменений (синхронизация с поставщиком в 18:00 МСК).
|
||
// Закрытие запоминается, чтобы не показывать повторно.
|
||
const CUTOFF_BANNER_KEY = 'projects.cutoffBannerDismissed';
|
||
const showCutoffBanner = ref(localStorage.getItem(CUTOFF_BANNER_KEY) !== '1');
|
||
function dismissCutoffBanner(): void {
|
||
showCutoffBanner.value = false;
|
||
localStorage.setItem(CUTOFF_BANNER_KEY, '1');
|
||
}
|
||
|
||
// Косяк 07: «пустой аккаунт» — ни одного проекта И фильтры не сужают выдачу.
|
||
// При нём прячем баннер «до 18:00» и фильтры/пагинацию, чтобы новичок не тонул
|
||
// в шуме (если фильтр активен и дал 0 — фильтры остаются, чтобы их сбросить).
|
||
const anyFilterActive = computed(() => {
|
||
const f = store.filters;
|
||
return !!f.signal_type || !!f.status || f.region != null || f.delivery_day != null || !!String(f.search ?? '').trim();
|
||
});
|
||
const isEmptyAccount = computed(() => !store.loading && store.total === 0 && !anyFilterActive.value);
|
||
|
||
const singleSelectedProject = computed<Project | null>(() => {
|
||
if (store.selectedIds.size !== 1) return null;
|
||
const [id] = store.selectedIds;
|
||
return store.items.find((p: Project) => p.id === id) ?? null;
|
||
});
|
||
|
||
// Task 14: обработчик toggle-active с перехватом 409 balance_insufficient.
|
||
async function onToggleActive(project: Project): Promise<void> {
|
||
const result = await store.toggleActive(project);
|
||
if (result?.deferred) {
|
||
balanceDialogPayload.value = result.balance;
|
||
balanceDialogOpen.value = true;
|
||
}
|
||
}
|
||
|
||
// Task 14: обработчик события balance-insufficient от ProjectDetailsDrawer.
|
||
function onDrawerBalanceInsufficient(balance: BalancePayload): void {
|
||
balanceDialogPayload.value = balance;
|
||
balanceDialogOpen.value = true;
|
||
}
|
||
|
||
function onDrawerClose(): void {
|
||
store.clearSelection();
|
||
}
|
||
function onDrawerSaved(appliesFrom: string | null): void {
|
||
// #4: после Save/Pause/Delete панель и галочка должны исчезнуть.
|
||
store.clearSelection();
|
||
void store.fetch();
|
||
showSavedSnackbar(appliesFrom);
|
||
}
|
||
function onProjectSaved(appliesFrom: string | null): void {
|
||
void store.fetch();
|
||
showSavedSnackbar(appliesFrom);
|
||
}
|
||
|
||
const typeFilters = [
|
||
{ title: 'Сайт', value: 'site' },
|
||
{ title: 'Звонок', value: 'call' },
|
||
{ title: 'СМС', value: 'sms' },
|
||
];
|
||
|
||
const statusFilters = [
|
||
{ title: 'Активные', value: 'active' },
|
||
{ title: 'На паузе', value: 'paused' },
|
||
];
|
||
|
||
// #6: per_page options (паттерн как в DealsView).
|
||
const PER_PAGE_OPTIONS = [20, 50, 100, 200];
|
||
|
||
// #7: фильтр регион — все 89 субъектов (без code=0 «вся РФ» — пустой regions[] и так попадает в выдачу).
|
||
const regionFilterItems = REGIONS_ALPHA.map((r) => ({ title: r.name, value: r.code }));
|
||
|
||
// #7: фильтр по дню недели — индексы совпадают с битами delivery_days_mask и dayLabels drawer'а.
|
||
const dayFilterItems = [
|
||
{ title: 'Пн', value: 0 },
|
||
{ title: 'Вт', value: 1 },
|
||
{ title: 'Ср', value: 2 },
|
||
{ title: 'Чт', value: 3 },
|
||
{ title: 'Пт', value: 4 },
|
||
{ title: 'Сб', value: 5 },
|
||
{ title: 'Вс', value: 6 },
|
||
];
|
||
|
||
// #7: сортировки. Префикс '-' = по убыванию (новые/больше — сверху).
|
||
const sortItems = [
|
||
{ title: 'Лидов сегодня — сначала больше', value: '-delivered_today' },
|
||
{ title: 'Лидов за месяц — сначала больше', value: '-delivered_in_month' },
|
||
{ title: 'Лимит — сначала больше', value: '-daily_limit_target' },
|
||
{ title: 'Лимит — сначала меньше', value: 'daily_limit_target' },
|
||
{ title: 'Название — А → Я', value: 'name' },
|
||
{ title: 'Название — Я → А', value: '-name' },
|
||
{ title: 'Создан — сначала новые', value: '-created_at' },
|
||
{ title: 'Создан — сначала старые', value: 'created_at' },
|
||
];
|
||
|
||
// #6: общее число страниц для пагинатора.
|
||
const pageCount = computed(() => Math.max(1, Math.ceil(store.total / Math.max(1, store.filters.per_page))));
|
||
|
||
// При смене per_page/sort/region/delivery_day — сброс на 1-ю страницу + fetch.
|
||
function onResetPageAndFetch(): void {
|
||
store.filters.page = 1;
|
||
void store.fetch();
|
||
}
|
||
|
||
let searchTimer: ReturnType<typeof setTimeout> | null = null;
|
||
function onSearchDebounced() {
|
||
if (searchTimer) clearTimeout(searchTimer);
|
||
searchTimer = setTimeout(() => store.fetch(), 300);
|
||
}
|
||
|
||
function openCreate() {
|
||
createOpen.value = true;
|
||
}
|
||
function openEdit(project: Project) {
|
||
editing.value = project;
|
||
editOpen.value = true;
|
||
}
|
||
|
||
function onToggleSelectAll(value: boolean | null) {
|
||
if (value) {
|
||
store.selectAllByFilter = true;
|
||
store.items.forEach((p: Project) => store.selectedIds.add(p.id));
|
||
} else {
|
||
store.selectAllByFilter = false;
|
||
store.clearSelection();
|
||
}
|
||
}
|
||
|
||
watch(
|
||
() => store.pendingIds.size,
|
||
(size) => {
|
||
if (size > 0) store.startPolling();
|
||
},
|
||
);
|
||
|
||
onMounted(store.fetch);
|
||
onUnmounted(() => store.stopPolling());
|
||
</script>
|
||
|
||
<style scoped>
|
||
.projects-grid {
|
||
display: grid;
|
||
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
|
||
gap: 16px;
|
||
}
|
||
/* Телефон: фильтры — во всю ширину в столбик (перебиваем inline max-width),
|
||
чтобы подписи не обрезались («Р…», «Д…»). */
|
||
@media (max-width: 599.98px) {
|
||
.filters-bar > * {
|
||
max-width: none !important;
|
||
flex: 1 1 100%;
|
||
}
|
||
/* Телефон: заголовок «Проекты» и кнопка «Создать проект» жались вплотную —
|
||
ставим кнопку под заголовок во всю ширину, с воздухом между ними. */
|
||
.projects-head {
|
||
flex-direction: column;
|
||
}
|
||
/* align-self перебивает utility-класс .align-center (у него !important на
|
||
align-items) — заголовок и кнопка прижаты к левому краю, как на десктопе. */
|
||
.projects-head > h1,
|
||
.projects-head > .v-btn {
|
||
align-self: flex-start;
|
||
}
|
||
}
|
||
.toolbar-check {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
cursor: pointer;
|
||
padding: 4px;
|
||
}
|
||
.toolbar-check input {
|
||
position: absolute;
|
||
opacity: 0;
|
||
pointer-events: none;
|
||
}
|
||
.toolbar-check__box {
|
||
width: 20px;
|
||
height: 20px;
|
||
border: 2px solid var(--liderra-noir);
|
||
border-radius: 4px;
|
||
background: #fff;
|
||
display: inline-block;
|
||
position: relative;
|
||
transition: background 150ms ease;
|
||
}
|
||
.toolbar-check input:checked + .toolbar-check__box {
|
||
background: var(--liderra-teal, #0f6e56);
|
||
border-color: var(--liderra-teal, #0f6e56);
|
||
}
|
||
.toolbar-check input:checked + .toolbar-check__box::after {
|
||
content: '';
|
||
position: absolute;
|
||
left: 5px;
|
||
top: 1px;
|
||
width: 6px;
|
||
height: 11px;
|
||
border: solid #fff;
|
||
border-width: 0 2px 2px 0;
|
||
transform: rotate(45deg);
|
||
}
|
||
.toolbar-check__box--partial {
|
||
background: var(--liderra-teal, #0f6e56);
|
||
border-color: var(--liderra-teal, #0f6e56);
|
||
}
|
||
.toolbar-check__box--partial::after {
|
||
content: '';
|
||
position: absolute;
|
||
left: 3px;
|
||
top: 7px;
|
||
width: 10px;
|
||
height: 2px;
|
||
background: #fff;
|
||
}
|
||
/* #5: отступ от тёмных границ страницы — как в KanbanView (pa-6 = 24px по всем сторонам).
|
||
Скоупим напрямую вместо utility-класса, чтобы has-drawer мог перекрыть правый отступ. */
|
||
.projects-view {
|
||
padding: 24px;
|
||
transition: padding-right 240ms cubic-bezier(0.16, 1, 0.3, 1);
|
||
}
|
||
.projects-view.has-drawer {
|
||
padding-right: 480px;
|
||
}
|
||
</style>
|