Files
portal/app/resources/js/views/ProjectsView.vue
T
Дмитрий bfdab40d88 feat(projects): integrate ProjectDetailsDrawer + swap bulk-bar condition >=2
Task 8 of project-details-drawer plan (2026-05-14):
- ProjectsView.vue: import ProjectDetailsDrawer + computed
  - singleSelectedProject computed (Project|null when selectedIds.size === 1)
  - onDrawerClose/onDrawerSaved handlers (clearSelection / fetch)
- Template: BulkActionsBar condition > 0 → >= 2 (mutual exclusion with drawer)
- Template: mount <ProjectDetailsDrawer> with :project / @close / @saved bindings
- Template: .has-drawer class on .projects-view root when single selected
- Style: .projects-view padding-right 480px transition for push effect
- Test: ProjectsView.spec.ts pre-existing 'shows BulkActionsBar' case updated
  to assert >=2 contract (selects 2 projects); 14 PDD tests + 3 view tests
  + 1 skip + toolbar tests all green

Vitest: 3 files / 20 passed / 1 skipped / 0 failed
2026-05-14 15:02:33 +03:00

253 lines
8.1 KiB
Vue

<template>
<div 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>
<div class="d-flex 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-text-field
v-model="store.filters.search"
label="Поиск"
prepend-inner-icon="mdi-magnify"
density="comfortable"
hide-details
style="max-width: 240px"
@input="onSearchDebounced"
/>
</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)"
@archive="(p: Project) => store.archive(p.id)"
/>
</div>
<BulkActionsBar v-if="store.selectedIds.size >= 2" />
<ProjectDetailsDrawer
:project="singleSelectedProject"
@close="onDrawerClose"
@saved="onDrawerSaved"
/>
<NewProjectDialog v-model="createOpen" mode="create" @saved="store.fetch()" />
<EditProjectDialog v-model="editOpen" :project="editing" @saved="store.fetch()" />
</div>
</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';
const store = useProjectsStore();
const createOpen = ref(false);
const editOpen = ref(false);
const editing = ref<Project | null>(null);
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(): void {
void store.fetch();
}
const typeFilters = [
{ title: 'Сайт', value: 'site' },
{ title: 'Звонок', value: 'call' },
{ title: 'СМС', value: 'sms' },
];
const statusFilters = [
{ title: 'Активные', value: 'active' },
{ title: 'На паузе', value: 'paused' },
{ title: 'Архивные', value: 'archived' },
];
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;
}
/* Workaround: MDI-шрифт не подключён в проекте (Диз-4),
`<i class="mdi-close-circle">` рендерится пустым. Подменяем глиф на Unicode ``
и показываем только когда поле имеет значение (Vuetify ставит `.v-field--dirty`). */
.projects-view :deep(.v-field__clearable) {
position: relative;
}
.projects-view :deep(.v-field__clearable .v-icon) {
color: transparent;
width: 20px;
height: 20px;
}
.projects-view :deep(.v-field--dirty .v-field__clearable)::after {
content: '✕';
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
color: rgba(1, 32, 25, 0.55);
font-size: 14px;
font-family: 'Inter', system-ui, sans-serif;
pointer-events: none;
transition: color 150ms ease;
}
.projects-view :deep(.v-field--dirty .v-field__clearable:hover)::after {
color: var(--liderra-noir, #012019);
}
.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;
}
.projects-view {
transition: padding-right 240ms cubic-bezier(0.16, 1, 0.3, 1);
}
.projects-view.has-drawer {
padding-right: 480px;
}
</style>