6e2ad108de
После правки 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>
398 lines
15 KiB
Vue
398 lines
15 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">
|
||
<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>
|