Files
autocrm/tests/Feature/PipelineBoardTest.php
T
Vasyka d9b198a235 feat: Pipeline board matches mockup pixel-by-pixel
Audit pass against /tmp/service/todo/psauto-pipeline-redesign.html — 10
gaps closed.

1. In-page TOPBAR (mockup had it; was missing): "Pipeline" title,
   sep, search box "Caută client, mașină, număr...", and right-side
   Filtre / Export / + Deal nou (primary) buttons. Search input is
   wire:model.live.debounce 300ms.

2. SEARCH actually filters cards: $searchQuery property in
   PipelineBoard scans subject + client_name + plate + code + phone
   across all 6 columns, case-insensitive.

3. "+ Deal nou" + "+ Adaugă cerere" (per-column bottom) now open the
   SAME right-side panel in "new form" mode. Inline create form:
   Nume / Telefon / Auto / Sursă / Notițe → createNewLead() inserts
   Lead with status=new, lands in col 1 instantly without leaving page.
   Validation: name + phone required.

4. EXPORT button calls exportCsv() — streams a CSV of current filtered
   columns (etapă, cod, subiect, client, telefon, auto, sumă,
   responsabil, stare timp).

5. PERIOD selector chip shows current month in Romanian
   (now()->locale('ro')->isoFormat('MMMM YYYY')) — matches "Iunie 2026".

6. HOVER icons now match mockup exactly per column:
   - request: 📅 schedule / 📞 phone / ⋮ edit
   - quote:   📅 schedule / 💬 wa / ⋮ edit
   - scheduled: 📄 file-plus (start WO) / 💬 wa / ⋮ edit
   - in_work: 👁 eye (open WO) / 💬 wa / ✓ mark Gata
   - ready:   💰 cash (mark paid) / 📞 phone / ⋮ edit
   - paid:    NONE (col 6 has no hover actions per mockup)

7. Col 6 "Achitat azi" cards now opacity:0.65, no hover actions,
   no time line, no assignee name (just avatar) — exactly as in mockup.

8. Sum display: amount == 0 renders "—" instead of "0 MDL", both in
   card footer and list view.

9. "Avans achitat" tag (blue) appears on Ready cards with partial
   payment (pay_status='partial'); "Neachitat" amber only when fully
   unpaid. Matches mockup col 5 example "Nissan Qashqai · Gata +
   Avans achitat".

10. Link tracking quick-action: appears in detail panel "Acțiuni rapide"
    grid when WO has tracking_url. Sits alongside WhatsApp / Sună / SMS.

Two-panel architecture: $showNewForm and $openCardKey are mutually
exclusive. Click outside or ✕ closes the panel; opening one closes
the other.

Tests: +4 (createNewLead happy path, validation, search filter,
partial payment tag). Suite 185/185 (was 181).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-04 21:18:54 +00:00

253 lines
13 KiB
PHP

