diff --git a/app/Filament/Tenant/Pages/PipelineBoard.php b/app/Filament/Tenant/Pages/PipelineBoard.php index 64bbebb..f4e9026 100644 --- a/app/Filament/Tenant/Pages/PipelineBoard.php +++ b/app/Filament/Tenant/Pages/PipelineBoard.php @@ -42,6 +42,13 @@ class PipelineBoard extends Page public string $activeFilter = 'all'; // all | mine | urgent | today public ?string $openCardKey = null; // "lead:5" / "deal:8" / "wo:12" + public bool $showNewForm = false; // panel in "new request" mode + public string $searchQuery = ''; + public string $newName = ''; + public string $newPhone = ''; + public string $newCar = ''; + public string $newSource = 'call'; + public string $newNotes = ''; public const COLUMNS = [ 'request' => ['Cerere nouă', '#94A3B8'], @@ -124,6 +131,18 @@ class PipelineBoard extends Page $cards['paid'][] = $this->woCard($wo); } + // Apply search query + $q = trim($this->searchQuery); + if ($q !== '') { + $needle = mb_strtolower($q); + foreach ($cards as $col => $list) { + $cards[$col] = array_values(array_filter($list, function ($c) use ($needle) { + $hay = mb_strtolower(($c['subject'] ?? '') . ' ' . ($c['client_name'] ?? '') . ' ' . ($c['plate'] ?? '') . ' ' . ($c['code'] ?? '') . ' ' . ($c['phone'] ?? '')); + return str_contains($hay, $needle); + })); + } + } + // Sort: urgent first, then time foreach ($cards as $col => $list) { usort($list, fn ($a, $b) => ($b['urgent'] ?? false) <=> ($a['urgent'] ?? false)); @@ -188,6 +207,52 @@ class PipelineBoard extends Page $this->activeFilter = in_array($filter, ['all', 'mine', 'urgent', 'today'], true) ? $filter : 'all'; } + public function openNewForm(): void + { + $this->showNewForm = true; + $this->openCardKey = null; + $this->newName = ''; + $this->newPhone = ''; + $this->newCar = ''; + $this->newSource = 'call'; + $this->newNotes = ''; + } + + public function createNewLead(): void + { + $data = ['name' => trim($this->newName), 'phone' => trim($this->newPhone), 'car' => trim($this->newCar) ?: null, 'source' => $this->newSource, 'message' => trim($this->newNotes) ?: null]; + if ($data['name'] === '' || $data['phone'] === '') { + $this->notify('Nume și telefon sunt obligatorii'); + return; + } + Lead::create(array_merge($data, ['status' => 'new'])); + $this->showNewForm = false; + $this->notify('Cerere nouă adăugată'); + } + + public function exportCsv() + { + $columns = $this->getColumns(); + $csv = "Etapă,Cod,Subiect,Client,Telefon,Auto,Sumă,Responsabil,Stare\n"; + foreach ($columns as $col) { + foreach ($col['cards'] as $card) { + $csv .= sprintf( + "%s,%s,%s,%s,%s,%s,%.2f,%s,%s\n", + $col['label'], + $card['code'], + str_replace(',', ' ', $card['subject']), + str_replace(',', ' ', $card['client_name']), + $card['phone'] ?? '', + $card['plate'], + $card['amount'], + $card['assignee']['name'], + str_replace(',', ' ', $card['time_text']), + ); + } + } + return response()->streamDownload(fn () => print $csv, 'pipeline-' . today()->format('Y-m-d') . '.csv', ['Content-Type' => 'text/csv']); + } + public function moveCard(string $key, string $toCol): void { [$kind, $id] = explode(':', $key, 2) + [null, null]; @@ -475,7 +540,9 @@ class PipelineBoard extends Page } if ($wo->status === 'ready') { $tags[] = ['label' => 'Gata', 'color' => 'green']; - if ($wo->pay_status !== 'paid') { + if ($wo->pay_status === 'partial') { + $tags[] = ['label' => 'Avans achitat', 'color' => 'blue']; + } elseif ($wo->pay_status !== 'paid') { $tags[] = ['label' => 'Neachitat', 'color' => 'amber']; } } diff --git a/resources/views/filament/tenant/pages/pipeline-board.blade.php b/resources/views/filament/tenant/pages/pipeline-board.blade.php index ecf3e4d..b7d602b 100644 --- a/resources/views/filament/tenant/pages/pipeline-board.blade.php +++ b/resources/views/filament/tenant/pages/pipeline-board.blade.php @@ -54,7 +54,7 @@ --pb-gray-text: #d1d5db; } -/* Break out of Filament chrome — full-bleed board */ +/* Break out of Filament chrome */ .fi-main-ctn:has(.pb-shell) { padding: 0 !important; } .fi-main:has(.pb-shell) { padding: 0 !important; } .fi-page:has(.pb-shell) > div { padding: 0 !important; gap: 0 !important; } @@ -62,6 +62,20 @@ .fi-page:has(.pb-shell) { gap: 0 !important; } .pb-shell { background:var(--pb-bg); color:var(--pb-text); margin:0; padding:0; min-height:calc(100vh - 64px); font-size:13px; display:flex; flex-direction:column; } + +/* TOPBAR */ +.pb-topbar { height:52px; background:var(--pb-surface); border-bottom:1px solid var(--pb-border); display:flex; align-items:center; padding:0 20px; gap:12px; flex-shrink:0; } +.pb-topbar-title { font-size:15px; font-weight:600; letter-spacing:-.2px; } +.pb-topbar-sep { width:1px; height:18px; background:var(--pb-border-md); } +.pb-search { display:flex; align-items:center; gap:6px; background:var(--pb-bg); border:1px solid var(--pb-border); border-radius:6px; padding:5px 10px; flex:1; max-width:320px; } +.pb-search input { border:none; background:transparent; outline:none; font-size:12px; color:var(--pb-text); width:100%; } +.pb-search-icon { color:var(--pb-text-3); font-size:14px; } +.pb-topbar-right { margin-left:auto; display:flex; align-items:center; gap:8px; } +.pb-btn { display:inline-flex; align-items:center; gap:5px; padding:6px 12px; border-radius:6px; font-size:12px; font-weight:500; cursor:pointer; border:1px solid var(--pb-border-md); background:var(--pb-surface); color:var(--pb-text); white-space:nowrap; text-decoration:none; } +.pb-btn:hover { background:var(--pb-bg); } +.pb-btn-primary { background:var(--pb-blue); color:#fff !important; border-color:var(--pb-blue); } +.pb-btn-primary:hover { background:#1557d4; } + .pb-stat-strip { background:var(--pb-surface); border-bottom:1px solid var(--pb-border); padding:10px 20px; display:flex; gap:20px; flex-wrap:wrap; flex-shrink:0; } .pb-stat-item { display:flex; flex-direction:column; gap:2px; min-width:60px; } .pb-stat-val { font-size:16px; font-weight:600; } @@ -76,6 +90,7 @@ .pb-chip:hover { border-color:var(--pb-border-md); } .pb-chip.active { background:var(--pb-blue-bg); border-color:#93B8F9; color:var(--pb-blue-text); } .pb-filter-sep { width:1px; height:20px; background:var(--pb-border); } +.pb-period { display:flex; align-items:center; gap:5px; padding:4px 10px; border:1px solid var(--pb-border); border-radius:6px; font-size:11px; background:var(--pb-surface); color:var(--pb-text-2); } .pb-total { margin-left:auto; font-size:11px; color:var(--pb-text-2); } .pb-total strong { color:var(--pb-text); } @@ -129,14 +144,18 @@ .pb-act-btn { width:24px; height:24px; border-radius:5px; border:1px solid var(--pb-border); background:var(--pb-surface); cursor:pointer; display:flex; align-items:center; justify-content:center; color:var(--pb-text-2); font-size:13px; text-decoration:none; } .pb-act-btn:hover { background:var(--pb-bg); color:var(--pb-text); border-color:var(--pb-border-md); } +/* Col 6 (Achitat azi): faded, no hover actions */ +.pb-col[data-col="paid"] .pb-deal { opacity:0.65; } +.pb-col[data-col="paid"] .pb-deal-actions { display:none !important; } + .pb-add-card { display:flex; align-items:center; justify-content:center; gap:6px; padding:8px 12px; border-radius:10px; border:1px dashed var(--pb-border-md); color:var(--pb-text-3); font-size:11px; cursor:pointer; background:transparent; text-decoration:none; flex-shrink:0; } .pb-add-card:hover { border-color:var(--pb-blue); color:var(--pb-blue); background:var(--pb-blue-bg); } .pb-overlay { position:fixed; inset:0; background:rgba(0,0,0,0.25); z-index:100; opacity:0; pointer-events:none; transition:opacity .2s; } .pb-overlay.open { opacity:1; pointer-events:all; } -.pb-panel { position:fixed; right:0; top:0; bottom:0; width:440px; max-width:92vw; background:var(--pb-surface); border-left:1px solid var(--pb-border); z-index:101; transform:translateX(100%); transition:transform .25s cubic-bezier(.4,0,.2,1); overflow-y:auto; display:flex; flex-direction:column; } +.pb-panel { position:fixed; right:0; top:0; bottom:0; width:440px; max-width:92vw; background:var(--pb-surface); border-left:1px solid var(--pb-border); z-index:101; transform:translateX(100%); transition:transform .25s cubic-bezier(.4,0,.2,1); overflow:hidden; display:flex; flex-direction:column; } .pb-panel.open { transform:translateX(0); } -.pb-panel-head { padding:16px 20px 12px; border-bottom:1px solid var(--pb-border); display:flex; align-items:flex-start; justify-content:space-between; position:sticky; top:0; background:var(--pb-surface); z-index:10; } +.pb-panel-head { padding:16px 20px 12px; border-bottom:1px solid var(--pb-border); display:flex; align-items:flex-start; justify-content:space-between; background:var(--pb-surface); flex-shrink:0; } .pb-panel-title { font-size:15px; font-weight:600; line-height:1.3; } .pb-panel-id { font-size:11px; color:var(--pb-text-3); margin-top:2px; } .pb-close-btn { width:28px; height:28px; border-radius:6px; border:1px solid var(--pb-border); background:var(--pb-bg); cursor:pointer; display:flex; align-items:center; justify-content:center; } @@ -161,16 +180,38 @@ .pb-quick-grid { display:grid; grid-template-columns:1fr 1fr; gap:6px; } .pb-quick-btn { display:flex; align-items:center; justify-content:center; gap:5px; padding:8px; font-size:11px; border:1px solid var(--pb-border-md); border-radius:6px; background:var(--pb-surface); color:var(--pb-text); cursor:pointer; text-decoration:none; } .pb-quick-btn:hover { background:var(--pb-bg); } -.pb-quick-btn.primary { background:var(--pb-blue); color:#fff; border-color:var(--pb-blue); } +.pb-quick-btn.primary { background:var(--pb-blue); color:#fff !important; border-color:var(--pb-blue); } .pb-quick-btn.primary:hover { background:#1557d4; } -.pb-panel-actions { padding:12px 20px; border-top:1px solid var(--pb-border); display:flex; gap:8px; background:var(--pb-surface); position:sticky; bottom:0; } +.pb-panel-actions { padding:12px 20px; border-top:1px solid var(--pb-border); display:flex; gap:8px; background:var(--pb-surface); flex-shrink:0; } .pb-panel-actions .pb-quick-btn { flex:1; } +/* New-card form */ +.pb-form-field { margin-bottom:12px; } +.pb-form-field label { display:block; font-size:10px; font-weight:600; color:var(--pb-text-3); text-transform:uppercase; letter-spacing:.5px; margin-bottom:4px; } +.pb-form-field input, .pb-form-field textarea, .pb-form-field select { width:100%; padding:8px 10px; font-size:13px; border:1px solid var(--pb-border-md); border-radius:6px; background:var(--pb-surface); color:var(--pb-text); outline:none; font-family:inherit; } +.pb-form-field input:focus, .pb-form-field textarea:focus, .pb-form-field select:focus { border-color:var(--pb-blue); } +.pb-form-field textarea { resize:vertical; min-height:60px; } + .pb-empty-col { display:flex; flex-direction:column; align-items:center; justify-content:center; padding:24px 12px; color:var(--pb-text-3); font-size:11px; text-align:center; }
+ {{-- TOPBAR --}} +
+ Pipeline +
+ +
+ + ⬇ Export + +
+
+ {{-- STAT STRIP --}}
@@ -195,7 +236,7 @@
{{ $stats['conversion_rate'] }}% - Rata conversie (30z) + Rata conversie
@@ -215,13 +256,15 @@
👤 Ale mele
⚠ Urgente
📅 Azi
+
+
📅 {{ now()->locale('ro')->isoFormat('MMMM YYYY') }}
Pipeline: {{ number_format($stats['pipeline_mdl'], 0, '.', ' ') }} MDL · {{ $stats['active'] }} deals
{{-- KANBAN --}}
@foreach ($columns as $colKey => $col) -
@endif
- @if (in_array($colKey, ['request', 'quote'])) - 📅 + @if ($colKey === 'request') + {{-- 📅 calendar-plus / 📞 phone / ⋮ --}} + 📅 + @if (!empty($card['phone']))📞@endif + + @elseif ($colKey === 'quote') + {{-- 📅 / 💬 message / ⋮ --}} + 📅 + @if (!empty($card['phone']))💬@endif + @elseif ($colKey === 'scheduled') - + {{-- 📄+ file-plus (start WO) / 💬 / ⋮ --}} + 📄 + @if (!empty($card['phone']))💬@endif + @elseif ($colKey === 'in_work') + {{-- 👁 eye / 💬 / ⋮ --}} + 👁 + @if (!empty($card['phone']))💬@endif @elseif ($colKey === 'ready') + {{-- 💰 cash / 💬 / ⋮ --}} 💰 + @if (!empty($card['phone']))📞@endif + @endif - @if (!empty($card['phone'])) - 📞 - 💬 - @endif -
{{ $card['code'] }}
{{ $card['subject'] }}
@@ -281,14 +335,14 @@ @if (!is_null($card['progress_pct']))
@endif - @if ($card['time_text']) + @if ($card['time_text'] && $colKey !== 'paid')
@if ($card['time_icon']==='check')✓@elseif($card['time_icon']==='phone')📞@elseif($card['time_icon']==='message')💬@else⏱@endif {{ $card['time_text'] }} @@ -298,10 +352,8 @@ @empty
Gol — trage un card aici
@endforelse - @if (in_array($colKey, ['request', 'quote', 'scheduled'])) - + Adaugă cerere - @elseif (in_array($colKey, ['in_work', 'ready'])) - + Fișă nouă + @if ($colKey !== 'paid') + @endif
@@ -327,7 +379,7 @@ {{ $card['client_name'] }} {{ $card['plate'] }} {{ $col['label'] }} - {{ number_format($card['amount'], 0, '.', ' ') }} MDL + {{ $card['amount'] > 0 ? number_format($card['amount'], 0, '.', ' ') . ' MDL' : '—' }} {{ $card['assignee']['name'] }} {{ $card['time_text'] }} @@ -337,10 +389,54 @@
- {{-- DETAIL PANEL --}} -
-
- @if ($detail) + {{-- PANEL: NEW FORM mode --}} +
+
+ @if ($showNewForm) +
+
+
Cerere nouă
+
Va fi adăugată în Cerere nouă
+
+
+
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+ + +
+ @endif +
+ + {{-- PANEL: DETAIL mode --}} +
+
+ @if ($detail && !$showNewForm)
{{ $detail['title'] }}
@@ -401,11 +497,6 @@
Deschide fișa pentru aprobare lucrare/piesă.
@endif - @if (!empty($detail['wo']['tracking_url'])) -
- Link tracking client: {{ $detail['wo']['tracking_url'] }} -
- @endif
@endif @@ -424,25 +515,26 @@
@endif - @if (!empty($detail['phone'])) -
-
Contactare rapidă
-
+
+
Acțiuni rapide
+
+ @if (!empty($detail['phone'])) 💚 WhatsApp 📞 Sună 💬 SMS - ↗ Editare -
+ @endif + @if (!empty($detail['wo']['tracking_url'])) + 🔗 Link tracking + @endif
- @endif +
- {{-- Bottom action bar — primary CTAs per stage --}}
@php $kind = explode(':', $openCardKey)[0] ?? '' @endphp @if (in_array($kind, ['lead', 'deal']) && empty($detail['wo'])) 📅 Programează - Editare deal + ↗ Deschide deal @elseif ($kind === 'wo') 📅 Calendar ↗ Deschide Fișă diff --git a/tests/Feature/PipelineBoardTest.php b/tests/Feature/PipelineBoardTest.php index 685ef62..089b41a 100644 --- a/tests/Feature/PipelineBoardTest.php +++ b/tests/Feature/PipelineBoardTest.php @@ -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']);