3911012c65
Broad sweep across the whole codebase:
- Blade views (39 files, 315 wraps): tag-text and title/placeholder/alt
attributes wrapped with {{ __() }}. Excludes scripts, styles, @php,
@verbatim, {{ }}, {!! !!}, comments to avoid touching interpolations.
- PHP (54 files, 179 wraps): array 'key' => 'RO value' patterns and
list items with diacritics wrapped with __(). Reverted __() inside
const arrays (PHP disallows non-constant expressions).
- Added 269 new keys to lang/{ru,en}.json (identity fallback for
unknowns → 161 human RU + 163 EN translations added for the most
common enums, stages, roles, statuses, payment methods, vehicle
categories, warehouse, portal, form actions.
Missing translations fall back to RO so the UI never breaks. All 306
tests pass; view cache compiles cleanly.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
799 lines
32 KiB
PHP
799 lines
32 KiB
PHP
<?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';
|
|
|
|
public static function getNavigationLabel(): string
|
|
{
|
|
return __('nav.label.Pipeline');
|
|
}
|
|
|
|
public static function getNavigationGroup(): ?string
|
|
{
|
|
return __('nav.group.CRM');
|
|
}
|
|
|
|
protected static ?int $navigationSort = 5;
|
|
|
|
protected static ?string $title = null;
|
|
|
|
public function getTitle(): string
|
|
{
|
|
return __('Pipeline');
|
|
}
|
|
|
|
protected string $view = 'filament.tenant.pages.pipeline-board';
|
|
|
|
public function getMaxContentWidth(): \Filament\Support\Enums\Width
|
|
{
|
|
return \Filament\Support\Enums\Width::Full;
|
|
}
|
|
|
|
public function getHeading(): string
|
|
{
|
|
return '';
|
|
}
|
|
|
|
public function getSubheading(): ?string
|
|
{
|
|
return null;
|
|
}
|
|
|
|
public string $activeFilter = 'all'; // all | mine | urgent | today
|
|
public ?string $openCardKey = null; // "lead:5" / "deal:8" / "wo:12"
|
|
public bool $showNewForm = false; // panel in "new request" mode
|
|
public string $searchQuery = '';
|
|
public string $newName = '';
|
|
public string $newPhone = '';
|
|
public string $newCar = '';
|
|
public string $newSource = 'call';
|
|
public string $newNotes = '';
|
|
|
|
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);
|
|
}
|
|
|
|
// Apply search query
|
|
$q = trim($this->searchQuery);
|
|
if ($q !== '') {
|
|
$needle = mb_strtolower($q);
|
|
foreach ($cards as $col => $list) {
|
|
$cards[$col] = array_values(array_filter($list, function ($c) use ($needle) {
|
|
$hay = mb_strtolower(($c['subject'] ?? '') . ' ' . ($c['client_name'] ?? '') . ' ' . ($c['plate'] ?? '') . ' ' . ($c['code'] ?? '') . ' ' . ($c['phone'] ?? ''));
|
|
return str_contains($hay, $needle);
|
|
}));
|
|
}
|
|
}
|
|
|
|
// 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 openNewForm(): void
|
|
{
|
|
$this->showNewForm = true;
|
|
$this->openCardKey = null;
|
|
$this->newName = '';
|
|
$this->newPhone = '';
|
|
$this->newCar = '';
|
|
$this->newSource = 'call';
|
|
$this->newNotes = '';
|
|
}
|
|
|
|
public function createNewLead(): void
|
|
{
|
|
$data = ['name' => trim($this->newName), 'phone' => trim($this->newPhone), 'car' => trim($this->newCar) ?: null, 'source' => $this->newSource, 'message' => trim($this->newNotes) ?: null];
|
|
if ($data['name'] === '' || $data['phone'] === '') {
|
|
$this->notify('Nume și telefon sunt obligatorii');
|
|
return;
|
|
}
|
|
Lead::create(array_merge($data, ['status' => 'new']));
|
|
$this->showNewForm = false;
|
|
$this->notify(__('Cerere nouă adăugată'));
|
|
}
|
|
|
|
public function exportCsv()
|
|
{
|
|
$columns = $this->getColumns();
|
|
$csv = "Etapă,Cod,Subiect,Client,Telefon,Auto,Sumă,Responsabil,Stare\n";
|
|
foreach ($columns as $col) {
|
|
foreach ($col['cards'] as $card) {
|
|
$csv .= sprintf(
|
|
"%s,%s,%s,%s,%s,%s,%.2f,%s,%s\n",
|
|
$col['label'],
|
|
$card['code'],
|
|
str_replace(',', ' ', $card['subject']),
|
|
str_replace(',', ' ', $card['client_name']),
|
|
$card['phone'] ?? '',
|
|
$card['plate'],
|
|
$card['amount'],
|
|
$card['assignee']['name'],
|
|
str_replace(',', ' ', $card['time_text']),
|
|
);
|
|
}
|
|
}
|
|
return response()->streamDownload(fn () => print $csv, 'pipeline-' . today()->format('Y-m-d') . '.csv', ['Content-Type' => 'text/csv']);
|
|
}
|
|
|
|
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 === 'partial') {
|
|
$tags[] = ['label' => __('Avans achitat'), 'color' => 'blue'];
|
|
} elseif ($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();
|
|
}
|
|
}
|