<?php
namespace Tests\Feature;
use App\Filament\Tenant\Pages\PipelineBoard;
use App\Models\Central\Company;
use App\Models\Central\Plan;
use App\Models\Tenant\Client;
use App\Models\Tenant\Deal;
use App\Models\Tenant\Lead;
use App\Models\Tenant\Payment;
use App\Models\Tenant\User;
use App\Models\Tenant\Vehicle;
use App\Models\Tenant\WorkOrder;
use App\Tenancy\TenantManager;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Livewire\Livewire;
use Tests\TestCase;
class PipelineBoardTest extends TestCase
{
use RefreshDatabase;
private Company $company;
protected function setUp(): void
{
parent::setUp();
$plan = Plan::firstOrCreate(['slug' => 'test'], ['name' => 'T', 'price' => 0, 'features' => []]);
$this->company = Company::create([
'plan_id' => $plan->id, 'slug' => 'pb-' . uniqid(),
'name' => 'PB Co', 'status' => 'active',
]);
app(TenantManager::class)->setCurrent($this->company);
}
public function test_pipeline_columns_partition_leads_deals_and_wos_by_stage(): void
{
$client = Client::create(['name' => 'C', 'phone' => '+37399000111', 'type' => 'individual', 'status' => 'active']);
$vehicle = Vehicle::create(['client_id' => $client->id, 'make' => 'BMW', 'model' => 'X5', 'plate' => 'X5-1']);
// 1 lead in col "request"
Lead::create(['name' => 'Anon', 'phone' => '+37300000001', 'source' => 'instagram', 'status' => 'new']);
// 1 deal stage=new also in "request"
Deal::create(['client_id' => $client->id, 'vehicle_id' => $vehicle->id, 'name' => 'BMW X5 — Frâne', 'price' => 5600, 'stage' => 'new']);
// 1 deal in "quote"
Deal::create(['client_id' => $client->id, 'vehicle_id' => $vehicle->id, 'name' => 'BMW X5 — Suspensie', 'price' => 8400, 'stage' => 'contact', 'quote_status' => 'sent', 'quote_sent_at' => now()->subHour()]);
// 1 deal in "scheduled"
Deal::create(['client_id' => $client->id, 'vehicle_id' => $vehicle->id, 'name' => 'BMW X5 — Revizie', 'price' => 3200, 'stage' => 'scheduled', 'scheduled_at' => now()->addDay()]);
// 1 WO in "in_work"
WorkOrder::create(['number' => WorkOrder::generateNumber($this->company->id), 'client_id' => $client->id, 'vehicle_id' => $vehicle->id, 'opened_at' => today(), 'status' => 'in_work', 'total' => 7200]);
// 1 WO in "ready"
WorkOrder::create(['number' => WorkOrder::generateNumber($this->company->id), 'client_id' => $client->id, 'vehicle_id' => $vehicle->id, 'opened_at' => today(), 'status' => 'ready', 'total' => 7800]);
// 1 WO paid today
$paidWo = WorkOrder::create(['number' => WorkOrder::generateNumber($this->company->id), 'client_id' => $client->id, 'vehicle_id' => $vehicle->id, 'opened_at' => today(), 'closed_at' => today(), 'status' => 'done', 'pay_status' => 'paid', 'total' => 4100]);
$page = new PipelineBoard;
$cols = $page->getColumns();
$this->assertEquals(2, $cols['request']['count'], 'lead + new deal');
$this->assertEquals(1, $cols['quote']['count']);
$this->assertEquals(1, $cols['scheduled']['count']);
$this->assertEquals(1, $cols['in_work']['count']);
$this->assertEquals(1, $cols['ready']['count']);
$this->assertEquals(1, $cols['paid']['count']);
// sums
$this->assertEquals(5600.0, $cols['request']['sum']); // lead has 0 budget, deal has 5600
$this->assertEquals(8400.0, $cols['quote']['sum']);
$this->assertEquals(7200.0, $cols['in_work']['sum']);
}
public function test_stats_computes_active_and_pipeline_total(): void
{
$client = Client::create(['name' => 'C', 'phone' => '+37399000222', 'type' => 'individual', 'status' => 'active']);
Deal::create(['client_id' => $client->id, 'name' => 'D1', 'price' => 1000, 'stage' => 'new']);
Deal::create(['client_id' => $client->id, 'name' => 'D2', 'price' => 2000, 'stage' => 'contact']);
Deal::create(['client_id' => $client->id, 'name' => 'D3', 'price' => 3000, 'stage' => 'scheduled', 'scheduled_at' => now()->addDay()]);
// Payments today
$wo = WorkOrder::create(['number' => WorkOrder::generateNumber($this->company->id), 'client_id' => $client->id, 'opened_at' => today(), 'status' => 'done', 'total' => 4500]);
Payment::create(['client_id' => $client->id, 'work_order_id' => $wo->id, 'paid_at' => today(), 'amount' => 4500, 'method' => 'cash']);
$stats = (new PipelineBoard)->getStats();
$this->assertEquals(3, $stats['active']);
$this->assertEquals(6000.0, $stats['pipeline_mdl']);
$this->assertEquals(4500.0, $stats['closed_today_mdl']);
}
public function test_move_lead_to_quote_converts_lead_into_deal(): void
{
$lead = Lead::create(['name' => 'X', 'phone' => '+37399123456', 'car' => 'VW', 'model' => 'Passat', 'budget' => 1500, 'status' => 'new', 'source' => 'site']);
Livewire::test(PipelineBoard::class)->call('moveCard', "lead:{$lead->id}", 'quote');
$lead->refresh();
$this->assertEquals('converted', $lead->status);
$this->assertNotNull($lead->deal_id);
$deal = Deal::find($lead->deal_id);
$this->assertEquals('contact', $deal->stage);
$this->assertEquals('sent', $deal->quote_status);
$this->assertNotNull($deal->quote_sent_at);
}
public function test_move_deal_to_in_work_creates_work_order(): void
{
$client = Client::create(['name' => 'C', 'phone' => '+37399111222', 'type' => 'individual', 'status' => 'active']);
$vehicle = Vehicle::create(['client_id' => $client->id, 'make' => 'BMW', 'model' => 'X5', 'plate' => 'WX-1']);
$deal = Deal::create(['client_id' => $client->id, 'vehicle_id' => $vehicle->id, 'name' => 'BMW X5 — Repair', 'price' => 7200, 'stage' => 'scheduled']);
Livewire::test(PipelineBoard::class)->call('moveCard', "deal:{$deal->id}", 'in_work');
$deal->refresh();
$this->assertEquals('in_work', $deal->stage);
$wo = WorkOrder::where('deal_id', $deal->id)->first();
$this->assertNotNull($wo);
$this->assertEquals('in_work', $wo->status);
$this->assertEquals(7200.0, (float) $wo->total);
$this->assertEquals($client->id, $wo->client_id);
$this->assertEquals($vehicle->id, $wo->vehicle_id);
}
public function test_move_wo_to_paid_creates_payment_for_balance_and_marks_done(): void
{
$client = Client::create(['name' => 'C', 'phone' => '+37399333444', 'type' => 'individual', 'status' => 'active']);
$wo = WorkOrder::create(['number' => WorkOrder::generateNumber($this->company->id), 'client_id' => $client->id, 'opened_at' => today(), 'status' => 'ready', 'total' => 7800]);
Livewire::test(PipelineBoard::class)->call('moveCard', "wo:{$wo->id}", 'paid');
$wo->refresh();
$this->assertEquals('done', $wo->status);
$this->assertEquals(today()->toDateString(), $wo->closed_at?->toDateString());
$this->assertEquals('paid', $wo->pay_status);
$this->assertEquals(7800.0, (float) Payment::where('work_order_id', $wo->id)->sum('amount'));
}
public function test_filter_mine_narrows_to_assigned_user(): void
{
$me = User::create(['name' => 'Me', 'email' => 'me@example.com', 'password' => bcrypt('x'), 'role' => 'master', 'status' => 'active']);
$other = User::create(['name' => 'Other', 'email' => 'other@example.com', 'password' => bcrypt('x'), 'role' => 'master', 'status' => 'active']);
$this->actingAs($me);
$client = Client::create(['name' => 'C', 'phone' => '+37399555666', 'type' => 'individual', 'status' => 'active']);
Deal::create(['client_id' => $client->id, 'name' => 'mine', 'price' => 1000, 'stage' => 'new', 'assigned_to' => $me->id]);
Deal::create(['client_id' => $client->id, 'name' => 'other', 'price' => 2000, 'stage' => 'new', 'assigned_to' => $other->id]);
$page = new PipelineBoard;
$page->activeFilter = 'mine';
$cols = $page->getColumns();
$this->assertEquals(1, $cols['request']['count']);
$this->assertEquals('mine', $cols['request']['cards'][0]['subject']);
}
public function test_create_new_lead_inserts_lead_in_request_column(): void
{
Livewire::test(PipelineBoard::class)
->set('newName', 'Anonim X')
->set('newPhone', '+37388999777')
->set('newCar', 'Audi A4')
->set('newSource', 'site')
->set('newNotes', 'are zgomot la pornire')
->call('createNewLead')
->assertSet('showNewForm', false);
$this->assertDatabaseHas('leads', [
'name' => 'Anonim X',
'phone' => '+37388999777',
'car' => 'Audi A4',
'source' => 'site',
'status' => 'new',
]);
}
public function test_create_new_lead_requires_name_and_phone(): void
{
Livewire::test(PipelineBoard::class)
->set('newName', '')
->set('newPhone', '+37300000000')
->call('createNewLead');
$this->assertEquals(0, Lead::count());
}
public function test_search_query_filters_cards_across_columns(): void
{
$client1 = Client::create(['name' => 'Muntean Alex', 'phone' => '+37399000001', 'type' => 'individual', 'status' => 'active']);
$client2 = Client::create(['name' => 'Cojocaru Ion', 'phone' => '+37399000002', 'type' => 'individual', 'status' => 'active']);
Deal::create(['client_id' => $client1->id, 'name' => 'VW Passat — Frâne', 'price' => 1000, 'stage' => 'new']);
Deal::create(['client_id' => $client2->id, 'name' => 'Renault Megane — Diag', 'price' => 1500, 'stage' => 'new']);
$page = new PipelineBoard;
$page->searchQuery = 'Muntean';
$cols = $page->getColumns();
$this->assertEquals(1, $cols['request']['count']);
$this->assertEquals('VW Passat — Frâne', $cols['request']['cards'][0]['subject']);
}
public function test_partial_payment_shows_avans_achitat_tag_on_ready_card(): void
{
$client = Client::create(['name' => 'C', 'phone' => '+37399112233', 'type' => 'individual', 'status' => 'active']);
$wo = WorkOrder::create(['number' => WorkOrder::generateNumber($this->company->id), 'client_id' => $client->id, 'opened_at' => today(), 'status' => 'ready', 'total' => 7100]);
// Half payment → partial
Payment::create(['client_id' => $client->id, 'work_order_id' => $wo->id, 'paid_at' => today(), 'amount' => 3000, 'method' => 'cash']);
$cols = (new PipelineBoard)->getColumns();
$cards = $cols['ready']['cards'];
$this->assertCount(1, $cards);
$tagLabels = array_column($cards[0]['tags'], 'label');
$this->assertContains('Gata', $tagLabels);
$this->assertContains('Avans achitat', $tagLabels);
}
public function test_quick_schedule_creates_appointment_and_moves_deal_to_scheduled(): void
{
$client = Client::create(['name' => 'C', 'phone' => '+37399123000', 'type' => 'individual', 'status' => 'active']);
$vehicle = Vehicle::create(['client_id' => $client->id, 'make' => 'X', 'model' => 'Y', 'plate' => 'SCH-1']);
$deal = Deal::create(['client_id' => $client->id, 'vehicle_id' => $vehicle->id, 'name' => 'X Y — Repair', 'price' => 1000, 'stage' => 'contact']);
Livewire::test(PipelineBoard::class)->call('quickSchedule', "deal:{$deal->id}");
$deal->refresh();
$this->assertEquals('scheduled', $deal->stage);
$this->assertNotNull($deal->scheduled_at);
$apt = \App\Models\Tenant\Appointment::where('client_id', $client->id)->first();
$this->assertNotNull($apt);
$this->assertEquals('scheduled', $apt->status);
$this->assertEquals(today()->addDay()->toDateString(), $apt->date->toDateString());
}
public function test_open_card_loads_panel_detail_for_deal(): void
{
$client = Client::create(['name' => 'Ion Popescu', 'phone' => '+37369123456', 'type' => 'individual', 'status' => 'active']);
$vehicle = Vehicle::create(['client_id' => $client->id, 'make' => 'BMW', 'model' => 'X5', 'plate' => 'CIU001']);
$deal = Deal::create(['client_id' => $client->id, 'vehicle_id' => $vehicle->id, 'name' => 'BMW X5 — Diag', 'price' => 7200, 'stage' => 'contact', 'quote_status' => 'sent', 'quote_sent_at' => now()->subHour()]);
$page = new PipelineBoard;
$page->openCard("deal:{$deal->id}");
$detail = $page->getOpenCardDetail();
$this->assertNotNull($detail);
$this->assertEquals('BMW X5 — Diag', $detail['title']);
$this->assertEquals('Ion Popescu', $detail['fields']['Client']);
$this->assertEquals('+37369123456', $detail['fields']['Telefon']);
$this->assertCount(6, $detail['stages']);
$this->assertTrue($detail['stages'][0]['done']); // Cerere
$this->assertTrue($detail['stages'][1]['current']); // Calculație
}
}