Deploy 3: Onboarding wizard + empty states + docs operationale
- 3-step onboarding wizard at /app/onboarding (auto-redirected via
RequireOnboarding middleware on first login per tenant)
- Empty states with icon + heading + description on Client, Vehicle,
WorkOrder, Lead, Part lists
- Docs: operations/{api,i18n,2fa,monitoring}.md, stack/reverb.md
- Updated 00-index.md and journal.md with status of all 15 items
This commit is contained in:
@@ -0,0 +1,146 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Filament\Tenant\Pages;
|
||||||
|
|
||||||
|
use App\Tenancy\TenantManager;
|
||||||
|
use Filament\Forms;
|
||||||
|
use Filament\Notifications\Notification;
|
||||||
|
use Filament\Pages\Page;
|
||||||
|
use Filament\Schemas;
|
||||||
|
use Filament\Schemas\Schema;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 3-step onboarding wizard. Hidden from navigation.
|
||||||
|
* Dashboard redirects here on first login when settings.onboarded_at is empty.
|
||||||
|
*/
|
||||||
|
class Onboarding extends Page
|
||||||
|
{
|
||||||
|
protected static string|\BackedEnum|null $navigationIcon = 'heroicon-o-sparkles';
|
||||||
|
|
||||||
|
protected static bool $shouldRegisterNavigation = false;
|
||||||
|
|
||||||
|
protected static ?string $title = 'Bun venit în AutoCRM!';
|
||||||
|
|
||||||
|
protected string $view = 'filament.tenant.pages.onboarding';
|
||||||
|
|
||||||
|
public ?array $data = [];
|
||||||
|
|
||||||
|
public int $step = 1;
|
||||||
|
|
||||||
|
public function mount(): void
|
||||||
|
{
|
||||||
|
$company = app(TenantManager::class)->current();
|
||||||
|
if (! $company) abort(404);
|
||||||
|
|
||||||
|
// Already onboarded → redirect to dashboard
|
||||||
|
if (! empty($company->settings['onboarded_at'])) {
|
||||||
|
redirect('/app');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$s = (array) ($company->settings ?? []);
|
||||||
|
$this->form->fill([
|
||||||
|
'display_name' => $company->display_name ?? $company->name,
|
||||||
|
'city' => $company->city,
|
||||||
|
'phone' => $company->phone,
|
||||||
|
'currency' => $s['currency'] ?? 'MDL',
|
||||||
|
'language' => $s['language'] ?? 'ro',
|
||||||
|
'theme_color' => $s['theme_color'] ?? '#3B82F6',
|
||||||
|
'labor_rate' => $s['labor_rate'] ?? 400,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function form(Schema $schema): Schema
|
||||||
|
{
|
||||||
|
return $schema
|
||||||
|
->components([
|
||||||
|
Schemas\Components\Section::make('Pas 1 — Datele afacerii')
|
||||||
|
->visible(fn () => $this->step === 1)
|
||||||
|
->columns(2)
|
||||||
|
->schema([
|
||||||
|
Forms\Components\TextInput::make('display_name')
|
||||||
|
->label('Denumire afișată')->required()->maxLength(120),
|
||||||
|
Forms\Components\TextInput::make('city')
|
||||||
|
->label('Oraș')->maxLength(60),
|
||||||
|
Forms\Components\TextInput::make('phone')
|
||||||
|
->label('Telefon principal')->tel()->maxLength(40),
|
||||||
|
Forms\Components\TextInput::make('currency')
|
||||||
|
->label('Monedă')->required()->maxLength(8),
|
||||||
|
]),
|
||||||
|
Schemas\Components\Section::make('Pas 2 — Brand & limbă')
|
||||||
|
->visible(fn () => $this->step === 2)
|
||||||
|
->columns(2)
|
||||||
|
->schema([
|
||||||
|
Forms\Components\Select::make('language')
|
||||||
|
->label('Limbă')
|
||||||
|
->options(['ro' => 'Română', 'ru' => 'Русский', 'en' => 'English'])
|
||||||
|
->required(),
|
||||||
|
Forms\Components\ColorPicker::make('theme_color')
|
||||||
|
->label('Culoare brand'),
|
||||||
|
Forms\Components\FileUpload::make('logo')
|
||||||
|
->label('Logo (opțional)')
|
||||||
|
->image()->imageEditor()->disk('public')
|
||||||
|
->directory('tmp-uploads')->visibility('public')
|
||||||
|
->maxSize(2048),
|
||||||
|
]),
|
||||||
|
Schemas\Components\Section::make('Pas 3 — Tarif & terminat')
|
||||||
|
->visible(fn () => $this->step === 3)
|
||||||
|
->columns(1)
|
||||||
|
->schema([
|
||||||
|
Forms\Components\TextInput::make('labor_rate')
|
||||||
|
->label('Tarif normo-oră (poți schimba oricând)')
|
||||||
|
->numeric()->required(),
|
||||||
|
]),
|
||||||
|
])
|
||||||
|
->statePath('data');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function next(): void
|
||||||
|
{
|
||||||
|
$this->form->getState();
|
||||||
|
$this->step = min(3, $this->step + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function prev(): void
|
||||||
|
{
|
||||||
|
$this->step = max(1, $this->step - 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function finish(): void
|
||||||
|
{
|
||||||
|
$data = $this->form->getState();
|
||||||
|
$company = app(TenantManager::class)->current();
|
||||||
|
if (! $company) return;
|
||||||
|
|
||||||
|
$company->update([
|
||||||
|
'display_name' => $data['display_name'] ?? $company->display_name,
|
||||||
|
'city' => $data['city'] ?? $company->city,
|
||||||
|
'phone' => $data['phone'] ?? $company->phone,
|
||||||
|
'settings' => array_merge((array) $company->settings, [
|
||||||
|
'currency' => $data['currency'] ?? 'MDL',
|
||||||
|
'language' => $data['language'] ?? 'ro',
|
||||||
|
'theme_color' => $data['theme_color'] ?? '#3B82F6',
|
||||||
|
'labor_rate' => (float) ($data['labor_rate'] ?? 400),
|
||||||
|
'onboarded_at' => now()->toIso8601String(),
|
||||||
|
]),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Logo upload
|
||||||
|
if (! empty($data['logo'])) {
|
||||||
|
$abs = \Illuminate\Support\Facades\Storage::disk('public')->path($data['logo']);
|
||||||
|
if (file_exists($abs)) {
|
||||||
|
$company->clearMediaCollection('logo');
|
||||||
|
$company->addMedia($abs)->preservingOriginal()->toMediaCollection('logo');
|
||||||
|
@unlink($abs);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Notification::make()
|
||||||
|
->title('🎉 Bun venit în AutoCRM!')
|
||||||
|
->body('Setările au fost salvate. Hai să adăugăm primul client.')
|
||||||
|
->success()
|
||||||
|
->send();
|
||||||
|
|
||||||
|
redirect('/app/clients/create');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -122,6 +122,9 @@ class ClientResource extends Resource
|
|||||||
Actions\EditAction::make(),
|
Actions\EditAction::make(),
|
||||||
Actions\DeleteAction::make(),
|
Actions\DeleteAction::make(),
|
||||||
])
|
])
|
||||||
|
->emptyStateHeading('Niciun client încă')
|
||||||
|
->emptyStateDescription('Adaugă primul tău client manual sau importă din CSV. Toate mașinile, fișele și plățile se vor lega automat de el.')
|
||||||
|
->emptyStateIcon('heroicon-o-users')
|
||||||
->defaultSort('created_at', 'desc');
|
->defaultSort('created_at', 'desc');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -134,6 +134,9 @@ class LeadResource extends Resource
|
|||||||
Actions\EditAction::make(),
|
Actions\EditAction::make(),
|
||||||
Actions\DeleteAction::make(),
|
Actions\DeleteAction::make(),
|
||||||
])
|
])
|
||||||
|
->emptyStateHeading('Nicio cerere primită')
|
||||||
|
->emptyStateDescription('Aici apar cererile clienților potențiali. Convertește-le în deal-uri sau direct în programări de la butonul „Convertește".')
|
||||||
|
->emptyStateIcon('heroicon-o-inbox-arrow-down')
|
||||||
->defaultSort('created_at', 'desc');
|
->defaultSort('created_at', 'desc');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -131,6 +131,9 @@ class PartResource extends Resource
|
|||||||
Actions\EditAction::make(),
|
Actions\EditAction::make(),
|
||||||
Actions\DeleteAction::make(),
|
Actions\DeleteAction::make(),
|
||||||
])
|
])
|
||||||
|
->emptyStateHeading('Depozit gol')
|
||||||
|
->emptyStateDescription('Adaugă piese manual, sau folosește Achiziții ca să le adaugi prin recepție de la furnizor (cu prețuri și stoc auto). Procentaj poate seta automat prețul de vânzare.')
|
||||||
|
->emptyStateIcon('heroicon-o-cube')
|
||||||
->defaultSort('name');
|
->defaultSort('name');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -96,6 +96,9 @@ class VehicleResource extends Resource
|
|||||||
Actions\EditAction::make(),
|
Actions\EditAction::make(),
|
||||||
Actions\DeleteAction::make(),
|
Actions\DeleteAction::make(),
|
||||||
])
|
])
|
||||||
|
->emptyStateHeading('Nicio mașină încă')
|
||||||
|
->emptyStateDescription('Adaugă mașini manual sau importă din CSV. Folosește VIN-căutare pentru decoder rapid și completare automată brand/model/an.')
|
||||||
|
->emptyStateIcon('heroicon-o-truck')
|
||||||
->defaultSort('created_at', 'desc');
|
->defaultSort('created_at', 'desc');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -169,6 +169,9 @@ class WorkOrderResource extends Resource
|
|||||||
Actions\EditAction::make(),
|
Actions\EditAction::make(),
|
||||||
Actions\DeleteAction::make(),
|
Actions\DeleteAction::make(),
|
||||||
])
|
])
|
||||||
|
->emptyStateHeading('Nicio fișă de lucru')
|
||||||
|
->emptyStateDescription('Crează prima fișă pentru o mașină existentă. Adaugă manopere, piese, plăți — totalul se calculează automat.')
|
||||||
|
->emptyStateIcon('heroicon-o-wrench-screwdriver')
|
||||||
->defaultSort('opened_at', 'desc');
|
->defaultSort('opened_at', 'desc');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,56 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Middleware;
|
||||||
|
|
||||||
|
use App\Tenancy\TenantManager;
|
||||||
|
use Closure;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* If the current tenant has never completed the onboarding wizard,
|
||||||
|
* redirect logged-in users to /app/onboarding.
|
||||||
|
*
|
||||||
|
* Skip when:
|
||||||
|
* - already on /app/onboarding
|
||||||
|
* - on /app/login or /app/logout
|
||||||
|
* - on /livewire/* (XHR — wizard handles its own POST)
|
||||||
|
*/
|
||||||
|
class RequireOnboarding
|
||||||
|
{
|
||||||
|
public function handle(Request $request, Closure $next)
|
||||||
|
{
|
||||||
|
$path = $request->path();
|
||||||
|
|
||||||
|
// Skip non-tenant-panel paths.
|
||||||
|
if (! str_starts_with($path, 'app')) {
|
||||||
|
return $next($request);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skip auth & onboarding paths.
|
||||||
|
if (
|
||||||
|
str_contains($path, 'login')
|
||||||
|
|| str_contains($path, 'logout')
|
||||||
|
|| str_contains($path, 'onboarding')
|
||||||
|
|| str_starts_with($path, 'livewire')
|
||||||
|
|| str_starts_with($path, 'filament')
|
||||||
|
) {
|
||||||
|
return $next($request);
|
||||||
|
}
|
||||||
|
|
||||||
|
$tenant = app(TenantManager::class)->current();
|
||||||
|
if (! $tenant) {
|
||||||
|
return $next($request);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only redirect authenticated users.
|
||||||
|
if (! auth('web')->check()) {
|
||||||
|
return $next($request);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (empty($tenant->settings['onboarded_at'])) {
|
||||||
|
return redirect('/app/onboarding');
|
||||||
|
}
|
||||||
|
|
||||||
|
return $next($request);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -70,6 +70,7 @@ class TenantPanelProvider extends PanelProvider
|
|||||||
AddQueuedCookiesToResponse::class,
|
AddQueuedCookiesToResponse::class,
|
||||||
StartSession::class,
|
StartSession::class,
|
||||||
\App\Http\Middleware\SetLocale::class,
|
\App\Http\Middleware\SetLocale::class,
|
||||||
|
\App\Http\Middleware\RequireOnboarding::class,
|
||||||
AuthenticateSession::class,
|
AuthenticateSession::class,
|
||||||
ShareErrorsFromSession::class,
|
ShareErrorsFromSession::class,
|
||||||
VerifyCsrfToken::class,
|
VerifyCsrfToken::class,
|
||||||
|
|||||||
@@ -0,0 +1,77 @@
|
|||||||
|
<x-filament-panels::page>
|
||||||
|
<style>
|
||||||
|
.ob-wrap { max-width: 720px; margin: 0 auto; }
|
||||||
|
.ob-stepper { display: flex; justify-content: center; gap: 8px; margin-bottom: 32px; }
|
||||||
|
.ob-step {
|
||||||
|
width: 48px; height: 48px; border-radius: 50%;
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
background: #e5e7eb; color: #9ca3af; font-weight: 600;
|
||||||
|
transition: all .2s;
|
||||||
|
}
|
||||||
|
.ob-step.active { background: var(--primary-500, #3b82f6); color: #fff; }
|
||||||
|
.ob-step.done { background: #10b981; color: #fff; }
|
||||||
|
.ob-step-line { flex: 0 0 60px; align-self: center; height: 2px; background: #e5e7eb; }
|
||||||
|
.ob-step-line.done { background: #10b981; }
|
||||||
|
|
||||||
|
.ob-buttons { display: flex; justify-content: space-between; margin-top: 24px; }
|
||||||
|
.ob-btn {
|
||||||
|
padding: 10px 20px; border-radius: 8px; font-weight: 500;
|
||||||
|
cursor: pointer; border: none; font-size: 14px;
|
||||||
|
}
|
||||||
|
.ob-btn-primary { background: var(--primary-500, #3b82f6); color: #fff; }
|
||||||
|
.ob-btn-primary:hover { background: var(--primary-600, #2563eb); }
|
||||||
|
.ob-btn-secondary { background: transparent; color: #6b7280; border: 1px solid #e5e7eb; }
|
||||||
|
.ob-btn-secondary:hover { background: #f9fafb; }
|
||||||
|
.ob-btn-success { background: #10b981; color: #fff; padding: 12px 28px; font-size: 15px; }
|
||||||
|
.ob-btn-success:hover { background: #059669; }
|
||||||
|
|
||||||
|
.ob-intro {
|
||||||
|
background: linear-gradient(135deg, #eff6ff, #dbeafe);
|
||||||
|
border: 1px solid #bfdbfe;
|
||||||
|
border-radius: 12px; padding: 20px;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
font-size: 14px; line-height: 1.6;
|
||||||
|
}
|
||||||
|
.dark .ob-intro { background: linear-gradient(135deg, #1e3a8a40, #1e40af40); border-color: #1e40af; color: #dbeafe; }
|
||||||
|
.ob-intro h3 { font-size: 16px; font-weight: 600; margin-bottom: 6px; }
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<div class="ob-wrap">
|
||||||
|
<div class="ob-intro">
|
||||||
|
<h3>👋 Bun venit!</h3>
|
||||||
|
Hai să facem 30 de secunde de configurare ca AutoCRM-ul tău să arate exact cum vrei tu.
|
||||||
|
Toate aceste setări pot fi schimbate oricând din meniul „Setări".
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{-- Stepper --}}
|
||||||
|
<div class="ob-stepper">
|
||||||
|
@for ($i = 1; $i <= 3; $i++)
|
||||||
|
<div class="ob-step {{ $step === $i ? 'active' : ($step > $i ? 'done' : '') }}">
|
||||||
|
{{ $step > $i ? '✓' : $i }}
|
||||||
|
</div>
|
||||||
|
@if ($i < 3)
|
||||||
|
<div class="ob-step-line {{ $step > $i ? 'done' : '' }}"></div>
|
||||||
|
@endif
|
||||||
|
@endfor
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form wire:submit.prevent="finish">
|
||||||
|
{{ $this->form }}
|
||||||
|
|
||||||
|
<div class="ob-buttons">
|
||||||
|
<div>
|
||||||
|
@if ($step > 1)
|
||||||
|
<button type="button" class="ob-btn ob-btn-secondary" wire:click="prev">← Înapoi</button>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
@if ($step < 3)
|
||||||
|
<button type="button" class="ob-btn ob-btn-primary" wire:click="next">Continuă →</button>
|
||||||
|
@else
|
||||||
|
<button type="submit" class="ob-btn ob-btn-success">🎉 Termină configurarea</button>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</x-filament-panels::page>
|
||||||
Reference in New Issue
Block a user