Files
portal/docs/superpowers/plans/2026-05-12-projects-bulk-actions.md
T
2026-05-12 14:29:35 +03:00

63 KiB
Raw Blame History

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 rules
  • app/Http/Controllers/Api/ProjectController.php — bulk() возвращает structured response
  • app/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.tsselectAllByFilter: Ref<boolean>, bulkUpdate(payload), filter watcher
  • app/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.vue
  • app/resources/js/components/projects/DaysBulkDialog.vue
  • app/resources/js/components/projects/LimitBulkDialog.vue
  • app/resources/js/constants/regions.ts — 8 ФО с битами и лейблами
  • app/resources/js/constants/weekdays.ts — 7 дней с битами

Tests (create):

  • app/tests/Frontend/projectsStore.bulkUpdate.spec.ts
  • app/tests/Frontend/ProjectsView.toolbar.spec.ts
  • app/tests/Frontend/RegionsBulkDialog.spec.ts
  • app/tests/Frontend/DaysBulkDialog.spec.ts
  • app/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:

  1. Toolbar viден с чекбоксом «Выбрать все» + счётчиком
  2. Выбор галочкой на карточке → BulkActionsBar появляется снизу
  3. Кнопка «Регионы…» → открывается диалог; выбрать пару регионов в Add → «Применить» → запрос проходит
  4. То же для «Дни сбора…» и «Лимит лидов…»
  5. Pause/Resume инстант, archive с confirm
  6. После применения — выбор сбрасывается, грид обновляется
  7. 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