Files
portal/app/resources/js/views/ProjectsView.vue
T
Дмитрий 6e2ad108de feat(slepok): Task 2.11 UI — applies_from toast «вступит в силу N.21:00 МСК»
После правки slepok-чувствительных полей проекта (regions / delivery_days_mask /
daily_limit_target / источник) backend возвращает ProjectResource.applies_from
= N.21:00 МСК (Task 2.11 backend slice, commit dd5954d8). Клиент Лидерры
теперь видит расширенный тост: «Сохранено. Изменения вступят в силу
DD.MM.YYYY в 21:00 МСК.» Когда правка не затронула slepok — обычное
«Сохранено.».

Изменения:
- composables/appliesFromMessage.ts — чистый форматтер (Moscow tz, не локаль клиента).
- ProjectDetailsDrawer / NewProjectDialog / EditProjectDialog — emit('saved', appliesFrom).
- ProjectsView — v-snackbar + onSaved/onDrawerSaved обработчики.
- tests/Frontend/appliesFromMessage.spec.ts — 5 invariant-кейсов.

Plan §Task 2.11 Step 5-6. Spec §4.2.5 UX block. R-15 + R-06..R-08 UX closure.
Vitest worktree-only 944/3sk GREEN, vue-tsc 3 pre-existing errors (вне диффа),
ESLint clean на затронутых файлах.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 09:52:42 +03:00

398 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 gap-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 gap-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 gap-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 gap-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>
<BulkActionsBar v-if="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>