Files
portal/app/resources/js/views/ProjectsView.vue
T
Дмитрий 73d4c8c14f fix/ui: убрать жаргон в клиентском UI — без конкурент/синхронизация/crm.bp-gr.ru/Pay-per-lead
U1 остаток: пояснения источника проекта без слова конкурент.
U5: баннер списка проектов про результат а не синхронизацию + статус Собирает заявки.
Деалы/импорт/логин: убрано имя поставщика и англ Pay-per-lead.
Активность сделки: технический supplier_webhook заменён понятной фразой.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 12:02:52 +03:00

404 lines
16 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<v-container fluid class="projects-view" :class="{ 'has-drawer': singleSelectedProject !== null }">
<div class="d-flex justify-space-between align-center mb-4">
<h1 class="text-h4">Проекты</h1>
<v-btn color="primary" prepend-icon="mdi-plus" @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">
<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 in store.items"
:key="project.id"
:project="project"
:selected="store.selectedIds.has(project.id)"
@toggle-select="store.toggleSelect"
@edit="openEdit"
@toggle-active="store.toggleActive"
@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" />
<NewProjectDialog v-model="createOpen" mode="create" @saved="onProjectSaved" />
<EditProjectDialog v-model="editOpen" :project="editing" @saved="onProjectSaved" />
<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 } 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 { REGIONS } 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);
// Тост «Сохранено» после правки проекта. Если правка задела 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;
});
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.filter((r) => r.code !== 0).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;
}
.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>