Files
autocrm/app/Filament/Tenant/Resources/DealResource.php
T
Vasyka 3603c0e43b 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>
2026-06-04 20:02:44 +00:00

120 lines
4.9 KiB
PHP

<?php
namespace App\Filament\Tenant\Resources;
use App\Filament\Tenant\Resources\DealResource\Pages;
use App\Models\Tenant\Client;
use App\Models\Tenant\Deal;
use App\Models\Tenant\Lead;
use App\Models\Tenant\User;
use App\Models\Tenant\Vehicle;
use Filament\Forms;
use Filament\Resources\Resource;
use Filament\Schemas\Schema;
use Filament\Actions;
use Filament\Schemas;
use Filament\Tables;
use Filament\Tables\Table;
class DealResource extends Resource
{
protected static ?string $model = Deal::class;
protected static string|\BackedEnum|null $navigationIcon = 'heroicon-o-funnel';
protected static ?string $navigationLabel = 'Pipeline (tabel)';
protected static string|\UnitEnum|null $navigationGroup = 'CRM';
public static function shouldRegisterNavigation(): bool
{
return false; // PipelineBoard page is the canonical entry; this resource keeps CRUD routes for edit/create.
}
protected static ?string $modelLabel = 'deal';
protected static ?string $pluralModelLabel = 'deal-uri';
protected static ?int $navigationSort = 6;
public static function form(Schema $schema): Schema
{
return $schema->components([
Schemas\Components\Section::make('Detalii')
->columns(2)
->schema([
Forms\Components\Select::make('client_id')
->label('Client')
->options(fn () => Client::pluck('name', 'id'))
->searchable()
->required(),
Forms\Components\Select::make('vehicle_id')
->label('Auto')
->options(fn (Schemas\Components\Utilities\Get $get) => $get('client_id')
? Vehicle::where('client_id', $get('client_id'))->pluck('plate', 'id')
: [])
->searchable(),
Forms\Components\TextInput::make('name')->label('Subiect')->required()->maxLength(160),
Forms\Components\TextInput::make('price')->label('Valoare')->numeric()->default(0),
Forms\Components\Select::make('stage')
->options(Deal::STAGES)
->default('new')
->required(),
Forms\Components\Select::make('source')
->options(Lead::SOURCES)
->searchable(),
Forms\Components\Select::make('assigned_to')
->label('Responsabil')
->options(fn () => User::pluck('name', 'id'))
->searchable(),
]),
Forms\Components\Textarea::make('note')->label('Notițe')->columnSpanFull()->rows(3),
]);
}
public static function table(Table $table): Table
{
return $table
->columns([
Tables\Columns\TextColumn::make('id')->label('#')->sortable(),
Tables\Columns\TextColumn::make('name')->label('Subiect')->searchable()->limit(40),
Tables\Columns\TextColumn::make('client.name')->label('Client')->searchable(),
Tables\Columns\TextColumn::make('vehicle.plate')->label('Auto')->placeholder('—'),
Tables\Columns\TextColumn::make('stage')
->formatStateUsing(fn ($state) => Deal::STAGES[$state] ?? $state)
->badge()
->colors([
'gray' => ['new'],
'info' => ['contact', 'agree'],
'warning' => ['scheduled', 'arrived', 'in_work'],
'success' => ['done'],
'danger' => ['lost'],
]),
Tables\Columns\TextColumn::make('price')->money('MDL')->sortable(),
Tables\Columns\TextColumn::make('source')->label('Sursă')->formatStateUsing(fn ($state) => Lead::SOURCES[$state] ?? $state)->placeholder('—'),
Tables\Columns\TextColumn::make('assignedTo.name')->label('Responsabil')->placeholder('—'),
Tables\Columns\TextColumn::make('created_at')->date()->sortable(),
])
->filters([
Tables\Filters\SelectFilter::make('stage')->options(Deal::STAGES),
Tables\Filters\SelectFilter::make('assigned_to')
->label('Responsabil')
->options(fn () => User::pluck('name', 'id')),
])
->actions([
Actions\EditAction::make(),
Actions\DeleteAction::make(),
])
->defaultSort('created_at', 'desc');
}
public static function getPages(): array
{
return [
'index' => Pages\ListDeals::route('/'),
'create' => Pages\CreateDeal::route('/create'),
'edit' => Pages\EditDeal::route('/{record}/edit'),
];
}
}