Files
fotospiel-app/app/Filament/Resources/PackageResource.php
Codex Agent d4ab9a3a20
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
Adjust watermark permissions and transparency
2026-01-19 13:45:43 +01:00

477 lines
20 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
namespace App\Filament\Resources;
use App\Filament\Clusters\WeeklyOps\WeeklyOpsCluster;
use App\Filament\Resources\PackageResource\Pages;
use App\Jobs\PullPackageFromPaddle;
use App\Jobs\SyncPackageToPaddle;
use App\Models\Package;
use App\Services\Audit\SuperAdminAuditLogger;
use BackedEnum;
use Filament\Actions;
use Filament\Actions\BulkActionGroup;
use Filament\Actions\DeleteAction;
use Filament\Actions\DeleteBulkAction;
use Filament\Actions\EditAction;
use Filament\Actions\ForceDeleteAction;
use Filament\Actions\ForceDeleteBulkAction;
use Filament\Actions\RestoreAction;
use Filament\Actions\RestoreBulkAction;
use Filament\Actions\ViewAction;
use Filament\Forms\Components\CheckboxList;
use Filament\Forms\Components\MarkdownEditor;
use Filament\Forms\Components\Placeholder;
use Filament\Forms\Components\Repeater;
use Filament\Forms\Components\Select;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Components\Toggle;
use Filament\Notifications\Notification;
use Filament\Resources\Resource;
use Filament\Schemas\Components\Section;
use Filament\Schemas\Components\Tabs as SchemaTabs;
use Filament\Schemas\Components\Tabs\Tab as SchemaTab;
use Filament\Schemas\Schema;
use Filament\Tables;
use Filament\Tables\Columns\BadgeColumn;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Filters\TrashedFilter;
use Filament\Tables\Table;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Database\Eloquent\SoftDeletingScope;
use Illuminate\Support\Str;
use Illuminate\Validation\Rules\Unique;
use UnitEnum;
class PackageResource extends Resource
{
protected static ?string $model = Package::class;
protected static ?string $cluster = WeeklyOpsCluster::class;
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-cube';
protected static string|UnitEnum|null $navigationGroup = null;
public static function getNavigationGroup(): UnitEnum|string|null
{
return __('admin.nav.commercial');
}
protected static ?int $navigationSort = 5;
public static function form(Schema $schema): Schema
{
$featureOptions = static::featureLabelMap();
return $schema->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('Eigenes 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',
];
}
}