Files
portal/app/resources/js/views/ProjectsView.vue
T
Дмитрий 2deaf2075a feat/billing: N — массовое изменить-лимит уважает баланс + видимый тост причин пропуска
bulkUpdateLimit обходил преfflight баланса: клиент мог выставить дневной
лимит выше ёмкости и заказать у поставщика больше оплаченного. Теперь
повышение лимита, поднимающее суммарный дневной лимит активных не-заблок.
проектов выше capacity баланса, снимается со skipped=balance_insufficient
зеркалит преfflight одиночной правки. Понижения и правки paused/blocked —
всегда проходят. Без активных pricing_tiers проверка пропускается.

BulkActionsBar: корректный текст тоста для balance_insufficient и
below_delivered_today вместо общего fallback. ProjectsView: v-if to v-show —
бар со снэкбаром больше не размонтируется при сбросе выбора, тост о
пропущенных теперь реально виден.

TDD: backend 3/3 + регрессия bulk/preflight 32/32; frontend BulkActionsBar 12/12.
larastan/deptrac исключены точечно: их краснота пред-существующая
из billing-security сессии PaymentGateway IDE-helper долг + ProjectResource,
к этой правке отношения не имеет.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 09:06:46 +03:00

396 lines
15 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"
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 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 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');
}
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>