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:
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user