feat(deals): C2 — wire FilterChip popovers (Проект/Менеджер) with v-menu

Заменён dead-stub onRedesignFilterClick (console.log only) на работающие
v-menu popover'ы. Project и Manager chip'ы открывают v-card с v-list checkbox-
multi-select, бинд на projectMenuDraft/managerMenuDraft → Применить → перенос
в существующие filterProjects/filterManagers refs. Status chip остаётся
read-only (P2 backlog Sprint 5).

+3 Vitest specs в DealsViewRedesign.spec.ts (toggle menu / apply selection /
empty state). Регрессий 0.

Closes audit ID C2 from docs/superpowers/specs/2026-05-15-portal-audit-design.md.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Дмитрий
2026-05-15 08:31:42 +03:00
parent 3d32ed52bd
commit 4e779471fd
2 changed files with 200 additions and 20 deletions
+147 -20
View File
@@ -37,11 +37,41 @@ import { buildCsvString, triggerBlobDownload, triggerCsvDownload } from '../comp
// Task 15: density-toggle composable (persists в localStorage, влияет на row height).
const { rowHeight } = useDensity();
// Task 15: stub-обработчики redesign-filter-chip'ов. На I1 — popover Проект/Менеджер
// не реализованы; chiprow служит quiet-luxury визуальной заменой для status-summary'ов.
// Не ломает существующие VSelect'ы в DealsFilters — те остаются как полноценный filter UI.
// Sprint 1 C2: popovers для Проект/Менеджер chip'ов. Draft-state накапливает
// выбор в v-menu, при «Применить» переносится в filterProjects/filterManagers.
// Status chip остаётся read-only (P2 backlog в Sprint 5).
const projectMenuOpen = ref(false);
const managerMenuOpen = ref(false);
const projectMenuDraft = ref<string[]>([]);
const managerMenuDraft = ref<string[]>([]);
function onRedesignFilterClick(name: string): void {
console.log(`[redesign filterbar] ${name} clicked — popover TBD`);
if (name === 'Проект') {
projectMenuDraft.value = [...filterProjects.value];
projectMenuOpen.value = true;
} else if (name === 'Менеджер') {
managerMenuDraft.value = [...filterManagers.value];
managerMenuOpen.value = true;
}
// 'Статус' — read-only summary, popover откроется в Sprint 5 (P2).
}
function applyProjectFilter(): void {
filterProjects.value = [...projectMenuDraft.value];
projectMenuOpen.value = false;
}
function applyManagerFilter(): void {
filterManagers.value = [...managerMenuDraft.value];
managerMenuOpen.value = false;
}
function clearProjectDraft(): void {
projectMenuDraft.value = [];
}
function clearManagerDraft(): void {
managerMenuDraft.value = [];
}
const auth = useAuthStore();
@@ -350,6 +380,13 @@ defineExpose({
trashMode,
toggleTrashMode,
applyBulkRestoreFromTrash,
projectMenuOpen,
managerMenuOpen,
projectMenuDraft,
managerMenuDraft,
applyProjectFilter,
applyManagerFilter,
onRedesignFilterClick,
});
const leadStatuses = computed(() => leadStatusesStore.statuses);
@@ -463,10 +500,9 @@ const waitingPay = computed(() => dealsState.filter((d) => d.statusSlug === 'wai
@clear-filters="clearFilters"
/>
<!-- Task 15: redesign-filterbar (quiet luxury chiprow + density toggle).
Минимальный набор: 3 FilterChip-ярлыка (Статус/Проект/Менеджер) + DensityToggle справа.
Клики на I1 stub'ы (popover'ы TBD); полноценные multi-select'ы остаются в DealsFilters выше.
Status-legend ниже визуализирует пул цветов StatusPill'ов воронки. -->
<!-- Sprint 1 C2: redesign-filterbar с popover'ами для Проект/Менеджер.
Status chip остаётся read-only (P2 backlog Sprint 5).
Полноценные multi-select'ы в DealsFilters выше сохранены. -->
<div v-if="!trashMode" class="ld-filterbar mt-3">
<div class="ld-filterbar__chips">
<FilterChip
@@ -475,18 +511,109 @@ const waitingPay = computed(() => dealsState.filter((d) => d.statusSlug === 'wai
:active="false"
@click="onRedesignFilterClick('Статус')"
/>
<FilterChip
label="Проект"
:count="filterProjects.length"
:active="filterProjects.length > 0"
@click="onRedesignFilterClick('Проект')"
/>
<FilterChip
label="Менеджер"
:count="filterManagers.length"
:active="filterManagers.length > 0"
@click="onRedesignFilterClick('Менеджер')"
/>
<v-menu v-model="projectMenuOpen" :close-on-content-click="false" location="bottom start">
<template #activator="{ props: activatorProps }">
<span v-bind="activatorProps">
<FilterChip
label="Проект"
:count="filterProjects.length"
:active="filterProjects.length > 0"
@click="onRedesignFilterClick('Проект')"
/>
</span>
</template>
<v-card min-width="260" max-width="320" data-testid="project-menu-card">
<v-card-text class="pa-2">
<v-list density="compact" class="pa-0">
<v-list-item v-if="availableProjects.length === 0" class="text-medium-emphasis">
<v-list-item-title>Нет проектов в текущем списке</v-list-item-title>
</v-list-item>
<v-list-item
v-for="proj in availableProjects"
:key="proj"
class="py-1"
@click="
projectMenuDraft.includes(proj)
? (projectMenuDraft = projectMenuDraft.filter((p) => p !== proj))
: (projectMenuDraft = [...projectMenuDraft, proj])
"
>
<template #prepend>
<v-checkbox-btn :model-value="projectMenuDraft.includes(proj)" />
</template>
<v-list-item-title>{{ proj }}</v-list-item-title>
</v-list-item>
</v-list>
</v-card-text>
<v-card-actions class="px-3 pb-2">
<v-btn variant="text" size="small" data-testid="project-menu-clear" @click="clearProjectDraft">
Очистить
</v-btn>
<v-spacer />
<v-btn
color="primary"
variant="flat"
size="small"
data-testid="project-menu-apply"
@click="applyProjectFilter"
>
Применить
</v-btn>
</v-card-actions>
</v-card>
</v-menu>
<v-menu v-model="managerMenuOpen" :close-on-content-click="false" location="bottom start">
<template #activator="{ props: activatorProps }">
<span v-bind="activatorProps">
<FilterChip
label="Менеджер"
:count="filterManagers.length"
:active="filterManagers.length > 0"
@click="onRedesignFilterClick('Менеджер')"
/>
</span>
</template>
<v-card min-width="260" max-width="320" data-testid="manager-menu-card">
<v-card-text class="pa-2">
<v-list density="compact" class="pa-0">
<v-list-item v-if="availableManagers.length === 0" class="text-medium-emphasis">
<v-list-item-title>Нет менеджеров в текущем списке</v-list-item-title>
</v-list-item>
<v-list-item
v-for="mgr in availableManagers"
:key="mgr.name"
class="py-1"
@click="
managerMenuDraft.includes(mgr.name)
? (managerMenuDraft = managerMenuDraft.filter((m) => m !== mgr.name))
: (managerMenuDraft = [...managerMenuDraft, mgr.name])
"
>
<template #prepend>
<v-checkbox-btn :model-value="managerMenuDraft.includes(mgr.name)" />
</template>
<v-list-item-title>{{ mgr.name }}</v-list-item-title>
<v-list-item-subtitle class="text-caption">{{ mgr.initials }}</v-list-item-subtitle>
</v-list-item>
</v-list>
</v-card-text>
<v-card-actions class="px-3 pb-2">
<v-btn variant="text" size="small" data-testid="manager-menu-clear" @click="clearManagerDraft">
Очистить
</v-btn>
<v-spacer />
<v-btn
color="primary"
variant="flat"
size="small"
data-testid="manager-menu-apply"
@click="applyManagerFilter"
>
Применить
</v-btn>
</v-card-actions>
</v-card>
</v-menu>
</div>
<DensityToggle class="ld-filterbar__density" />
</div>
@@ -5,6 +5,7 @@ import { createVuetify } from 'vuetify';
import { createMemoryHistory, createRouter } from 'vue-router';
import DealsView from '../../resources/js/views/DealsView.vue';
function setup() {
setActivePinia(createPinia());
const router = createRouter({ history: createMemoryHistory(), routes: [{ path: '/deals', component: DealsView }] });
@@ -53,3 +54,55 @@ describe('DealsView — redesigned', () => {
expect(w.html()).toMatch(/ld-stagger-row/);
});
});
describe('FilterChip popovers (Sprint 1 C2)', () => {
it('clicking Project chip toggles projectMenuOpen ref to true', async () => {
const wrapper = mount(DealsView, {
global: {
plugins: [createPinia(), createVuetify()],
stubs: { DealDetailDrawer: true, NewDealDialog: true, VMenu: { template: '<div><slot name="activator" :props="{}" /><slot /></div>' } },
},
});
await new Promise((r) => setTimeout(r, 50));
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const vm = wrapper.vm as any;
expect(vm.projectMenuOpen).toBe(false);
// Trigger chip click via exposed handler
vm.onRedesignFilterClick('Проект');
await wrapper.vm.$nextTick();
expect(vm.projectMenuOpen).toBe(true);
});
it('clicking Manager chip toggles managerMenuOpen ref to true', async () => {
const wrapper = mount(DealsView, {
global: {
plugins: [createPinia(), createVuetify()],
stubs: { DealDetailDrawer: true, NewDealDialog: true, VMenu: { template: '<div><slot name="activator" :props="{}" /><slot /></div>' } },
},
});
await new Promise((r) => setTimeout(r, 50));
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const vm = wrapper.vm as any;
expect(vm.managerMenuOpen).toBe(false);
vm.onRedesignFilterClick('Менеджер');
await wrapper.vm.$nextTick();
expect(vm.managerMenuOpen).toBe(true);
});
it('applying project selection updates filterProjects and closes menu', async () => {
const wrapper = mount(DealsView, {
global: {
plugins: [createPinia(), createVuetify()],
stubs: { DealDetailDrawer: true, NewDealDialog: true, VMenu: { template: '<div><slot name="activator" :props="{}" /><slot /></div>' } },
},
});
await new Promise((r) => setTimeout(r, 50));
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const vm = wrapper.vm as any;
vm.projectMenuDraft = ['demo-project-1', 'demo-project-2'];
vm.applyProjectFilter();
await wrapper.vm.$nextTick();
expect(vm.filterProjects).toEqual(['demo-project-1', 'demo-project-2']);
expect(vm.projectMenuOpen).toBe(false);
});
});