63 KiB
Projects Bulk Actions Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Расширить bulk-операции на странице /projects — добавить регионы/дни/лимит как Add/Remove операции и select-all по фильтрам.
Architecture: Расширение существующего POST /api/projects/bulk action-discriminator'ом (5 actions), bitmask-операции в ProjectService, 3 новых v-dialog'а во фронте, единый bulkUpdate(payload) в store. Per-project skip при конфликте лимита с delivered_today.
Tech Stack: PHP 8.3 / Laravel 13 / Pest 4 / Vue 3 / Vuetify 3 / Pinia / Vitest 4 / Histoire 1.
Spec: docs/superpowers/specs/2026-05-12-projects-bulk-actions-design.md
Важная коррекция спека: region_mask и delivery_days_mask — bitmask integers (regions: 0..255 8 ФО РФ, days: 0..127 Пн-Вс), не arrays. API принимает add: number / remove: number как bitmask.
File Structure
Backend (modify):
app/Http/Requests/BulkProjectActionRequest.php— расширение validation enum + payload rulesapp/Http/Controllers/Api/ProjectController.php— bulk() возвращает structured responseapp/Services/Project/ProjectService.php— extend bulkAction: split на private методы per action
Backend (create):
tests/Feature/Api/ProjectBulkActionsTest.php— все новые actions + scope + edge cases
Frontend (modify):
app/resources/js/stores/projectsStore.ts—selectAllByFilter: Ref<boolean>,bulkUpdate(payload), filter watcherapp/resources/js/views/ProjectsView.vue— inline toolbar (select-all + counter)app/resources/js/components/projects/BulkActionsBar.vue— 4 категории, 3 диалог-кнопки
Frontend (create):
app/resources/js/components/projects/RegionsBulkDialog.vueapp/resources/js/components/projects/DaysBulkDialog.vueapp/resources/js/components/projects/LimitBulkDialog.vueapp/resources/js/constants/regions.ts— 8 ФО с битами и лейбламиapp/resources/js/constants/weekdays.ts— 7 дней с битами
Tests (create):
app/tests/Frontend/projectsStore.bulkUpdate.spec.tsapp/tests/Frontend/ProjectsView.toolbar.spec.tsapp/tests/Frontend/RegionsBulkDialog.spec.tsapp/tests/Frontend/DaysBulkDialog.spec.tsapp/tests/Frontend/LimitBulkDialog.spec.ts
Tests (modify): app/tests/Frontend/BulkActionsBar.spec.ts (если есть; иначе создать)
Histoire (create): RegionsBulkDialog.story.vue, DaysBulkDialog.story.vue, LimitBulkDialog.story.vue в соответствующих папках stories.
Task 1: Extend BulkProjectActionRequest validation
Files:
-
Modify:
app/app/Http/Requests/BulkProjectActionRequest.php -
Test:
app/tests/Feature/Api/ProjectBulkActionsTest.php(create) -
Step 1: Write failing test для нового набора actions + scope
Create app/tests/Feature/Api/ProjectBulkActionsTest.php:
<?php
declare(strict_types=1);
use App\Models\Project;
use App\Models\Tenant;
use App\Models\User;
it('accepts update_regions action with add/remove bitmask', function () {
$tenant = Tenant::factory()->create();
$user = User::factory()->for($tenant)->create();
$p = Project::factory()->for($tenant)->create(['region_mask' => 1]);
actingAs($user)
->postJson('/api/projects/bulk', [
'action' => 'update_regions',
'ids' => [$p->id],
'add' => 6, // биты 2+4 = Северо-Западный + Южный
'remove' => 1, // бит 1 = Центральный
])
->assertOk()
->assertJsonStructure(['updated', 'skipped', 'warnings']);
});
it('rejects unknown action', function () {
$user = User::factory()->create();
actingAs($user)
->postJson('/api/projects/bulk', [
'action' => 'nuke_everything',
'ids' => [1],
])
->assertStatus(422)
->assertJsonValidationErrors(['action']);
});
it('rejects update_limit with both delta and replace', function () {
$user = User::factory()->create();
actingAs($user)
->postJson('/api/projects/bulk', [
'action' => 'update_limit',
'ids' => [1],
'delta' => 50,
'replace' => 500,
])
->assertStatus(422);
});
it('rejects empty ids without scope', function () {
$user = User::factory()->create();
actingAs($user)
->postJson('/api/projects/bulk', [
'action' => 'pause',
])
->assertStatus(422);
});
- Step 2: Run tests to verify they fail
Run: composer test -- --filter=ProjectBulkActionsTest
Expected: 4 failures (request rejects valid update_regions, accepts unknown action, etc.)
- Step 3: Update BulkProjectActionRequest rules
Replace contents of app/app/Http/Requests/BulkProjectActionRequest.php:
<?php
declare(strict_types=1);
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class BulkProjectActionRequest extends FormRequest
{
public function authorize(): bool
{
return $this->user() !== null;
}
public function rules(): array
{
$action = $this->input('action');
$rules = [
'action' => ['required', Rule::in([
'pause', 'resume', 'archive',
'update_regions', 'update_days', 'update_limit',
])],
'ids' => ['nullable', 'array', 'max:500'],
'ids.*' => ['integer', 'min:1'],
'scope' => ['nullable', 'array'],
'scope.filter' => ['nullable', 'array'],
'scope.filter.signal_type' => ['nullable', 'string', Rule::in(['site', 'call', 'sms'])],
'scope.filter.status' => ['nullable', 'string', Rule::in(['active', 'paused', 'archived'])],
'scope.filter.search' => ['nullable', 'string', 'max:255'],
];
if ($action === 'update_regions' || $action === 'update_days') {
$maxMask = $action === 'update_regions' ? 255 : 127;
$rules['add'] = ['nullable', 'integer', 'min:0', "max:{$maxMask}"];
$rules['remove'] = ['nullable', 'integer', 'min:0', "max:{$maxMask}"];
}
if ($action === 'update_limit') {
$rules['delta'] = ['nullable', 'integer'];
$rules['replace'] = ['nullable', 'integer', 'min:0'];
}
return $rules;
}
public function withValidator($validator): void
{
$validator->after(function ($v) {
$hasIds = !empty($this->input('ids'));
$hasScope = !empty($this->input('scope.filter'));
if (!$hasIds && !$hasScope) {
$v->errors()->add('ids', 'Either ids or scope.filter is required.');
}
if ($this->input('action') === 'update_limit') {
$hasDelta = $this->has('delta');
$hasReplace = $this->has('replace');
if ($hasDelta && $hasReplace) {
$v->errors()->add('delta', 'Cannot use both delta and replace.');
}
if (!$hasDelta && !$hasReplace) {
$v->errors()->add('delta', 'Either delta or replace is required for update_limit.');
}
}
});
}
}
- Step 4: Run tests to verify they pass
Run: composer test -- --filter=ProjectBulkActionsTest
Expected: 3 PASS (validation tests). The "accepts update_regions" test still fails because service doesn't handle it yet — that's fine, fixed in Task 2.
Manually disable the first test with ->todo() for now, OR keep failing and move on.
- Step 5: Commit
git add app/app/Http/Requests/BulkProjectActionRequest.php app/tests/Feature/Api/ProjectBulkActionsTest.php
git commit -m "feat(projects-bulk): extend validation for 6 actions + scope"
Task 2: ProjectService — update_regions handler
Files:
-
Modify:
app/app/Services/Project/ProjectService.php -
Test:
app/tests/Feature/Api/ProjectBulkActionsTest.php(add cases) -
Step 1: Add failing test for region bitmask add/remove
Append to ProjectBulkActionsTest.php:
it('applies update_regions add and remove correctly', function () {
$tenant = Tenant::factory()->create();
$user = User::factory()->for($tenant)->create();
$p1 = Project::factory()->for($tenant)->create(['region_mask' => 3]); // 1+2
$p2 = Project::factory()->for($tenant)->create(['region_mask' => 5]); // 1+4
actingAs($user)
->postJson('/api/projects/bulk', [
'action' => 'update_regions',
'ids' => [$p1->id, $p2->id],
'add' => 16, // 16 = Приволжский
'remove' => 1, // 1 = Центральный
])
->assertOk()
->assertJson(['updated' => 2, 'skipped' => [], 'warnings' => []]);
expect($p1->fresh()->region_mask)->toBe((3 | 16) & ~1); // = 18
expect($p2->fresh()->region_mask)->toBe((5 | 16) & ~1); // = 20
});
- Step 2: Run test to verify it fails
Run: composer test -- --filter=ProjectBulkActionsTest
Expected: FAIL — action not implemented in service.
- Step 3: Extend ProjectService::bulkAction
Replace bulkAction() method in app/app/Services/Project/ProjectService.php:
public function bulkAction(int $tenantId, string $action, array $payload): array
{
$ids = $payload['ids'] ?? [];
if (empty($ids)) {
return ['updated' => 0, 'skipped' => [], 'warnings' => []];
}
$query = Project::where('tenant_id', $tenantId)->whereIn('id', $ids);
return match ($action) {
'pause' => $this->bulkSimpleUpdate($query, ['is_active' => false]),
'resume' => $this->bulkSimpleUpdate($query, ['is_active' => true]),
'archive' => $this->bulkSimpleUpdate($query, ['is_active' => false, 'archived_at' => now()]),
'update_regions' => $this->bulkUpdateRegions($query, $payload),
'update_days' => $this->bulkUpdateDays($query, $payload),
'update_limit' => $this->bulkUpdateLimit($query, $payload),
};
}
private function bulkSimpleUpdate($query, array $update): array
{
$updated = $query->update($update);
return ['updated' => $updated, 'skipped' => [], 'warnings' => []];
}
private function bulkUpdateRegions($query, array $payload): array
{
$add = $payload['add'] ?? 0;
$remove = $payload['remove'] ?? 0;
// region_mask = (region_mask | add) & ~remove
$updated = $query->update([
'region_mask' => \DB::raw("(region_mask | {$add}) & ~{$remove} & 255"),
]);
return ['updated' => $updated, 'skipped' => [], 'warnings' => []];
}
(bulkUpdateDays and bulkUpdateLimit are stubs returning ['updated' => 0, ...] for now — implemented in Tasks 3-4.)
Add stubs at end of class:
private function bulkUpdateDays($query, array $payload): array
{
return ['updated' => 0, 'skipped' => [], 'warnings' => []];
}
private function bulkUpdateLimit($query, array $payload): array
{
return ['updated' => 0, 'skipped' => [], 'warnings' => []];
}
- Step 4: Update controller to pass full payload + return structured response
Modify app/app/Http/Controllers/Api/ProjectController.php::bulk():
public function bulk(BulkProjectActionRequest $request): JsonResponse
{
$result = $this->projects->bulkAction(
$request->user()->tenant_id,
$request->validated('action'),
$request->validated(),
);
return response()->json($result);
}
- Step 5: Run tests, verify regions test passes
Run: composer test -- --filter=ProjectBulkActionsTest
Expected: Regions test PASS. Validation tests still PASS.
- Step 6: Commit
git add app/app/Services/Project/ProjectService.php app/app/Http/Controllers/Api/ProjectController.php app/tests/Feature/Api/ProjectBulkActionsTest.php
git commit -m "feat(projects-bulk): update_regions handler with bitmask OR/AND-NOT"
Task 3: ProjectService — update_days handler
Files:
-
Modify:
app/app/Services/Project/ProjectService.php -
Test:
app/tests/Feature/Api/ProjectBulkActionsTest.php -
Step 1: Add failing test
Append:
it('applies update_days add and remove correctly', function () {
$tenant = Tenant::factory()->create();
$user = User::factory()->for($tenant)->create();
$p = Project::factory()->for($tenant)->create(['delivery_days_mask' => 31]); // Пн-Пт
actingAs($user)
->postJson('/api/projects/bulk', [
'action' => 'update_days',
'ids' => [$p->id],
'add' => 96, // 32+64 = Сб+Вс
'remove' => 1, // Пн
])
->assertOk()
->assertJson(['updated' => 1, 'skipped' => [], 'warnings' => []]);
expect($p->fresh()->delivery_days_mask)->toBe((31 | 96) & ~1); // = 126
});
- Step 2: Run, verify fail
Run: composer test -- --filter=ProjectBulkActionsTest
Expected: FAIL — bulkUpdateDays returns stub 0.
- Step 3: Implement
bulkUpdateDays
Replace stub in ProjectService.php:
private function bulkUpdateDays($query, array $payload): array
{
$add = $payload['add'] ?? 0;
$remove = $payload['remove'] ?? 0;
$updated = $query->update([
'delivery_days_mask' => \DB::raw("(delivery_days_mask | {$add}) & ~{$remove} & 127"),
]);
return ['updated' => $updated, 'skipped' => [], 'warnings' => []];
}
- Step 4: Run tests, verify pass
Expected: days test PASS, regions test still PASS.
- Step 5: Commit
git add app/app/Services/Project/ProjectService.php app/tests/Feature/Api/ProjectBulkActionsTest.php
git commit -m "feat(projects-bulk): update_days handler with bitmask OR/AND-NOT"
Task 4: ProjectService — update_limit handler (with skipped logic)
Files:
-
Modify:
app/app/Services/Project/ProjectService.php -
Test:
app/tests/Feature/Api/ProjectBulkActionsTest.php -
Step 1: Add failing tests (delta + replace + skip)
Append:
it('applies update_limit delta to all projects', function () {
$tenant = Tenant::factory()->create();
$user = User::factory()->for($tenant)->create();
$p1 = Project::factory()->for($tenant)->create(['daily_limit_target' => 100, 'delivered_today' => 0]);
$p2 = Project::factory()->for($tenant)->create(['daily_limit_target' => 200, 'delivered_today' => 0]);
actingAs($user)
->postJson('/api/projects/bulk', [
'action' => 'update_limit',
'ids' => [$p1->id, $p2->id],
'delta' => 50,
])
->assertOk()
->assertJson(['updated' => 2, 'skipped' => [], 'warnings' => []]);
expect($p1->fresh()->daily_limit_target)->toBe(150);
expect($p2->fresh()->daily_limit_target)->toBe(250);
});
it('skips projects when limit delta would drop below delivered_today', function () {
$tenant = Tenant::factory()->create();
$user = User::factory()->for($tenant)->create();
$p1 = Project::factory()->for($tenant)->create(['daily_limit_target' => 100, 'delivered_today' => 80]);
$p2 = Project::factory()->for($tenant)->create(['daily_limit_target' => 50, 'delivered_today' => 30]);
actingAs($user)
->postJson('/api/projects/bulk', [
'action' => 'update_limit',
'ids' => [$p1->id, $p2->id],
'delta' => -40, // p1: 100-40=60 < 80 → SKIP; p2: 50-40=10 < 30 → SKIP
])
->assertOk()
->assertJson([
'updated' => 0,
'skipped' => [
['id' => $p1->id, 'reason' => 'below_delivered_today'],
['id' => $p2->id, 'reason' => 'below_delivered_today'],
],
]);
expect($p1->fresh()->daily_limit_target)->toBe(100); // unchanged
expect($p2->fresh()->daily_limit_target)->toBe(50);
});
it('applies update_limit replace with skip for conflicts', function () {
$tenant = Tenant::factory()->create();
$user = User::factory()->for($tenant)->create();
$p1 = Project::factory()->for($tenant)->create(['daily_limit_target' => 100, 'delivered_today' => 30]);
$p2 = Project::factory()->for($tenant)->create(['daily_limit_target' => 200, 'delivered_today' => 150]);
actingAs($user)
->postJson('/api/projects/bulk', [
'action' => 'update_limit',
'ids' => [$p1->id, $p2->id],
'replace' => 100, // p1: ok (100>=30); p2: 100<150 → skip
])
->assertOk()
->assertJson([
'updated' => 1,
'skipped' => [['id' => $p2->id, 'reason' => 'below_delivered_today']],
]);
expect($p1->fresh()->daily_limit_target)->toBe(100);
expect($p2->fresh()->daily_limit_target)->toBe(200);
});
- Step 2: Run tests, verify failures
Expected: 3 new FAIL — bulkUpdateLimit returns 0.
- Step 3: Implement
bulkUpdateLimit
Replace stub:
private function bulkUpdateLimit($query, array $payload): array
{
$delta = $payload['delta'] ?? null;
$replace = $payload['replace'] ?? null;
$projects = (clone $query)->select(['id', 'daily_limit_target', 'delivered_today'])->get();
$updatableIds = [];
$skipped = [];
foreach ($projects as $p) {
$newValue = $replace !== null
? (int) $replace
: (int) $p->daily_limit_target + (int) $delta;
if ($newValue < (int) $p->delivered_today) {
$skipped[] = ['id' => $p->id, 'reason' => 'below_delivered_today'];
} else {
$updatableIds[$p->id] = $newValue;
}
}
$updated = 0;
if (!empty($updatableIds)) {
// Группируем по новому значению (для delta все разные; для replace все одинаковые).
if ($replace !== null) {
$updated = Project::whereIn('id', array_keys($updatableIds))
->update(['daily_limit_target' => (int) $replace]);
} else {
// delta — обновляем pо одному (count small bounded by MAX 500).
foreach ($updatableIds as $id => $newValue) {
Project::where('id', $id)->update(['daily_limit_target' => $newValue]);
$updated++;
}
}
}
return ['updated' => $updated, 'skipped' => $skipped, 'warnings' => []];
}
- Step 4: Run tests, verify pass
Expected: all limit tests PASS, прежние тесты PASS.
- Step 5: Commit
git add app/app/Services/Project/ProjectService.php app/tests/Feature/Api/ProjectBulkActionsTest.php
git commit -m "feat(projects-bulk): update_limit handler with per-project skip on delivered_today conflict"
Task 5: Scope filter resolution + MAX 500 guard
Files:
-
Modify:
app/app/Services/Project/ProjectService.php -
Modify:
app/app/Http/Controllers/Api/ProjectController.php -
Test:
app/tests/Feature/Api/ProjectBulkActionsTest.php -
Step 1: Add failing tests for scope.filter and 500-limit guard
Append:
it('resolves scope.filter to project ids and applies action', function () {
$tenant = Tenant::factory()->create();
$user = User::factory()->for($tenant)->create();
Project::factory()->for($tenant)->count(3)->create(['signal_type' => 'site', 'is_active' => true]);
Project::factory()->for($tenant)->count(2)->create(['signal_type' => 'sms', 'is_active' => true]);
actingAs($user)
->postJson('/api/projects/bulk', [
'action' => 'pause',
'scope' => ['filter' => ['signal_type' => 'site']],
])
->assertOk()
->assertJson(['updated' => 3]);
expect(Project::where('tenant_id', $tenant->id)->where('signal_type', 'site')->where('is_active', false)->count())->toBe(3);
expect(Project::where('tenant_id', $tenant->id)->where('signal_type', 'sms')->where('is_active', true)->count())->toBe(2);
});
it('rejects bulk when scope.filter captures more than 500 projects', function () {
$tenant = Tenant::factory()->create();
$user = User::factory()->for($tenant)->create();
Project::factory()->for($tenant)->count(501)->create();
actingAs($user)
->postJson('/api/projects/bulk', [
'action' => 'pause',
'scope' => ['filter' => []],
])
->assertStatus(422)
->assertJsonValidationErrors(['scope']);
});
- Step 2: Run, verify failures
Expected: 2 FAIL.
- Step 3: Add scope resolver helper to ProjectService
Insert in ProjectService.php (before bulkAction):
private const BULK_MAX = 500;
public function resolveBulkScope(int $tenantId, ?array $ids, ?array $filter): array
{
if (!empty($ids)) {
return array_values(array_unique($ids));
}
$query = Project::where('tenant_id', $tenantId);
if (!empty($filter['signal_type'])) {
$query->where('signal_type', $filter['signal_type']);
}
if (!empty($filter['status'])) {
match ($filter['status']) {
'active' => $query->where('is_active', true)->whereNull('archived_at'),
'paused' => $query->where('is_active', false)->whereNull('archived_at'),
'archived' => $query->whereNotNull('archived_at'),
};
}
if (!empty($filter['search'])) {
$query->where('name', 'ilike', '%' . $filter['search'] . '%');
}
return $query->pluck('id')->all();
}
- Step 4: Wire scope resolution into controller
Modify bulk() in ProjectController.php:
public function bulk(BulkProjectActionRequest $request): JsonResponse
{
$tenantId = $request->user()->tenant_id;
$ids = $this->projects->resolveBulkScope(
$tenantId,
$request->validated('ids'),
$request->validated('scope.filter'),
);
if (count($ids) > 500) {
return response()->json([
'errors' => ['scope' => ['Слишком много проектов под фильтр (>500). Уточните фильтры или выберите вручную.']],
], 422);
}
$payload = array_merge($request->validated(), ['ids' => $ids]);
$result = $this->projects->bulkAction(
$tenantId,
$request->validated('action'),
$payload,
);
return response()->json($result);
}
- Step 5: Run tests, verify pass
Expected: 2 new tests PASS, прежние PASS.
- Step 6: Commit
git add app/app/Services/Project/ProjectService.php app/app/Http/Controllers/Api/ProjectController.php app/tests/Feature/Api/ProjectBulkActionsTest.php
git commit -m "feat(projects-bulk): scope.filter resolver + 500-limit guard"
Task 6: RLS smoke test + edge cases
Files:
-
Test:
app/tests/Feature/Api/ProjectBulkActionsTest.php -
Step 1: Add cross-tenant RLS test
Append:
it('does not affect projects of other tenants (RLS)', function () {
$tenantA = Tenant::factory()->create();
$tenantB = Tenant::factory()->create();
$userA = User::factory()->for($tenantA)->create();
$pA = Project::factory()->for($tenantA)->create(['is_active' => true]);
$pB = Project::factory()->for($tenantB)->create(['is_active' => true]);
actingAs($userA)
->postJson('/api/projects/bulk', [
'action' => 'pause',
'ids' => [$pA->id, $pB->id],
])
->assertOk()
->assertJson(['updated' => 1]); // Only tenant A's project
expect($pA->fresh()->is_active)->toBeFalse();
expect($pB->fresh()->is_active)->toBeTrue(); // unchanged
});
it('returns 0 updated when ids empty after filter resolution', function () {
$tenant = Tenant::factory()->create();
$user = User::factory()->for($tenant)->create();
actingAs($user)
->postJson('/api/projects/bulk', [
'action' => 'pause',
'scope' => ['filter' => ['signal_type' => 'site']], // no projects exist
])
->assertOk()
->assertJson(['updated' => 0]);
});
- Step 2: Run tests, verify pass
Expected: PASS (since bulkAction already filters by tenant_id).
- Step 3: Commit
git add app/tests/Feature/Api/ProjectBulkActionsTest.php
git commit -m "test(projects-bulk): RLS cross-tenant isolation + empty-resolve edge case"
Task 7: Constants for regions and weekdays
Files:
-
Create:
app/resources/js/constants/regions.ts -
Create:
app/resources/js/constants/weekdays.ts -
Step 1: Create
regions.ts
export interface Region {
bit: number; // 1, 2, 4, ..., 128
label: string;
}
export const REGIONS: Region[] = [
{ bit: 1, label: 'Центральный' },
{ bit: 2, label: 'Северо-Западный' },
{ bit: 4, label: 'Южный' },
{ bit: 8, label: 'Северо-Кавказский' },
{ bit: 16, label: 'Приволжский' },
{ bit: 32, label: 'Уральский' },
{ bit: 64, label: 'Сибирский' },
{ bit: 128, label: 'Дальневосточный' },
];
- Step 2: Create
weekdays.ts
export interface Weekday {
bit: number; // 1, 2, 4, ..., 64
label: string;
short: string;
}
export const WEEKDAYS: Weekday[] = [
{ bit: 1, label: 'Понедельник', short: 'Пн' },
{ bit: 2, label: 'Вторник', short: 'Вт' },
{ bit: 4, label: 'Среда', short: 'Ср' },
{ bit: 8, label: 'Четверг', short: 'Чт' },
{ bit: 16, label: 'Пятница', short: 'Пт' },
{ bit: 32, label: 'Суббота', short: 'Сб' },
{ bit: 64, label: 'Воскресенье', short: 'Вс' },
];
- Step 3: Commit
git add app/resources/js/constants/regions.ts app/resources/js/constants/weekdays.ts
git commit -m "feat(projects-bulk): regions + weekdays constants (bitmask mapping)"
Task 8: projectsStore — selectAllByFilter + bulkUpdate
Files:
-
Modify:
app/resources/js/stores/projectsStore.ts -
Test:
app/tests/Frontend/projectsStore.bulkUpdate.spec.ts(create) -
Step 1: Write failing test
Create app/tests/Frontend/projectsStore.bulkUpdate.spec.ts:
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { setActivePinia, createPinia } from 'pinia';
import axios from 'axios';
import { useProjectsStore } from '../../resources/js/stores/projectsStore';
vi.mock('axios');
describe('projectsStore.bulkUpdate', () => {
beforeEach(() => {
setActivePinia(createPinia());
vi.mocked(axios.post).mockResolvedValue({ data: { updated: 2, skipped: [], warnings: [] } });
vi.mocked(axios.get).mockResolvedValue({ data: { data: [], meta: { total: 0 } } });
});
it('sends ids array when selectAllByFilter=false', async () => {
const store = useProjectsStore();
store.selectedIds.add(3);
store.selectedIds.add(7);
await store.bulkUpdate({ action: 'pause' });
expect(axios.post).toHaveBeenCalledWith('/api/projects/bulk', {
action: 'pause',
ids: [3, 7],
});
});
it('sends scope.filter when selectAllByFilter=true', async () => {
const store = useProjectsStore();
store.selectAllByFilter = true;
store.filters.signal_type = 'sms';
store.filters.status = 'active';
store.filters.search = 'окна';
await store.bulkUpdate({ action: 'update_regions', add: 6, remove: 1 });
expect(axios.post).toHaveBeenCalledWith('/api/projects/bulk', {
action: 'update_regions',
add: 6,
remove: 1,
scope: { filter: { signal_type: 'sms', status: 'active', search: 'окна' } },
});
});
it('toggleSelect drops out of selectAllByFilter mode', () => {
const store = useProjectsStore();
store.selectAllByFilter = true;
store.selectedIds.add(1);
store.selectedIds.add(2);
store.toggleSelect(1);
expect(store.selectAllByFilter).toBe(false);
expect(store.selectedIds.has(1)).toBe(false);
expect(store.selectedIds.has(2)).toBe(true);
});
it('clears selection after successful bulkUpdate', async () => {
const store = useProjectsStore();
store.selectedIds.add(1);
store.selectAllByFilter = true;
await store.bulkUpdate({ action: 'pause' });
expect(store.selectedIds.size).toBe(0);
expect(store.selectAllByFilter).toBe(false);
});
});
- Step 2: Run test, verify fail
Run: cd app && npx vitest run tests/Frontend/projectsStore.bulkUpdate.spec.ts
Expected: FAIL — bulkUpdate undefined, selectAllByFilter undefined.
- Step 3: Extend projectsStore
Modify app/resources/js/stores/projectsStore.ts. Add after existing state refs:
const selectAllByFilter = ref<boolean>(false);
Modify toggleSelect to drop out of selectAllByFilter mode when user interacts with individual checkboxes:
function toggleSelect(id: number) {
selectAllByFilter.value = false; // user opted into manual mode
if (selectedIds.value.has(id)) {
selectedIds.value.delete(id);
} else {
selectedIds.value.add(id);
}
}
Add new function before return:
interface BulkPayload {
action: 'pause' | 'resume' | 'archive' | 'update_regions' | 'update_days' | 'update_limit';
add?: number;
remove?: number;
delta?: number;
replace?: number;
}
async function bulkUpdate(payload: BulkPayload): Promise<{ updated: number; skipped: Array<{ id: number; reason: string }>; warnings: string[] }> {
const body: Record<string, unknown> = { ...payload };
if (selectAllByFilter.value) {
const f: Record<string, unknown> = {};
if (filters.signal_type) f.signal_type = filters.signal_type;
if (filters.status) f.status = filters.status;
if (filters.search) f.search = filters.search;
body.scope = { filter: f };
} else {
body.ids = Array.from(selectedIds.value);
}
const { data } = await axios.post('/api/projects/bulk', body);
clearSelection();
selectAllByFilter.value = false;
await fetch();
return data;
}
// Watch filter changes — clear selection on switch
watch(
() => [filters.signal_type, filters.status, filters.search] as const,
() => {
clearSelection();
selectAllByFilter.value = false;
},
);
Also import watch at top:
import { ref, reactive, watch } from 'vue';
Add to return statement:
return {
items, total, filters, selectedIds, pendingIds, loading,
selectAllByFilter,
fetch, create, update, archive, syncNow, toggleActive,
toggleSelect, clearSelection,
bulkAction,
bulkUpdate,
startPolling, stopPolling,
};
- Step 4: Run test, verify pass
Expected: 3/3 PASS.
- Step 5: Run full Vitest to ensure nothing else broke
Run: cd app && npx vitest run
Expected: All previously-passing tests still PASS (notably existing bulkAction test).
- Step 6: Commit
git add app/resources/js/stores/projectsStore.ts app/tests/Frontend/projectsStore.bulkUpdate.spec.ts
git commit -m "feat(projects-bulk): store selectAllByFilter + bulkUpdate with scope discriminator"
Task 9: Toolbar with select-all in ProjectsView
Files:
-
Modify:
app/resources/js/views/ProjectsView.vue -
Test:
app/tests/Frontend/ProjectsView.toolbar.spec.ts(create) -
Step 1: Write failing test
Create app/tests/Frontend/ProjectsView.toolbar.spec.ts:
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { mount } from '@vue/test-utils';
import { createPinia, setActivePinia } from 'pinia';
import { createVuetify } from 'vuetify';
import axios from 'axios';
import ProjectsView from '../../resources/js/views/ProjectsView.vue';
vi.mock('axios');
const mountView = async () => {
setActivePinia(createPinia());
vi.mocked(axios.get).mockResolvedValue({
data: {
data: [
{ id: 1, name: 'A', signal_type: 'site', daily_limit_target: 100, delivered_today: 0, is_active: true, archived_at: null, region_mask: 1, region_mode: 'include', delivery_days_mask: 31, sync_status: 'ok' },
{ id: 2, name: 'B', signal_type: 'site', daily_limit_target: 100, delivered_today: 0, is_active: true, archived_at: null, region_mask: 1, region_mode: 'include', delivery_days_mask: 31, sync_status: 'ok' },
],
meta: { total: 2 },
},
});
const wrapper = mount(ProjectsView, {
global: {
plugins: [createVuetify()],
stubs: { ProjectCard: true, BulkActionsBar: true, NewProjectDialog: true, EditProjectDialog: true },
},
});
await new Promise((r) => setTimeout(r, 0)); // flush
return wrapper;
};
describe('ProjectsView toolbar — select all', () => {
it('renders select-all checkbox above grid', async () => {
const wrapper = await mountView();
expect(wrapper.find('[data-testid="select-all-toggle"]').exists()).toBe(true);
});
it('shows count "Выбрано: N из M"', async () => {
const wrapper = await mountView();
expect(wrapper.text()).toMatch(/Выбрано:\s*0\s*из\s*2/);
});
it('clicking select-all marks all visible projects', async () => {
const wrapper = await mountView();
const toggle = wrapper.find('[data-testid="select-all-toggle"] input');
await toggle.setValue(true);
const { useProjectsStore } = await import('../../resources/js/stores/projectsStore');
const store = useProjectsStore();
expect(store.selectAllByFilter).toBe(true);
expect(store.selectedIds.size).toBe(2);
});
});
- Step 2: Run, verify fail
Run: cd app && npx vitest run tests/Frontend/ProjectsView.toolbar.spec.ts
Expected: FAIL — no select-all toggle.
- Step 3: Add toolbar to ProjectsView
Modify app/resources/js/views/ProjectsView.vue template — add block between filter row and grid:
<div class="projects-toolbar d-flex align-center gap-3 mb-3" v-if="!store.loading && store.items.length > 0">
<v-checkbox
data-testid="select-all-toggle"
:model-value="store.selectAllByFilter"
:indeterminate="!store.selectAllByFilter && store.selectedIds.size > 0"
hide-details
density="compact"
@change="onToggleSelectAll"
/>
<span class="text-body-2">Выбрано: {{ store.selectedIds.size }} из {{ store.total }} (по текущим фильтрам)</span>
</div>
In <script setup>, add:
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();
}
}
- Step 4: Run test, verify pass
Expected: 3/3 PASS.
- Step 5: Commit
git add app/resources/js/views/ProjectsView.vue app/tests/Frontend/ProjectsView.toolbar.spec.ts
git commit -m "feat(projects-bulk): select-all toolbar with counter and indeterminate state"
Task 10: RegionsBulkDialog
Files:
-
Create:
app/resources/js/components/projects/RegionsBulkDialog.vue -
Test:
app/tests/Frontend/RegionsBulkDialog.spec.ts -
Step 1: Write failing test
Create app/tests/Frontend/RegionsBulkDialog.spec.ts:
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { mount } from '@vue/test-utils';
import { createVuetify } from 'vuetify';
import { createPinia, setActivePinia } from 'pinia';
import RegionsBulkDialog from '../../resources/js/components/projects/RegionsBulkDialog.vue';
const mountDialog = (count = 5) =>
mount(RegionsBulkDialog, {
props: { modelValue: true, count },
global: { plugins: [createVuetify(), createPinia()], stubs: { VDialog: true } },
});
describe('RegionsBulkDialog', () => {
beforeEach(() => setActivePinia(createPinia()));
it('renders 8 region chips for Add and Remove', () => {
const wrapper = mountDialog();
const addChips = wrapper.findAll('[data-testid^="region-add-"]');
const removeChips = wrapper.findAll('[data-testid^="region-remove-"]');
expect(addChips.length).toBe(8);
expect(removeChips.length).toBe(8);
});
it('shows count from prop', () => {
const wrapper = mountDialog(7);
expect(wrapper.text()).toContain('7');
});
it('emits apply with computed bitmasks', async () => {
const wrapper = mountDialog();
// Toggle Центральный (bit 1) in Add
await wrapper.find('[data-testid="region-add-1"]').trigger('click');
// Toggle Сибирский (bit 64) in Remove
await wrapper.find('[data-testid="region-remove-64"]').trigger('click');
await wrapper.find('[data-testid="apply"]').trigger('click');
expect(wrapper.emitted('apply')?.[0]).toEqual([{ add: 1, remove: 64 }]);
});
it('apply button disabled when both add and remove are 0', () => {
const wrapper = mountDialog();
const btn = wrapper.find('[data-testid="apply"]');
expect(btn.attributes('disabled')).toBeDefined();
});
});
- Step 2: Run, verify fail
Expected: file not found / component not defined.
- Step 3: Create RegionsBulkDialog
Create app/resources/js/components/projects/RegionsBulkDialog.vue:
<template>
<v-dialog v-model="open" max-width="560">
<v-card>
<v-card-title>Регионы — для {{ count }} проектов</v-card-title>
<v-card-text>
<div class="mb-4">
<div class="text-caption text-success font-weight-medium mb-2">➕ Добавить</div>
<div class="d-flex flex-wrap gap-2">
<v-chip
v-for="r in REGIONS"
:key="`add-${r.bit}`"
:data-testid="`region-add-${r.bit}`"
:color="(addMask & r.bit) ? 'success' : undefined"
:variant="(addMask & r.bit) ? 'flat' : 'outlined'"
size="small"
@click="toggleAdd(r.bit)"
>{{ r.label }}</v-chip>
</div>
</div>
<div>
<div class="text-caption text-error font-weight-medium mb-2">➖ Убрать</div>
<div class="d-flex flex-wrap gap-2">
<v-chip
v-for="r in REGIONS"
:key="`remove-${r.bit}`"
:data-testid="`region-remove-${r.bit}`"
:color="(removeMask & r.bit) ? 'error' : undefined"
:variant="(removeMask & r.bit) ? 'flat' : 'outlined'"
size="small"
@click="toggleRemove(r.bit)"
>{{ r.label }}</v-chip>
</div>
</div>
</v-card-text>
<v-card-actions>
<v-spacer />
<v-btn data-testid="cancel" @click="open = false">Отмена</v-btn>
<v-btn
color="primary"
data-testid="apply"
:disabled="addMask === 0 && removeMask === 0"
@click="apply"
>Применить к {{ count }}</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import { REGIONS } from '../../constants/regions';
const props = defineProps<{ modelValue: boolean; count: number }>();
const emit = defineEmits<{
'update:modelValue': [value: boolean];
apply: [payload: { add: number; remove: number }];
}>();
const open = ref(props.modelValue);
const addMask = ref(0);
const removeMask = ref(0);
function toggleAdd(bit: number) {
addMask.value ^= bit;
if (addMask.value & bit) removeMask.value &= ~bit;
}
function toggleRemove(bit: number) {
removeMask.value ^= bit;
if (removeMask.value & bit) addMask.value &= ~bit;
}
function apply() {
emit('apply', { add: addMask.value, remove: removeMask.value });
addMask.value = 0;
removeMask.value = 0;
open.value = false;
}
</script>
- Step 4: Run test, verify pass
Expected: 4/4 PASS.
- Step 5: Commit
git add app/resources/js/components/projects/RegionsBulkDialog.vue app/tests/Frontend/RegionsBulkDialog.spec.ts
git commit -m "feat(projects-bulk): RegionsBulkDialog Add/Remove (8 ФО bitmask)"
Task 11: DaysBulkDialog
Files:
-
Create:
app/resources/js/components/projects/DaysBulkDialog.vue -
Test:
app/tests/Frontend/DaysBulkDialog.spec.ts -
Step 1: Write failing test
Create app/tests/Frontend/DaysBulkDialog.spec.ts:
import { describe, it, expect } from 'vitest';
import { mount } from '@vue/test-utils';
import { createVuetify } from 'vuetify';
import DaysBulkDialog from '../../resources/js/components/projects/DaysBulkDialog.vue';
const mountDialog = (count = 5) =>
mount(DaysBulkDialog, {
props: { modelValue: true, count },
global: { plugins: [createVuetify()], stubs: { VDialog: true } },
});
describe('DaysBulkDialog', () => {
it('renders 7 weekday buttons for Add and Remove', () => {
const wrapper = mountDialog();
expect(wrapper.findAll('[data-testid^="day-add-"]').length).toBe(7);
expect(wrapper.findAll('[data-testid^="day-remove-"]').length).toBe(7);
});
it('emits apply with bitmasks (add Tue+Thu, remove Sat+Sun)', async () => {
const wrapper = mountDialog();
await wrapper.find('[data-testid="day-add-2"]').trigger('click'); // Вт
await wrapper.find('[data-testid="day-add-8"]').trigger('click'); // Чт
await wrapper.find('[data-testid="day-remove-32"]').trigger('click'); // Сб
await wrapper.find('[data-testid="day-remove-64"]').trigger('click'); // Вс
await wrapper.find('[data-testid="apply"]').trigger('click');
expect(wrapper.emitted('apply')?.[0]).toEqual([{ add: 10, remove: 96 }]);
});
it('apply disabled when both masks are 0', () => {
const wrapper = mountDialog();
expect(wrapper.find('[data-testid="apply"]').attributes('disabled')).toBeDefined();
});
});
-
Step 2: Run, verify fail
-
Step 3: Create DaysBulkDialog
Create app/resources/js/components/projects/DaysBulkDialog.vue:
<template>
<v-dialog v-model="open" max-width="560">
<v-card>
<v-card-title>Дни сбора лидов — для {{ count }} проектов</v-card-title>
<v-card-text>
<div class="mb-4">
<div class="text-caption text-success font-weight-medium mb-2">➕ Добавить дни</div>
<div class="d-flex gap-2">
<v-btn
v-for="d in WEEKDAYS"
:key="`add-${d.bit}`"
:data-testid="`day-add-${d.bit}`"
:color="(addMask & d.bit) ? 'success' : undefined"
:variant="(addMask & d.bit) ? 'flat' : 'outlined'"
size="small"
@click="toggleAdd(d.bit)"
>{{ d.short }}</v-btn>
</div>
</div>
<div>
<div class="text-caption text-error font-weight-medium mb-2">➖ Убрать дни</div>
<div class="d-flex gap-2">
<v-btn
v-for="d in WEEKDAYS"
:key="`remove-${d.bit}`"
:data-testid="`day-remove-${d.bit}`"
:color="(removeMask & d.bit) ? 'error' : undefined"
:variant="(removeMask & d.bit) ? 'flat' : 'outlined'"
size="small"
@click="toggleRemove(d.bit)"
>{{ d.short }}</v-btn>
</div>
</div>
</v-card-text>
<v-card-actions>
<v-spacer />
<v-btn data-testid="cancel" @click="open = false">Отмена</v-btn>
<v-btn
color="primary"
data-testid="apply"
:disabled="addMask === 0 && removeMask === 0"
@click="apply"
>Применить к {{ count }}</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import { WEEKDAYS } from '../../constants/weekdays';
const props = defineProps<{ modelValue: boolean; count: number }>();
const emit = defineEmits<{
'update:modelValue': [value: boolean];
apply: [payload: { add: number; remove: number }];
}>();
const open = ref(props.modelValue);
const addMask = ref(0);
const removeMask = ref(0);
function toggleAdd(bit: number) {
addMask.value ^= bit;
if (addMask.value & bit) removeMask.value &= ~bit;
}
function toggleRemove(bit: number) {
removeMask.value ^= bit;
if (removeMask.value & bit) addMask.value &= ~bit;
}
function apply() {
emit('apply', { add: addMask.value, remove: removeMask.value });
addMask.value = 0;
removeMask.value = 0;
open.value = false;
}
</script>
- Step 4: Run test, verify pass
Expected: 3/3 PASS.
- Step 5: Commit
git add app/resources/js/components/projects/DaysBulkDialog.vue app/tests/Frontend/DaysBulkDialog.spec.ts
git commit -m "feat(projects-bulk): DaysBulkDialog Add/Remove (7 weekday bitmask)"
Task 12: LimitBulkDialog
Files:
-
Create:
app/resources/js/components/projects/LimitBulkDialog.vue -
Test:
app/tests/Frontend/LimitBulkDialog.spec.ts -
Step 1: Write failing test
Create app/tests/Frontend/LimitBulkDialog.spec.ts:
import { describe, it, expect } from 'vitest';
import { mount } from '@vue/test-utils';
import { createVuetify } from 'vuetify';
import LimitBulkDialog from '../../resources/js/components/projects/LimitBulkDialog.vue';
const mountDialog = (count = 5) =>
mount(LimitBulkDialog, {
props: { modelValue: true, count },
global: { plugins: [createVuetify()], stubs: { VDialog: true } },
});
describe('LimitBulkDialog', () => {
it('emits apply with positive delta when only add filled', async () => {
const wrapper = mountDialog();
await wrapper.find('[data-testid="add-input"] input').setValue('100');
await wrapper.find('[data-testid="apply"]').trigger('click');
expect(wrapper.emitted('apply')?.[0]).toEqual([{ delta: 100 }]);
});
it('emits apply with negative delta when only remove filled', async () => {
const wrapper = mountDialog();
await wrapper.find('[data-testid="remove-input"] input').setValue('50');
await wrapper.find('[data-testid="apply"]').trigger('click');
expect(wrapper.emitted('apply')?.[0]).toEqual([{ delta: -50 }]);
});
it('emits apply with combined delta when both filled', async () => {
const wrapper = mountDialog();
await wrapper.find('[data-testid="add-input"] input').setValue('100');
await wrapper.find('[data-testid="remove-input"] input').setValue('30');
await wrapper.find('[data-testid="apply"]').trigger('click');
expect(wrapper.emitted('apply')?.[0]).toEqual([{ delta: 70 }]);
});
it('emits apply with replace when replace mode toggled', async () => {
const wrapper = mountDialog();
await wrapper.find('[data-testid="replace-toggle"] input').setValue(true);
await wrapper.find('[data-testid="replace-input"] input').setValue('500');
await wrapper.find('[data-testid="apply"]').trigger('click');
expect(wrapper.emitted('apply')?.[0]).toEqual([{ replace: 500 }]);
});
it('apply disabled when neither input is filled', () => {
const wrapper = mountDialog();
expect(wrapper.find('[data-testid="apply"]').attributes('disabled')).toBeDefined();
});
});
-
Step 2: Run, verify fail
-
Step 3: Create LimitBulkDialog
Create app/resources/js/components/projects/LimitBulkDialog.vue:
<template>
<v-dialog v-model="open" max-width="480">
<v-card>
<v-card-title>Лимит лидов — для {{ count }} проектов</v-card-title>
<v-card-text>
<template v-if="!useReplace">
<v-text-field
v-model.number="addValue"
type="number"
min="0"
label="➕ Прибавить к лимиту"
suffix="лидов/день"
data-testid="add-input"
density="compact"
/>
<v-text-field
v-model.number="removeValue"
type="number"
min="0"
label="➖ Убавить лимит"
suffix="лидов/день"
data-testid="remove-input"
density="compact"
class="mt-2"
/>
</template>
<template v-else>
<v-text-field
v-model.number="replaceValue"
type="number"
min="0"
label="Установить лимит"
suffix="лидов/день"
data-testid="replace-input"
density="compact"
/>
</template>
<v-checkbox
v-model="useReplace"
label="Заменить на абсолютное значение"
data-testid="replace-toggle"
density="compact"
hide-details
class="mt-3"
/>
</v-card-text>
<v-card-actions>
<v-spacer />
<v-btn data-testid="cancel" @click="open = false">Отмена</v-btn>
<v-btn
color="primary"
data-testid="apply"
:disabled="!canApply"
@click="apply"
>Применить к {{ count }}</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue';
const props = defineProps<{ modelValue: boolean; count: number }>();
const emit = defineEmits<{
'update:modelValue': [value: boolean];
apply: [payload: { delta?: number; replace?: number }];
}>();
const open = ref(props.modelValue);
const useReplace = ref(false);
const addValue = ref<number | null>(null);
const removeValue = ref<number | null>(null);
const replaceValue = ref<number | null>(null);
const canApply = computed(() => {
if (useReplace.value) return replaceValue.value !== null && replaceValue.value >= 0;
return (addValue.value ?? 0) > 0 || (removeValue.value ?? 0) > 0;
});
function apply() {
if (useReplace.value && replaceValue.value !== null) {
emit('apply', { replace: replaceValue.value });
} else {
const delta = (addValue.value ?? 0) - (removeValue.value ?? 0);
emit('apply', { delta });
}
addValue.value = null;
removeValue.value = null;
replaceValue.value = null;
open.value = false;
}
</script>
- Step 4: Run test, verify pass
Expected: 5/5 PASS.
- Step 5: Commit
git add app/resources/js/components/projects/LimitBulkDialog.vue app/tests/Frontend/LimitBulkDialog.spec.ts
git commit -m "feat(projects-bulk): LimitBulkDialog delta or replace mode"
Task 13: Wire dialogs into BulkActionsBar
Files:
-
Modify:
app/resources/js/components/projects/BulkActionsBar.vue -
Test:
app/tests/Frontend/BulkActionsBar.spec.ts(create if missing, extend if exists) -
Step 1: Check if test exists, write/extend tests
Run: cd app && ls tests/Frontend/BulkActionsBar.spec.ts 2>&1
If absent, create app/tests/Frontend/BulkActionsBar.spec.ts:
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { mount } from '@vue/test-utils';
import { createPinia, setActivePinia } from 'pinia';
import { createVuetify } from 'vuetify';
import axios from 'axios';
import BulkActionsBar from '../../resources/js/components/projects/BulkActionsBar.vue';
import { useProjectsStore } from '../../resources/js/stores/projectsStore';
vi.mock('axios');
const mountBar = async () => {
setActivePinia(createPinia());
vi.mocked(axios.post).mockResolvedValue({ data: { updated: 1, skipped: [], warnings: [] } });
vi.mocked(axios.get).mockResolvedValue({ data: { data: [], meta: { total: 0 } } });
const store = useProjectsStore();
store.selectedIds.add(1);
store.selectedIds.add(2);
const wrapper = mount(BulkActionsBar, {
global: { plugins: [createVuetify(), createPinia()], stubs: { VDialog: true, DevIndexBadge: true } },
});
return { wrapper, store };
};
describe('BulkActionsBar — extended', () => {
it('renders 3 new dialog-open buttons (Regions/Days/Limit)', async () => {
const { wrapper } = await mountBar();
expect(wrapper.find('[data-testid="bulk-regions"]').exists()).toBe(true);
expect(wrapper.find('[data-testid="bulk-days"]').exists()).toBe(true);
expect(wrapper.find('[data-testid="bulk-limit"]').exists()).toBe(true);
});
it('opens RegionsBulkDialog on regions button click', async () => {
const { wrapper } = await mountBar();
await wrapper.find('[data-testid="bulk-regions"]').trigger('click');
expect(wrapper.vm.regionsOpen).toBe(true);
});
it('keeps existing pause/resume/archive buttons', async () => {
const { wrapper } = await mountBar();
expect(wrapper.find('[data-testid="bulk-pause"]').exists()).toBe(true);
expect(wrapper.find('[data-testid="bulk-resume"]').exists()).toBe(true);
expect(wrapper.find('[data-testid="bulk-archive"]').exists()).toBe(true);
});
});
- Step 2: Run, verify fail
Expected: FAIL — new buttons don't exist.
- Step 3: Extend BulkActionsBar
Replace contents of app/resources/js/components/projects/BulkActionsBar.vue:
<template>
<v-card class="bulk-actions-bar" elevation="6">
<DevIndexBadge :index="20" label="BulkActionsBar" :dialog-mode="true" style="top: 4px; right: 4px;" />
<v-card-text class="d-flex align-center gap-3 flex-wrap">
<strong>Выбрано: {{ store.selectedIds.size }}</strong>
<v-divider vertical />
<v-btn color="primary" variant="outlined" data-testid="bulk-regions" @click="regionsOpen = true">
🌍 Регионы…
</v-btn>
<v-btn color="primary" variant="outlined" data-testid="bulk-days" @click="daysOpen = true">
📅 Дни сбора…
</v-btn>
<v-btn color="primary" variant="outlined" data-testid="bulk-limit" @click="limitOpen = true">
🎯 Лимит лидов…
</v-btn>
<v-divider vertical />
<v-btn color="warning" prepend-icon="mdi-pause" data-testid="bulk-pause" @click="confirmAndRun('pause')">
Приостановить
</v-btn>
<v-btn color="success" prepend-icon="mdi-play" data-testid="bulk-resume" @click="confirmAndRun('resume')">
Возобновить
</v-btn>
<v-divider vertical />
<v-btn color="error" prepend-icon="mdi-archive" data-testid="bulk-archive" @click="confirmAndRun('archive')">
Архивировать
</v-btn>
<v-spacer />
<v-btn variant="text" data-testid="bulk-clear" @click="store.clearSelection">Снять выбор</v-btn>
</v-card-text>
<RegionsBulkDialog
v-model="regionsOpen"
:count="store.selectedIds.size"
@apply="(p) => runBulk({ action: 'update_regions', ...p })"
/>
<DaysBulkDialog
v-model="daysOpen"
:count="store.selectedIds.size"
@apply="(p) => runBulk({ action: 'update_days', ...p })"
/>
<LimitBulkDialog
v-model="limitOpen"
:count="store.selectedIds.size"
@apply="(p) => runBulk({ action: 'update_limit', ...p })"
/>
</v-card>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import { useProjectsStore } from '../../stores/projectsStore';
import DevIndexBadge from '../DevIndexBadge.vue';
import RegionsBulkDialog from './RegionsBulkDialog.vue';
import DaysBulkDialog from './DaysBulkDialog.vue';
import LimitBulkDialog from './LimitBulkDialog.vue';
const store = useProjectsStore();
const regionsOpen = ref(false);
const daysOpen = ref(false);
const limitOpen = ref(false);
const messages: Record<string, string> = {
pause: 'Приостановить выбранные проекты?',
resume: 'Возобновить выбранные проекты?',
archive: 'Архивировать выбранные проекты?\nДействие необратимо в Plan 5 (восстановление потребует ручного запроса).',
};
async function confirmAndRun(action: 'pause' | 'resume' | 'archive') {
if (!window.confirm(messages[action])) return;
await runBulk({ action });
}
async function runBulk(payload: Parameters<typeof store.bulkUpdate>[0]) {
const result = await store.bulkUpdate(payload);
if (result.skipped.length > 0) {
window.alert(`Применено: ${result.updated}. Пропущено: ${result.skipped.length} (конфликт с уже доставленными лидами).`);
}
}
defineExpose({ regionsOpen, daysOpen, limitOpen });
</script>
<style scoped>
.bulk-actions-bar {
position: fixed;
bottom: 24px;
left: 50%;
transform: translateX(-50%);
z-index: 10;
max-width: calc(100vw - 48px);
}
</style>
- Step 4: Run tests, verify pass
Run: cd app && npx vitest run tests/Frontend/BulkActionsBar.spec.ts
Expected: 3/3 PASS.
- Step 5: Run full Vitest to ensure no regressions
Run: cd app && npx vitest run
Expected: All previously-passing tests still PASS.
- Step 6: Commit
git add app/resources/js/components/projects/BulkActionsBar.vue app/tests/Frontend/BulkActionsBar.spec.ts
git commit -m "feat(projects-bulk): wire 3 new dialogs into BulkActionsBar"
Task 14: Histoire stories for new dialogs
Files:
-
Create:
app/resources/js/components/projects/RegionsBulkDialog.story.vue -
Create:
app/resources/js/components/projects/DaysBulkDialog.story.vue -
Create:
app/resources/js/components/projects/LimitBulkDialog.story.vue -
Step 1: Create RegionsBulkDialog.story.vue
<script setup lang="ts">
import { ref } from 'vue';
import RegionsBulkDialog from './RegionsBulkDialog.vue';
const open = ref(true);
</script>
<template>
<Story title="projects/RegionsBulkDialog">
<Variant title="open (5 projects)">
<RegionsBulkDialog v-model="open" :count="5" />
</Variant>
</Story>
</template>
- Step 2: Create DaysBulkDialog.story.vue
<script setup lang="ts">
import { ref } from 'vue';
import DaysBulkDialog from './DaysBulkDialog.vue';
const open = ref(true);
</script>
<template>
<Story title="projects/DaysBulkDialog">
<Variant title="open (5 projects)">
<DaysBulkDialog v-model="open" :count="5" />
</Variant>
</Story>
</template>
- Step 3: Create LimitBulkDialog.story.vue
<script setup lang="ts">
import { ref } from 'vue';
import LimitBulkDialog from './LimitBulkDialog.vue';
const open = ref(true);
</script>
<template>
<Story title="projects/LimitBulkDialog">
<Variant title="open (5 projects)">
<LimitBulkDialog v-model="open" :count="5" />
</Variant>
</Story>
</template>
- Step 4: Run Histoire smoke
Run: cd app && npm run story -- --quiet 2>&1 | tail -10
Expected: stories registered, no errors.
- Step 5: Commit
git add app/resources/js/components/projects/*.story.vue
git commit -m "feat(projects-bulk): Histoire stories for 3 bulk dialogs"
Task 15: Manual E2E smoke + final verification
Files: none (verification only)
- Step 1: Run full Pest suite
Run: cd app && composer test 2>&1 | tail -15
Expected: All tests PASS (existing + new ProjectBulkActionsTest).
- Step 2: Run full Vitest suite
Run: cd app && npx vitest run 2>&1 | tail -10
Expected: All tests PASS.
- Step 3: Manual browser smoke (localhost)
Open http://localhost:8000/projects (Vite must be running). Verify manually:
- Toolbar viден с чекбоксом «Выбрать все» + счётчиком
- Выбор галочкой на карточке → BulkActionsBar появляется снизу
- Кнопка «Регионы…» → открывается диалог; выбрать пару регионов в Add → «Применить» → запрос проходит
- То же для «Дни сбора…» и «Лимит лидов…»
- Pause/Resume инстант, archive с confirm
- После применения — выбор сбрасывается, грид обновляется
- Select-all чекбокс выбирает все видимые карточки
- Step 4: Final commit (если что-то правил по дороге)
git status
git add <changed files if any>
git commit -m "fix(projects-bulk): smoke fixes after manual verification" 2>&1 || echo "nothing to commit"
Acceptance criteria (из спека)
- Toolbar с select-all чекбоксом виден над гридом, корректно показывает 3 состояния
- BulkActionsBar показывает все 4 категории + Архив + Снять выбор
- 3 диалога (Regions/Days/Limit) открываются, валидируют, отправляют корректный payload
- Pause/Resume инстант-кнопки работают без диалога
- При смене фильтров — выбор сбрасывается
- Backend принимает оба scope (ids/filter), отдаёт
{updated, skipped, warnings} - Лимит ниже
delivered_todayпопадает вskipped, не блокирует остальных - > 500 проектов по filter → 422 с message «Уточните фильтры»
- Pest 100% pass, Vitest 100% pass, Histoire stories рендерятся
- RLS: bulk не пересекает tenant