Faza 2: multi-tenancy + Filament dual panels + seed PSauto

Schema centrală:
- companies (slug unique, status, plan_id, settings JSON, trial/active dates)
- super_admins (operator platform)
- plans (free/basic/pro)

Schema tenant (toate cu company_id NOT NULL):
- users (UNIQUE company_id+email)
- clients
- vehicles

Tenancy core:
- App\Tenancy\TenantManager singleton
- App\Models\Concerns\BelongsToTenant trait + TenantScope
- ResolveTenant middleware (slug → Company, 404 pentru rezervate/missing)
- CheckTenantStatus middleware (suspended/expired/archived)
- Fail-safe: TenantScope returns 0 rows când tenant nu e rezolvat

Auth guards:
- 'central' guard cu super_admins provider (panou platform)
- 'web' guard cu users provider (per-tenant)

Filament panels:
- CentralPanelProvider la service.mir.md/admin
- TenantPanelProvider la <slug>.service.mir.md/app
- CompanyResource (central): CRUD companii cu status badge + filtre
- ClientResource (tenant): CRUD clienți cu status, sursă, sold
- VehicleResource (tenant): CRUD mașini cu marcă/model/VIN

Seed:
- 3 plans (free/basic/pro)
- super-admin: vasyka.moraru@gmail.com / admin123
- demo company 'psauto' cu admin user admin@psauto.md / admin123
- 3 clienți + 3 mașini preluate din AutoCRM.html

