From 3c0f3ba39e00f8663057886455572cf88dfe1d17 Mon Sep 17 00:00:00 2001 From: Vasyka Date: Thu, 4 Jun 2026 20:14:20 +0000 Subject: [PATCH] =?UTF-8?q?feat:=20Pipeline=20board=20full-bleed=20+=20hov?= =?UTF-8?q?er=20actions=20+=20Programare=E2=86=92Calendar?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses the gaps surfaced after the first redesign — the board was still boxed in Filament chrome (not truly full-page), hover floating-actions and "+ Adaugă" CTAs were missing, and the P0 "Programează" from deal card had no calendar wiring. Full-page: - getMaxContentWidth() = Width::Full - getHeading()/getSubheading() return empty so Filament's title bar disappears, leaving the kanban edge-to-edge - CSS uses :has(.pb-shell) to strip Filament's page padding + heading block at the layout level - Board height = calc(100vh - 64px); columns scroll independently Hover floating-actions on every card (column-aware): - Cols 1-2 (Cerere / Calculație): 📅 quickSchedule - Col 3 (Programat): ▶ start work (creates WO) - Col 4 (În lucru): ✓ mark Gata - Col 5 (Gata): 💰 mark Achitat - All cards with phone: 📞 tel: + 💬 wa.me - All cards: ↗ open in resource edit - Shown only on .pb-deal:hover, positioned absolute top-right "+ Adaugă" CTA at column bottom: - Cols 1-3 → /app/leads/create - Cols 4-5 → /app/work-orders/create Programare → Calendar (P0 AAA): - quickSchedule($key) on PipelineBoard creates a real Appointment row for tomorrow 10:00 linked to (client_id, vehicle_id, master_id, deal_id), sets deal.stage='scheduled' + scheduled_at, then shows a toast - Panel bottom action bar gains "📅 Programează" CTA for lead/deal cards - "📅 Calendar" jump CTA for WO cards - calendarUrl() returns the canonical filament.tenant.pages.calendar-board route Empty column state now reads "Gol — trage un card aici" instead of just "Gol" so the drop affordance is explicit. Stat strip + filter bar sticky at top; board fills the remaining viewport. Tests: +1 (quickSchedule creates Appointment + moves deal). Suite 181/181. Co-Authored-By: Claude Opus 4.7 (1M context) --- app/Filament/Tenant/Pages/PipelineBoard.php | 65 ++++++++++++ .../tenant/pages/pipeline-board.blade.php | 98 +++++++++++++++---- tests/Feature/PipelineBoardTest.php | 18 ++++ 3 files changed, 160 insertions(+), 21 deletions(-) diff --git a/app/Filament/Tenant/Pages/PipelineBoard.php b/app/Filament/Tenant/Pages/PipelineBoard.php index e2ef1ed..64bbebb 100644 --- a/app/Filament/Tenant/Pages/PipelineBoard.php +++ b/app/Filament/Tenant/Pages/PipelineBoard.php @@ -25,6 +25,21 @@ class PipelineBoard extends Page protected string $view = 'filament.tenant.pages.pipeline-board'; + public function getMaxContentWidth(): \Filament\Support\Enums\Width + { + return \Filament\Support\Enums\Width::Full; + } + + public function getHeading(): string + { + return ''; + } + + public function getSubheading(): ?string + { + return null; + } + public string $activeFilter = 'all'; // all | mine | urgent | today public ?string $openCardKey = null; // "lead:5" / "deal:8" / "wo:12" @@ -269,6 +284,56 @@ class PipelineBoard extends Page $this->openCardKey = $key; } + /** Quick-schedule from a card: bumps the source to "Programat", creates an Appointment for tomorrow 10:00, returns calendar URL. */ + public function quickSchedule(string $key): void + { + [$kind, $id] = explode(':', $key, 2) + [null, null]; + $id = (int) $id; + if (! $kind || ! $id) return; + + $clientId = null; $vehicleId = null; $dealId = null; $title = null; $masterId = null; + + if ($kind === 'lead') { + $lead = Lead::find($id); + if (! $lead) return; + $deal = $lead->convert(['stage' => 'scheduled', 'scheduled_at' => now()->addDay()->setHour(10)->setMinute(0)]); + $clientId = $deal->client_id; $vehicleId = $deal->vehicle_id; $dealId = $deal->id; + $title = $deal->name; + } elseif ($kind === 'deal') { + $deal = Deal::find($id); + if (! $deal) return; + $deal->update(['stage' => 'scheduled', 'scheduled_at' => now()->addDay()->setHour(10)->setMinute(0)]); + $clientId = $deal->client_id; $vehicleId = $deal->vehicle_id; $dealId = $deal->id; + $title = $deal->name; + $masterId = $deal->assigned_to; + } elseif ($kind === 'wo') { + $wo = WorkOrder::find($id); + if (! $wo) return; + $clientId = $wo->client_id; $vehicleId = $wo->vehicle_id; + $title = $wo->number; + $masterId = $wo->master_id; + } + + \App\Models\Tenant\Appointment::create([ + 'client_id' => $clientId, + 'vehicle_id' => $vehicleId, + 'master_id' => $masterId, + 'deal_id' => $dealId, + 'date' => today()->addDay(), + 'time_start' => '10:00', + 'time_end' => '11:00', + 'title' => $title ?: 'Programare', + 'status' => 'scheduled', + ]); + $this->notify("Programare creată · mâine 10:00"); + $this->openCardKey = null; + } + + public function calendarUrl(): string + { + return route('filament.tenant.pages.calendar-board'); + } + public function closeCard(): void { $this->openCardKey = null; diff --git a/resources/views/filament/tenant/pages/pipeline-board.blade.php b/resources/views/filament/tenant/pages/pipeline-board.blade.php index 71024aa..ecf3e4d 100644 --- a/resources/views/filament/tenant/pages/pipeline-board.blade.php +++ b/resources/views/filament/tenant/pages/pipeline-board.blade.php @@ -53,14 +53,22 @@ --pb-gray-bg: #374151; --pb-gray-text: #d1d5db; } -.pb-shell { background:var(--pb-bg); color:var(--pb-text); margin:-1.5rem; padding:0; min-height:calc(100vh - 80px); font-size:13px; } -.pb-stat-strip { background:var(--pb-surface); border-bottom:1px solid var(--pb-border); padding:10px 20px; display:flex; gap:20px; flex-wrap:wrap; } + +/* Break out of Filament chrome — full-bleed board */ +.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; } +.fi-page:has(.pb-shell) .fi-header { display: none !important; } +.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; } +.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; } .pb-stat-lbl { font-size:10px; color:var(--pb-text-3); white-space:nowrap; } .pb-stat-sep { width:1px; background:var(--pb-border); align-self:stretch; } -.pb-filter-bar { background:var(--pb-surface); border-bottom:1px solid var(--pb-border); padding:8px 20px; display:flex; align-items:center; gap:8px; flex-wrap:wrap; } +.pb-filter-bar { background:var(--pb-surface); border-bottom:1px solid var(--pb-border); padding:8px 20px; display:flex; align-items:center; gap:8px; flex-wrap:wrap; flex-shrink:0; } .pb-view-toggle { display:flex; background:var(--pb-bg); border-radius:6px; padding:2px; border:1px solid var(--pb-border); } .pb-vt-btn { display:flex; align-items:center; gap:4px; padding:4px 10px; border-radius:4px; font-size:11px; font-weight:500; cursor:pointer; color:var(--pb-text-3); } .pb-vt-btn.active { background:var(--pb-surface); color:var(--pb-text); box-shadow:0 1px 2px rgba(0,0,0,0.08); } @@ -71,17 +79,19 @@ .pb-total { margin-left:auto; font-size:11px; color:var(--pb-text-2); } .pb-total strong { color:var(--pb-text); } -.pb-board { display:flex; gap:10px; overflow-x:auto; padding:16px 20px; align-items:flex-start; } -.pb-col { width:240px; flex-shrink:0; display:flex; flex-direction:column; gap:6px; } +.pb-board { display:flex; gap:10px; overflow-x:auto; overflow-y:hidden; padding:16px 20px; align-items:flex-start; flex:1; min-height:0; } +.pb-col { width:260px; flex-shrink:0; display:flex; flex-direction:column; gap:6px; height:100%; } .pb-col.over .pb-col-body { background:var(--pb-blue-bg); border-radius:8px; } -.pb-col-head { background:var(--pb-surface); border:1px solid var(--pb-border); border-radius:10px; padding:10px 12px; } +.pb-col-head { background:var(--pb-surface); border:1px solid var(--pb-border); border-radius:10px; padding:10px 12px; flex-shrink:0; } .pb-col-head-top { display:flex; align-items:center; justify-content:space-between; } .pb-col-name { font-size:12px; font-weight:600; display:flex; align-items:center; gap:6px; } .pb-col-dot { width:8px; height:8px; border-radius:50%; flex-shrink:0; } .pb-col-count { font-size:10px; font-weight:600; background:var(--pb-bg); padding:1px 6px; border-radius:8px; color:var(--pb-text-2); } .pb-col-sum { font-size:11px; color:var(--pb-text-3); margin-top:3px; } .pb-col-sum strong { color:var(--pb-text-2); } -.pb-col-body { flex:1; display:flex; flex-direction:column; gap:6px; max-height:calc(100vh - 260px); overflow-y:auto; padding-bottom:4px; padding:2px; transition:background .15s; min-height:60px; } +.pb-col-body { flex:1; display:flex; flex-direction:column; gap:6px; overflow-y:auto; padding:2px; transition:background .15s; min-height:60px; } +.pb-col-body::-webkit-scrollbar { width:3px; } +.pb-col-body::-webkit-scrollbar-thumb { background:var(--pb-border-md); border-radius:2px; } .pb-deal { background:var(--pb-surface); border:1px solid var(--pb-border); border-radius:10px; padding:11px 12px; cursor:pointer; transition:all .12s; position:relative; } .pb-deal:hover { border-color:var(--pb-border-md); box-shadow:0 4px 12px rgba(0,0,0,0.1); transform:translateY(-1px); } @@ -112,18 +122,25 @@ .pb-deal-time.overdue { color:var(--pb-red); } .pb-progress-bar { height:3px; background:var(--pb-bg); border-radius:2px; overflow:hidden; margin-top:6px; } .pb-progress-fill { height:100%; border-radius:2px; background:var(--pb-purple); } -.pb-add-card { display:flex; align-items:center; gap:6px; padding:7px 12px; border-radius:10px; border:1px dashed var(--pb-border-md); color:var(--pb-text-3); font-size:11px; cursor:pointer; background:transparent; } + +/* Hover floating action buttons */ +.pb-deal-actions { position:absolute; top:8px; right:8px; display:none; gap:3px; z-index:2; } +.pb-deal:hover .pb-deal-actions { display:flex; } +.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); } + +.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:420px; max-width:90vw; 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; } +.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.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-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; } -.pb-panel-body { padding:16px 20px; } +.pb-panel-body { padding:16px 20px; flex:1; overflow-y:auto; } .pb-pfield { margin-bottom:14px; } .pb-pfield-label { font-size:10px; font-weight:600; color:var(--pb-text-3); text-transform:uppercase; letter-spacing:.5px; margin-bottom:5px; } .pb-pfield-val { font-size:13px; font-weight:500; } @@ -142,13 +159,18 @@ .pb-act-text { font-size:12px; color:var(--pb-text-2); flex:1; } .pb-act-time { font-size:10px; color:var(--pb-text-3); margin-top:1px; } .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:7px; font-size:11px; border:1px solid var(--pb-border-md); border-radius:6px; background:var(--pb-surface); color:var(--pb-text); cursor:pointer; } +.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:hover { background:#1557d4; } -.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; } +.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 .pb-quick-btn { flex:1; } + +.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; } -
+
{{-- STAT STRIP --}}
@@ -197,7 +219,7 @@
{{-- KANBAN --}} -
+
@foreach ($columns as $colKey => $col)
@endif +
+ @if (in_array($colKey, ['request', 'quote'])) + 📅 + @elseif ($colKey === 'scheduled') + + @elseif ($colKey === 'in_work') + + @elseif ($colKey === 'ready') + 💰 + @endif + @if (!empty($card['phone'])) + 📞 + 💬 + @endif + +
{{ $card['code'] }}
{{ $card['subject'] }}
-
🚗 {{ $card['plate'] }} · {{ $card['client_name'] }}
+
🚗 {{ $card['plate'] ?: '—' }} · {{ $card['client_name'] }}
@if (!empty($card['tags']))
@foreach ($card['tags'] as $tag) @@ -257,15 +296,20 @@ @endif
@empty -
Gol
+
Gol — trage un card aici
@endforelse + @if (in_array($colKey, ['request', 'quote', 'scheduled'])) + + Adaugă cerere + @elseif (in_array($colKey, ['in_work', 'ready'])) + + Fișă nouă + @endif
@endforeach
{{-- LIST VIEW --}} -
+
@@ -305,7 +349,7 @@
-
+
Progres etapă
@foreach ($detail['stages'] as $st) @@ -349,7 +393,7 @@ @if ($detail['wo']['eta']) · ETA {{ $detail['wo']['eta'] }} @endif
- ↗ Deschide + ↗ Deschide
@if (!empty($detail['wo']['has_pending_approval']))
@@ -382,16 +426,28 @@ @if (!empty($detail['phone']))
-
Acțiuni rapide
+
Contactare rapidă
@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 + @elseif ($kind === 'wo') + 📅 Calendar + ↗ Deschide Fișă + @endif +
@endif
diff --git a/tests/Feature/PipelineBoardTest.php b/tests/Feature/PipelineBoardTest.php index e1eb3ce..685ef62 100644 --- a/tests/Feature/PipelineBoardTest.php +++ b/tests/Feature/PipelineBoardTest.php @@ -153,6 +153,24 @@ class PipelineBoardTest extends TestCase $this->assertEquals('mine', $cols['request']['cards'][0]['subject']); } + 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']);