Files
fotospiel-app/docs/archive/plan-superadmin-filament.md
2025-11-20 12:31:21 +01:00

22 KiB

SuperAdmin Filament Resource Spezifikationen

1. Erweiterte TenantResource

Form Schema (Erweiterung der bestehenden)

// 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)

// 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)

// 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

// 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)

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)

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)

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

// 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

// 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

// 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

// 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

// 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. PAT-Tooling: Management für Tenant-PATs (TBD)
  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.