feat: rich Pipeline board — unified Lead/Deal/WO Kanban with SLA + drag-drop transitions

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>
This commit is contained in:
2026-06-04 20:02:44 +00:00
parent 0620635abb
commit 3603c0e43b
8 changed files with 1316 additions and 152 deletions
-58
View File
@@ -1,58 +0,0 @@
<?php
namespace App\Filament\Tenant\Pages;
use App\Models\Tenant\WorkOrder;
use Filament\Notifications\Notification;
use Filament\Pages\Page;
class Kanban extends Page
{
protected static string|\BackedEnum|null $navigationIcon = 'heroicon-o-view-columns';
protected static ?string $navigationLabel = 'Kanban';
protected static string|\UnitEnum|null $navigationGroup = 'Service';
protected static ?int $navigationSort = 31;
protected static ?string $title = 'Kanban — Fișe de lucru';
protected string $view = 'filament.tenant.pages.kanban';
public function getColumns(): array
{
$statuses = ['new', 'diagnosis', 'agreement', 'in_work', 'awaiting_parts', 'ready'];
$byStatus = WorkOrder::whereIn('status', $statuses)
->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();
}
}
+655
View File
@@ -0,0 +1,655 @@
<?php
namespace App\Filament\Tenant\Pages;
use App\Models\Tenant\Deal;
use App\Models\Tenant\Lead;
use App\Models\Tenant\Payment;
use App\Models\Tenant\WorkOrder;
use Filament\Notifications\Notification;
use Filament\Pages\Page;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\DB;
class PipelineBoard extends Page
{
protected static string|\BackedEnum|null $navigationIcon = 'heroicon-o-funnel';
protected static ?string $navigationLabel = 'Pipeline';
protected static string|\UnitEnum|null $navigationGroup = 'CRM';
protected static ?int $navigationSort = 5;
protected static ?string $title = 'Pipeline';
protected string $view = 'filament.tenant.pages.pipeline-board';
public string $activeFilter = 'all'; // all | mine | urgent | today
public ?string $openCardKey = null; // "lead:5" / "deal:8" / "wo:12"
public const COLUMNS = [
'request' => ['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();
}
}
@@ -22,10 +22,15 @@ class DealResource extends Resource
protected static string|\BackedEnum|null $navigationIcon = 'heroicon-o-funnel'; 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'; 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 $modelLabel = 'deal';
protected static ?string $pluralModelLabel = 'deal-uri'; protected static ?string $pluralModelLabel = 'deal-uri';
+31 -1
View File
@@ -14,7 +14,7 @@ class Deal extends Model
public const STAGES = [ public const STAGES = [
'new' => 'Nou', 'new' => 'Nou',
'contact' => 'Contact', 'contact' => 'Calculație',
'agree' => 'Aprobare', 'agree' => 'Aprobare',
'scheduled' => 'Programat', 'scheduled' => 'Programat',
'arrived' => 'Sosit', 'arrived' => 'Sosit',
@@ -23,18 +23,48 @@ class Deal extends Model
'lost' => 'Pierdut', '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 = [ protected $fillable = [
'company_id', 'client_id', 'vehicle_id', 'company_id', 'client_id', 'vehicle_id',
'name', 'price', 'stage', 'source', 'note', 'name', 'price', 'stage', 'source', 'note',
'assigned_to', 'won_at', 'lost_at', 'lost_reason', '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 = [ protected $casts = [
'price' => 'decimal:2', 'price' => 'decimal:2',
'won_at' => 'datetime', 'won_at' => 'datetime',
'lost_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 public function client(): BelongsTo
{ {
return $this->belongsTo(Client::class); return $this->belongsTo(Client::class);
@@ -0,0 +1,52 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('deals', function (Blueprint $t) {
if (! Schema::hasColumn('deals', 'urgent')) {
$t->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);
}
}
});
}
};
@@ -1,92 +0,0 @@
<x-filament-panels::page>
@php $columns = $this->getColumns(); @endphp
<style>
.kb-board { display: flex; gap: 12px; overflow-x: auto; padding-bottom: 1rem; }
.kb-col {
width: 290px; flex-shrink: 0; padding: 12px;
background: #f1f5f9; border-radius: 10px;
border: 2px solid transparent;
transition: border-color .15s;
}
.dark .kb-col { background: #1f2937; }
.kb-col.over { border-color: #3b82f6; }
.kb-col-head { display: flex; justify-content: space-between; align-items: center; margin-bottom: 12px; }
.kb-col-title { font-size: 14px; font-weight: 600; }
.kb-col-count { font-size: 11px; padding: 2px 8px; background: #e2e8f0; border-radius: 999px; }
.dark .kb-col-count { background: #374151; }
.kb-list { display: flex; flex-direction: column; gap: 8px; min-height: 80px; }
.kb-card {
background: #fff; border: 1px solid #e5e7eb; border-radius: 8px;
padding: 12px; cursor: move;
transition: box-shadow .15s, opacity .15s;
}
.dark .kb-card { background: #111827; border-color: #374151; }
.kb-card:hover { box-shadow: 0 4px 8px rgba(0,0,0,0.08); }
.kb-card.dragging { opacity: 0.4; }
.kb-num { font-size: 11px; font-family: monospace; color: #6b7280; }
.kb-title { font-size: 14px; font-weight: 600; margin-top: 4px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.kb-sub { font-size: 12px; color: #6b7280; margin-top: 4px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.kb-foot { display: flex; justify-content: space-between; align-items: center; margin-top: 8px; font-size: 11px; }
.kb-link { display: block; margin-top: 8px; font-size: 11px; color: #3b82f6; text-decoration: none; }
.kb-link:hover { text-decoration: underline; }
.kb-empty { font-size: 11px; color: #9ca3af; text-align: center; padding: 16px 0; }
</style>
{{-- Live: poll fallback every 5s + instant refresh on WebSocket event --}}
<div x-data="{ dragId: null }"
wire:poll.5s
x-on:autocrm:wo-updated.window="$wire.$refresh()">
<div class="kb-board">
@foreach ($columns as $status => $col)
<div
class="kb-col"
@dragover.prevent="$el.classList.add('over')"
@dragleave="$el.classList.remove('over')"
@drop.prevent="
$el.classList.remove('over');
if (dragId) {
$wire.moveCard(parseInt(dragId), '{{ $status }}');
dragId = null;
}
"
>
<div class="kb-col-head">
<span class="kb-col-title">{{ $col['label'] }}</span>
<span class="kb-col-count">{{ $col['count'] }}</span>
</div>
<div class="kb-list">
@forelse ($col['cards'] as $wo)
<div
class="kb-card"
draggable="true"
@dragstart="dragId = '{{ $wo->id }}'; $el.classList.add('dragging')"
@dragend="$el.classList.remove('dragging')"
>
<div class="kb-num">{{ $wo->number }}</div>
<div class="kb-title">{{ $wo->client?->name ?? '—' }}</div>
<div class="kb-sub">
{{ $wo->vehicle?->make }} {{ $wo->vehicle?->model }}
@if ($wo->vehicle?->plate)
[{{ $wo->vehicle->plate }}]
@endif
</div>
<div class="kb-foot">
<span style="color:#6b7280">{{ $wo->master?->name ?? '—' }}</span>
<span style="font-weight:600">{{ number_format((float)$wo->total, 0, '.', ' ') }} MDL</span>
</div>
<a class="kb-link" href="{{ route('filament.tenant.resources.work-orders.edit', ['record' => $wo->id]) }}">Deschide </a>
</div>
@empty
<div class="kb-empty">Gol</div>
@endforelse
</div>
</div>
@endforeach
</div>
<div style="font-size: 11px; color: #6b7280; margin-top: 12px;">
💡 Drag-drop carduri între coloane pentru a schimba statusul.
</div>
</div>
</x-filament-panels::page>
@@ -0,0 +1,398 @@
<x-filament-panels::page>
@php
$columns = $this->getColumns();
$stats = $this->getStats();
$detail = $this->getOpenCardDetail();
@endphp
<style>
:root {
--pb-bg: #F7F7F5;
--pb-surface: #FFFFFF;
--pb-border: rgba(0,0,0,0.08);
--pb-border-md: rgba(0,0,0,0.14);
--pb-text: #111;
--pb-text-2: #555;
--pb-text-3: #999;
--pb-blue: #1C6EF2;
--pb-blue-bg: #EBF2FF;
--pb-blue-text: #1347A8;
--pb-green: #16A34A;
--pb-green-bg: #DCFCE7;
--pb-green-text: #166534;
--pb-amber: #D97706;
--pb-amber-bg: #FEF3C7;
--pb-amber-text: #92400E;
--pb-red: #DC2626;
--pb-red-bg: #FEE2E2;
--pb-red-text: #991B1B;
--pb-purple: #7C3AED;
--pb-purple-bg: #EDE9FE;
--pb-purple-text: #4C1D95;
--pb-gray-bg: #F1F0EE;
--pb-gray-text: #444;
}
.dark {
--pb-bg: #0f172a;
--pb-surface: #1f2937;
--pb-border: rgba(255,255,255,0.08);
--pb-border-md: rgba(255,255,255,0.18);
--pb-text: #f1f5f9;
--pb-text-2: #cbd5e1;
--pb-text-3: #94a3b8;
--pb-blue-bg: #1e293b;
--pb-blue-text: #93c5fd;
--pb-green-bg: #14532d;
--pb-green-text: #86efac;
--pb-amber-bg: #422006;
--pb-amber-text: #fcd34d;
--pb-red-bg: #450a0a;
--pb-red-text: #fca5a5;
--pb-purple-bg: #3b0764;
--pb-purple-text: #c4b5fd;
--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; }
.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-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); }
.pb-chip { display:flex; align-items:center; gap:4px; padding:4px 10px; border-radius:20px; font-size:11px; font-weight:500; cursor:pointer; border:1px solid var(--pb-border); background:var(--pb-surface); color:var(--pb-text-2); }
.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-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-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-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-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); }
.pb-deal.dragging { opacity:0.4; }
.pb-deal-urgent { position:absolute; top:0; left:0; width:3px; height:100%; background:var(--pb-red); border-radius:10px 0 0 10px; }
.pb-deal-id { font-size:10px; color:var(--pb-text-3); font-weight:500; letter-spacing:.3px; margin-bottom:4px; }
.pb-deal-subject { font-size:12px; font-weight:600; margin-bottom:3px; line-height:1.3; }
.pb-deal-car { font-size:11px; color:var(--pb-text-2); display:flex; align-items:center; gap:4px; margin-bottom:6px; }
.pb-deal-meta { display:flex; align-items:center; gap:6px; flex-wrap:wrap; margin-bottom:8px; }
.pb-tag { font-size:10px; font-weight:500; padding:2px 7px; border-radius:4px; white-space:nowrap; }
.pb-tag-blue { background:var(--pb-blue-bg); color:var(--pb-blue-text); }
.pb-tag-green { background:var(--pb-green-bg); color:var(--pb-green-text); }
.pb-tag-amber { background:var(--pb-amber-bg); color:var(--pb-amber-text); }
.pb-tag-red { background:var(--pb-red-bg); color:var(--pb-red-text); }
.pb-tag-gray { background:var(--pb-gray-bg); color:var(--pb-gray-text); }
.pb-tag-purple { background:var(--pb-purple-bg); color:var(--pb-purple-text); }
.pb-deal-footer { display:flex; align-items:center; justify-content:space-between; }
.pb-deal-assignee { display:flex; align-items:center; gap:5px; }
.pb-av { width:20px; height:20px; border-radius:50%; display:flex; align-items:center; justify-content:center; font-size:8px; font-weight:700; flex-shrink:0; }
.pb-av-blue { background:var(--pb-blue-bg); color:var(--pb-blue-text); }
.pb-av-green { background:var(--pb-green-bg); color:var(--pb-green-text); }
.pb-av-purple { background:var(--pb-purple-bg); color:var(--pb-purple-text); }
.pb-av-amber { background:var(--pb-amber-bg); color:var(--pb-amber-text); }
.pb-av-gray { background:var(--pb-gray-bg); color:var(--pb-gray-text); }
.pb-deal-name { font-size:11px; color:var(--pb-text-2); }
.pb-deal-amount { font-size:12px; font-weight:600; }
.pb-deal-time { font-size:10px; color:var(--pb-text-3); margin-top:5px; display:flex; align-items:center; gap:3px; }
.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; }
.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.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-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; }
.pb-two-cols { display:grid; grid-template-columns:1fr 1fr; gap:12px; }
.pb-panel-section { margin-top:16px; padding-top:16px; border-top:1px solid var(--pb-border); }
.pb-panel-sec-title { font-size:11px; font-weight:600; color:var(--pb-text-3); text-transform:uppercase; letter-spacing:.5px; margin-bottom:10px; }
.pb-stage-stepper { display:flex; gap:4px; }
.pb-stage-step { flex:1; height:4px; border-radius:2px; background:var(--pb-bg); }
.pb-stage-step.done { background:var(--pb-green); }
.pb-stage-step.current { background:var(--pb-blue); }
.pb-stage-labels { display:flex; gap:4px; margin-top:5px; }
.pb-stage-lbl { flex:1; font-size:9px; color:var(--pb-text-3); text-align:center; }
.pb-stage-lbl.current { color:var(--pb-blue); font-weight:600; }
.pb-activity-item { display:flex; gap:10px; margin-bottom:10px; }
.pb-act-icon { width:24px; height:24px; border-radius:6px; flex-shrink:0; display:flex; align-items:center; justify-content:center; font-size:12px; }
.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:hover { background:var(--pb-bg); }
.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; }
</style>
<div class="pb-shell" x-data="{ view: 'kanban', dragKey: null }">
{{-- STAT STRIP --}}
<div class="pb-stat-strip">
<div class="pb-stat-item">
<span class="pb-stat-val">{{ $stats['active'] }}</span>
<span class="pb-stat-lbl">Total deals active</span>
</div>
<div class="pb-stat-sep"></div>
<div class="pb-stat-item">
<span class="pb-stat-val" style="color:var(--pb-blue)">{{ number_format($stats['pipeline_mdl'], 0, '.', ' ') }}</span>
<span class="pb-stat-lbl">MDL pipeline total</span>
</div>
<div class="pb-stat-sep"></div>
<div class="pb-stat-item">
<span class="pb-stat-val" style="color:var(--pb-green)">{{ number_format($stats['closed_today_mdl'], 0, '.', ' ') }}</span>
<span class="pb-stat-lbl">MDL închise azi</span>
</div>
<div class="pb-stat-sep"></div>
<div class="pb-stat-item">
<span class="pb-stat-val" style="color:var(--pb-amber)">{{ $stats['need_action'] }}</span>
<span class="pb-stat-lbl">Necesită acțiune</span>
</div>
<div class="pb-stat-sep"></div>
<div class="pb-stat-item">
<span class="pb-stat-val">{{ $stats['conversion_rate'] }}%</span>
<span class="pb-stat-lbl">Rata conversie (30z)</span>
</div>
<div class="pb-stat-sep"></div>
<div class="pb-stat-item">
<span class="pb-stat-val" style="color:var(--pb-red)">{{ $stats['overdue'] }}</span>
<span class="pb-stat-lbl">Depășit termen</span>
</div>
</div>
{{-- FILTER BAR --}}
<div class="pb-filter-bar">
<div class="pb-view-toggle">
<div class="pb-vt-btn" :class="view==='kanban' && 'active'" @click="view='kanban'">📋 Kanban</div>
<div class="pb-vt-btn" :class="view==='list' && 'active'" @click="view='list'"> Listă</div>
</div>
<div class="pb-filter-sep"></div>
<div class="pb-chip {{ $activeFilter === 'all' ? 'active' : '' }}" wire:click="setFilter('all')">Toate</div>
<div class="pb-chip {{ $activeFilter === 'mine' ? 'active' : '' }}" wire:click="setFilter('mine')">👤 Ale mele</div>
<div class="pb-chip {{ $activeFilter === 'urgent' ? 'active' : '' }}" wire:click="setFilter('urgent')" style="color:var(--pb-red-text)"> Urgente</div>
<div class="pb-chip {{ $activeFilter === 'today' ? 'active' : '' }}" wire:click="setFilter('today')">📅 Azi</div>
<div class="pb-total">Pipeline: <strong>{{ number_format($stats['pipeline_mdl'], 0, '.', ' ') }} MDL</strong> · <strong>{{ $stats['active'] }} deals</strong></div>
</div>
{{-- KANBAN --}}
<div class="pb-board" x-show="view==='kanban'" wire:poll.10s>
@foreach ($columns as $colKey => $col)
<div class="pb-col"
@dragover.prevent="$el.classList.add('over')"
@dragleave="$el.classList.remove('over')"
@drop.prevent="
$el.classList.remove('over');
if (dragKey) {
$wire.moveCard(dragKey, '{{ $colKey }}');
dragKey = null;
}
">
<div class="pb-col-head">
<div class="pb-col-head-top">
<div class="pb-col-name">
<div class="pb-col-dot" style="background:{{ $col['color'] }}"></div>
{{ $col['label'] }}
</div>
<span class="pb-col-count">{{ $col['count'] }}</span>
</div>
<div class="pb-col-sum"><strong>{{ number_format($col['sum'], 0, '.', ' ') }} MDL</strong></div>
</div>
<div class="pb-col-body">
@forelse ($col['cards'] as $card)
<div class="pb-deal"
draggable="true"
wire:click="openCard('{{ $card['key'] }}')"
@dragstart="dragKey='{{ $card['key'] }}'; $el.classList.add('dragging')"
@dragend="$el.classList.remove('dragging')">
@if ($card['urgent'])
<div class="pb-deal-urgent"></div>
@endif
<div class="pb-deal-id">{{ $card['code'] }}</div>
<div class="pb-deal-subject">{{ $card['subject'] }}</div>
<div class="pb-deal-car">🚗 {{ $card['plate'] }} · {{ $card['client_name'] }}</div>
@if (!empty($card['tags']))
<div class="pb-deal-meta">
@foreach ($card['tags'] as $tag)
<span class="pb-tag pb-tag-{{ $tag['color'] }}">{{ $tag['label'] }}</span>
@endforeach
</div>
@endif
<div class="pb-deal-footer">
<div class="pb-deal-assignee">
<div class="pb-av pb-av-{{ $card['assignee']['color'] }}">{{ $card['assignee']['initials'] }}</div>
<span class="pb-deal-name">{{ $card['assignee']['name'] }}</span>
</div>
<span class="pb-deal-amount">{{ number_format($card['amount'], 0, '.', ' ') }} MDL</span>
</div>
@if (!is_null($card['progress_pct']))
<div class="pb-progress-bar"><div class="pb-progress-fill" style="width:{{ $card['progress_pct'] }}%"></div></div>
@endif
@if ($card['time_text'])
<div class="pb-deal-time {{ $card['time_overdue'] ? 'overdue' : '' }}">
@if ($card['time_icon']==='check')@elseif($card['time_icon']==='phone')📞@elseif($card['time_icon']==='message')💬@else@endif
{{ $card['time_text'] }}
</div>
@endif
</div>
@empty
<div class="pb-empty-col">Gol</div>
@endforelse
</div>
</div>
@endforeach
</div>
{{-- LIST VIEW --}}
<div x-show="view==='list'" style="padding:16px 20px;" x-cloak>
<table style="width:100%; border-collapse:collapse; background:var(--pb-surface); border:1px solid var(--pb-border); border-radius:10px;">
<thead>
<tr>
@foreach (['#', 'Subiect', 'Client', 'Auto', 'Etapă', 'Sumă', 'Responsabil', 'Stare timp'] as $h)
<th style="text-align:left; padding:8px 12px; font-size:10px; font-weight:600; color:var(--pb-text-3); text-transform:uppercase; border-bottom:1px solid var(--pb-border);">{{ $h }}</th>
@endforeach
</tr>
</thead>
<tbody>
@foreach ($columns as $colKey => $col)
@foreach ($col['cards'] as $card)
<tr wire:click="openCard('{{ $card['key'] }}')" style="cursor:pointer; border-bottom:1px solid var(--pb-border);">
<td style="padding:10px 12px; font-size:12px; color:var(--pb-text-3);">{{ $card['code'] }}</td>
<td style="padding:10px 12px; font-size:13px; font-weight:500;">{{ $card['subject'] }}</td>
<td style="padding:10px 12px; font-size:12px;">{{ $card['client_name'] }}</td>
<td style="padding:10px 12px; font-size:12px;">{{ $card['plate'] }}</td>
<td style="padding:10px 12px;"><span class="pb-tag pb-tag-gray">{{ $col['label'] }}</span></td>
<td style="padding:10px 12px; font-size:12px; font-weight:600;">{{ number_format($card['amount'], 0, '.', ' ') }} MDL</td>
<td style="padding:10px 12px; font-size:12px;">{{ $card['assignee']['name'] }}</td>
<td style="padding:10px 12px; font-size:11px; color:{{ $card['time_overdue'] ? 'var(--pb-red)' : 'var(--pb-text-3)' }};">{{ $card['time_text'] }}</td>
</tr>
@endforeach
@endforeach
</tbody>
</table>
</div>
{{-- DETAIL PANEL --}}
<div class="pb-overlay {{ $openCardKey ? 'open' : '' }}" wire:click="closeCard()"></div>
<div class="pb-panel {{ $openCardKey ? 'open' : '' }}">
@if ($detail)
<div class="pb-panel-head">
<div>
<div class="pb-panel-title">{{ $detail['title'] }}</div>
<div class="pb-panel-id">{{ $detail['subtitle'] }}</div>
</div>
<div class="pb-close-btn" wire:click="closeCard()"></div>
</div>
<div class="pb-panel-body">
<div class="pb-panel-section" style="margin-top:0; padding-top:0; border-top:none;">
<div class="pb-panel-sec-title">Progres etapă</div>
<div class="pb-stage-stepper">
@foreach ($detail['stages'] as $st)
<div class="pb-stage-step {{ $st['done'] ? 'done' : '' }} {{ $st['current'] ? 'current' : '' }}"></div>
@endforeach
</div>
<div class="pb-stage-labels">
@foreach ($detail['stages'] as $st)
<div class="pb-stage-lbl {{ $st['current'] ? 'current' : '' }}">{{ $st['label'] }}</div>
@endforeach
</div>
</div>
<div class="pb-panel-section">
<div class="pb-two-cols">
@foreach ($detail['fields'] as $label => $value)
@if ($value)
<div class="pb-pfield">
<div class="pb-pfield-label">{{ $label }}</div>
<div class="pb-pfield-val" @if($label==='Telefon') style="color:var(--pb-blue)" @endif>{{ $value }}</div>
</div>
@endif
@endforeach
</div>
@if ($detail['note'])
<div class="pb-pfield">
<div class="pb-pfield-label">Notițe / Reclamație</div>
<div style="font-size:12px; color:var(--pb-text-2); white-space:pre-wrap;">{{ $detail['note'] }}</div>
</div>
@endif
</div>
@if (!empty($detail['wo']))
<div class="pb-panel-section">
<div class="pb-panel-sec-title">Fișă de lucru</div>
<div style="background:var(--pb-purple-bg); border:1px solid #DDD6FE; border-radius:6px; padding:10px 12px; display:flex; align-items:center; justify-content:space-between;">
<div>
<div style="font-size:12px; font-weight:600; color:var(--pb-purple-text);">{{ $detail['wo']['number'] }} · {{ $detail['wo']['status_label'] }}</div>
<div style="font-size:11px; color:var(--pb-purple-text); opacity:.8; margin-top:2px;">
@if (!is_null($detail['wo']['progress_pct'])){{ $detail['wo']['progress_pct'] }}% finalizat @endif
@if ($detail['wo']['eta']) · ETA {{ $detail['wo']['eta'] }} @endif
</div>
</div>
<a class="pb-quick-btn" style="font-size:11px;" href="{{ $detail['edit_url'] }}"> Deschide</a>
</div>
@if (!empty($detail['wo']['has_pending_approval']))
<div style="margin-top:8px; background:var(--pb-amber-bg); border:1px solid #FDE68A; border-radius:6px; padding:8px 12px;">
<div style="font-size:11px; font-weight:600; color:var(--pb-amber-text);"> Necesită aprobare client</div>
<div style="font-size:11px; color:var(--pb-amber-text); margin-top:2px;">Deschide fișa pentru aprobare lucrare/piesă.</div>
</div>
@endif
@if (!empty($detail['wo']['tracking_url']))
<div style="margin-top:6px; font-size:11px;">
Link tracking client: <a href="{{ $detail['wo']['tracking_url'] }}" target="_blank" style="color:var(--pb-blue);">{{ $detail['wo']['tracking_url'] }}</a>
</div>
@endif
</div>
@endif
@if (!empty($detail['activity']))
<div class="pb-panel-section">
<div class="pb-panel-sec-title">Activitate recentă</div>
@foreach ($detail['activity'] as $a)
<div class="pb-activity-item">
<div class="pb-act-icon" style="background:var(--pb-blue-bg); color:var(--pb-blue-text);"></div>
<div>
<div class="pb-act-text">{{ $a['text'] }}</div>
<div class="pb-act-time">{{ $a['time'] }}</div>
</div>
</div>
@endforeach
</div>
@endif
@if (!empty($detail['phone']))
<div class="pb-panel-section">
<div class="pb-panel-sec-title">Acțiuni rapide</div>
<div class="pb-quick-grid">
<a class="pb-quick-btn" target="_blank" href="https://wa.me/{{ preg_replace('/\D/', '', $detail['phone']) }}">💚 WhatsApp</a>
<a class="pb-quick-btn" href="tel:{{ $detail['phone'] }}">📞 Sună</a>
<a class="pb-quick-btn" href="sms:{{ $detail['phone'] }}">💬 SMS</a>
<a class="pb-quick-btn" href="{{ $detail['edit_url'] }}"> Deschide</a>
</div>
</div>
@endif
</div>
@endif
</div>
</div>
</x-filament-panels::page>
+174
View File
@@ -0,0 +1,174 @@
<?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
}
}