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>
This commit is contained in:
2026-06-04 21:18:54 +00:00
parent 3c0f3ba39e
commit d9b198a235
3 changed files with 262 additions and 43 deletions
+60
View File
@@ -153,6 +153,66 @@ class PipelineBoardTest extends TestCase
$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']);