Bootstrap:
- TrustProxies (Cloudflare→Traefik HTTPS detection)
- forceScheme/forceRootUrl când APP_URL e HTTPS
- Helper global tenant() în app/helpers.php (autoload via composer)
- RUN_SEED env var în entrypoint pentru db:seed condiționat
This commit is contained in:
2026-05-05 21:29:52 +00:00
parent 125566cb81
commit 4b1635d045
48 changed files with 1510 additions and 386 deletions
@@ -0,0 +1,119 @@
<?php
namespace App\Filament\Tenant\Resources;
use App\Filament\Tenant\Resources\ClientResource\Pages;
use App\Models\Tenant\Client;
use Filament\Forms;
use Filament\Resources\Resource;
use Filament\Schemas\Schema;
use Filament\Tables;
use Filament\Tables\Table;
class ClientResource extends Resource
{
protected static ?string $model = Client::class;
protected static string|\BackedEnum|null $navigationIcon = 'heroicon-o-users';
protected static ?string $navigationLabel = 'Clienți';
protected static ?string $modelLabel = 'client';
protected static ?string $pluralModelLabel = 'clienți';
protected static ?int $navigationSort = 10;
public static function form(Schema $schema): Schema
{
return $schema->components([
Forms\Components\Section::make('Date generale')
->columns(2)
->schema([
Forms\Components\Select::make('type')
->label('Tip')
->options(['individual' => 'Persoană fizică', 'company' => 'Persoană juridică'])
->default('individual')
->required()
->live(),
Forms\Components\TextInput::make('name')->label('Nume')->required()->maxLength(120),
Forms\Components\TextInput::make('company_name')
->label('Denumire companie')
->visible(fn (Forms\Get $get) => $get('type') === 'company')
->maxLength(160),
Forms\Components\Select::make('status')
->options([
'new' => 'Nou', 'active' => 'Activ', 'vip' => 'VIP',
'debtor' => 'Datornic', 'blocked' => 'Blocat', 'lost' => 'Pierdut',
])
->default('active')
->required(),
]),
Forms\Components\Section::make('Contacte')
->columns(2)
->schema([
Forms\Components\TextInput::make('phone')->label('Telefon')->tel()->required()->maxLength(40),
Forms\Components\TextInput::make('phone_alt')->label('Telefon alternativ')->tel()->maxLength(40),
Forms\Components\TextInput::make('email')->email()->maxLength(120),
Forms\Components\TextInput::make('telegram')->maxLength(60),
Forms\Components\TextInput::make('whatsapp')->maxLength(60),
Forms\Components\TextInput::make('viber')->maxLength(60),
]),
Forms\Components\Section::make('Marketing')
->columns(2)
->schema([
Forms\Components\TextInput::make('source')->label('Sursă')->maxLength(60),
Forms\Components\TextInput::make('marketing_channel')->label('Canal marketing')->maxLength(60),
]),
Forms\Components\Section::make('Financiar')
->columns(2)
->schema([
Forms\Components\TextInput::make('balance')->label('Sold')->numeric()->default(0),
Forms\Components\TextInput::make('discount_pct')->label('Discount %')->numeric()->default(0),
]),
Forms\Components\Textarea::make('notes')->label('Notițe')->columnSpanFull()->rows(3),
]);
}
public static function table(Table $table): Table
{
return $table
->columns([
Tables\Columns\TextColumn::make('name')->searchable()->sortable(),
Tables\Columns\TextColumn::make('phone')->searchable()->copyable(),
Tables\Columns\TextColumn::make('email')->searchable()->toggleable(),
Tables\Columns\TextColumn::make('vehicles_count')->counts('vehicles')->label('Mașini'),
Tables\Columns\TextColumn::make('status')
->badge()
->colors([
'success' => ['active', 'vip'],
'gray' => ['new'],
'danger' => ['debtor', 'blocked', 'lost'],
]),
Tables\Columns\TextColumn::make('balance')
->money(fn () => tenant()?->settings['currency'] ?? 'MDL')
->color(fn ($state) => $state < 0 ? 'danger' : 'success'),
Tables\Columns\TextColumn::make('created_at')->date()->sortable(),
])
->filters([
Tables\Filters\SelectFilter::make('status')->options([
'new' => 'Nou', 'active' => 'Activ', 'vip' => 'VIP',
'debtor' => 'Datornic', 'blocked' => 'Blocat', 'lost' => 'Pierdut',
]),
])
->actions([
Tables\Actions\EditAction::make(),
Tables\Actions\DeleteAction::make(),
])
->defaultSort('created_at', 'desc');
}
public static function getPages(): array
{
return [
'index' => Pages\ListClients::route('/'),
'create' => Pages\CreateClient::route('/create'),
'edit' => Pages\EditClient::route('/{record}/edit'),
];
}
}
@@ -0,0 +1,11 @@
<?php
namespace App\Filament\Tenant\Resources\ClientResource\Pages;
use App\Filament\Tenant\Resources\ClientResource;
use Filament\Resources\Pages\CreateRecord;
class CreateClient extends CreateRecord
{
protected static string $resource = ClientResource::class;
}
@@ -0,0 +1,17 @@
<?php
namespace App\Filament\Tenant\Resources\ClientResource\Pages;
use App\Filament\Tenant\Resources\ClientResource;
use Filament\Actions;
use Filament\Resources\Pages\EditRecord;
class EditClient extends EditRecord
{
protected static string $resource = ClientResource::class;
protected function getHeaderActions(): array
{
return [Actions\DeleteAction::make()];
}
}
@@ -0,0 +1,17 @@
<?php
namespace App\Filament\Tenant\Resources\ClientResource\Pages;
use App\Filament\Tenant\Resources\ClientResource;
use Filament\Actions;
use Filament\Resources\Pages\ListRecords;
class ListClients extends ListRecords
{
protected static string $resource = ClientResource::class;
protected function getHeaderActions(): array
{
return [Actions\CreateAction::make()];
}
}
@@ -0,0 +1,90 @@
<?php
namespace App\Filament\Tenant\Resources;
use App\Filament\Tenant\Resources\VehicleResource\Pages;
use App\Models\Tenant\Client;
use App\Models\Tenant\Vehicle;
use Filament\Forms;
use Filament\Resources\Resource;
use Filament\Schemas\Schema;
use Filament\Tables;
use Filament\Tables\Table;
class VehicleResource extends Resource
{
protected static ?string $model = Vehicle::class;
protected static string|\BackedEnum|null $navigationIcon = 'heroicon-o-truck';
protected static ?string $navigationLabel = 'Automobile';
protected static ?string $modelLabel = 'mașină';
protected static ?string $pluralModelLabel = 'mașini';
protected static ?int $navigationSort = 20;
public static function form(Schema $schema): Schema
{
return $schema->components([
Forms\Components\Section::make('Identificare')
->columns(2)
->schema([
Forms\Components\Select::make('client_id')
->label('Proprietar')
->options(fn () => Client::pluck('name', 'id'))
->searchable()
->required(),
Forms\Components\TextInput::make('plate')->label('Nr. înmatriculare')->maxLength(16),
Forms\Components\TextInput::make('make')->label('Marca')->required()->maxLength(60),
Forms\Components\TextInput::make('model')->required()->maxLength(60),
Forms\Components\TextInput::make('year')->numeric()->minValue(1950)->maxValue(2100),
Forms\Components\TextInput::make('vin')->maxLength(32),
]),
Forms\Components\Section::make('Tehnice')
->columns(2)
->schema([
Forms\Components\TextInput::make('engine')->maxLength(60),
Forms\Components\TextInput::make('gearbox')->maxLength(60),
Forms\Components\Select::make('fuel')
->options([
'Benzină' => 'Benzină', 'Diesel' => 'Diesel', 'Hybrid' => 'Hybrid',
'EV' => 'Electric', 'GPL' => 'GPL', 'GNC' => 'GNC',
]),
Forms\Components\TextInput::make('mileage')->label('Kilometraj')->numeric()->default(0),
Forms\Components\TextInput::make('color')->maxLength(40),
]),
Forms\Components\Textarea::make('notes')->label('Notițe')->columnSpanFull()->rows(3),
]);
}
public static function table(Table $table): Table
{
return $table
->columns([
Tables\Columns\TextColumn::make('plate')->label('Nr.')->searchable(),
Tables\Columns\TextColumn::make('make')->sortable(),
Tables\Columns\TextColumn::make('model'),
Tables\Columns\TextColumn::make('year'),
Tables\Columns\TextColumn::make('client.name')->label('Proprietar')->searchable(),
Tables\Columns\TextColumn::make('vin')->toggleable(isToggledHiddenByDefault: true),
Tables\Columns\TextColumn::make('mileage')->label('Km')->numeric(),
Tables\Columns\TextColumn::make('created_at')->date()->sortable(),
])
->actions([
Tables\Actions\EditAction::make(),
Tables\Actions\DeleteAction::make(),
])
->defaultSort('created_at', 'desc');
}
public static function getPages(): array
{
return [
'index' => Pages\ListVehicles::route('/'),
'create' => Pages\CreateVehicle::route('/create'),
'edit' => Pages\EditVehicle::route('/{record}/edit'),
];
}
}
@@ -0,0 +1,11 @@
<?php
namespace App\Filament\Tenant\Resources\VehicleResource\Pages;
use App\Filament\Tenant\Resources\VehicleResource;
use Filament\Resources\Pages\CreateRecord;
class CreateVehicle extends CreateRecord
{
protected static string $resource = VehicleResource::class;
}
@@ -0,0 +1,17 @@
<?php
namespace App\Filament\Tenant\Resources\VehicleResource\Pages;
use App\Filament\Tenant\Resources\VehicleResource;
use Filament\Actions;
use Filament\Resources\Pages\EditRecord;
class EditVehicle extends EditRecord
{
protected static string $resource = VehicleResource::class;
protected function getHeaderActions(): array
{
return [Actions\DeleteAction::make()];
}
}
@@ -0,0 +1,17 @@
<?php
namespace App\Filament\Tenant\Resources\VehicleResource\Pages;
use App\Filament\Tenant\Resources\VehicleResource;
use Filament\Actions;
use Filament\Resources\Pages\ListRecords;
class ListVehicles extends ListRecords
{
protected static string $resource = VehicleResource::class;
protected function getHeaderActions(): array
{
return [Actions\CreateAction::make()];
}
}