feat(projects): информационный баннер о сроке изменений до 18:00 МСК

Закрывается крестиком, закрытие запоминается в localStorage. Чисто фронтенд (информация, без блокировок, без бэкенда). +3 Vitest.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Дмитрий
2026-05-21 11:17:04 +03:00
parent 83613b4509
commit 68f42ad385
2 changed files with 67 additions and 0 deletions
+34
View File
@@ -5,6 +5,31 @@
<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 gap-3 mb-4">
<v-select
v-model="store.filters.signal_type"
@@ -101,6 +126,15 @@ const createOpen = ref(false);
const editOpen = ref(false);
const editing = ref<Project | null>(null);
// Информационный баннер о сроке внесения изменений (синхронизация с поставщиком в 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;
+33
View File
@@ -239,3 +239,36 @@ describe('ProjectsView × ProjectDetailsDrawer integration', () => {
expect(wrapper.find('.projects-view').classes()).not.toContain('has-drawer');
});
});
describe('ProjectsView 18:00 cutoff banner', () => {
beforeEach(() => {
localStorage.clear();
(axios.get as unknown as ReturnType<typeof vi.fn>).mockResolvedValue({
data: { data: [], meta: { total: 0, current_page: 1, per_page: 20 } },
});
});
it('shows the cutoff banner with the 18:00 deadline by default', async () => {
const wrapper = factory();
await flushPromises();
const banner = wrapper.find('[data-testid="cutoff-banner"]');
expect(banner.exists()).toBe(true);
expect(banner.text()).toContain('18:00');
});
it('hides the banner after the close button and remembers it in localStorage', async () => {
const wrapper = factory();
await flushPromises();
await wrapper.find('[data-testid="cutoff-banner-close"]').trigger('click');
await wrapper.vm.$nextTick();
expect(wrapper.find('[data-testid="cutoff-banner"]').exists()).toBe(false);
expect(localStorage.getItem('projects.cutoffBannerDismissed')).toBe('1');
});
it('stays hidden on next mount when previously dismissed', async () => {
localStorage.setItem('projects.cutoffBannerDismissed', '1');
const wrapper = factory();
await flushPromises();
expect(wrapper.find('[data-testid="cutoff-banner"]').exists()).toBe(false);
});
});