Files
portal/app/resources/js/views/ProjectsView.vue
T
Дмитрий 9ff7bc8fa0 feat(биллинг-фронт): баланс на запуске — сообщения в рублях, метка «не запущен», группы
Фронт для правила «баланс блокирует запуск, а не создание»:
- ProjectLimitOverloadDialog переформулирован в «Проект не запущен — пополните
  ~X ₽ или уменьшите объём» (сумма в рублях topup_rub).
- NewProjectDialog: создание всегда успешно; при launch.deferred показывает
  сообщение (не блок). Update-лимита 409 → тот же диалог.
- Возобновление (toggle-active) ловит 409 balance_insufficient (стор не
  переключает is_active), показывает диалог; постоянная метка «Не запущен —
  не хватает баланса» на карточке/в панели по preflight_blocked_at/balance_blocked.
- BulkActionsBar: сводка «Запущено N, отложено M — не хватает баланса» на resume.
- Автоподбор: CreateScreen показывает сводку запуска (рубли); FieldCompetitor
  массовые pause/resume через общий POST /api/projects/bulk (наследует BULK_MAX,
  слепок-защиту, балансовый гейт).
- ProjectResource отдаёт preflight_blocked_at.

Новые/обновлённые фронт-тесты зелёные. Пред-существующий красный baseline
фронт-набора (ErrorView/Settings/Legal/… + ProjectsView v-show) не трогался.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 12:04:16 +03:00

434 lines
17 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="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 } 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.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>