Files
Vasyka 3911012c65 feat(i18n): mega-wrap 494 raw RO strings + 269 lang entries + 161 human RU/163 EN
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>
2026-07-15 05:53:22 +00:00

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();
}
}