schema([ SchemaTabs::make('translations') ->columnSpanFull() ->tabs([ SchemaTab::make('Deutsch') ->schema([ TextInput::make('name_translations.de') ->label('Name (DE)') ->required() ->maxLength(255), MarkdownEditor::make('description_translations.de') ->label('Beschreibung (DE)') ->required() ->columnSpanFull(), ]), SchemaTab::make('English') ->schema([ TextInput::make('name_translations.en') ->label('Name (EN)') ->required() ->maxLength(255), MarkdownEditor::make('description_translations.en') ->label('Description (EN)') ->required() ->columnSpanFull(), ]), ]), Section::make('Allgemeine Einstellungen') ->columns(3) ->schema([ TextInput::make('slug') ->label('Slug') ->required() ->maxLength(191) ->unique( ignoreRecord: true, modifyRuleUsing: fn (Unique $rule) => $rule->withoutTrashed() ), Select::make('type') ->label('Paket-Typ') ->options([ 'endcustomer' => 'Endkunde', 'reseller' => 'Reseller', ]) ->required(), TextInput::make('price') ->label('Preis') ->numeric() ->step(0.01) ->prefix('€') ->required(), TextInput::make('max_photos') ->label('Max. Fotos') ->numeric() ->minValue(0) ->nullable(), TextInput::make('max_guests') ->label('Max. Gäste') ->numeric() ->minValue(0) ->nullable(), TextInput::make('gallery_days') ->label('Galeriedauer (Tage)') ->numeric() ->minValue(0) ->nullable(), TextInput::make('max_tasks') ->label('Max. Fotoaufgaben') ->numeric() ->minValue(0) ->nullable(), TextInput::make('max_events_per_year') ->label('Events pro Jahr') ->numeric() ->minValue(0) ->nullable() ->visible(fn ($get) => $get('type') === 'reseller'), Toggle::make('watermark_allowed') ->label('Wasserzeichen erlaubt') ->default(true), Toggle::make('branding_allowed') ->label('Eigenes Branding erlaubt') ->default(false), ]), Section::make('Features & Kennzahlen') ->columns(1) ->schema([ CheckboxList::make('features') ->label('Aktive Features') ->options($featureOptions) ->columns(2) ->default([]), Repeater::make('description_table') ->label('Kenndaten') ->schema([ TextInput::make('title') ->label('Titel') ->maxLength(255), TextInput::make('value') ->label('Wert / Beschreibung') ->maxLength(255), ]) ->addActionLabel('Eintrag hinzufügen') ->reorderable() ->columnSpanFull() ->default([]), ]), Section::make('Paddle Billing') ->columns(2) ->schema([ TextInput::make('paddle_product_id') ->label('Paddle Produkt-ID') ->maxLength(191) ->helperText('Produkt aus Paddle Billing. Leer lassen, wenn noch nicht synchronisiert.') ->placeholder('nicht verknüpft'), TextInput::make('paddle_price_id') ->label('Paddle Preis-ID') ->maxLength(191) ->helperText('Preis-ID aus Paddle Billing, verknüpft mit diesem Paket.') ->placeholder('nicht verknüpft'), Placeholder::make('paddle_sync_status') ->label('Sync-Status') ->content(fn (?Package $record) => $record?->paddle_sync_status ? Str::headline($record->paddle_sync_status) : '–') ->columnSpanFull(), Placeholder::make('paddle_synced_at') ->label('Zuletzt synchronisiert') ->content(fn (?Package $record) => $record?->paddle_synced_at ? $record->paddle_synced_at->diffForHumans() : '–') ->columnSpanFull(), Placeholder::make('paddle_sync_error') ->label('Letzter Fehler') ->content(fn (?Package $record) => $record?->paddle_sync_error_message ?? '–') ->visible(fn (?Package $record) => filled($record?->paddle_sync_error_message)) ->columnSpanFull(), ]), ]); } public static function formatFeaturesForDisplay(mixed $features): string { if (is_string($features)) { $decoded = json_decode($features, true); if (json_last_error() === JSON_ERROR_NONE) { $features = $decoded; } } if (! is_array($features)) { return ''; } $labels = static::featureLabelMap(); if (array_is_list($features)) { return collect($features) ->filter(fn ($value) => is_string($value) && $value !== '') ->map(fn ($value) => $labels[$value] ?? $value) ->implode(', '); } return collect($features) ->filter(fn ($value) => (bool) $value) ->keys() ->map(fn ($value) => $labels[$value] ?? $value) ->implode(', '); } public static function table(Table $table): Table { return $table ->columns([ TextColumn::make('name_translations.de') ->label('Name (DE)') ->searchable() ->sortable(), TextColumn::make('name_translations.en') ->label('Name (EN)') ->toggleable(isToggledHiddenByDefault: true), BadgeColumn::make('type') ->label('Typ') ->colors([ 'info' => 'endcustomer', 'warning' => 'reseller', ]), TextColumn::make('price') ->label('Preis') ->money('EUR') ->sortable(), TextColumn::make('max_photos') ->label('Fotos') ->sortable(), TextColumn::make('max_guests') ->label('Gäste') ->sortable() ->toggleable(isToggledHiddenByDefault: true), TextColumn::make('features') ->label('Features') ->wrap() ->formatStateUsing(fn ($state) => static::formatFeaturesForDisplay($state)), TextColumn::make('paddle_product_id') ->label('Paddle Produkt') ->toggleable(isToggledHiddenByDefault: true) ->formatStateUsing(fn ($state) => $state ?: '-'), TextColumn::make('paddle_price_id') ->label('Paddle Preis') ->toggleable(isToggledHiddenByDefault: true) ->formatStateUsing(fn ($state) => $state ?: '-'), BadgeColumn::make('paddle_sync_status') ->label('Sync-Status') ->colors([ 'success' => 'synced', 'warning' => 'syncing', 'info' => ['dry-run', 'linked', 'pulled'], 'danger' => ['failed', 'pull-failed'], ]) ->formatStateUsing(fn ($state) => $state ? Str::headline($state) : null) ->toggleable(isToggledHiddenByDefault: true), TextColumn::make('paddle_synced_at') ->label('Sync am') ->dateTime() ->toggleable(isToggledHiddenByDefault: true), TextColumn::make('paddle_sync_error_message') ->label('Sync-Fehler') ->getStateUsing(fn (Package $record) => $record->paddle_sync_error_message) ->wrap() ->toggleable(isToggledHiddenByDefault: true), ]) ->filters([ Tables\Filters\SelectFilter::make('type') ->label('Typ') ->options([ 'endcustomer' => 'Endkunde', 'reseller' => 'Reseller', ]), TrashedFilter::make(), ]) ->actions([ Actions\Action::make('syncPaddle') ->label('Mit Paddle abgleichen') ->icon('heroicon-o-cloud-arrow-up') ->color('success') ->requiresConfirmation() ->disabled(fn (Package $record) => $record->paddle_sync_status === 'syncing') ->action(function (Package $record) { SyncPackageToPaddle::dispatch($record->id); Notification::make() ->success() ->title('Paddle-Sync gestartet') ->body('Das Paket wird im Hintergrund mit Paddle abgeglichen.') ->send(); }), Actions\Action::make('linkPaddle') ->label('Paddle verknüpfen') ->icon('heroicon-o-link') ->color('info') ->form([ TextInput::make('paddle_product_id') ->label('Paddle Produkt-ID') ->required() ->maxLength(191), TextInput::make('paddle_price_id') ->label('Paddle Preis-ID') ->required() ->maxLength(191), ]) ->fillForm(fn (Package $record) => [ 'paddle_product_id' => $record->paddle_product_id, 'paddle_price_id' => $record->paddle_price_id, ]) ->action(function (Package $record, array $data): void { $record->linkPaddleIds($data['paddle_product_id'], $data['paddle_price_id']); PullPackageFromPaddle::dispatch($record->id); app(SuperAdminAuditLogger::class)->recordModelMutation( 'linked', $record, SuperAdminAuditLogger::fieldsMetadata($data), static::class ); Notification::make() ->success() ->title('Paddle-Verknüpfung gespeichert') ->body('Die IDs wurden gespeichert und ein Pull wurde angestoßen.') ->send(); }), Actions\Action::make('pullPaddle') ->label('Status von Paddle holen') ->icon('heroicon-o-cloud-arrow-down') ->disabled(fn (Package $record) => ! $record->paddle_product_id && ! $record->paddle_price_id) ->requiresConfirmation() ->action(function (Package $record) { PullPackageFromPaddle::dispatch($record->id); Notification::make() ->info() ->title('Paddle-Abgleich angefordert') ->body('Der aktuelle Stand aus Paddle wird geladen und hier hinterlegt.') ->send(); }), ViewAction::make(), EditAction::make() ->after(fn (array $data, Package $record) => app(SuperAdminAuditLogger::class)->recordModelMutation( 'updated', $record, SuperAdminAuditLogger::fieldsMetadata($data), static::class )), DeleteAction::make() ->visible(fn (Package $record) => ! $record->trashed()) ->after(fn (Package $record) => app(SuperAdminAuditLogger::class)->recordModelMutation( 'deleted', $record, source: static::class )), RestoreAction::make() ->visible(fn (Package $record) => $record->trashed()) ->after(fn (Package $record) => app(SuperAdminAuditLogger::class)->recordModelMutation( 'restored', $record, source: static::class )), ForceDeleteAction::make() ->visible(fn (Package $record) => $record->trashed()) ->requiresConfirmation() ->after(fn (Package $record) => app(SuperAdminAuditLogger::class)->recordModelMutation( 'force_deleted', $record, source: static::class )), ]) ->bulkActions([ BulkActionGroup::make([ DeleteBulkAction::make() ->after(function (Collection $records): void { $logger = app(SuperAdminAuditLogger::class); foreach ($records as $record) { $logger->recordModelMutation( 'deleted', $record, source: static::class ); } }), RestoreBulkAction::make() ->after(function (Collection $records): void { $logger = app(SuperAdminAuditLogger::class); foreach ($records as $record) { $logger->recordModelMutation( 'restored', $record, source: static::class ); } }), ForceDeleteBulkAction::make() ->requiresConfirmation() ->after(function (Collection $records): void { $logger = app(SuperAdminAuditLogger::class); foreach ($records as $record) { $logger->recordModelMutation( 'force_deleted', $record, source: static::class ); } }), ]), ]); } public static function getEloquentQuery(): Builder { return parent::getEloquentQuery() ->withoutGlobalScopes([ SoftDeletingScope::class, ]); } public static function getPages(): array { return [ 'index' => Pages\ListPackages::route('/'), 'create' => Pages\CreatePackage::route('/create'), 'edit' => Pages\EditPackage::route('/{record}/edit'), ]; } protected static function featureLabelMap(): array { return [ 'basic_uploads' => 'Basis-Uploads', 'limited_sharing' => 'Begrenztes Teilen', 'unlimited_sharing' => 'Unbegrenztes Teilen', 'no_watermark' => 'Kein Wasserzeichen', 'custom_branding' => 'Eigenes Branding', 'custom_tasks' => 'Eigene Aufgaben', 'reseller_dashboard' => 'Reseller-Dashboard', 'advanced_analytics' => 'Erweiterte Analytics', 'advanced_reporting' => 'Erweiterte Reports', 'live_slideshow' => 'Live-Slideshow', 'priority_support' => 'Priorisierter Support', ]; } }