# SuperAdmin Filament Resource Spezifikationen ## 1. Erweiterte TenantResource ### Form Schema (Erweiterung der bestehenden) ```php // In TenantResource::form() TextInput::make('name')->required()->maxLength(255), TextInput::make('slug')->required()->unique()->maxLength(255), TextInput::make('contact_email')->email()->required()->maxLength(255), TextInput::make('event_credits_balance')->numeric()->default(1), // Free tier Select::make('subscription_tier') ->options([ 'free' => 'Free', 'starter' => 'Starter (€4.99/mo)', 'pro' => 'Pro (€14.99/mo)', 'agency' => 'Agency (€19.99/mo)', 'lifetime' => 'Lifetime (€49.99)' ]) ->default('free'), DateTimePicker::make('subscription_expires_at'), TextInput::make('total_revenue')->money('EUR')->readOnly(), KeyValue::make('features')->keyLabel('Feature')->valueLabel('Enabled'), Toggle::make('is_active')->label('Account Active'), Toggle::make('is_suspended')->label('Suspended')->default(false), ``` ### Table Columns (Erweiterung) ```php // In TenantResource::table() Tables\Columns\TextColumn::make('name')->searchable()->sortable(), Tables\Columns\TextColumn::make('slug')->badge()->color('primary'), Tables\Columns\TextColumn::make('contact_email')->copyable(), Tables\Columns\TextColumn::make('event_credits_balance') ->label('Credits') ->badge() ->color(fn ($state) => $state < 5 ? 'warning' : 'success'), Tables\Columns\TextColumn::make('subscription_tier') ->badge() ->color(fn (string $state): string => match($state) { 'free' => 'gray', 'starter' => 'info', 'pro' => 'success', 'agency' => 'warning', 'lifetime' => 'danger', }), Tables\Columns\TextColumn::make('total_revenue') ->money('EUR') ->sortable(), Tables\Columns\IconColumn::make('is_active') ->boolean() ->color(fn (bool $state): string => $state ? 'success' : 'danger'), Tables\Columns\TextColumn::make('last_activity_at') ->dateTime() ->sortable() ->toggleable(), Tables\Columns\TextColumn::make('created_at') ->dateTime() ->sortable() ->toggleable(), ``` ### Relations Manager (Purchase History) ```php // In TenantResource\RelationManagers\PurchasesRelationManager public static function table(Table $table): Table { return $table ->recordTitleAttribute('package_id') ->columns([ Tables\Columns\TextColumn::make('package_id')->badge(), Tables\Columns\TextColumn::make('credits_added')->badge(), Tables\Columns\TextColumn::make('price')->money('EUR'), Tables\Columns\TextColumn::make('platform')->badge(), Tables\Columns\TextColumn::make('purchased_at')->dateTime(), Tables\Columns\TextColumn::make('transaction_id')->copyable(), ]) ->filters([ Tables\Filters\SelectFilter::make('platform') ->options(['ios' => 'iOS', 'android' => 'Android', 'web' => 'Web']), Tables\Filters\SelectFilter::make('package_id') ->options(['starter' => 'Starter', 'pro' => 'Pro', 'lifetime' => 'Lifetime']), ]) ->headerActions([]) ->actions([Tables\Actions\ViewAction::make()]) ->bulkActions([Tables\Actions\BulkActionGroup::make([])]); } ``` ### Actions ```php // In TenantResource::table() ->actions([ Actions\ViewAction::make(), Actions\EditAction::make(), Actions\Action::make('add_credits') ->label('Credits hinzufügen') ->icon('heroicon-o-plus') ->form([ Forms\Components\TextInput::make('credits')->numeric()->required()->minValue(1), Forms\Components\Textarea::make('reason')->label('Grund')->rows(3), ]) ->action(function (Tenant $record, array $data) { $record->increment('event_credits_balance', $data['credits']); // Log purchase_history entry PurchaseHistory::create([ 'tenant_id' => $record->id, 'package_id' => 'manual_adjustment', 'credits_added' => $data['credits'], 'price' => 0, 'reason' => $data['reason'], ]); }), Actions\Action::make('suspend') ->label('Suspendieren') ->color('danger') ->requiresConfirmation() ->action(fn (Tenant $record) => $record->update(['is_suspended' => true])), Actions\Action::make('export') ->label('Daten exportieren') ->icon('heroicon-o-arrow-down-tray') ->url(fn (Tenant $record) => route('admin.tenants.export', $record)) ->openUrlInNewTab(), ]) ``` ## 2. Neue PurchaseHistoryResource ### Model (app/Models/PurchaseHistory.php) ```php class PurchaseHistory extends Model { protected $table = 'purchase_history'; protected $guarded = []; protected $casts = [ 'price' => 'decimal:2', 'purchased_at' => 'datetime', ]; public function tenant(): BelongsTo { return $this->belongsTo(Tenant::class); } public function scopeByTenant($query, $tenantId) { return $query->where('tenant_id', $tenantId); } public function scopeByPlatform($query, $platform) { return $query->where('platform', $platform); } } ``` ### Resource (app/Filament/Resources/PurchaseHistoryResource.php) ```php class PurchaseHistoryResource extends Resource { protected static ?string $model = PurchaseHistory::class; protected static ?string $navigationIcon = 'heroicon-o-shopping-cart'; protected static ?string $navigationGroup = 'Billing'; protected static ?int $navigationSort = 10; public static function form(Form $form): Form { return $form ->schema([ Select::make('tenant_id') ->label('Tenant') ->relationship('tenant', 'name') ->searchable() ->preload() ->required(), Select::make('package_id') ->label('Paket') ->options([ 'starter_pack' => 'Starter Pack (5 Events)', 'pro_pack' => 'Pro Pack (20 Events)', 'lifetime_unlimited' => 'Lifetime Unlimited', 'monthly_pro' => 'Pro Subscription', 'monthly_agency' => 'Agency Subscription', ]) ->required(), TextInput::make('credits_added') ->label('Credits hinzugefügt') ->numeric() ->required() ->minValue(0), TextInput::make('price') ->label('Preis') ->money('EUR') ->required(), Select::make('platform') ->label('Plattform') ->options([ 'ios' => 'iOS', 'android' => 'Android', 'web' => 'Web', 'manual' => 'Manuell', ]) ->required(), TextInput::make('transaction_id') ->label('Transaktions-ID') ->maxLength(255), Textarea::make('reason') ->label('Beschreibung') ->maxLength(65535) ->columnSpanFull(), ]) ->columns(2); } public static function table(Table $table): Table { return $table ->columns([ Tables\Columns\TextColumn::make('tenant.name') ->label('Tenant') ->searchable() ->sortable(), Tables\Columns\TextColumn::make('package_id') ->label('Paket') ->badge() ->color(fn (string $state): string => match($state) { 'starter_pack' => 'info', 'pro_pack' => 'success', 'lifetime_unlimited' => 'danger', 'monthly_pro' => 'warning', default => 'gray', }), Tables\Columns\TextColumn::make('credits_added') ->label('Credits') ->badge() ->color('success'), Tables\Columns\TextColumn::make('price') ->label('Preis') ->money('EUR') ->sortable(), Tables\Columns\TextColumn::make('platform') ->badge() ->color(fn (string $state): string => match($state) { 'ios' => 'info', 'android' => 'success', 'web' => 'warning', 'manual' => 'gray', }), Tables\Columns\TextColumn::make('purchased_at') ->dateTime() ->sortable(), Tables\Columns\TextColumn::make('transaction_id') ->copyable() ->toggleable(), ]) ->filters([ Tables\Filters\Filter::make('created_at') ->form([ Forms\Components\DatePicker::make('started_from'), Forms\Components\DatePicker::make('ended_before'), ]) ->query(function ($query, array $data) { return $query ->when($data['started_from'], fn ($q) => $q->whereDate('created_at', '>=', $data['started_from'])) ->when($data['ended_before'], fn ($q) => $q->whereDate('created_at', '<=', $data['ended_before'])); }), Tables\Filters\SelectFilter::make('platform') ->options([ 'ios' => 'iOS', 'android' => 'Android', 'web' => 'Web', 'manual' => 'Manuell', ]), Tables\Filters\SelectFilter::make('package_id') ->options([ 'starter_pack' => 'Starter Pack', 'pro_pack' => 'Pro Pack', 'lifetime_unlimited' => 'Lifetime', 'monthly_pro' => 'Pro Subscription', 'monthly_agency' => 'Agency Subscription', ]), Tables\Filters\TernaryFilter::make('successful') ->label('Erfolgreich') ->trueLabel('Ja') ->falseLabel('Nein') ->placeholder('Alle') ->query(fn ($query) => $query->whereNotNull('transaction_id')), ]) ->actions([ Tables\Actions\ViewAction::make(), Tables\Actions\Action::make('refund') ->label('Rückerstattung') ->color('danger') ->icon('heroicon-o-arrow-uturn-left') ->requiresConfirmation() ->visible(fn ($record) => $record->transaction_id && !$record->refunded) ->action(function (PurchaseHistory $record) { // Stripe/RevenueCat Refund API call $record->update(['refunded' => true, 'refunded_at' => now()]); $record->tenant->decrement('event_credits_balance', $record->credits_added); }), ]) ->bulkActions([ Tables\Actions\BulkActionGroup::make([ Tables\Actions\DeleteBulkAction::make(), ExportBulkAction::make() ->label('Export CSV') ->exporter(PurchaseHistoryExporter::class), ]), ]) ->emptyStateHeading('Keine Käufe gefunden') ->emptyStateDescription('Erstelle den ersten Kauf oder überprüfe die Filter.'); } } ``` ## 3. OAuthClientResource (für SuperAdmin) ### Resource (app/Filament/Resources/OAuthClientResource.php) ```php class OAuthClientResource extends Resource { protected static ?string $model = OAuthClient::class; protected static ?string $navigationIcon = 'heroicon-o-key'; protected static ?string $navigationGroup = 'Security'; protected static ?int $navigationSort = 20; public static function form(Form $form): Form { return $form ->schema([ TextInput::make('client_id') ->label('Client ID') ->required() ->unique(ignoreRecord: true) ->maxLength(255), TextInput::make('client_secret') ->label('Client Secret') ->password() ->required() ->maxLength(255) ->dehydrateStateUsing(fn ($state) => Hash::make($state)), TextInput::make('name') ->label('Name') ->required() ->maxLength(255), Textarea::make('redirect_uris') ->label('Redirect URIs') ->rows(3) ->placeholder('https://tenant-admin-app.example.com/callback') ->required(), KeyValue::make('scopes') ->label('Scopes') ->keyLabel('Scope') ->valueLabel('Beschreibung') ->default([ 'tenant:read' => 'Lesen von Tenant-Daten', 'tenant:write' => 'Schreiben von Tenant-Daten', 'tenant:admin' => 'Administrative Aktionen', ]), Toggle::make('is_active') ->label('Aktiv') ->default(true), Textarea::make('description') ->label('Beschreibung') ->maxLength(65535) ->columnSpanFull(), ]) ->columns(2); } public static function table(Table $table): Table { return $table ->columns([ Tables\Columns\TextColumn::make('name') ->searchable() ->sortable(), Tables\Columns\TextColumn::make('client_id') ->copyable() ->sortable(), Tables\Columns\IconColumn::make('is_active') ->boolean() ->color('success'), Tables\Columns\TextColumn::make('redirect_uris') ->limit(50) ->tooltip(function (Tables\Columns\Column $column): ?string { $state = $column->getState(); if (is_array($state)) { return implode("\n", $state); } return $state; }), Tables\Columns\TextColumn::make('scopes') ->badge() ->color('info'), Tables\Columns\TextColumn::make('created_at') ->dateTime() ->sortable() ->toggleable(), ]) ->filters([ Tables\Filters\TernaryFilter::make('is_active') ->label('Status') ->trueLabel('Aktiv') ->falseLabel('Inaktiv') ->placeholder('Alle'), ]) ->actions([ Tables\Actions\ViewAction::make(), Tables\Actions\EditAction::make(), Tables\Actions\Action::make('regenerate_secret') ->label('Secret neu generieren') ->icon('heroicon-o-arrow-path') ->color('warning') ->requiresConfirmation() ->action(function (OAuthClient $record) { $record->update(['client_secret' => Str::random(40)]); }), Tables\Actions\DeleteAction::make() ->requiresConfirmation() ->before(function (OAuthClient $record) { // Revoke all associated tokens RefreshToken::where('client_id', $record->client_id)->delete(); }), ]) ->bulkActions([ Tables\Actions\BulkActionGroup::make([ Tables\Actions\DeleteBulkAction::make(), ]), ]); } } ``` ## 4. SuperAdmin Dashboard Widgets ### RevenueChart Widget ```php // app/Filament/Widgets/RevenueChart.php class RevenueChart extends LineChartWidget { protected static ?string $heading = 'Monatliche Einnahmen'; protected static ?int $sort = 1; protected function getData(): array { $data = PurchaseHistory::selectRaw(' DATE_FORMAT(purchased_at, "%Y-%m") as month, SUM(price) as revenue ') ->groupBy('month') ->orderBy('month') ->limit(12) ->get(); return [ 'datasets' => [ [ 'label' => 'Einnahmen (€)', 'data' => $data->pluck('revenue')->values(), 'borderColor' => '#3B82F6', 'backgroundColor' => 'rgba(59, 130, 246, 0.1)', ], ], 'labels' => $data->pluck('month')->values(), ]; } protected function getFilters(): ?array { return [ '12_months' => 'Letzte 12 Monate', '6_months' => 'Letzte 6 Monate', '3_months' => 'Letzte 3 Monate', ]; } } ``` ### TopTenantsWidget ```php // app/Filament/Widgets/TopTenantsByRevenue.php class TopTenantsByRevenue extends TableWidget { protected static ?string $heading = 'Top Tenants nach Einnahmen'; protected static ?int $sort = 2; protected function getTableQuery(): Builder { return Tenant::withCount('purchases') ->withSum('purchases', 'price') ->orderByDesc('purchases_sum_price') ->limit(10); } protected function getTableColumns(): array { return [ Tables\Columns\TextColumn::make('name') ->label('Tenant') ->searchable() ->sortable(), Tables\Columns\TextColumn::make('purchases_sum_price') ->label('Gesamt (€)') ->money('EUR') ->sortable(), Tables\Columns\TextColumn::make('purchases_count') ->label('Käufe') ->badge() ->sortable(), Tables\Columns\TextColumn::make('event_credits_balance') ->label('Aktuelle Credits') ->badge(), ]; } } ``` ### CreditAlertsWidget ```php // app/Filament/Widgets/CreditAlerts.php class CreditAlerts extends StatsOverviewWidget { protected function getCards(): array { $lowBalanceTenants = Tenant::where('event_credits_balance', '<', 5) ->where('is_active', true) ->count(); $totalRevenueThisMonth = PurchaseHistory::whereMonth('purchased_at', now()->month) ->sum('price'); $activeSubscriptions = Tenant::whereNotNull('subscription_expires_at') ->where('subscription_expires_at', '>', now()) ->count(); return [ StatsOverviewWidget\Stat::make('Tenants mit niedrigen Credits', $lowBalanceTenants) ->description('Benötigen möglicherweise Support') ->descriptionIcon('heroicon-m-exclamation-triangle') ->color('warning') ->url(route('filament.admin.resources.tenants.index')), StatsOverviewWidget\Stat::make('Einnahmen diesen Monat', $totalRevenueThisMonth) ->description('€') ->descriptionIcon('heroicon-m-currency-euro') ->color('success'), StatsOverviewWidget\Stat::make('Aktive Abos', $activeSubscriptions) ->description('Recurring Revenue') ->descriptionIcon('heroicon-m-arrow-trending-up') ->color('info'), ]; } } ``` ## 5. Permissions und Middleware ### Policy für SuperAdmin ```php // app/Policies/TenantPolicy.php class TenantPolicy { public function before(User $user): ?bool { if ($user->role === 'superadmin') { return true; } return null; } public function viewAny(User $user): bool { return $user->role === 'superadmin'; } public function view(User $user, Tenant $tenant): bool { return $user->role === 'superadmin' || $user->tenant_id === $tenant->id; } public function create(User $user): bool { return $user->role === 'superadmin'; } public function update(User $user, Tenant $tenant): bool { return $user->role === 'superadmin'; } public function delete(User $user, Tenant $tenant): bool { return $user->role === 'superadmin'; } public function addCredits(User $user, Tenant $tenant): bool { return $user->role === 'superadmin'; } public function suspend(User $user, Tenant $tenant): bool { return $user->role === 'superadmin'; } } ``` ### SuperAdmin Middleware ```php // app/Http/Middleware/SuperAdminMiddleware.php class SuperAdminMiddleware { public function handle(Request $request, Closure $next) { if (!auth()->check() || auth()->user()->role !== 'superadmin') { abort(403, 'SuperAdmin-Zugriff erforderlich'); } return $next($request); } } ``` ## Implementierungsreihenfolge 1. **Model & Migration:** PurchaseHistory, OAuthClient Tabellen 2. **TenantResource:** Erweiterung mit neuen Feldern, Relations, Actions 3. **PurchaseHistoryResource:** Vollständige CRUD mit Filtern und Export 4. **OAuthClientResource:** Management für OAuth-Clients 5. **Widgets:** Dashboard-Übersicht mit Charts und Stats 6. **Policies & Middleware:** Security für SuperAdmin-Funktionen 7. **Tests:** Feature-Tests für Credit-Management, Permissions Dieser Plan erweitert den SuperAdmin-Bereich um umfassende Billing- und Security-Management-Funktionen.