['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; } /** Quick-schedule from a card: bumps the source to "Programat", creates an Appointment for tomorrow 10:00, returns calendar URL. */ public function quickSchedule(string $key): void { [$kind, $id] = explode(':', $key, 2) + [null, null]; $id = (int) $id; if (! $kind || ! $id) return; $clientId = null; $vehicleId = null; $dealId = null; $title = null; $masterId = null; if ($kind === 'lead') { $lead = Lead::find($id); if (! $lead) return; $deal = $lead->convert(['stage' => 'scheduled', 'scheduled_at' => now()->addDay()->setHour(10)->setMinute(0)]); $clientId = $deal->client_id; $vehicleId = $deal->vehicle_id; $dealId = $deal->id; $title = $deal->name; } elseif ($kind === 'deal') { $deal = Deal::find($id); if (! $deal) return; $deal->update(['stage' => 'scheduled', 'scheduled_at' => now()->addDay()->setHour(10)->setMinute(0)]); $clientId = $deal->client_id; $vehicleId = $deal->vehicle_id; $dealId = $deal->id; $title = $deal->name; $masterId = $deal->assigned_to; } elseif ($kind === 'wo') { $wo = WorkOrder::find($id); if (! $wo) return; $clientId = $wo->client_id; $vehicleId = $wo->vehicle_id; $title = $wo->number; $masterId = $wo->master_id; } \App\Models\Tenant\Appointment::create([ 'client_id' => $clientId, 'vehicle_id' => $vehicleId, 'master_id' => $masterId, 'deal_id' => $dealId, 'date' => today()->addDay(), 'time_start' => '10:00', 'time_end' => '11:00', 'title' => $title ?: 'Programare', 'status' => 'scheduled', ]); $this->notify("Programare creată · mâine 10:00"); $this->openCardKey = null; } public function calendarUrl(): string { return route('filament.tenant.pages.calendar-board'); } public function closeCard(): void { $this->openCardKey = null; } 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(); } }