feat: Pipeline board matches mockup pixel-by-pixel

Audit pass against /tmp/service/todo/psauto-pipeline-redesign.html — 10
gaps closed.

1. In-page TOPBAR (mockup had it; was missing): "Pipeline" title,
   sep, search box "Caută client, mașină, număr...", and right-side
   Filtre / Export / + Deal nou (primary) buttons. Search input is
   wire:model.live.debounce 300ms.

2. SEARCH actually filters cards: $searchQuery property in
   PipelineBoard scans subject + client_name + plate + code + phone
   across all 6 columns, case-insensitive.

3. "+ Deal nou" + "+ Adaugă cerere" (per-column bottom) now open the
   SAME right-side panel in "new form" mode. Inline create form:
   Nume / Telefon / Auto / Sursă / Notițe → createNewLead() inserts
   Lead with status=new, lands in col 1 instantly without leaving page.
   Validation: name + phone required.

4. EXPORT button calls exportCsv() — streams a CSV of current filtered
   columns (etapă, cod, subiect, client, telefon, auto, sumă,
   responsabil, stare timp).

5. PERIOD selector chip shows current month in Romanian
   (now()->locale('ro')->isoFormat('MMMM YYYY')) — matches "Iunie 2026".

6. HOVER icons now match mockup exactly per column:
   - request: 📅 schedule / 📞 phone / ⋮ edit
   - quote:   📅 schedule / 💬 wa / ⋮ edit
   - scheduled: 📄 file-plus (start WO) / 💬 wa / ⋮ edit
   - in_work: 👁 eye (open WO) / 💬 wa / ✓ mark Gata
   - ready:   💰 cash (mark paid) / 📞 phone / ⋮ edit
   - paid:    NONE (col 6 has no hover actions per mockup)

7. Col 6 "Achitat azi" cards now opacity:0.65, no hover actions,
   no time line, no assignee name (just avatar) — exactly as in mockup.

8. Sum display: amount == 0 renders "—" instead of "0 MDL", both in
   card footer and list view.

9. "Avans achitat" tag (blue) appears on Ready cards with partial
   payment (pay_status='partial'); "Neachitat" amber only when fully
   unpaid. Matches mockup col 5 example "Nissan Qashqai · Gata +
   Avans achitat".

10. Link tracking quick-action: appears in detail panel "Acțiuni rapide"
    grid when WO has tracking_url. Sits alongside WhatsApp / Sună / SMS.

Two-panel architecture: $showNewForm and $openCardKey are mutually
exclusive. Click outside or ✕ closes the panel; opening one closes
the other.

