From 3603c0e43bd633309d97a7101b0b3d01b9963a81 Mon Sep 17 00:00:00 2001 From: Vasyka Date: Thu, 4 Jun 2026 20:02:44 +0000 Subject: [PATCH] =?UTF-8?q?feat:=20rich=20Pipeline=20board=20=E2=80=94=20u?= =?UTF-8?q?nified=20Lead/Deal/WO=20Kanban=20with=20SLA=20+=20drag-drop=20t?= =?UTF-8?q?ransitions?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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/), 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) --- app/Filament/Tenant/Pages/Kanban.php | 58 -- app/Filament/Tenant/Pages/PipelineBoard.php | 655 ++++++++++++++++++ .../Tenant/Resources/DealResource.php | 7 +- app/Models/Tenant/Deal.php | 32 +- ...000001_extend_deals_for_pipeline_board.php | 52 ++ .../filament/tenant/pages/kanban.blade.php | 92 --- .../tenant/pages/pipeline-board.blade.php | 398 +++++++++++ tests/Feature/PipelineBoardTest.php | 174 +++++ 8 files changed, 1316 insertions(+), 152 deletions(-) delete mode 100644 app/Filament/Tenant/Pages/Kanban.php create mode 100644 app/Filament/Tenant/Pages/PipelineBoard.php create mode 100644 database/migrations/2026_06_04_000001_extend_deals_for_pipeline_board.php delete mode 100644 resources/views/filament/tenant/pages/kanban.blade.php create mode 100644 resources/views/filament/tenant/pages/pipeline-board.blade.php create mode 100644 tests/Feature/PipelineBoardTest.php diff --git a/app/Filament/Tenant/Pages/Kanban.php b/app/Filament/Tenant/Pages/Kanban.php deleted file mode 100644 index d08c489..0000000 --- a/app/Filament/Tenant/Pages/Kanban.php +++ /dev/null @@ -1,58 +0,0 @@ -with(['client:id,name', 'vehicle:id,plate,make,model', 'master:id,name']) - ->orderBy('opened_at') - ->get() - ->groupBy('status'); - - $columns = []; - foreach ($statuses as $status) { - $columns[$status] = [ - 'label' => WorkOrder::STATUSES[$status] ?? $status, - 'cards' => $byStatus->get($status, collect())->all(), - 'count' => $byStatus->get($status, collect())->count(), - ]; - } - return $columns; - } - - public function moveCard(int $id, string $status): void - { - if (! in_array($status, array_keys(WorkOrder::STATUSES), true)) { - return; - } - $wo = WorkOrder::find($id); - if (! $wo) return; - - $wo->update(['status' => $status]); - - Notification::make() - ->title("Fișa #{$wo->number} → " . (WorkOrder::STATUSES[$status] ?? $status)) - ->success() - ->send(); - } -} diff --git a/app/Filament/Tenant/Pages/PipelineBoard.php b/app/Filament/Tenant/Pages/PipelineBoard.php new file mode 100644 index 0000000..e2ef1ed --- /dev/null +++ b/app/Filament/Tenant/Pages/PipelineBoard.php @@ -0,0 +1,655 @@ + ['Cerere nouă', '#94A3B8'], + 'quote' => ['Calculație', '#F59E0B'], + 'scheduled' => ['Programat', '#3B82F6'], + 'in_work' => ['În lucru', '#8B5CF6'], + 'ready' => ['Gata de ridicare', '#10B981'], + 'paid' => ['Achitat azi', '#6EE7B7'], + ]; + + public function getColumns(): array + { + $userId = auth()->id(); + $mineOnly = $this->activeFilter === 'mine'; + $urgentOnly = $this->activeFilter === 'urgent'; + $todayOnly = $this->activeFilter === 'today'; + + // Col 1: leads (not converted) + deals at stage=new + $leads = Lead::query() + ->whereIn('status', ['new', 'contacted', 'no_answer']) + ->whereNull('deal_id') + ->when($mineOnly, fn ($q) => $q->where('assigned_to', $userId)) + ->orderByDesc('created_at') + ->get(); + + $dealQ = Deal::query() + ->with(['client:id,name,phone', 'vehicle:id,plate,make,model', 'assignedTo:id,name']) + ->whereNotIn('stage', ['done', 'lost']) + ->when($mineOnly, fn ($q) => $q->where('assigned_to', $userId)) + ->when($urgentOnly, fn ($q) => $q->where('urgent', true)) + ->orderByDesc('updated_at') + ->get(); + + $woQ = WorkOrder::query() + ->with(['client:id,name,phone', 'vehicle:id,plate,make,model', 'master:id,name']) + ->whereNotIn('status', ['done', 'cancelled']) + ->when($mineOnly, fn ($q) => $q->where('master_id', $userId)) + ->when($urgentOnly, fn ($q) => $q->where('urgency', '!=', 'normal')) + ->orderBy('opened_at') + ->get(); + + $paidToday = WorkOrder::query() + ->with(['client:id,name,phone', 'vehicle:id,plate,make,model', 'master:id,name']) + ->where('status', 'done') + ->where('pay_status', 'paid') + ->whereDate('closed_at', today()) + ->when($mineOnly, fn ($q) => $q->where('master_id', $userId)) + ->orderByDesc('closed_at') + ->get(); + + $cards = [ + 'request' => [], + 'quote' => [], + 'scheduled' => [], + 'in_work' => [], + 'ready' => [], + 'paid' => [], + ]; + + foreach ($leads as $lead) { + $cards['request'][] = $this->leadCard($lead); + } + foreach ($dealQ as $deal) { + $col = match ($deal->stage) { + 'new' => 'request', + 'contact', 'agree' => 'quote', + 'scheduled', 'arrived' => 'scheduled', + 'in_work' => null, // shouldn't happen: in_work creates WO + default => null, + }; + if ($col) { + $cards[$col][] = $this->dealCard($deal); + } + } + foreach ($woQ as $wo) { + $col = $wo->status === 'ready' ? 'ready' : 'in_work'; + $cards[$col][] = $this->woCard($wo); + } + foreach ($paidToday as $wo) { + $cards['paid'][] = $this->woCard($wo); + } + + // Sort: urgent first, then time + foreach ($cards as $col => $list) { + usort($list, fn ($a, $b) => ($b['urgent'] ?? false) <=> ($a['urgent'] ?? false)); + $cards[$col] = $list; + } + + // "Today" filter narrows further: only scheduled today OR opened today OR paid today. + if ($todayOnly) { + $cards['request'] = array_filter($cards['request'], fn ($c) => str_contains($c['time_text'], 'azi') || str_contains($c['time_text'], 'min')); + // others: keep — Scheduled column inherently shows soon dates; In Work / Ready / Paid show today by default + } + + $columns = []; + foreach (self::COLUMNS as $key => [$label, $color]) { + $list = array_values($cards[$key]); + $sum = array_sum(array_map(fn ($c) => (float) $c['amount'], $list)); + $columns[$key] = [ + 'label' => $label, + 'color' => $color, + 'count' => count($list), + 'sum' => $sum, + 'cards' => $list, + ]; + } + return $columns; + } + + public function getStats(): array + { + $cols = $this->getColumns(); + $active = $cols['request']['count'] + $cols['quote']['count'] + $cols['scheduled']['count'] + $cols['in_work']['count'] + $cols['ready']['count']; + $pipeline = $cols['request']['sum'] + $cols['quote']['sum'] + $cols['scheduled']['sum'] + $cols['in_work']['sum'] + $cols['ready']['sum']; + $closedToday = (float) Payment::whereDate('paid_at', today())->sum('amount'); + $needAction = 0; + foreach (['request', 'quote', 'scheduled', 'in_work', 'ready'] as $key) { + foreach ($cols[$key]['cards'] as $card) { + if (! empty($card['time_overdue']) || ! empty($card['urgent']) || ! empty($card['has_pending_approval'])) { + $needAction++; + } + } + } + $overdue = WorkOrder::whereNotIn('status', ['done', 'cancelled']) + ->whereNotNull('eta_at') + ->where('eta_at', '<', now()) + ->count(); + $won = Deal::whereNotNull('won_at')->where('won_at', '>=', now()->subDays(30))->count(); + $lost = Deal::whereNotNull('lost_at')->where('lost_at', '>=', now()->subDays(30))->count(); + $conversionRate = ($won + $lost) > 0 ? round(100 * $won / ($won + $lost)) : 0; + + return [ + 'active' => $active, + 'pipeline_mdl' => $pipeline, + 'closed_today_mdl' => $closedToday, + 'need_action' => $needAction, + 'conversion_rate' => $conversionRate, + 'overdue' => $overdue, + ]; + } + + public function setFilter(string $filter): void + { + $this->activeFilter = in_array($filter, ['all', 'mine', 'urgent', 'today'], true) ? $filter : 'all'; + } + + public function moveCard(string $key, string $toCol): void + { + [$kind, $id] = explode(':', $key, 2) + [null, null]; + $id = (int) $id; + if (! $kind || ! $id || ! isset(self::COLUMNS[$toCol])) { + return; + } + DB::transaction(function () use ($kind, $id, $toCol) { + switch ($kind . '→' . $toCol) { + // Lead in col 1, dragged to col 2 → convert to Deal at quote stage + case "lead→quote": + $lead = Lead::find($id); + if (! $lead) return; + $deal = $lead->convert(['stage' => 'contact', 'quote_status' => 'sent', 'quote_sent_at' => now()]); + $this->notify("Lead → Deal CIU-{$deal->id} · Calculație trimisă"); + return; + + // Lead → scheduled / in_work: convert + skip + case "lead→scheduled": + $lead = Lead::find($id); + if (! $lead) return; + $deal = $lead->convert(['stage' => 'scheduled', 'scheduled_at' => now()->addDay()]); + $this->notify("Lead → Deal CIU-{$deal->id} · Programat"); + return; + + case "lead→in_work": + $lead = Lead::find($id); + if (! $lead) return; + $deal = $lead->convert(['stage' => 'in_work']); + $wo = $this->createWorkOrderFromDeal($deal); + $this->notify("Lead → Fișă {$wo->number}"); + return; + + // Deal in col 1/2/3 → moving across deal stages + case "deal→request": + Deal::where('id', $id)->update(['stage' => 'new']); + return; + case "deal→quote": + Deal::where('id', $id)->update([ + 'stage' => 'contact', + 'quote_status' => 'sent', + 'quote_sent_at' => now(), + ]); + return; + case "deal→scheduled": + Deal::where('id', $id)->update([ + 'stage' => 'scheduled', + 'scheduled_at' => Deal::find($id)?->scheduled_at ?? now()->addDay(), + ]); + return; + + // Deal → În lucru: create work order + case "deal→in_work": + $deal = Deal::find($id); + if (! $deal) return; + $wo = $this->createWorkOrderFromDeal($deal); + $this->notify("Fișă {$wo->number} creată din deal"); + return; + + // WO between cols + case "wo→in_work": + WorkOrder::where('id', $id)->update(['status' => 'in_work']); + return; + case "wo→ready": + WorkOrder::where('id', $id)->update(['status' => 'ready']); + $this->notify("Fișa marcată ca Gata de ridicare"); + // Fire notification to client (dispatcher handles channel choice) + $wo = WorkOrder::find($id); + if ($wo) app(\App\Services\NotificationDispatcher::class)->workOrderReady($wo); + return; + case "wo→paid": + $wo = WorkOrder::find($id); + if (! $wo) return; + $due = (float) $wo->total - (float) Payment::where('work_order_id', $wo->id)->sum('amount'); + if ($due > 0.01) { + Payment::create([ + 'work_order_id' => $wo->id, + 'client_id' => $wo->client_id, + 'paid_at' => today(), + 'amount' => round($due, 2), + 'method' => 'cash', + 'notes' => 'Achitat din Pipeline', + ]); + } + $wo->update(['status' => 'done', 'closed_at' => today()]); + $this->notify("Fișa {$wo->number} → Achitat"); + return; + } + }); + } + + public function openCard(string $key): void + { + $this->openCardKey = $key; + } + + public function closeCard(): void + { + $this->openCardKey = null; + } + + public function getOpenCardDetail(): ?array + { + if (! $this->openCardKey) return null; + [$kind, $id] = explode(':', $this->openCardKey, 2) + [null, null]; + $id = (int) $id; + if (! $kind || ! $id) return null; + + return match ($kind) { + 'lead' => $this->leadDetail(Lead::find($id)), + 'deal' => $this->dealDetail(Deal::with(['client', 'vehicle', 'assignedTo'])->find($id)), + 'wo' => $this->woDetail(WorkOrder::with(['client', 'vehicle', 'master', 'parts', 'works'])->find($id)), + default => null, + }; + } + + // ============== card builders ============== + + private function leadCard(Lead $lead): array + { + $tags = []; + if (in_array($lead->source, ['instagram', 'facebook', 'site', 'google', 'call'])) { + $tags[] = ['label' => Lead::SOURCES[$lead->source] ?? $lead->source, 'color' => 'gray']; + } + if ($lead->status === 'no_answer') { + $tags[] = ['label' => 'Fără răspuns', 'color' => 'red']; + } + $diffMin = $lead->created_at?->diffInMinutes(now()) ?? 0; + $overdue = $diffMin > 60 && $lead->status !== 'contacted'; + return [ + 'kind' => 'lead', + 'id' => $lead->id, + 'key' => "lead:{$lead->id}", + 'code' => 'CR-' . str_pad((string) $lead->id, 4, '0', STR_PAD_LEFT), + 'subject' => trim(($lead->car ?: '') . ' ' . ($lead->model ?: '')) ?: $lead->name, + 'plate' => $lead->car ?: '', + 'client_name' => $lead->name ?: 'Anonim', + 'phone' => $lead->phone, + 'source' => $lead->source, + 'amount' => (float) $lead->budget, + 'urgent' => $overdue, + 'tags' => $tags, + 'time_text' => $this->humanTime($lead->created_at), + 'time_overdue' => $overdue, + 'time_icon' => 'clock', + 'assignee' => $this->assignee($lead->assignedTo), + 'progress_pct' => null, + 'has_pending_approval' => false, + 'edit_url' => route('filament.tenant.resources.leads.edit', ['record' => $lead->id]), + ]; + } + + private function dealCard(Deal $deal): array + { + $tags = []; + if ($deal->urgent) { + $tags[] = ['label' => 'Urgent', 'color' => 'red']; + } + if ($deal->source && in_array($deal->source, ['instagram', 'site', 'call', 'whatsapp', 'telegram'])) { + $tags[] = ['label' => Lead::SOURCES[$deal->source] ?? $deal->source, 'color' => 'gray']; + } + if ($deal->stage === 'contact' && $deal->quote_status) { + $color = match ($deal->quote_status) { + 'sent' => 'amber', + 'seen' => 'blue', + 'responded' => 'green', + default => 'gray', + }; + $tags[] = ['label' => Deal::QUOTE_STATUSES[$deal->quote_status] ?? $deal->quote_status, 'color' => $color]; + } + if (in_array($deal->stage, ['scheduled', 'arrived'])) { + if ($deal->scheduled_at) { + $tags[] = ['label' => $deal->scheduled_at->format('d.m · H:i'), 'color' => 'blue']; + } + if ($deal->bay) { + $tags[] = ['label' => $deal->bay, 'color' => 'gray']; + } + } + + // Time line + $timeText = ''; + $timeIcon = 'clock'; + $overdue = false; + if ($deal->stage === 'contact' && $deal->quote_sent_at && in_array($deal->quote_status, ['sent', null])) { + $mins = $deal->quote_sent_at->diffInMinutes(now()); + if ($mins > 120) { + $overdue = true; + $timeText = 'Trimis acum ' . $this->humanDiff($deal->quote_sent_at); + } else { + $timeText = 'Trimis ' . $this->humanDiff($deal->quote_sent_at); + } + } elseif ($deal->stage === 'contact' && $deal->quote_status === 'seen' && $deal->quote_seen_at) { + $timeText = 'văzut ' . $this->humanDiff($deal->quote_seen_at); + } elseif (in_array($deal->stage, ['scheduled', 'arrived']) && $deal->confirmed_at) { + $timeText = 'Confirmat ' . (Deal::CONFIRM_CHANNELS[$deal->confirmed_via] ?? ''); + $timeIcon = 'check'; + } elseif (in_array($deal->stage, ['scheduled', 'arrived'])) { + $timeText = 'Neconfirmat'; + } else { + $timeText = $this->humanTime($deal->updated_at); + } + + return [ + 'kind' => 'deal', + 'id' => $deal->id, + 'key' => "deal:{$deal->id}", + 'code' => 'CIU-' . str_pad((string) $deal->id, 4, '0', STR_PAD_LEFT), + 'subject' => $deal->name, + 'plate' => $deal->vehicle?->plate ?: '', + 'client_name' => $deal->client?->name ?? '—', + 'phone' => $deal->client?->phone, + 'source' => $deal->source, + 'amount' => (float) $deal->price, + 'urgent' => (bool) $deal->urgent, + 'tags' => $tags, + 'time_text' => $timeText, + 'time_overdue' => $overdue, + 'time_icon' => $timeIcon, + 'assignee' => $this->assignee($deal->assignedTo), + 'progress_pct' => null, + 'has_pending_approval' => false, + 'edit_url' => route('filament.tenant.resources.deals.edit', ['record' => $deal->id]), + ]; + } + + private function woCard(WorkOrder $wo): array + { + $tags = []; + $tags[] = ['label' => "Fișă {$wo->number}", 'color' => 'purple']; + if ($wo->status === 'agreement') { + $tags[] = ['label' => 'Necesită aprobare', 'color' => 'amber']; + } + if ($wo->status === 'awaiting_parts') { + $tags[] = ['label' => 'Așteaptă piese', 'color' => 'amber']; + } + if ($wo->status === 'ready') { + $tags[] = ['label' => 'Gata', 'color' => 'green']; + if ($wo->pay_status !== 'paid') { + $tags[] = ['label' => 'Neachitat', 'color' => 'amber']; + } + } + if ($wo->status === 'done' && $wo->pay_status === 'paid') { + $tags[] = ['label' => '✓ Achitat', 'color' => 'green']; + } + + $progress = null; + if (in_array($wo->status, ['in_work', 'diagnosis', 'agreement', 'approved', 'awaiting_parts'])) { + $total = max(1, $wo->works()->count() + $wo->parts()->count()); + $done = $wo->works()->where('status', 'done')->count() + $wo->parts()->where('status', 'installed')->count(); + $progress = (int) round(100 * $done / $total); + } + + $timeText = ''; + $timeIcon = 'clock'; + $overdue = false; + if ($wo->status === 'ready') { + $minsSinceReady = $wo->updated_at?->diffInMinutes(now()) ?? 0; + if ($minsSinceReady > 30 && $wo->pay_status !== 'paid') { + $overdue = true; + $timeText = 'Notificat acum ' . $this->humanDiff($wo->updated_at); + $timeIcon = 'phone'; + } else { + $timeText = 'Notificat ' . $this->humanDiff($wo->updated_at); + $timeIcon = 'message'; + } + } elseif ($wo->eta_at) { + $timeText = 'ETA ' . $wo->eta_at->format('H:i') . ($progress ? " · {$progress}% gata" : ''); + $overdue = $wo->eta_at->isPast(); + } else { + $timeText = $this->humanTime($wo->opened_at); + } + + return [ + 'kind' => 'wo', + 'id' => $wo->id, + 'key' => "wo:{$wo->id}", + 'code' => $wo->number, + 'subject' => ($wo->vehicle?->make . ' ' . $wo->vehicle?->model) . ($wo->complaint ? ' — ' . str($wo->complaint)->limit(40) : ''), + 'plate' => $wo->vehicle?->plate ?: '', + 'client_name' => $wo->client?->name ?? '—', + 'phone' => $wo->client?->phone, + 'source' => null, + 'amount' => (float) $wo->total, + 'urgent' => $wo->urgency !== 'normal', + 'tags' => $tags, + 'time_text' => $timeText, + 'time_overdue' => $overdue, + 'time_icon' => $timeIcon, + 'assignee' => $this->assignee($wo->master), + 'progress_pct' => $progress, + 'has_pending_approval' => $wo->status === 'agreement', + 'edit_url' => route('filament.tenant.resources.work-orders.edit', ['record' => $wo->id]), + ]; + } + + private function leadDetail(?Lead $lead): ?array + { + if (! $lead) return null; + $card = $this->leadCard($lead); + return array_merge($card, [ + 'title' => $card['subject'] ?: ('Cerere · ' . $lead->name), + 'subtitle' => $card['code'] . ' · Cerere', + 'stages' => $this->stageStepper(0), + 'fields' => [ + 'Client' => $lead->name, + 'Telefon' => $lead->phone, + 'Email' => $lead->email, + 'Automobil' => trim(($lead->car ?: '') . ' ' . ($lead->model ?: '')), + 'Sursă' => Lead::SOURCES[$lead->source] ?? $lead->source, + 'Buget estimat' => $lead->budget ? number_format($lead->budget, 0, '.', ' ') . ' MDL' : null, + ], + 'note' => $lead->message ?? $lead->notes, + 'activity' => [], + 'wo' => null, + ]); + } + + private function dealDetail(?Deal $deal): ?array + { + if (! $deal) return null; + $card = $this->dealCard($deal); + $stageIdx = match ($deal->stage) { + 'new' => 0, + 'contact', 'agree' => 1, + 'scheduled', 'arrived' => 2, + default => 0, + }; + return array_merge($card, [ + 'title' => $deal->name, + 'subtitle' => $card['code'] . ' · ' . (Deal::STAGES[$deal->stage] ?? $deal->stage), + 'stages' => $this->stageStepper($stageIdx), + 'fields' => [ + 'Client' => $deal->client?->name, + 'Telefon' => $deal->client?->phone, + 'Automobil' => trim(($deal->vehicle?->make ?? '') . ' ' . ($deal->vehicle?->model ?? '') . ' · ' . ($deal->vehicle?->plate ?? '')), + 'Sursă' => $deal->source ? (Lead::SOURCES[$deal->source] ?? $deal->source) : null, + 'Responsabil' => $deal->assignedTo?->name, + 'Sumă' => number_format($deal->price, 0, '.', ' ') . ' MDL', + ], + 'note' => $deal->note, + 'activity' => $this->loadActivity($deal), + 'wo' => null, + ]); + } + + private function woDetail(?WorkOrder $wo): ?array + { + if (! $wo) return null; + $card = $this->woCard($wo); + $stageIdx = match ($wo->status) { + 'new', 'diagnosis', 'agreement', 'approved' => 3, + 'in_work', 'awaiting_parts' => 3, + 'ready' => 4, + 'done' => 5, + default => 3, + }; + $balanceDue = (float) $wo->total - (float) Payment::where('work_order_id', $wo->id)->sum('amount'); + return array_merge($card, [ + 'title' => ($wo->vehicle?->make . ' ' . $wo->vehicle?->model) . ($wo->complaint ? ' — ' . str($wo->complaint)->limit(60) : ''), + 'subtitle' => $wo->number . ' · ' . (WorkOrder::STATUSES[$wo->status] ?? $wo->status), + 'stages' => $this->stageStepper($stageIdx), + 'fields' => [ + 'Client' => $wo->client?->name, + 'Telefon' => $wo->client?->phone, + 'Automobil' => trim(($wo->vehicle?->make ?? '') . ' · ' . ($wo->vehicle?->plate ?? '')), + 'Responsabil' => $wo->master?->name, + 'Sumă' => number_format($wo->total, 0, '.', ' ') . ' MDL', + 'De achitat' => number_format(max(0, $balanceDue), 0, '.', ' ') . ' MDL', + ], + 'note' => $wo->diagnosis ?: $wo->complaint, + 'activity' => $this->loadActivity($wo), + 'wo' => [ + 'number' => $wo->number, + 'status_label' => WorkOrder::STATUSES[$wo->status] ?? $wo->status, + 'progress_pct' => $card['progress_pct'], + 'eta' => $wo->eta_at?->format('H:i'), + 'has_pending_approval' => $card['has_pending_approval'], + 'tracking_url' => $wo->trackingUrl(), + ], + ]); + } + + private function loadActivity($model): array + { + $items = []; + if (method_exists($model, 'activities')) { + foreach ($model->activities()->latest()->take(6)->get() as $a) { + $items[] = [ + 'icon' => match ($a->event ?? 'updated') { + 'created' => 'plus', + 'deleted' => 'trash', + default => 'edit', + }, + 'color' => 'blue', + 'text' => $a->description ?: ucfirst($a->event ?? 'actualizat'), + 'time' => $a->created_at?->diffForHumans(), + ]; + } + } + return $items; + } + + private function stageStepper(int $currentIdx): array + { + $labels = ['Cerere', 'Calcul.', 'Programat', 'În lucru', 'Gata', 'Achitat']; + $out = []; + foreach ($labels as $i => $label) { + $out[] = [ + 'label' => $label, + 'done' => $i < $currentIdx, + 'current' => $i === $currentIdx, + ]; + } + return $out; + } + + private function assignee($user): array + { + if (! $user) { + return ['initials' => '?', 'name' => '—', 'color' => 'gray']; + } + $parts = preg_split('/\s+/', trim($user->name ?? '?')); + $initials = strtoupper(substr($parts[0] ?? '?', 0, 1) . substr($parts[1] ?? '', 0, 1)); + // hash to a deterministic color + $colors = ['blue', 'green', 'purple', 'amber']; + $color = $colors[abs(crc32($user->id ?? 1)) % 4]; + return [ + 'initials' => $initials ?: '?', + 'name' => $this->shortName($user->name ?? ''), + 'color' => $color, + ]; + } + + private function shortName(string $name): string + { + $parts = preg_split('/\s+/', trim($name)); + if (count($parts) < 2) return $name; + return $parts[0] . ' ' . strtoupper(substr($parts[1], 0, 1)) . '.'; + } + + private function humanTime(?Carbon $dt): string + { + if (! $dt) return ''; + $mins = $dt->diffInMinutes(now()); + if ($mins < 60) return "acum $mins min"; + if ($mins < 60 * 24) return "acum " . round($mins / 60) . "h"; + return $dt->format('d.m'); + } + + private function humanDiff(?Carbon $dt): string + { + if (! $dt) return ''; + $mins = $dt->diffInMinutes(now()); + if ($mins < 60) return "$mins min"; + if ($mins < 60 * 24) return round($mins / 60) . "h"; + return $dt->format('d.m'); + } + + private function createWorkOrderFromDeal(Deal $deal): WorkOrder + { + $wo = WorkOrder::create([ + 'number' => WorkOrder::generateNumber($deal->company_id), + 'client_id' => $deal->client_id, + 'vehicle_id' => $deal->vehicle_id, + 'master_id' => $deal->assigned_to, + 'deal_id' => $deal->id, + 'opened_at' => today(), + 'status' => 'in_work', + 'total' => $deal->price ?: 0, + 'complaint' => $deal->note ?: $deal->name, + ]); + $deal->update(['stage' => 'in_work']); + return $wo; + } + + private function notify(string $text): void + { + Notification::make()->title($text)->success()->send(); + } +} diff --git a/app/Filament/Tenant/Resources/DealResource.php b/app/Filament/Tenant/Resources/DealResource.php index cbc1f28..0cd5acb 100644 --- a/app/Filament/Tenant/Resources/DealResource.php +++ b/app/Filament/Tenant/Resources/DealResource.php @@ -22,10 +22,15 @@ class DealResource extends Resource protected static string|\BackedEnum|null $navigationIcon = 'heroicon-o-funnel'; - protected static ?string $navigationLabel = 'Pipeline'; + protected static ?string $navigationLabel = 'Pipeline (tabel)'; protected static string|\UnitEnum|null $navigationGroup = 'CRM'; + public static function shouldRegisterNavigation(): bool + { + return false; // PipelineBoard page is the canonical entry; this resource keeps CRUD routes for edit/create. + } + protected static ?string $modelLabel = 'deal'; protected static ?string $pluralModelLabel = 'deal-uri'; diff --git a/app/Models/Tenant/Deal.php b/app/Models/Tenant/Deal.php index 3cebeef..8f23b8e 100644 --- a/app/Models/Tenant/Deal.php +++ b/app/Models/Tenant/Deal.php @@ -14,7 +14,7 @@ class Deal extends Model public const STAGES = [ 'new' => 'Nou', - 'contact' => 'Contact', + 'contact' => 'Calculație', 'agree' => 'Aprobare', 'scheduled' => 'Programat', 'arrived' => 'Sosit', @@ -23,18 +23,48 @@ class Deal extends Model 'lost' => 'Pierdut', ]; + public const QUOTE_STATUSES = [ + 'pending' => 'În așteptare', + 'sent' => 'Trimis · fără răspuns', + 'seen' => 'Văzut ✓', + 'responded' => 'A răspuns', + ]; + + public const CONFIRM_CHANNELS = [ + 'whatsapp' => 'WhatsApp', + 'sms' => 'SMS', + 'telegram' => 'Telegram', + 'call' => 'Apel', + ]; + protected $fillable = [ 'company_id', 'client_id', 'vehicle_id', 'name', 'price', 'stage', 'source', 'note', 'assigned_to', 'won_at', 'lost_at', 'lost_reason', + 'urgent', 'quote_sent_at', 'quote_status', 'quote_seen_at', + 'scheduled_at', 'bay', 'confirmed_at', 'confirmed_via', + 'last_action_at', ]; protected $casts = [ 'price' => 'decimal:2', 'won_at' => 'datetime', 'lost_at' => 'datetime', + 'urgent' => 'boolean', + 'quote_sent_at' => 'datetime', + 'quote_seen_at' => 'datetime', + 'scheduled_at' => 'datetime', + 'confirmed_at' => 'datetime', + 'last_action_at' => 'datetime', ]; + protected static function booted(): void + { + static::saving(function (self $deal) { + $deal->last_action_at = now(); + }); + } + public function client(): BelongsTo { return $this->belongsTo(Client::class); diff --git a/database/migrations/2026_06_04_000001_extend_deals_for_pipeline_board.php b/database/migrations/2026_06_04_000001_extend_deals_for_pipeline_board.php new file mode 100644 index 0000000..1a3b516 --- /dev/null +++ b/database/migrations/2026_06_04_000001_extend_deals_for_pipeline_board.php @@ -0,0 +1,52 @@ +boolean('urgent')->default(false)->after('source'); + } + if (! Schema::hasColumn('deals', 'quote_sent_at')) { + $t->timestamp('quote_sent_at')->nullable()->after('urgent'); + } + if (! Schema::hasColumn('deals', 'quote_status')) { + $t->string('quote_status', 16)->nullable()->after('quote_sent_at'); + } + if (! Schema::hasColumn('deals', 'quote_seen_at')) { + $t->timestamp('quote_seen_at')->nullable()->after('quote_status'); + } + if (! Schema::hasColumn('deals', 'scheduled_at')) { + $t->timestamp('scheduled_at')->nullable()->after('quote_seen_at'); + } + if (! Schema::hasColumn('deals', 'bay')) { + $t->string('bay', 32)->nullable()->after('scheduled_at'); + } + if (! Schema::hasColumn('deals', 'confirmed_at')) { + $t->timestamp('confirmed_at')->nullable()->after('bay'); + } + if (! Schema::hasColumn('deals', 'confirmed_via')) { + $t->string('confirmed_via', 16)->nullable()->after('confirmed_at'); + } + if (! Schema::hasColumn('deals', 'last_action_at')) { + $t->timestamp('last_action_at')->nullable()->after('confirmed_via'); + } + }); + } + + public function down(): void + { + Schema::table('deals', function (Blueprint $t) { + foreach (['urgent', 'quote_sent_at', 'quote_status', 'quote_seen_at', 'scheduled_at', 'bay', 'confirmed_at', 'confirmed_via', 'last_action_at'] as $col) { + if (Schema::hasColumn('deals', $col)) { + $t->dropColumn($col); + } + } + }); + } +}; diff --git a/resources/views/filament/tenant/pages/kanban.blade.php b/resources/views/filament/tenant/pages/kanban.blade.php deleted file mode 100644 index b6454eb..0000000 --- a/resources/views/filament/tenant/pages/kanban.blade.php +++ /dev/null @@ -1,92 +0,0 @@ - - @php $columns = $this->getColumns(); @endphp - - - - {{-- Live: poll fallback every 5s + instant refresh on WebSocket event --}} -
-
- @foreach ($columns as $status => $col) -
-
- {{ $col['label'] }} - {{ $col['count'] }} -
-
- @forelse ($col['cards'] as $wo) -
-
{{ $wo->number }}
-
{{ $wo->client?->name ?? '—' }}
-
- {{ $wo->vehicle?->make }} {{ $wo->vehicle?->model }} - @if ($wo->vehicle?->plate) - [{{ $wo->vehicle->plate }}] - @endif -
-
- {{ $wo->master?->name ?? '—' }} - {{ number_format((float)$wo->total, 0, '.', ' ') }} MDL -
- Deschide → -
- @empty -
Gol
- @endforelse -
-
- @endforeach -
- -
- 💡 Drag-drop carduri între coloane pentru a schimba statusul. -
-
-
diff --git a/resources/views/filament/tenant/pages/pipeline-board.blade.php b/resources/views/filament/tenant/pages/pipeline-board.blade.php new file mode 100644 index 0000000..71024aa --- /dev/null +++ b/resources/views/filament/tenant/pages/pipeline-board.blade.php @@ -0,0 +1,398 @@ + +@php + $columns = $this->getColumns(); + $stats = $this->getStats(); + $detail = $this->getOpenCardDetail(); +@endphp + + + +
+ {{-- STAT STRIP --}} +
+
+ {{ $stats['active'] }} + Total deals active +
+
+
+ {{ number_format($stats['pipeline_mdl'], 0, '.', ' ') }} + MDL pipeline total +
+
+
+ {{ number_format($stats['closed_today_mdl'], 0, '.', ' ') }} + MDL închise azi +
+
+
+ {{ $stats['need_action'] }} + Necesită acțiune +
+
+
+ {{ $stats['conversion_rate'] }}% + Rata conversie (30z) +
+
+
+ {{ $stats['overdue'] }} + Depășit termen +
+
+ + {{-- FILTER BAR --}} +
+
+
📋 Kanban
+
≡ Listă
+
+
+
Toate
+
👤 Ale mele
+
⚠ Urgente
+
📅 Azi
+
Pipeline: {{ number_format($stats['pipeline_mdl'], 0, '.', ' ') }} MDL · {{ $stats['active'] }} deals
+
+ + {{-- KANBAN --}} +
+ @foreach ($columns as $colKey => $col) +
+
+
+
+
+ {{ $col['label'] }} +
+ {{ $col['count'] }} +
+
{{ number_format($col['sum'], 0, '.', ' ') }} MDL
+
+
+ @forelse ($col['cards'] as $card) +
+ @if ($card['urgent']) +
+ @endif +
{{ $card['code'] }}
+
{{ $card['subject'] }}
+
🚗 {{ $card['plate'] }} · {{ $card['client_name'] }}
+ @if (!empty($card['tags'])) +
+ @foreach ($card['tags'] as $tag) + {{ $tag['label'] }} + @endforeach +
+ @endif + + @if (!is_null($card['progress_pct'])) +
+ @endif + @if ($card['time_text']) +
+ @if ($card['time_icon']==='check')✓@elseif($card['time_icon']==='phone')📞@elseif($card['time_icon']==='message')💬@else⏱@endif + {{ $card['time_text'] }} +
+ @endif +
+ @empty +
Gol
+ @endforelse +
+
+ @endforeach +
+ + {{-- LIST VIEW --}} +
+ + + + @foreach (['#', 'Subiect', 'Client', 'Auto', 'Etapă', 'Sumă', 'Responsabil', 'Stare timp'] as $h) + + @endforeach + + + + @foreach ($columns as $colKey => $col) + @foreach ($col['cards'] as $card) + + + + + + + + + + + @endforeach + @endforeach + +
{{ $h }}
{{ $card['code'] }}{{ $card['subject'] }}{{ $card['client_name'] }}{{ $card['plate'] }}{{ $col['label'] }}{{ number_format($card['amount'], 0, '.', ' ') }} MDL{{ $card['assignee']['name'] }}{{ $card['time_text'] }}
+
+ + {{-- DETAIL PANEL --}} +
+
+ @if ($detail) +
+
+
{{ $detail['title'] }}
+
{{ $detail['subtitle'] }}
+
+
+
+
+
+
Progres etapă
+
+ @foreach ($detail['stages'] as $st) +
+ @endforeach +
+
+ @foreach ($detail['stages'] as $st) +
{{ $st['label'] }}
+ @endforeach +
+
+ +
+
+ @foreach ($detail['fields'] as $label => $value) + @if ($value) +
+
{{ $label }}
+
{{ $value }}
+
+ @endif + @endforeach +
+ @if ($detail['note']) +
+
Notițe / Reclamație
+
{{ $detail['note'] }}
+
+ @endif +
+ + @if (!empty($detail['wo'])) +
+
Fișă de lucru
+
+
+
{{ $detail['wo']['number'] }} · {{ $detail['wo']['status_label'] }}
+
+ @if (!is_null($detail['wo']['progress_pct'])){{ $detail['wo']['progress_pct'] }}% finalizat @endif + @if ($detail['wo']['eta']) · ETA {{ $detail['wo']['eta'] }} @endif +
+
+ ↗ Deschide +
+ @if (!empty($detail['wo']['has_pending_approval'])) +
+
⚠ Necesită aprobare client
+
Deschide fișa pentru aprobare lucrare/piesă.
+
+ @endif + @if (!empty($detail['wo']['tracking_url'])) +
+ Link tracking client: {{ $detail['wo']['tracking_url'] }} +
+ @endif +
+ @endif + + @if (!empty($detail['activity'])) +
+
Activitate recentă
+ @foreach ($detail['activity'] as $a) +
+
+
+
{{ $a['text'] }}
+
{{ $a['time'] }}
+
+
+ @endforeach +
+ @endif + + @if (!empty($detail['phone'])) +
+
Acțiuni rapide
+ +
+ @endif +
+ @endif +
+
+
diff --git a/tests/Feature/PipelineBoardTest.php b/tests/Feature/PipelineBoardTest.php new file mode 100644 index 0000000..e1eb3ce --- /dev/null +++ b/tests/Feature/PipelineBoardTest.php @@ -0,0 +1,174 @@ + '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 + } +}