3603c0e43b
Replaces the bare 6-status WO Kanban with the unified Pipeline view from
/tmp/service/todo/psauto-pipeline-redesign.html. Six columns now span the
entire customer journey end-to-end:
Cerere nouă → Calculație → Programat → În lucru → Gata → Achitat azi
└─ Lead/Deal └─ Deal └─ Deal └─ WO └─ WO └─ WO+Payment
Cross-model drag-drop transitions:
- Lead → Calculație: Lead::convert() creates Deal at stage=contact, marks
quote_sent_at = now, quote_status = sent
- Deal (any earlier stage) → În lucru: spawns a WorkOrder from the deal
(client, vehicle, master, total, complaint), sets deal.stage=in_work,
links wo.deal_id
- WO → Gata: status=ready + fires NotificationDispatcher::workOrderReady
so client gets Telegram/email automatically
- WO → Achitat: creates Payment for remaining balance + status=done,
closed_at=today (pay_status syncs to paid via Payment booted hook)
Rich card content per the mockup:
- Red urgent stripe (left border) for Deal.urgent or WO.urgency!=normal
- Source tag (Instagram/Site/Apel/etc.) on lead/deal cards
- Quote status badge ("Trimis · fără răspuns" amber / "Văzut ✓" blue /
"A răspuns" green) based on deal.quote_status
- Scheduled time + bay tag ("05.06 · 09:00" + "Post 2")
- Fișă FL-NNN purple tag on WO cards
- "Necesită aprobare" amber tag when wo.status=agreement
- Progress bar (purple, 0-100%) on in-work cards: works_done + parts_installed
over total lines
- SLA time line per card with overdue red color:
* Lead 60+ min not contacted = overdue
* Quote 2h+ no response = overdue
* Ready 30+ min not paid = overdue (with phone icon)
* WO past ETA = overdue
- Assignee avatar (deterministic CRC32 color: blue/green/purple/amber)
- Amount in MDL, formatted
Stat strip (6 metrics computed live):
- Total deals active (sum of cols 1-5)
- MDL pipeline total
- MDL closed today (Payment sum where paid_at=today)
- Necesită acțiune (overdue + urgent + pending approval)
- Rata conversie 30d (won / (won+lost) %)
- Depășit termen (count WO past eta_at)
Filter chips wire-driven: Toate / Ale mele (assigned_to=me) /
Urgente (urgent=true OR wo.urgency!=normal) / Azi.
View toggle: Kanban ↔ Listă (table with all cards flat, sortable by stage).
Slide-in detail panel:
- 6-step stage stepper highlighting current
- Client / Telefon (blue clickable) / Auto / Sursă / Responsabil / Sumă /
De achitat (live computed balanceDue for WOs)
- Note / Reclamație
- Linked Fișă card with status badge, progress, ETA, "necesită aprobare"
alert + tracking link
- Activity timeline from Spatie activity-log
- Quick actions: WhatsApp (wa.me/<phone>), Sună (tel:), SMS (sms:),
Deschide (jumps to Filament resource edit)
DealResource hidden from nav (shouldRegisterNavigation=false) since
PipelineBoard is the canonical entry, but its edit/create routes stay
intact — the panel deep-links to them.
Auto-refresh: wire:poll.10s keeps the board live without WebSocket
dependency. Drag-drop is HTML5 native + Livewire wire:click for ops.
Dark mode supported via CSS variables overridden in .dark scope.
Migration: extend deals table with urgent, quote_sent_at, quote_status,
quote_seen_at, scheduled_at, bay, confirmed_at, confirmed_via,
last_action_at. Idempotent (hasColumn guards). Deal model auto-updates
last_action_at on saving.
Tests: 7 new + full suite 180/180 green (was 173).
- partition leads/deals/wos by column
- stats computation: active, pipeline_mdl, closed_today_mdl
- lead→quote transition converts lead into deal
- deal→in_work creates WorkOrder linked back to deal
- wo→paid creates payment for balance + marks done
- filter "mine" narrows to assigned user
- openCard loads panel detail with correct stepper position
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
175 lines
9.2 KiB
PHP
175 lines
9.2 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_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
|
|
}
|
|
}
|