Tests: +4 (createNewLead happy path, validation, search filter,
partial payment tag). Suite 185/185 (was 181).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-04 21:18:54 +00:00
parent 3c0f3ba39e
commit d9b198a235
3 changed files with 262 additions and 43 deletions
+68 -1
View File
@@ -42,6 +42,13 @@ class PipelineBoard extends Page
public string $activeFilter = 'all'; // all | mine | urgent | today public string $activeFilter = 'all'; // all | mine | urgent | today
public ?string $openCardKey = null; // "lead:5" / "deal:8" / "wo:12" 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 = [ public const COLUMNS = [
'request' => ['Cerere nouă', '#94A3B8'], 'request' => ['Cerere nouă', '#94A3B8'],
@@ -124,6 +131,18 @@ class PipelineBoard extends Page
$cards['paid'][] = $this->woCard($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 // Sort: urgent first, then time
foreach ($cards as $col => $list) { foreach ($cards as $col => $list) {
usort($list, fn ($a, $b) => ($b['urgent'] ?? false) <=> ($a['urgent'] ?? false)); usort($list, fn ($a, $b) => ($b['urgent'] ?? false) <=> ($a['urgent'] ?? false));
@@ -188,6 +207,52 @@ class PipelineBoard extends Page
$this->activeFilter = in_array($filter, ['all', 'mine', 'urgent', 'today'], true) ? $filter : 'all'; $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 public function moveCard(string $key, string $toCol): void
{ {
[$kind, $id] = explode(':', $key, 2) + [null, null]; [$kind, $id] = explode(':', $key, 2) + [null, null];
@@ -475,7 +540,9 @@ class PipelineBoard extends Page
} }
if ($wo->status === 'ready') { if ($wo->status === 'ready') {
$tags[] = ['label' => 'Gata', 'color' => 'green']; $tags[] = ['label' => 'Gata', 'color' => 'green'];
if ($wo->pay_status !== 'paid') { if ($wo->pay_status === 'partial') {
$tags[] = ['label' => 'Avans achitat', 'color' => 'blue'];
} elseif ($wo->pay_status !== 'paid') {
$tags[] = ['label' => 'Neachitat', 'color' => 'amber']; $tags[] = ['label' => 'Neachitat', 'color' => 'amber'];
} }
} }
@@ -54,7 +54,7 @@
--pb-gray-text: #d1d5db; --pb-gray-text: #d1d5db;
} }
/* Break out of Filament chrome — full-bleed board */ /* Break out of Filament chrome */
.fi-main-ctn:has(.pb-shell) { padding: 0 !important; } .fi-main-ctn:has(.pb-shell) { padding: 0 !important; }
.fi-main:has(.pb-shell) { padding: 0 !important; } .fi-main:has(.pb-shell) { padding: 0 !important; }
.fi-page:has(.pb-shell) > div { padding: 0 !important; gap: 0 !important; } .fi-page:has(.pb-shell) > div { padding: 0 !important; gap: 0 !important; }
@@ -62,6 +62,20 @@
.fi-page:has(.pb-shell) { gap: 0 !important; } .fi-page:has(.pb-shell) { gap: 0 !important; }
.pb-shell { background:var(--pb-bg); color:var(--pb-text); margin:0; padding:0; min-height:calc(100vh - 64px); font-size:13px; display:flex; flex-direction:column; } .pb-shell { background:var(--pb-bg); color:var(--pb-text); margin:0; padding:0; min-height:calc(100vh - 64px); font-size:13px; display:flex; flex-direction:column; }
/* TOPBAR */
.pb-topbar { height:52px; background:var(--pb-surface); border-bottom:1px solid var(--pb-border); display:flex; align-items:center; padding:0 20px; gap:12px; flex-shrink:0; }
.pb-topbar-title { font-size:15px; font-weight:600; letter-spacing:-.2px; }
.pb-topbar-sep { width:1px; height:18px; background:var(--pb-border-md); }
.pb-search { display:flex; align-items:center; gap:6px; background:var(--pb-bg); border:1px solid var(--pb-border); border-radius:6px; padding:5px 10px; flex:1; max-width:320px; }
.pb-search input { border:none; background:transparent; outline:none; font-size:12px; color:var(--pb-text); width:100%; }
.pb-search-icon { color:var(--pb-text-3); font-size:14px; }
.pb-topbar-right { margin-left:auto; display:flex; align-items:center; gap:8px; }
.pb-btn { display:inline-flex; align-items:center; gap:5px; padding:6px 12px; border-radius:6px; font-size:12px; font-weight:500; cursor:pointer; border:1px solid var(--pb-border-md); background:var(--pb-surface); color:var(--pb-text); white-space:nowrap; text-decoration:none; }
.pb-btn:hover { background:var(--pb-bg); }
.pb-btn-primary { background:var(--pb-blue); color:#fff !important; border-color:var(--pb-blue); }
.pb-btn-primary:hover { background:#1557d4; }
.pb-stat-strip { background:var(--pb-surface); border-bottom:1px solid var(--pb-border); padding:10px 20px; display:flex; gap:20px; flex-wrap:wrap; flex-shrink:0; } .pb-stat-strip { background:var(--pb-surface); border-bottom:1px solid var(--pb-border); padding:10px 20px; display:flex; gap:20px; flex-wrap:wrap; flex-shrink:0; }
.pb-stat-item { display:flex; flex-direction:column; gap:2px; min-width:60px; } .pb-stat-item { display:flex; flex-direction:column; gap:2px; min-width:60px; }
.pb-stat-val { font-size:16px; font-weight:600; } .pb-stat-val { font-size:16px; font-weight:600; }
@@ -76,6 +90,7 @@
.pb-chip:hover { border-color:var(--pb-border-md); } .pb-chip:hover { border-color:var(--pb-border-md); }
.pb-chip.active { background:var(--pb-blue-bg); border-color:#93B8F9; color:var(--pb-blue-text); } .pb-chip.active { background:var(--pb-blue-bg); border-color:#93B8F9; color:var(--pb-blue-text); }
.pb-filter-sep { width:1px; height:20px; background:var(--pb-border); } .pb-filter-sep { width:1px; height:20px; background:var(--pb-border); }
.pb-period { display:flex; align-items:center; gap:5px; padding:4px 10px; border:1px solid var(--pb-border); border-radius:6px; font-size:11px; background:var(--pb-surface); color:var(--pb-text-2); }
.pb-total { margin-left:auto; font-size:11px; color:var(--pb-text-2); } .pb-total { margin-left:auto; font-size:11px; color:var(--pb-text-2); }
.pb-total strong { color:var(--pb-text); } .pb-total strong { color:var(--pb-text); }
@@ -129,14 +144,18 @@
.pb-act-btn { width:24px; height:24px; border-radius:5px; border:1px solid var(--pb-border); background:var(--pb-surface); cursor:pointer; display:flex; align-items:center; justify-content:center; color:var(--pb-text-2); font-size:13px; text-decoration:none; } .pb-act-btn { width:24px; height:24px; border-radius:5px; border:1px solid var(--pb-border); background:var(--pb-surface); cursor:pointer; display:flex; align-items:center; justify-content:center; color:var(--pb-text-2); font-size:13px; text-decoration:none; }
.pb-act-btn:hover { background:var(--pb-bg); color:var(--pb-text); border-color:var(--pb-border-md); } .pb-act-btn:hover { background:var(--pb-bg); color:var(--pb-text); border-color:var(--pb-border-md); }
/* Col 6 (Achitat azi): faded, no hover actions */
.pb-col[data-col="paid"] .pb-deal { opacity:0.65; }
.pb-col[data-col="paid"] .pb-deal-actions { display:none !important; }
.pb-add-card { display:flex; align-items:center; justify-content:center; gap:6px; padding:8px 12px; border-radius:10px; border:1px dashed var(--pb-border-md); color:var(--pb-text-3); font-size:11px; cursor:pointer; background:transparent; text-decoration:none; flex-shrink:0; } .pb-add-card { display:flex; align-items:center; justify-content:center; gap:6px; padding:8px 12px; border-radius:10px; border:1px dashed var(--pb-border-md); color:var(--pb-text-3); font-size:11px; cursor:pointer; background:transparent; text-decoration:none; flex-shrink:0; }
.pb-add-card:hover { border-color:var(--pb-blue); color:var(--pb-blue); background:var(--pb-blue-bg); } .pb-add-card:hover { border-color:var(--pb-blue); color:var(--pb-blue); background:var(--pb-blue-bg); }
.pb-overlay { position:fixed; inset:0; background:rgba(0,0,0,0.25); z-index:100; opacity:0; pointer-events:none; transition:opacity .2s; } .pb-overlay { position:fixed; inset:0; background:rgba(0,0,0,0.25); z-index:100; opacity:0; pointer-events:none; transition:opacity .2s; }
.pb-overlay.open { opacity:1; pointer-events:all; } .pb-overlay.open { opacity:1; pointer-events:all; }
.pb-panel { position:fixed; right:0; top:0; bottom:0; width:440px; max-width:92vw; background:var(--pb-surface); border-left:1px solid var(--pb-border); z-index:101; transform:translateX(100%); transition:transform .25s cubic-bezier(.4,0,.2,1); overflow-y:auto; display:flex; flex-direction:column; } .pb-panel { position:fixed; right:0; top:0; bottom:0; width:440px; max-width:92vw; background:var(--pb-surface); border-left:1px solid var(--pb-border); z-index:101; transform:translateX(100%); transition:transform .25s cubic-bezier(.4,0,.2,1); overflow:hidden; display:flex; flex-direction:column; }
.pb-panel.open { transform:translateX(0); } .pb-panel.open { transform:translateX(0); }
.pb-panel-head { padding:16px 20px 12px; border-bottom:1px solid var(--pb-border); display:flex; align-items:flex-start; justify-content:space-between; position:sticky; top:0; background:var(--pb-surface); z-index:10; } .pb-panel-head { padding:16px 20px 12px; border-bottom:1px solid var(--pb-border); display:flex; align-items:flex-start; justify-content:space-between; background:var(--pb-surface); flex-shrink:0; }
.pb-panel-title { font-size:15px; font-weight:600; line-height:1.3; } .pb-panel-title { font-size:15px; font-weight:600; line-height:1.3; }
.pb-panel-id { font-size:11px; color:var(--pb-text-3); margin-top:2px; } .pb-panel-id { font-size:11px; color:var(--pb-text-3); margin-top:2px; }
.pb-close-btn { width:28px; height:28px; border-radius:6px; border:1px solid var(--pb-border); background:var(--pb-bg); cursor:pointer; display:flex; align-items:center; justify-content:center; } .pb-close-btn { width:28px; height:28px; border-radius:6px; border:1px solid var(--pb-border); background:var(--pb-bg); cursor:pointer; display:flex; align-items:center; justify-content:center; }
@@ -161,16 +180,38 @@
.pb-quick-grid { display:grid; grid-template-columns:1fr 1fr; gap:6px; } .pb-quick-grid { display:grid; grid-template-columns:1fr 1fr; gap:6px; }
.pb-quick-btn { display:flex; align-items:center; justify-content:center; gap:5px; padding:8px; font-size:11px; border:1px solid var(--pb-border-md); border-radius:6px; background:var(--pb-surface); color:var(--pb-text); cursor:pointer; text-decoration:none; } .pb-quick-btn { display:flex; align-items:center; justify-content:center; gap:5px; padding:8px; font-size:11px; border:1px solid var(--pb-border-md); border-radius:6px; background:var(--pb-surface); color:var(--pb-text); cursor:pointer; text-decoration:none; }
.pb-quick-btn:hover { background:var(--pb-bg); } .pb-quick-btn:hover { background:var(--pb-bg); }
.pb-quick-btn.primary { background:var(--pb-blue); color:#fff; border-color:var(--pb-blue); } .pb-quick-btn.primary { background:var(--pb-blue); color:#fff !important; border-color:var(--pb-blue); }
.pb-quick-btn.primary:hover { background:#1557d4; } .pb-quick-btn.primary:hover { background:#1557d4; }
.pb-panel-actions { padding:12px 20px; border-top:1px solid var(--pb-border); display:flex; gap:8px; background:var(--pb-surface); position:sticky; bottom:0; } .pb-panel-actions { padding:12px 20px; border-top:1px solid var(--pb-border); display:flex; gap:8px; background:var(--pb-surface); flex-shrink:0; }
.pb-panel-actions .pb-quick-btn { flex:1; } .pb-panel-actions .pb-quick-btn { flex:1; }
/* New-card form */
.pb-form-field { margin-bottom:12px; }
.pb-form-field label { display:block; font-size:10px; font-weight:600; color:var(--pb-text-3); text-transform:uppercase; letter-spacing:.5px; margin-bottom:4px; }
.pb-form-field input, .pb-form-field textarea, .pb-form-field select { width:100%; padding:8px 10px; font-size:13px; border:1px solid var(--pb-border-md); border-radius:6px; background:var(--pb-surface); color:var(--pb-text); outline:none; font-family:inherit; }
.pb-form-field input:focus, .pb-form-field textarea:focus, .pb-form-field select:focus { border-color:var(--pb-blue); }
.pb-form-field textarea { resize:vertical; min-height:60px; }
.pb-empty-col { display:flex; flex-direction:column; align-items:center; justify-content:center; padding:24px 12px; color:var(--pb-text-3); font-size:11px; text-align:center; } .pb-empty-col { display:flex; flex-direction:column; align-items:center; justify-content:center; padding:24px 12px; color:var(--pb-text-3); font-size:11px; text-align:center; }
</style> </style>
<div class="pb-shell" x-data="{ view: 'kanban', dragKey: null }" wire:poll.10s> <div class="pb-shell" x-data="{ view: 'kanban', dragKey: null }" wire:poll.10s>
{{-- TOPBAR --}}
<div class="pb-topbar">
<span class="pb-topbar-title">Pipeline</span>
<div class="pb-topbar-sep"></div>
<div class="pb-search">
<span class="pb-search-icon">🔍</span>
<input placeholder="Caută client, mașină, număr..." wire:model.live.debounce.300ms="searchQuery" />
</div>
<div class="pb-topbar-right">
<button class="pb-btn" wire:click="setFilter('urgent')"> Filtre</button>
<a class="pb-btn" wire:click="exportCsv" wire:loading.attr="disabled"> Export</a>
<button class="pb-btn pb-btn-primary" wire:click="openNewForm">+ Deal nou</button>
</div>
</div>
{{-- STAT STRIP --}} {{-- STAT STRIP --}}
<div class="pb-stat-strip"> <div class="pb-stat-strip">
<div class="pb-stat-item"> <div class="pb-stat-item">
@@ -195,7 +236,7 @@
<div class="pb-stat-sep"></div> <div class="pb-stat-sep"></div>
<div class="pb-stat-item"> <div class="pb-stat-item">
<span class="pb-stat-val">{{ $stats['conversion_rate'] }}%</span> <span class="pb-stat-val">{{ $stats['conversion_rate'] }}%</span>
<span class="pb-stat-lbl">Rata conversie (30z)</span> <span class="pb-stat-lbl">Rata conversie</span>
</div> </div>
<div class="pb-stat-sep"></div> <div class="pb-stat-sep"></div>
<div class="pb-stat-item"> <div class="pb-stat-item">
@@ -215,13 +256,15 @@
<div class="pb-chip {{ $activeFilter === 'mine' ? 'active' : '' }}" wire:click="setFilter('mine')">👤 Ale mele</div> <div class="pb-chip {{ $activeFilter === 'mine' ? 'active' : '' }}" wire:click="setFilter('mine')">👤 Ale mele</div>
<div class="pb-chip {{ $activeFilter === 'urgent' ? 'active' : '' }}" wire:click="setFilter('urgent')" style="color:var(--pb-red-text)"> Urgente</div> <div class="pb-chip {{ $activeFilter === 'urgent' ? 'active' : '' }}" wire:click="setFilter('urgent')" style="color:var(--pb-red-text)"> Urgente</div>
<div class="pb-chip {{ $activeFilter === 'today' ? 'active' : '' }}" wire:click="setFilter('today')">📅 Azi</div> <div class="pb-chip {{ $activeFilter === 'today' ? 'active' : '' }}" wire:click="setFilter('today')">📅 Azi</div>
<div class="pb-filter-sep"></div>
<div class="pb-period">📅 {{ now()->locale('ro')->isoFormat('MMMM YYYY') }}</div>
<div class="pb-total">Pipeline: <strong>{{ number_format($stats['pipeline_mdl'], 0, '.', ' ') }} MDL</strong> · <strong>{{ $stats['active'] }} deals</strong></div> <div class="pb-total">Pipeline: <strong>{{ number_format($stats['pipeline_mdl'], 0, '.', ' ') }} MDL</strong> · <strong>{{ $stats['active'] }} deals</strong></div>
</div> </div>
{{-- KANBAN --}} {{-- KANBAN --}}
<div class="pb-board" x-show="view==='kanban'"> <div class="pb-board" x-show="view==='kanban'">
@foreach ($columns as $colKey => $col) @foreach ($columns as $colKey => $col)
<div class="pb-col" <div class="pb-col" data-col="{{ $colKey }}"
@dragover.prevent="$el.classList.add('over')" @dragover.prevent="$el.classList.add('over')"
@dragleave="$el.classList.remove('over')" @dragleave="$el.classList.remove('over')"
@drop.prevent=" @drop.prevent="
@@ -252,21 +295,32 @@
<div class="pb-deal-urgent"></div> <div class="pb-deal-urgent"></div>
@endif @endif
<div class="pb-deal-actions" @click.stop> <div class="pb-deal-actions" @click.stop>
@if (in_array($colKey, ['request', 'quote'])) @if ($colKey === 'request')
<a class="pb-act-btn" wire:click.stop="quickSchedule('{{ $card['key'] }}')" title="Programare" {{-- 📅 calendar-plus / 📞 phone / --}}
wire:loading.attr="disabled">📅</a> <a class="pb-act-btn" wire:click.stop="quickSchedule('{{ $card['key'] }}')" title="Programare">📅</a>
@if (!empty($card['phone']))<a class="pb-act-btn" href="tel:{{ $card['phone'] }}" @click.stop title="Sună">📞</a>@endif
<a class="pb-act-btn" href="{{ $card['edit_url'] }}" @click.stop title="Editare"></a>
@elseif ($colKey === 'quote')
{{-- 📅 / 💬 message / --}}
<a class="pb-act-btn" wire:click.stop="quickSchedule('{{ $card['key'] }}')" title="Programare">📅</a>
@if (!empty($card['phone']))<a class="pb-act-btn" href="https://wa.me/{{ preg_replace('/\D/', '', $card['phone']) }}" target="_blank" @click.stop title="WhatsApp">💬</a>@endif
<a class="pb-act-btn" href="{{ $card['edit_url'] }}" @click.stop title="Editare"></a>
@elseif ($colKey === 'scheduled') @elseif ($colKey === 'scheduled')
<a class="pb-act-btn" wire:click.stop="moveCard('{{ $card['key'] }}', 'in_work')" title="Începe lucrul"></a> {{-- 📄+ file-plus (start WO) / 💬 / --}}
<a class="pb-act-btn" wire:click.stop="moveCard('{{ $card['key'] }}', 'in_work')" title="Începe lucrul (creează fișă)">📄</a>
@if (!empty($card['phone']))<a class="pb-act-btn" href="https://wa.me/{{ preg_replace('/\D/', '', $card['phone']) }}" target="_blank" @click.stop title="WhatsApp">💬</a>@endif
<a class="pb-act-btn" href="{{ $card['edit_url'] }}" @click.stop title="Editare"></a>
@elseif ($colKey === 'in_work') @elseif ($colKey === 'in_work')
{{-- 👁 eye / 💬 / --}}
<a class="pb-act-btn" href="{{ $card['edit_url'] }}" @click.stop title="Deschide fișa">👁</a>
@if (!empty($card['phone']))<a class="pb-act-btn" href="https://wa.me/{{ preg_replace('/\D/', '', $card['phone']) }}" target="_blank" @click.stop title="WhatsApp">💬</a>@endif
<a class="pb-act-btn" wire:click.stop="moveCard('{{ $card['key'] }}', 'ready')" title="Marchează Gata"></a> <a class="pb-act-btn" wire:click.stop="moveCard('{{ $card['key'] }}', 'ready')" title="Marchează Gata"></a>
@elseif ($colKey === 'ready') @elseif ($colKey === 'ready')
{{-- 💰 cash / 💬 / --}}
<a class="pb-act-btn" wire:click.stop="moveCard('{{ $card['key'] }}', 'paid')" title="Achitat">💰</a> <a class="pb-act-btn" wire:click.stop="moveCard('{{ $card['key'] }}', 'paid')" title="Achitat">💰</a>
@if (!empty($card['phone']))<a class="pb-act-btn" href="tel:{{ $card['phone'] }}" @click.stop title="Sună">📞</a>@endif
<a class="pb-act-btn" href="{{ $card['edit_url'] }}" @click.stop title="Editare"></a>
@endif @endif
@if (!empty($card['phone']))
<a class="pb-act-btn" href="tel:{{ $card['phone'] }}" @click.stop title="Sună">📞</a>
<a class="pb-act-btn" href="https://wa.me/{{ preg_replace('/\D/', '', $card['phone']) }}" target="_blank" @click.stop title="WhatsApp">💬</a>
@endif
<a class="pb-act-btn" href="{{ $card['edit_url'] }}" @click.stop title="Deschide"></a>
</div> </div>
<div class="pb-deal-id">{{ $card['code'] }}</div> <div class="pb-deal-id">{{ $card['code'] }}</div>
<div class="pb-deal-subject">{{ $card['subject'] }}</div> <div class="pb-deal-subject">{{ $card['subject'] }}</div>
@@ -281,14 +335,14 @@
<div class="pb-deal-footer"> <div class="pb-deal-footer">
<div class="pb-deal-assignee"> <div class="pb-deal-assignee">
<div class="pb-av pb-av-{{ $card['assignee']['color'] }}">{{ $card['assignee']['initials'] }}</div> <div class="pb-av pb-av-{{ $card['assignee']['color'] }}">{{ $card['assignee']['initials'] }}</div>
<span class="pb-deal-name">{{ $card['assignee']['name'] }}</span> @if ($colKey !== 'paid')<span class="pb-deal-name">{{ $card['assignee']['name'] }}</span>@endif
</div> </div>
<span class="pb-deal-amount">{{ number_format($card['amount'], 0, '.', ' ') }} MDL</span> <span class="pb-deal-amount">{{ $card['amount'] > 0 ? number_format($card['amount'], 0, '.', ' ') . ' MDL' : '—' }}</span>
</div> </div>
@if (!is_null($card['progress_pct'])) @if (!is_null($card['progress_pct']))
<div class="pb-progress-bar"><div class="pb-progress-fill" style="width:{{ $card['progress_pct'] }}%"></div></div> <div class="pb-progress-bar"><div class="pb-progress-fill" style="width:{{ $card['progress_pct'] }}%"></div></div>
@endif @endif
@if ($card['time_text']) @if ($card['time_text'] && $colKey !== 'paid')
<div class="pb-deal-time {{ $card['time_overdue'] ? 'overdue' : '' }}"> <div class="pb-deal-time {{ $card['time_overdue'] ? 'overdue' : '' }}">
@if ($card['time_icon']==='check')@elseif($card['time_icon']==='phone')📞@elseif($card['time_icon']==='message')💬@else@endif @if ($card['time_icon']==='check')@elseif($card['time_icon']==='phone')📞@elseif($card['time_icon']==='message')💬@else@endif
{{ $card['time_text'] }} {{ $card['time_text'] }}
@@ -298,10 +352,8 @@
@empty @empty
<div class="pb-empty-col">Gol trage un card aici</div> <div class="pb-empty-col">Gol trage un card aici</div>
@endforelse @endforelse
@if (in_array($colKey, ['request', 'quote', 'scheduled'])) @if ($colKey !== 'paid')
<a class="pb-add-card" href="{{ route('filament.tenant.resources.leads.create') }}">+ Adaugă cerere</a> <button type="button" class="pb-add-card" wire:click="openNewForm">+ Adaugă cerere</button>
@elseif (in_array($colKey, ['in_work', 'ready']))
<a class="pb-add-card" href="{{ route('filament.tenant.resources.work-orders.create') }}">+ Fișă nouă</a>
@endif @endif
</div> </div>
</div> </div>
@@ -327,7 +379,7 @@
<td style="padding:10px 12px; font-size:12px;">{{ $card['client_name'] }}</td> <td style="padding:10px 12px; font-size:12px;">{{ $card['client_name'] }}</td>
<td style="padding:10px 12px; font-size:12px;">{{ $card['plate'] }}</td> <td style="padding:10px 12px; font-size:12px;">{{ $card['plate'] }}</td>
<td style="padding:10px 12px;"><span class="pb-tag pb-tag-gray">{{ $col['label'] }}</span></td> <td style="padding:10px 12px;"><span class="pb-tag pb-tag-gray">{{ $col['label'] }}</span></td>
<td style="padding:10px 12px; font-size:12px; font-weight:600;">{{ number_format($card['amount'], 0, '.', ' ') }} MDL</td> <td style="padding:10px 12px; font-size:12px; font-weight:600;">{{ $card['amount'] > 0 ? number_format($card['amount'], 0, '.', ' ') . ' MDL' : '—' }}</td>
<td style="padding:10px 12px; font-size:12px;">{{ $card['assignee']['name'] }}</td> <td style="padding:10px 12px; font-size:12px;">{{ $card['assignee']['name'] }}</td>
<td style="padding:10px 12px; font-size:11px; color:{{ $card['time_overdue'] ? 'var(--pb-red)' : 'var(--pb-text-3)' }};">{{ $card['time_text'] }}</td> <td style="padding:10px 12px; font-size:11px; color:{{ $card['time_overdue'] ? 'var(--pb-red)' : 'var(--pb-text-3)' }};">{{ $card['time_text'] }}</td>
</tr> </tr>
@@ -337,10 +389,54 @@
</table> </table>
</div> </div>
{{-- DETAIL PANEL --}} {{-- PANEL: NEW FORM mode --}}
<div class="pb-overlay {{ $openCardKey ? 'open' : '' }}" wire:click="closeCard()"></div> <div class="pb-overlay {{ $showNewForm ? 'open' : '' }}" wire:click="$set('showNewForm', false)"></div>
<div class="pb-panel {{ $openCardKey ? 'open' : '' }}"> <div class="pb-panel {{ $showNewForm ? 'open' : '' }}">
@if ($detail) @if ($showNewForm)
<div class="pb-panel-head">
<div>
<div class="pb-panel-title">Cerere nouă</div>
<div class="pb-panel-id">Va fi adăugată în Cerere nouă</div>
</div>
<div class="pb-close-btn" wire:click="$set('showNewForm', false)"></div>
</div>
<form class="pb-panel-body" wire:submit.prevent="createNewLead">
<div class="pb-form-field">
<label>Nume *</label>
<input type="text" wire:model="newName" placeholder="Ion Popescu" autofocus>
</div>
<div class="pb-form-field">
<label>Telefon *</label>
<input type="text" wire:model="newPhone" placeholder="+373 69 ...">
</div>
<div class="pb-form-field">
<label>Auto / model</label>
<input type="text" wire:model="newCar" placeholder="VW Passat 2018">
</div>
<div class="pb-form-field">
<label>Sursă</label>
<select wire:model="newSource">
@foreach (\App\Models\Tenant\Lead::SOURCES as $val => $lbl)
<option value="{{ $val }}">{{ $lbl }}</option>
@endforeach
</select>
</div>
<div class="pb-form-field">
<label>Notițe / reclamație</label>
<textarea wire:model="newNotes" placeholder="Ce a spus clientul..."></textarea>
</div>
</form>
<div class="pb-panel-actions">
<button type="button" class="pb-quick-btn" wire:click="$set('showNewForm', false)">Anulează</button>
<button type="button" class="pb-quick-btn primary" wire:click="createNewLead">Creează cerere</button>
</div>
@endif
</div>
{{-- PANEL: DETAIL mode --}}
<div class="pb-overlay {{ $openCardKey && !$showNewForm ? 'open' : '' }}" wire:click="closeCard()"></div>
<div class="pb-panel {{ $openCardKey && !$showNewForm ? 'open' : '' }}">
@if ($detail && !$showNewForm)
<div class="pb-panel-head"> <div class="pb-panel-head">
<div> <div>
<div class="pb-panel-title">{{ $detail['title'] }}</div> <div class="pb-panel-title">{{ $detail['title'] }}</div>
@@ -401,11 +497,6 @@
<div style="font-size:11px; color:var(--pb-amber-text); margin-top:2px;">Deschide fișa pentru aprobare lucrare/piesă.</div> <div style="font-size:11px; color:var(--pb-amber-text); margin-top:2px;">Deschide fișa pentru aprobare lucrare/piesă.</div>
</div> </div>
@endif @endif
@if (!empty($detail['wo']['tracking_url']))
<div style="margin-top:6px; font-size:11px;">
Link tracking client: <a href="{{ $detail['wo']['tracking_url'] }}" target="_blank" style="color:var(--pb-blue);">{{ $detail['wo']['tracking_url'] }}</a>
</div>
@endif
</div> </div>
@endif @endif
@@ -424,25 +515,26 @@
</div> </div>
@endif @endif
@if (!empty($detail['phone']))
<div class="pb-panel-section"> <div class="pb-panel-section">
<div class="pb-panel-sec-title">Contactare rapidă</div> <div class="pb-panel-sec-title">Acțiuni rapide</div>
<div class="pb-quick-grid"> <div class="pb-quick-grid">
@if (!empty($detail['phone']))
<a class="pb-quick-btn" target="_blank" href="https://wa.me/{{ preg_replace('/\D/', '', $detail['phone']) }}">💚 WhatsApp</a> <a class="pb-quick-btn" target="_blank" href="https://wa.me/{{ preg_replace('/\D/', '', $detail['phone']) }}">💚 WhatsApp</a>
<a class="pb-quick-btn" href="tel:{{ $detail['phone'] }}">📞 Sună</a> <a class="pb-quick-btn" href="tel:{{ $detail['phone'] }}">📞 Sună</a>
<a class="pb-quick-btn" href="sms:{{ $detail['phone'] }}">💬 SMS</a> <a class="pb-quick-btn" href="sms:{{ $detail['phone'] }}">💬 SMS</a>
<a class="pb-quick-btn" href="{{ $detail['edit_url'] }}"> Editare</a> @endif
</div> @if (!empty($detail['wo']['tracking_url']))
</div> <a class="pb-quick-btn" href="{{ $detail['wo']['tracking_url'] }}" target="_blank">🔗 Link tracking</a>
@endif @endif
</div> </div>
</div>
</div>
{{-- Bottom action bar primary CTAs per stage --}}
<div class="pb-panel-actions"> <div class="pb-panel-actions">
@php $kind = explode(':', $openCardKey)[0] ?? '' @endphp @php $kind = explode(':', $openCardKey)[0] ?? '' @endphp
@if (in_array($kind, ['lead', 'deal']) && empty($detail['wo'])) @if (in_array($kind, ['lead', 'deal']) && empty($detail['wo']))
<a class="pb-quick-btn" wire:click="quickSchedule('{{ $openCardKey }}')">📅 Programează</a> <a class="pb-quick-btn" wire:click="quickSchedule('{{ $openCardKey }}')">📅 Programează</a>
<a class="pb-quick-btn primary" href="{{ $detail['edit_url'] }}">Editare deal</a> <a class="pb-quick-btn primary" href="{{ $detail['edit_url'] }}"> Deschide deal</a>
@elseif ($kind === 'wo') @elseif ($kind === 'wo')
<a class="pb-quick-btn" href="{{ $this->calendarUrl() }}">📅 Calendar</a> <a class="pb-quick-btn" href="{{ $this->calendarUrl() }}">📅 Calendar</a>
<a class="pb-quick-btn primary" href="{{ $detail['edit_url'] }}"> Deschide Fișă</a> <a class="pb-quick-btn primary" href="{{ $detail['edit_url'] }}"> Deschide Fișă</a>
+60
View File
@@ -153,6 +153,66 @@ class PipelineBoardTest extends TestCase
$this->assertEquals('mine', $cols['request']['cards'][0]['subject']); $this->assertEquals('mine', $cols['request']['cards'][0]['subject']);
} }
public function test_create_new_lead_inserts_lead_in_request_column(): void
{
Livewire::test(PipelineBoard::class)
->set('newName', 'Anonim X')
->set('newPhone', '+37388999777')
->set('newCar', 'Audi A4')
->set('newSource', 'site')
->set('newNotes', 'are zgomot la pornire')
->call('createNewLead')
->assertSet('showNewForm', false);
$this->assertDatabaseHas('leads', [
'name' => 'Anonim X',
'phone' => '+37388999777',
'car' => 'Audi A4',
'source' => 'site',
'status' => 'new',
]);
}
public function test_create_new_lead_requires_name_and_phone(): void
{
Livewire::test(PipelineBoard::class)
->set('newName', '')
->set('newPhone', '+37300000000')
->call('createNewLead');
$this->assertEquals(0, Lead::count());
}
public function test_search_query_filters_cards_across_columns(): void
{
$client1 = Client::create(['name' => 'Muntean Alex', 'phone' => '+37399000001', 'type' => 'individual', 'status' => 'active']);
$client2 = Client::create(['name' => 'Cojocaru Ion', 'phone' => '+37399000002', 'type' => 'individual', 'status' => 'active']);
Deal::create(['client_id' => $client1->id, 'name' => 'VW Passat — Frâne', 'price' => 1000, 'stage' => 'new']);
Deal::create(['client_id' => $client2->id, 'name' => 'Renault Megane — Diag', 'price' => 1500, 'stage' => 'new']);
$page = new PipelineBoard;
$page->searchQuery = 'Muntean';
$cols = $page->getColumns();
$this->assertEquals(1, $cols['request']['count']);
$this->assertEquals('VW Passat — Frâne', $cols['request']['cards'][0]['subject']);
}
public function test_partial_payment_shows_avans_achitat_tag_on_ready_card(): void
{
$client = Client::create(['name' => 'C', 'phone' => '+37399112233', 'type' => 'individual', 'status' => 'active']);
$wo = WorkOrder::create(['number' => WorkOrder::generateNumber($this->company->id), 'client_id' => $client->id, 'opened_at' => today(), 'status' => 'ready', 'total' => 7100]);
// Half payment → partial
Payment::create(['client_id' => $client->id, 'work_order_id' => $wo->id, 'paid_at' => today(), 'amount' => 3000, 'method' => 'cash']);
$cols = (new PipelineBoard)->getColumns();
$cards = $cols['ready']['cards'];
$this->assertCount(1, $cards);
$tagLabels = array_column($cards[0]['tags'], 'label');
$this->assertContains('Gata', $tagLabels);
$this->assertContains('Avans achitat', $tagLabels);
}
public function test_quick_schedule_creates_appointment_and_moves_deal_to_scheduled(): void public function test_quick_schedule_creates_appointment_and_moves_deal_to_scheduled(): void
{ {
$client = Client::create(['name' => 'C', 'phone' => '+37399123000', 'type' => 'individual', 'status' => 'active']); $client = Client::create(['name' => 'C', 'phone' => '+37399123000', 'type' => 'individual', 'status' => 'active']);