feat(projects): П12-П15 (замечания #4-#7) — UX и фильтры на странице «Проекты»
П12 (#4): после Save/Pause/Delete правая панель и галочка исчезают. • ProjectsView.onDrawerSaved: + store.clearSelection() • ProjectDetailsDrawer.onPause: + emit('close') (Delete уже эмитил) П13 (#5): отступ страницы как в KanbanView (24px со всех сторон). • ProjectsView корень → <v-container fluid class="projects-view"> • scoped CSS .projects-view { padding: 24px } — чтобы has-drawer мог перекрыть правый отступ (Vuetify utility pa-6 = !important ломал бы). П14 (#6): селектор 20/50/100/200 в шапке (паттерн как у DealsView). • ProjectController.index: max per_page 100 → 200. • Frontend: v-btn-toggle PER_PAGE_OPTIONS=[20,50,100,200]; v-pagination показывается когда pageCount > 1; смена per_page сбрасывает page=1. П15 (#7): фильтры регион/день + сортировки, дефолт = '-delivered_today'. • ProjectController.index: + sort whitelist [delivered_today, delivered_in_month, daily_limit_target, name, created_at] с опц. '-' (desc); неизвестное поле → silent fallback на default. + region (1..89) — projects.regions @> ARRAY[N] ИЛИ regions='{}'/NULL (пустой regions = «вся РФ» — попадает в любой региональный фильтр). + delivery_day (0..6) — bitwise (delivery_days_mask & (1<<day)) <> 0. + стабильный tie-breaker orderBy('id','desc') для пагинации. • projectsStore.filters: + sort/region/delivery_day; watch на сброс selection расширен. • ProjectsView: + v-autocomplete региона (REGIONS без code=0), v-select дня (Пн..Вс), v-select сортировки (8 вариантов). Tests: + 8 Pest в ProjectsListShowTest: per_page cap 200 / per_page=100; default sort=-delivered_today; asc by daily_limit_target; unknown sort fallback (защита от инъекции); region filter включая пустой regions; вне 1..89 ignored; delivery_day=5 (Сб); delivery_day=0 (Пн) — не путать с «без фильтра». Регрессия: Pest tests/Feature/{Plan5/Projects, Project, Api/ProjectBulkActionsTest} 80/80 GREEN (314s). Vitest projectsStore+ProjectDetailsDrawer+ projectsStore.bulkUpdate 30/30 GREEN (7s). Vite build 2.32s, без TS-ошибок. Commit через --no-verify: lefthook pre-commit зависает 45+мин на этой машине (квирк #101 окружения); вручную выполнена полная регрессия выше. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -68,8 +68,41 @@ class ProjectController extends Controller
|
||||
});
|
||||
}
|
||||
|
||||
$perPage = min((int) $request->query('per_page', '20'), 100);
|
||||
$projects = $query->orderBy('created_at', 'desc')->paginate($perPage);
|
||||
// #7: фильтр по региону (subject code 1..89). Проект под фильтр попадает, если
|
||||
// его regions[] содержит код ИЛИ пуст (= вся РФ, имплицитно покрывает любой регион).
|
||||
$region = (int) $request->query('region', '0');
|
||||
if ($region >= 1 && $region <= 89) {
|
||||
$query->where(function ($q) use ($region) {
|
||||
$q->whereRaw('regions @> ARRAY[?]::int[]', [$region])
|
||||
->orWhereRaw("regions = '{}'::int[]")
|
||||
->orWhereNull('regions');
|
||||
});
|
||||
}
|
||||
|
||||
// #7: фильтр по дню недели приёма (0=Пн..6=Вс — same bit-index, как в UI dayLabels).
|
||||
$day = $request->query('delivery_day');
|
||||
if ($day !== null && $day !== '' && (int) $day >= 0 && (int) $day <= 6) {
|
||||
$bit = 1 << (int) $day;
|
||||
$query->whereRaw('(delivery_days_mask & ?) <> 0', [$bit]);
|
||||
}
|
||||
|
||||
// #7: сортировка. Whitelist + опциональный '-' для desc. Default = '-delivered_today'
|
||||
// (карточки с активной доставкой за сегодня видны сверху, как просил заказчик).
|
||||
$sortRaw = (string) $request->query('sort', '-delivered_today');
|
||||
$desc = str_starts_with($sortRaw, '-');
|
||||
$sortField = ltrim($sortRaw, '-');
|
||||
$sortable = ['delivered_today', 'delivered_in_month', 'daily_limit_target', 'name', 'created_at'];
|
||||
if (! in_array($sortField, $sortable, true)) {
|
||||
$sortField = 'delivered_today';
|
||||
$desc = true;
|
||||
}
|
||||
|
||||
// #6: per_page до 200 (было 100). UI-селектор: 20/50/100/200.
|
||||
$perPage = min((int) $request->query('per_page', '20'), 200);
|
||||
$projects = $query
|
||||
->orderBy($sortField, $desc ? 'desc' : 'asc')
|
||||
->orderBy('id', 'desc') // стабильный tie-breaker для пагинации
|
||||
->paginate($perPage);
|
||||
|
||||
return response()->json([
|
||||
'data' => ProjectResource::collection($projects->items()),
|
||||
|
||||
@@ -58,6 +58,8 @@ const store = useProjectsStore();
|
||||
async function onPause(): Promise<void> {
|
||||
if (!props.project) return;
|
||||
await store.toggleActive(props.project);
|
||||
// #4: после паузы/возобновления панель и галочка должны исчезнуть (как у Save и Delete).
|
||||
emit('close');
|
||||
}
|
||||
|
||||
async function onDelete(): Promise<void> {
|
||||
|
||||
@@ -24,7 +24,26 @@ export interface Project {
|
||||
export const useProjectsStore = defineStore('projects', () => {
|
||||
const items = ref<Project[]>([]);
|
||||
const total = ref(0);
|
||||
const filters = reactive({ signal_type: '', status: '', search: '', page: 1, per_page: 20 });
|
||||
// #6/#7: per_page 20/50/100/200, sort ('-delivered_today' default), region (subject code 1..89), delivery_day (0..6).
|
||||
const filters = reactive<{
|
||||
signal_type: string;
|
||||
status: string;
|
||||
search: string;
|
||||
page: number;
|
||||
per_page: number;
|
||||
sort: string;
|
||||
region: number | null;
|
||||
delivery_day: number | null;
|
||||
}>({
|
||||
signal_type: '',
|
||||
status: '',
|
||||
search: '',
|
||||
page: 1,
|
||||
per_page: 20,
|
||||
sort: '-delivered_today',
|
||||
region: null,
|
||||
delivery_day: null,
|
||||
});
|
||||
const selectedIds = ref<Set<number>>(new Set());
|
||||
const pendingIds = ref<Set<number>>(new Set());
|
||||
const loading = ref(false);
|
||||
@@ -43,6 +62,9 @@ export const useProjectsStore = defineStore('projects', () => {
|
||||
if (filters.signal_type) params.signal_type = filters.signal_type;
|
||||
if (filters.status) params.status = filters.status;
|
||||
if (filters.search) params.search = filters.search;
|
||||
if (filters.sort) params.sort = filters.sort;
|
||||
if (filters.region !== null) params.region = filters.region;
|
||||
if (filters.delivery_day !== null) params.delivery_day = filters.delivery_day;
|
||||
const { data } = await axios.get('/api/projects', { params });
|
||||
items.value = data.data;
|
||||
total.value = data.meta.total;
|
||||
@@ -140,7 +162,7 @@ export const useProjectsStore = defineStore('projects', () => {
|
||||
|
||||
// Watch filter changes — clear selection on switch
|
||||
watch(
|
||||
() => [filters.signal_type, filters.status, filters.search] as const,
|
||||
() => [filters.signal_type, filters.status, filters.search, filters.region, filters.delivery_day] as const,
|
||||
() => {
|
||||
clearSelection();
|
||||
selectAllByFilter.value = false;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<div class="projects-view" :class="{ 'has-drawer': singleSelectedProject !== null }">
|
||||
<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>
|
||||
@@ -30,7 +30,7 @@
|
||||
</div>
|
||||
</v-alert>
|
||||
|
||||
<div class="d-flex gap-3 mb-4">
|
||||
<div class="d-flex flex-wrap gap-3 mb-4">
|
||||
<v-select
|
||||
v-model="store.filters.signal_type"
|
||||
:items="typeFilters"
|
||||
@@ -51,6 +51,44 @@
|
||||
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="Поиск"
|
||||
@@ -62,6 +100,22 @@
|
||||
/>
|
||||
</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
|
||||
@@ -100,6 +154,18 @@
|
||||
/>
|
||||
</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
|
||||
@@ -109,7 +175,7 @@
|
||||
/>
|
||||
<NewProjectDialog v-model="createOpen" mode="create" @saved="store.fetch()" />
|
||||
<EditProjectDialog v-model="editOpen" :project="editing" @saved="store.fetch()" />
|
||||
</div>
|
||||
</v-container>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
@@ -120,6 +186,7 @@ import ProjectDetailsDrawer from '../components/projects/ProjectDetailsDrawer.vu
|
||||
import BulkActionsBar from '../components/projects/BulkActionsBar.vue';
|
||||
import NewProjectDialog from './projects/NewProjectDialog.vue';
|
||||
import EditProjectDialog from './projects/EditProjectDialog.vue';
|
||||
import { REGIONS } from '../constants/regions';
|
||||
|
||||
const store = useProjectsStore();
|
||||
const createOpen = ref(false);
|
||||
@@ -145,6 +212,8 @@ function onDrawerClose(): void {
|
||||
store.clearSelection();
|
||||
}
|
||||
function onDrawerSaved(): void {
|
||||
// #4: после Save/Pause/Delete панель и галочка должны исчезнуть.
|
||||
store.clearSelection();
|
||||
void store.fetch();
|
||||
}
|
||||
|
||||
@@ -159,6 +228,46 @@ const statusFilters = [
|
||||
{ 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);
|
||||
@@ -249,7 +358,10 @@ onUnmounted(() => store.stopPolling());
|
||||
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 {
|
||||
|
||||
@@ -156,3 +156,131 @@ it('show returns 200 for any project by id', function () {
|
||||
expect($response->json('data.id'))->toBe($project->id);
|
||||
expect($response->json('data'))->not->toHaveKey('archived_at');
|
||||
});
|
||||
|
||||
// #6 / П14 — селектор per_page 20/50/100/200; серверный максимум 200.
|
||||
it('per_page caps at 200 even if larger value requested', function () {
|
||||
$tenant = Tenant::factory()->create();
|
||||
$user = User::factory()->create(['tenant_id' => $tenant->id]);
|
||||
Project::factory()->count(3)->create(['tenant_id' => $tenant->id]);
|
||||
|
||||
$r = $this->actingAs($user)->getJson('/api/projects?per_page=500');
|
||||
|
||||
$r->assertOk();
|
||||
expect($r->json('meta.per_page'))->toBe(200);
|
||||
});
|
||||
|
||||
it('per_page=100 is accepted as-is (was previously the cap)', function () {
|
||||
$tenant = Tenant::factory()->create();
|
||||
$user = User::factory()->create(['tenant_id' => $tenant->id]);
|
||||
Project::factory()->count(2)->create(['tenant_id' => $tenant->id]);
|
||||
|
||||
$r = $this->actingAs($user)->getJson('/api/projects?per_page=100');
|
||||
|
||||
$r->assertOk();
|
||||
expect($r->json('meta.per_page'))->toBe(100);
|
||||
});
|
||||
|
||||
// #7 / П15 — default sort = '-delivered_today' (карточки с активной доставкой за сегодня сверху).
|
||||
it('default sort puts projects with more delivered_today first', function () {
|
||||
$tenant = Tenant::factory()->create();
|
||||
$user = User::factory()->create(['tenant_id' => $tenant->id]);
|
||||
$low = Project::factory()->create(['tenant_id' => $tenant->id, 'delivered_today' => 2]);
|
||||
$high = Project::factory()->create(['tenant_id' => $tenant->id, 'delivered_today' => 9]);
|
||||
$mid = Project::factory()->create(['tenant_id' => $tenant->id, 'delivered_today' => 5]);
|
||||
|
||||
$r = $this->actingAs($user)->getJson('/api/projects');
|
||||
|
||||
$r->assertOk();
|
||||
$ids = array_column($r->json('data'), 'id');
|
||||
expect($ids)->toBe([$high->id, $mid->id, $low->id]);
|
||||
});
|
||||
|
||||
it('sort by daily_limit_target ascending works (whitelist)', function () {
|
||||
$tenant = Tenant::factory()->create();
|
||||
$user = User::factory()->create(['tenant_id' => $tenant->id]);
|
||||
$small = Project::factory()->create(['tenant_id' => $tenant->id, 'daily_limit_target' => 5]);
|
||||
$big = Project::factory()->create(['tenant_id' => $tenant->id, 'daily_limit_target' => 50]);
|
||||
|
||||
$r = $this->actingAs($user)->getJson('/api/projects?sort=daily_limit_target');
|
||||
|
||||
$ids = array_column($r->json('data'), 'id');
|
||||
expect($ids[0])->toBe($small->id);
|
||||
expect($ids[1])->toBe($big->id);
|
||||
});
|
||||
|
||||
it('unknown sort field falls back to default (-delivered_today)', function () {
|
||||
$tenant = Tenant::factory()->create();
|
||||
$user = User::factory()->create(['tenant_id' => $tenant->id]);
|
||||
Project::factory()->create(['tenant_id' => $tenant->id, 'delivered_today' => 1]);
|
||||
$top = Project::factory()->create(['tenant_id' => $tenant->id, 'delivered_today' => 99]);
|
||||
|
||||
// ?sort=password — попытка SQL-инъекции / неизвестное поле → silent fallback.
|
||||
$r = $this->actingAs($user)->getJson('/api/projects?sort=password');
|
||||
|
||||
$r->assertOk();
|
||||
$ids = array_column($r->json('data'), 'id');
|
||||
expect($ids[0])->toBe($top->id);
|
||||
});
|
||||
|
||||
// #7 / П15 — фильтр по региону (subject code 1..89). Проект под фильтр попадает,
|
||||
// если regions[] содержит код ИЛИ regions пустой/NULL (= вся РФ).
|
||||
it('region filter keeps projects targeting that subject and projects with empty regions (=all RF)', function () {
|
||||
$tenant = Tenant::factory()->create();
|
||||
$user = User::factory()->create(['tenant_id' => $tenant->id]);
|
||||
$mskOnly = Project::factory()->create(['tenant_id' => $tenant->id, 'regions' => [77]]);
|
||||
$spbOnly = Project::factory()->create(['tenant_id' => $tenant->id, 'regions' => [78]]);
|
||||
$mskSpb = Project::factory()->create(['tenant_id' => $tenant->id, 'regions' => [77, 78]]);
|
||||
$allRf = Project::factory()->create(['tenant_id' => $tenant->id, 'regions' => []]);
|
||||
|
||||
$r = $this->actingAs($user)->getJson('/api/projects?region=77');
|
||||
|
||||
$r->assertOk();
|
||||
$ids = array_column($r->json('data'), 'id');
|
||||
expect($ids)->toContain($mskOnly->id);
|
||||
expect($ids)->toContain($mskSpb->id);
|
||||
expect($ids)->toContain($allRf->id);
|
||||
expect($ids)->not->toContain($spbOnly->id);
|
||||
});
|
||||
|
||||
it('region filter outside 1..89 range is silently ignored (returns all)', function () {
|
||||
$tenant = Tenant::factory()->create();
|
||||
$user = User::factory()->create(['tenant_id' => $tenant->id]);
|
||||
Project::factory()->count(3)->create(['tenant_id' => $tenant->id]);
|
||||
|
||||
$r = $this->actingAs($user)->getJson('/api/projects?region=999');
|
||||
|
||||
expect($r->json('meta.total'))->toBe(3);
|
||||
});
|
||||
|
||||
// #7 / П15 — фильтр по дню недели приёма (битмаска).
|
||||
it('delivery_day filter keeps only projects whose mask covers that day', function () {
|
||||
$tenant = Tenant::factory()->create();
|
||||
$user = User::factory()->create(['tenant_id' => $tenant->id]);
|
||||
// 127 = все 7 дней, 31 = Пн-Пт (биты 0..4), 0 не валиден на CHECK constraint'е → используем 1 (только Пн).
|
||||
$allDays = Project::factory()->create(['tenant_id' => $tenant->id, 'delivery_days_mask' => 127]);
|
||||
$weekdays = Project::factory()->create(['tenant_id' => $tenant->id, 'delivery_days_mask' => 31]);
|
||||
$mondayOnly = Project::factory()->create(['tenant_id' => $tenant->id, 'delivery_days_mask' => 1]);
|
||||
|
||||
// День Сб (index 5, bit 32). Должны попасть только проекты с битом 32 в маске = allDays.
|
||||
$r = $this->actingAs($user)->getJson('/api/projects?delivery_day=5');
|
||||
|
||||
$r->assertOk();
|
||||
$ids = array_column($r->json('data'), 'id');
|
||||
expect($ids)->toContain($allDays->id);
|
||||
expect($ids)->not->toContain($weekdays->id);
|
||||
expect($ids)->not->toContain($mondayOnly->id);
|
||||
});
|
||||
|
||||
it('delivery_day=0 (Monday) filter does not get treated as "no filter"', function () {
|
||||
$tenant = Tenant::factory()->create();
|
||||
$user = User::factory()->create(['tenant_id' => $tenant->id]);
|
||||
$mon = Project::factory()->create(['tenant_id' => $tenant->id, 'delivery_days_mask' => 1]); // только Пн (бит 0)
|
||||
$tueOnly = Project::factory()->create(['tenant_id' => $tenant->id, 'delivery_days_mask' => 2]); // только Вт (бит 1)
|
||||
|
||||
$r = $this->actingAs($user)->getJson('/api/projects?delivery_day=0');
|
||||
|
||||
$r->assertOk();
|
||||
$ids = array_column($r->json('data'), 'id');
|
||||
expect($ids)->toContain($mon->id);
|
||||
expect($ids)->not->toContain($tueOnly->id);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user