Central panel SaaS upgrade — Plans/Subscriptions/SuperAdmins/Detail page

Models & migrations:
- subscriptions table (company, plan, period, amount, status, dates, invoice)
- super_admins: role enum (owner/admin/support/sales/finance) + phone + notes
- Subscription model with STATUSES/PERIODS/PAYMENT_METHODS + invoice number
  generator + extends company.active_until on mark_paid
- Company model: subscriptions() + latestSubscription() relations
- SuperAdmin model: role helpers (isOwner, canManageBilling, canManageTenants)

Filament Central panel:
- PlanResource (CRUD, features checklist, limits per plan, abonati count badge)
- SubscriptionResource (CRUD, mark_paid action, navigation badge for overdue)
- SuperAdminResource (CRUD, reset password, toggle 2FA, can't self-delete)
- ViewCompany page with live stats (users/clients/vehicles/WO/parts/revenue/
  storage/last_login + days_until_expiry), subscriptions history table,
  config snapshot, action buttons (open/issue invoice/upload logo/suspend)
- CompanyResource: row click → view, openUrlInNewTab action, recordTitleAttribute,
  empty state, view route registered
- PlatformStats widget upgraded: 6 cards (incl. MRR realized this month, overdue
  invoices count, click-through to filtered tables)
- RevenueChart: 12-month MRR line chart
- RecentTenants: latest 8 tenants with click-through
- PendingPayments: pending+overdue invoices table
- Database notifications enabled + Cmd+K global search
- HEAD_END render hook: PWA manifest + theme color + emoji favicon
- /admin-manifest.json route

Seeder:
- Plans aligned with new FEATURE_OPTIONS (kanban/pdf/reports/ai/api/reverb/etc)
- 4 plans: Free / Basic / Pro / Enterprise (with proper limits)
- SuperAdmin gets role='owner'
- Demo subscription for psauto on Pro plan, marked paid this month
This commit is contained in:
2026-05-07 22:02:44 +00:00
parent 0399262514
commit 10426d0c91
27 changed files with 1442 additions and 16 deletions
@@ -25,6 +25,15 @@ class CompanyResource extends Resource
protected static ?string $pluralModelLabel = 'companii';
protected static ?int $navigationSort = 10;
protected static ?string $recordTitleAttribute = 'name';
public static function getGloballySearchableAttributes(): array
{
return ['slug', 'name', 'display_name', 'city', 'phone', 'email'];
}
public static function form(Schema $schema): Schema
{
return $schema->components([
@@ -123,7 +132,16 @@ class CompanyResource extends Resource
'suspended' => 'Suspendat', 'archived' => 'Arhivat',
]),
])
->recordUrl(fn (Company $r) => CompanyResource::getUrl('view', ['record' => $r]))
->actions([
Actions\ViewAction::make()
->url(fn (Company $r) => CompanyResource::getUrl('view', ['record' => $r])),
Actions\Action::make('open_tenant')
->label('Deschide')
->icon('heroicon-m-arrow-top-right-on-square')
->color('primary')
->url(fn (Company $r) => $r->url('/app'))
->openUrlInNewTab(),
Actions\Action::make('suspend')
->label('Suspendă')
->icon('heroicon-m-no-symbol')
@@ -141,6 +159,9 @@ class CompanyResource extends Resource
Actions\EditAction::make(),
Actions\DeleteAction::make(),
])
->emptyStateHeading('Niciun tenant încă')
->emptyStateDescription('Adaugă primul client SaaS folosind butonul „Adaugă companie" — se va crea un tenant nou cu admin pe `<slug>.service.mir.md/app`.')
->emptyStateIcon('heroicon-o-building-office-2')
->defaultSort('created_at', 'desc');
}
@@ -149,6 +170,7 @@ class CompanyResource extends Resource
return [
'index' => Pages\ListCompanies::route('/'),
'create' => Pages\CreateCompany::route('/create'),
'view' => Pages\ViewCompany::route('/{record}'),
'edit' => Pages\EditCompany::route('/{record}/edit'),
];
}
@@ -0,0 +1,176 @@
<?php
namespace App\Filament\Central\Resources\CompanyResource\Pages;
use App\Filament\Central\Resources\CompanyResource;
use App\Models\Central\Company;
use App\Models\Central\Subscription;
use App\Models\Tenant\Client;
use App\Models\Tenant\Part;
use App\Models\Tenant\Payment;
use App\Models\Tenant\User as TenantUser;
use App\Models\Tenant\Vehicle;
use App\Models\Tenant\WorkOrder;
use App\Tenancy\TenantManager;
use Filament\Actions;
use Filament\Forms;
use Filament\Notifications\Notification;
use Filament\Resources\Pages\Page;
class ViewCompany extends Page
{
protected static string $resource = CompanyResource::class;
protected string $view = 'filament.central.resources.company.view';
public Company $record;
public function mount(int|string $record): void
{
$this->record = Company::with(['plan', 'subscriptions' => fn ($q) => $q->latest('period_end')->limit(10)])->findOrFail($record);
}
public function getTitle(): string
{
return $this->record->display_name ?? $this->record->name;
}
public function getStats(): array
{
// Set tenant context to query scoped tables.
app(TenantManager::class)->setCurrent($this->record);
app(\Spatie\Permission\PermissionRegistrar::class)
->setPermissionsTeamId($this->record->id);
$stats = [
'users' => TenantUser::count(),
'clients' => Client::count(),
'vehicles' => Vehicle::count(),
'work_orders' => WorkOrder::count(),
'work_orders_open' => WorkOrder::whereNotIn('status', ['done', 'cancelled'])->count(),
'parts' => Part::count(),
'parts_low_stock' => Part::where('is_active', true)
->whereColumn('stock', '<=', 'low_stock_threshold')
->where('stock', '>', 0)
->count(),
'revenue_this_month' => (float) Payment::whereYear('paid_at', date('Y'))
->whereMonth('paid_at', date('m'))->sum('amount'),
'revenue_last_month' => (float) Payment::whereYear('paid_at', now()->subMonth()->year)
->whereMonth('paid_at', now()->subMonth()->month)->sum('amount'),
'last_login' => TenantUser::whereNotNull('last_login_at')->max('last_login_at'),
'storage_mb' => $this->calculateStorageMb(),
];
app(TenantManager::class)->setCurrent(null);
return $stats;
}
private function calculateStorageMb(): float
{
try {
$bytes = (int) \DB::table('media')
->where('model_type', \App\Models\Central\Company::class)
->where('model_id', $this->record->id)
->sum('size');
return round($bytes / 1024 / 1024, 2);
} catch (\Throwable) {
return 0;
}
}
public function getDaysUntilExpiry(): ?int
{
$until = $this->record->active_until ?? $this->record->trial_ends_at;
if (! $until) return null;
$diff = now()->diffInDays($until, false);
return (int) $diff;
}
protected function getHeaderActions(): array
{
return [
Actions\Action::make('view_as')
->label('Deschide tenantul')
->icon('heroicon-m-arrow-top-right-on-square')
->color('primary')
->url(fn () => $this->record->url('/app'))
->openUrlInNewTab(),
Actions\Action::make('issue_invoice')
->label('Generează factură')
->icon('heroicon-m-document-plus')
->color('success')
->visible(fn () => (bool) $this->record->plan_id)
->schema([
Forms\Components\Select::make('period')->options(\App\Models\Central\Subscription::PERIODS)->default('monthly')->required(),
])
->action(function (array $data) {
$plan = $this->record->plan;
if (! $plan) return;
$start = today();
$end = $data['period'] === 'yearly' ? $start->copy()->addYear() : $start->copy()->addMonth();
$sub = Subscription::create([
'company_id' => $this->record->id,
'plan_id' => $plan->id,
'period' => $data['period'],
'amount' => $data['period'] === 'yearly' ? $plan->price_yearly : $plan->price_monthly,
'currency' => $plan->currency,
'status' => 'pending',
'period_start' => $start,
'period_end' => $end,
'due_at' => $start->copy()->addDays(7),
'invoice_number' => Subscription::generateInvoiceNumber(),
]);
Notification::make()
->title('Factură generată: ' . $sub->invoice_number)
->body('Suma: ' . number_format($sub->amount, 2) . ' ' . $sub->currency)
->success()->send();
$this->dispatch('$refresh');
}),
Actions\Action::make('upload_logo')
->label('Logo')
->icon('heroicon-m-photo')
->color('gray')
->schema([
Forms\Components\FileUpload::make('logo')
->image()
->imageEditor()
->disk('public')
->directory('central-uploads')
->maxSize(2048)
->required(),
])
->action(function (array $data) {
if (empty($data['logo'])) return;
$abs = \Illuminate\Support\Facades\Storage::disk('public')->path($data['logo']);
if (file_exists($abs)) {
$this->record->clearMediaCollection('logo');
$this->record->addMedia($abs)->preservingOriginal()->toMediaCollection('logo');
@unlink($abs);
Notification::make()->title('Logo actualizat')->success()->send();
}
}),
Actions\Action::make('edit')
->label('Editează')
->icon('heroicon-m-pencil')
->url(fn () => CompanyResource::getUrl('edit', ['record' => $this->record])),
Actions\Action::make('suspend')
->label('Suspendă')->icon('heroicon-m-no-symbol')->color('danger')
->visible(fn () => in_array($this->record->status, ['active', 'trial']))
->requiresConfirmation()
->action(function () {
app(\App\Services\CompanyProvisioner::class)->suspend($this->record);
Notification::make()->title('Tenant suspendat.')->success()->send();
$this->dispatch('$refresh');
}),
Actions\Action::make('activate')
->label('Reactivează')->icon('heroicon-m-check-circle')->color('success')
->visible(fn () => in_array($this->record->status, ['suspended', 'expired']))
->requiresConfirmation()
->action(function () {
app(\App\Services\CompanyProvisioner::class)->reactivate($this->record);
Notification::make()->title('Tenant activat.')->success()->send();
$this->dispatch('$refresh');
}),
];
}
}
@@ -0,0 +1,125 @@
<?php
namespace App\Filament\Central\Resources;
use App\Filament\Central\Resources\PlanResource\Pages;
use App\Models\Central\Plan;
use Filament\Actions;
use Filament\Forms;
use Filament\Resources\Resource;
use Filament\Schemas;
use Filament\Schemas\Schema;
use Filament\Tables;
use Filament\Tables\Table;
class PlanResource extends Resource
{
protected static ?string $model = Plan::class;
protected static string|\BackedEnum|null $navigationIcon = 'heroicon-o-rectangle-stack';
protected static ?string $navigationLabel = 'Planuri';
protected static ?string $modelLabel = 'plan';
protected static ?string $pluralModelLabel = 'planuri';
protected static ?int $navigationSort = 20;
public const FEATURE_OPTIONS = [
'kanban' => 'Kanban WO',
'reports' => 'Rapoarte avansate',
'ai' => 'Asistent AI',
'pdf' => 'Generare PDF',
'reverb' => 'WebSocket real-time',
'api' => 'REST API + tokens',
'multi_user' => 'Multi-user (>1 cont)',
'white_label' => 'White-label complet',
'priority_support' => 'Suport prioritar',
'custom_domain' => 'Domeniu propriu',
];
public static function form(Schema $schema): Schema
{
return $schema->components([
Schemas\Components\Section::make('Identificare')
->columns(2)
->schema([
Forms\Components\TextInput::make('slug')
->required()->alphaDash()->unique(ignoreRecord: true)
->dehydrateStateUsing(fn ($s) => strtolower((string) $s))
->extraInputAttributes(['style' => 'text-transform:lowercase']),
Forms\Components\TextInput::make('name')->required()->maxLength(60),
Forms\Components\Toggle::make('is_active')->label('Activ')->default(true),
Forms\Components\Toggle::make('is_public')->label('Public (afișat la signup)')->default(true),
]),
Schemas\Components\Section::make('Preț')
->columns(3)
->schema([
Forms\Components\TextInput::make('price_monthly')->label('Preț lunar')->numeric()->required()->suffix(fn (Forms\Get $get) => $get('currency') ?? 'MDL'),
Forms\Components\TextInput::make('price_yearly')->label('Preț anual')->numeric()->helperText('De obicei 10× preț lunar (2 luni gratis).')->suffix(fn (Forms\Get $get) => $get('currency') ?? 'MDL'),
Forms\Components\Select::make('currency')->options(['MDL' => 'MDL', 'EUR' => 'EUR', 'USD' => 'USD'])->default('MDL'),
]),
Schemas\Components\Section::make('Funcționalități incluse')
->columns(2)
->schema([
Forms\Components\CheckboxList::make('features')
->options(self::FEATURE_OPTIONS)
->columns(2)
->columnSpanFull(),
]),
Schemas\Components\Section::make('Limite')
->columns(3)
->schema([
Forms\Components\TextInput::make('limits.max_users')->label('Max useri')->numeric()->placeholder('∞'),
Forms\Components\TextInput::make('limits.max_clients')->label('Max clienți')->numeric()->placeholder('∞'),
Forms\Components\TextInput::make('limits.max_vehicles')->label('Max mașini')->numeric()->placeholder('∞'),
Forms\Components\TextInput::make('limits.max_work_orders_month')->label('Max fișe/lună')->numeric()->placeholder('∞'),
Forms\Components\TextInput::make('limits.storage_mb')->label('Storage (MB)')->numeric()->placeholder('∞'),
Forms\Components\TextInput::make('limits.ai_messages_month')->label('Mesaje AI/lună')->numeric()->placeholder('∞'),
]),
]);
}
public static function table(Table $table): Table
{
return $table
->columns([
Tables\Columns\TextColumn::make('name')->searchable()->sortable(),
Tables\Columns\TextColumn::make('price_monthly')
->money(fn ($r) => $r->currency)
->label('Lunar')
->sortable(),
Tables\Columns\TextColumn::make('price_yearly')
->money(fn ($r) => $r->currency)
->label('Anual'),
Tables\Columns\TextColumn::make('companies_count')
->counts('companies')
->label('Abonați')
->badge()
->color('primary'),
Tables\Columns\TextColumn::make('features')
->label('Funcționalități')
->formatStateUsing(fn ($s) => is_array($s) ? count($s) . ' incluse' : '—')
->badge(),
Tables\Columns\IconColumn::make('is_active')->boolean()->label('Activ'),
Tables\Columns\IconColumn::make('is_public')->boolean()->label('Public'),
])
->actions([
Actions\EditAction::make(),
Actions\DeleteAction::make(),
])
->emptyStateHeading('Niciun plan definit')
->emptyStateDescription('Creează planuri (ex: Free, Basic, Pro) și atribuie-le companiilor.')
->defaultSort('price_monthly');
}
public static function getPages(): array
{
return [
'index' => Pages\ListPlans::route('/'),
'create' => Pages\CreatePlan::route('/create'),
'edit' => Pages\EditPlan::route('/{record}/edit'),
];
}
}
@@ -0,0 +1,11 @@
<?php
namespace App\Filament\Central\Resources\PlanResource\Pages;
use App\Filament\Central\Resources\PlanResource;
use Filament\Resources\Pages\CreateRecord;
class CreatePlan extends CreateRecord
{
protected static string $resource = PlanResource::class;
}
@@ -0,0 +1,17 @@
<?php
namespace App\Filament\Central\Resources\PlanResource\Pages;
use App\Filament\Central\Resources\PlanResource;
use Filament\Actions;
use Filament\Resources\Pages\EditRecord;
class EditPlan extends EditRecord
{
protected static string $resource = PlanResource::class;
protected function getHeaderActions(): array
{
return [Actions\DeleteAction::make()];
}
}
@@ -0,0 +1,17 @@
<?php
namespace App\Filament\Central\Resources\PlanResource\Pages;
use App\Filament\Central\Resources\PlanResource;
use Filament\Actions;
use Filament\Resources\Pages\ListRecords;
class ListPlans extends ListRecords
{
protected static string $resource = PlanResource::class;
protected function getHeaderActions(): array
{
return [Actions\CreateAction::make()];
}
}
@@ -0,0 +1,174 @@
<?php
namespace App\Filament\Central\Resources;
use App\Filament\Central\Resources\SubscriptionResource\Pages;
use App\Models\Central\Company;
use App\Models\Central\Plan;
use App\Models\Central\Subscription;
use Filament\Actions;
use Filament\Forms;
use Filament\Notifications\Notification;
use Filament\Resources\Resource;
use Filament\Schemas;
use Filament\Schemas\Schema;
use Filament\Tables;
use Filament\Tables\Table;
class SubscriptionResource extends Resource
{
protected static ?string $model = Subscription::class;
protected static string|\BackedEnum|null $navigationIcon = 'heroicon-o-credit-card';
protected static ?string $navigationLabel = 'Facturi & abonamente';
protected static ?string $modelLabel = 'factură';
protected static ?string $pluralModelLabel = 'facturi';
protected static ?int $navigationSort = 30;
public static function getNavigationBadge(): ?string
{
$overdue = static::$model::where('status', 'overdue')->count();
return $overdue > 0 ? (string) $overdue : null;
}
public static function getNavigationBadgeColor(): ?string { return 'danger'; }
public static function form(Schema $schema): Schema
{
return $schema->components([
Schemas\Components\Section::make('Companie & plan')
->columns(2)
->schema([
Forms\Components\Select::make('company_id')
->label('Companie')
->options(fn () => Company::orderBy('name')->pluck('name', 'id'))
->required()->searchable()->live(),
Forms\Components\Select::make('plan_id')
->label('Plan')
->options(fn () => Plan::pluck('name', 'id'))
->required()->searchable()->live()
->afterStateUpdated(function ($state, Forms\Set $set, Forms\Get $get) {
if (! $state) return;
$plan = Plan::find($state);
if (! $plan) return;
$period = $get('period') ?? 'monthly';
$set('amount', $period === 'yearly' ? $plan->price_yearly : $plan->price_monthly);
$set('currency', $plan->currency);
}),
Forms\Components\Select::make('period')
->options(Subscription::PERIODS)->default('monthly')->required()->live()
->afterStateUpdated(function ($state, Forms\Set $set, Forms\Get $get) {
$plan = Plan::find($get('plan_id'));
if (! $plan) return;
$set('amount', $state === 'yearly' ? $plan->price_yearly : $plan->price_monthly);
// Auto-fill period_end based on period
$start = $get('period_start') ?? now()->toDateString();
$end = $state === 'yearly'
? \Carbon\Carbon::parse($start)->addYear()->toDateString()
: \Carbon\Carbon::parse($start)->addMonth()->toDateString();
$set('period_end', $end);
}),
Forms\Components\Select::make('status')
->options(Subscription::STATUSES)->default('pending')->required(),
]),
Schemas\Components\Section::make('Sumă')
->columns(3)
->schema([
Forms\Components\TextInput::make('amount')->numeric()->required()->suffix(fn (Forms\Get $get) => $get('currency') ?? 'MDL'),
Forms\Components\Select::make('currency')->options(['MDL' => 'MDL', 'EUR' => 'EUR', 'USD' => 'USD'])->default('MDL'),
Forms\Components\Select::make('payment_method')
->options(Subscription::PAYMENT_METHODS),
]),
Schemas\Components\Section::make('Perioadă')
->columns(3)
->schema([
Forms\Components\DatePicker::make('period_start')->required()->default(today()),
Forms\Components\DatePicker::make('period_end')->required(),
Forms\Components\DateTimePicker::make('due_at')->label('Scadent la'),
Forms\Components\DateTimePicker::make('paid_at')->label('Plătit la'),
]),
Schemas\Components\Section::make('Detalii')
->columns(2)
->schema([
Forms\Components\TextInput::make('invoice_number')->label('Nr. factură')->placeholder('auto-generat')->maxLength(30),
Forms\Components\TextInput::make('reference')->label('Referință (Stripe id, transfer)')->maxLength(100),
Forms\Components\Textarea::make('notes')->columnSpanFull()->rows(2),
]),
]);
}
public static function table(Table $table): Table
{
return $table
->columns([
Tables\Columns\TextColumn::make('invoice_number')
->label('Factură')
->placeholder(fn ($r) => '—')
->copyable()
->searchable(),
Tables\Columns\TextColumn::make('company.name')
->label('Companie')
->searchable()
->url(fn ($r) => route('filament.central.resources.companies.edit', ['record' => $r->company_id])),
Tables\Columns\TextColumn::make('plan.name')->label('Plan')->placeholder('—'),
Tables\Columns\TextColumn::make('period')
->formatStateUsing(fn ($s) => Subscription::PERIODS[$s] ?? $s)
->badge(),
Tables\Columns\TextColumn::make('amount')
->money(fn ($r) => $r->currency)
->sortable()
->weight('bold'),
Tables\Columns\TextColumn::make('status')
->badge()
->formatStateUsing(fn ($s) => Subscription::STATUSES[$s] ?? $s)
->color(fn ($s) => match ($s) {
'paid' => 'success',
'overdue' => 'danger',
'pending' => 'warning',
'cancelled', 'refunded' => 'gray',
default => 'primary',
}),
Tables\Columns\TextColumn::make('period_end')->label('Până la')->date(),
Tables\Columns\TextColumn::make('paid_at')->label('Plătit')->date()->placeholder('—'),
])
->filters([
Tables\Filters\SelectFilter::make('status')->options(Subscription::STATUSES),
Tables\Filters\SelectFilter::make('period')->options(Subscription::PERIODS),
])
->actions([
Actions\Action::make('mark_paid')
->label('Marchează plătit')
->icon('heroicon-m-check-circle')
->color('success')
->visible(fn ($r) => $r->status !== 'paid')
->requiresConfirmation()
->action(function (Subscription $r) {
$r->update(['status' => 'paid', 'paid_at' => now()]);
// Auto-extend company subscription
$r->company->update([
'status' => 'active',
'active_until' => $r->period_end,
]);
Notification::make()->title('Plată confirmată. Abonament extins până la ' . $r->period_end->format('d.m.Y'))->success()->send();
}),
Actions\EditAction::make(),
Actions\DeleteAction::make(),
])
->emptyStateHeading('Nicio factură generată')
->emptyStateDescription('Crează manual prima factură sau folosește butonul „Generează factură" din pagina Companiei.')
->defaultSort('created_at', 'desc');
}
public static function getPages(): array
{
return [
'index' => Pages\ListSubscriptions::route('/'),
'create' => Pages\CreateSubscription::route('/create'),
'edit' => Pages\EditSubscription::route('/{record}/edit'),
];
}
}
@@ -0,0 +1,20 @@
<?php
namespace App\Filament\Central\Resources\SubscriptionResource\Pages;
use App\Filament\Central\Resources\SubscriptionResource;
use App\Models\Central\Subscription;
use Filament\Resources\Pages\CreateRecord;
class CreateSubscription extends CreateRecord
{
protected static string $resource = SubscriptionResource::class;
protected function mutateFormDataBeforeCreate(array $data): array
{
if (empty($data['invoice_number'])) {
$data['invoice_number'] = Subscription::generateInvoiceNumber();
}
return $data;
}
}
@@ -0,0 +1,17 @@
<?php
namespace App\Filament\Central\Resources\SubscriptionResource\Pages;
use App\Filament\Central\Resources\SubscriptionResource;
use Filament\Actions;
use Filament\Resources\Pages\EditRecord;
class EditSubscription extends EditRecord
{
protected static string $resource = SubscriptionResource::class;
protected function getHeaderActions(): array
{
return [Actions\DeleteAction::make()];
}
}
@@ -0,0 +1,17 @@
<?php
namespace App\Filament\Central\Resources\SubscriptionResource\Pages;
use App\Filament\Central\Resources\SubscriptionResource;
use Filament\Actions;
use Filament\Resources\Pages\ListRecords;
class ListSubscriptions extends ListRecords
{
protected static string $resource = SubscriptionResource::class;
protected function getHeaderActions(): array
{
return [Actions\CreateAction::make()];
}
}
@@ -0,0 +1,150 @@
<?php
namespace App\Filament\Central\Resources;
use App\Filament\Central\Resources\SuperAdminResource\Pages;
use App\Models\Central\SuperAdmin;
use Filament\Actions;
use Filament\Forms;
use Filament\Notifications\Notification;
use Filament\Resources\Resource;
use Filament\Schemas;
use Filament\Schemas\Schema;
use Filament\Tables;
use Filament\Tables\Table;
use Illuminate\Support\Facades\Hash;
class SuperAdminResource extends Resource
{
protected static ?string $model = SuperAdmin::class;
protected static string|\BackedEnum|null $navigationIcon = 'heroicon-o-shield-check';
protected static ?string $navigationLabel = 'Super-admini';
protected static ?string $modelLabel = 'super-admin';
protected static ?string $pluralModelLabel = 'super-admini';
protected static string|\UnitEnum|null $navigationGroup = 'Acces';
protected static ?int $navigationSort = 50;
public static function form(Schema $schema): Schema
{
return $schema->components([
Schemas\Components\Section::make('Identificare')
->columns(2)
->schema([
Forms\Components\TextInput::make('name')->required()->maxLength(120),
Forms\Components\TextInput::make('email')->email()->required()->unique(ignoreRecord: true)->maxLength(120),
Forms\Components\TextInput::make('phone')->tel()->maxLength(40),
Forms\Components\Toggle::make('is_active')->label('Activ')->default(true),
]),
Schemas\Components\Section::make('Permisiuni')
->columns(1)
->schema([
Forms\Components\Select::make('role')
->options(SuperAdmin::ROLES)
->default('support')
->required()
->helperText('Owner = drepturi totale. Admin = aproape la fel. Sales = doar tenanți + planuri. Finance = facturi. Support = read-only.'),
]),
Schemas\Components\Section::make('Parolă')
->columns(1)
->schema([
Forms\Components\TextInput::make('password')
->password()
->revealable()
->dehydrated(fn ($state) => filled($state))
->dehydrateStateUsing(fn ($state) => Hash::make($state))
->required(fn (string $operation) => $operation === 'create')
->minLength(8)
->helperText(fn (string $operation) => $operation === 'edit' ? 'Lasă gol ca să o păstrezi.' : 'Min 8 caractere.'),
]),
Forms\Components\Textarea::make('notes')->label('Note interne')->rows(2)->columnSpanFull(),
]);
}
public static function table(Table $table): Table
{
return $table
->columns([
Tables\Columns\TextColumn::make('name')->searchable()->sortable()->weight('bold'),
Tables\Columns\TextColumn::make('email')->searchable()->copyable(),
Tables\Columns\TextColumn::make('role')
->badge()
->formatStateUsing(fn ($s) => array_keys(SuperAdmin::ROLES, SuperAdmin::ROLES[$s] ?? $s)[0] ?? $s)
->color(fn ($s) => match ($s) {
'owner' => 'danger',
'admin' => 'warning',
'finance' => 'success',
'sales' => 'info',
default => 'gray',
}),
Tables\Columns\IconColumn::make('is_active')->boolean()->label('Activ'),
Tables\Columns\IconColumn::make('app_authentication_secret')
->label('2FA')
->boolean()
->getStateUsing(fn ($r) => $r->app_authentication_secret !== null)
->trueIcon('heroicon-o-lock-closed')
->falseIcon('heroicon-o-lock-open')
->trueColor('success')
->falseColor('warning'),
Tables\Columns\TextColumn::make('last_login_at')->label('Ultim login')->dateTime()->placeholder('—'),
Tables\Columns\TextColumn::make('created_at')->date(),
])
->filters([
Tables\Filters\SelectFilter::make('role')->options(SuperAdmin::ROLES),
Tables\Filters\TernaryFilter::make('is_active')->label('Activ'),
])
->actions([
Actions\Action::make('reset_password')
->label('Reset parolă')
->icon('heroicon-m-key')
->color('warning')
->schema([
Forms\Components\TextInput::make('new_password')
->password()->required()->minLength(8)->revealable(),
])
->action(function (SuperAdmin $r, array $data) {
$r->update(['password' => Hash::make($data['new_password'])]);
Notification::make()->title('Parolă resetată.')->success()->send();
}),
Actions\Action::make('toggle_2fa')
->label(fn ($r) => $r->app_authentication_secret ? 'Dezactivează 2FA' : 'Forțează re-setare 2FA')
->icon('heroicon-m-lock-open')
->color('danger')
->visible(fn ($r) => $r->app_authentication_secret !== null)
->requiresConfirmation()
->action(function (SuperAdmin $r) {
$r->forceFill([
'app_authentication_secret' => null,
'app_authentication_recovery_codes' => null,
'email_authentication_at' => null,
])->saveQuietly();
Notification::make()->title('2FA dezactivat.')->success()->send();
}),
Actions\EditAction::make(),
Actions\DeleteAction::make()
->before(function (SuperAdmin $r) {
if (auth('central')->id() === $r->id) {
Notification::make()->title('Nu te poți șterge pe tine!')->danger()->send();
return false;
}
}),
])
->emptyStateHeading('Doar tu ești aici')
->emptyStateDescription('Adaugă echipa ta — colegi de la suport, sales, finance — fiecare cu rolul lui.')
->defaultSort('created_at', 'desc');
}
public static function getPages(): array
{
return [
'index' => Pages\ListSuperAdmins::route('/'),
'create' => Pages\CreateSuperAdmin::route('/create'),
'edit' => Pages\EditSuperAdmin::route('/{record}/edit'),
];
}
}
@@ -0,0 +1,11 @@
<?php
namespace App\Filament\Central\Resources\SuperAdminResource\Pages;
use App\Filament\Central\Resources\SuperAdminResource;
use Filament\Resources\Pages\CreateRecord;
class CreateSuperAdmin extends CreateRecord
{
protected static string $resource = SuperAdminResource::class;
}
@@ -0,0 +1,17 @@
<?php
namespace App\Filament\Central\Resources\SuperAdminResource\Pages;
use App\Filament\Central\Resources\SuperAdminResource;
use Filament\Actions;
use Filament\Resources\Pages\EditRecord;
class EditSuperAdmin extends EditRecord
{
protected static string $resource = SuperAdminResource::class;
protected function getHeaderActions(): array
{
return [Actions\DeleteAction::make()];
}
}
@@ -0,0 +1,17 @@
<?php
namespace App\Filament\Central\Resources\SuperAdminResource\Pages;
use App\Filament\Central\Resources\SuperAdminResource;
use Filament\Actions;
use Filament\Resources\Pages\ListRecords;
class ListSuperAdmins extends ListRecords
{
protected static string $resource = SuperAdminResource::class;
protected function getHeaderActions(): array
{
return [Actions\CreateAction::make()];
}
}