477 lines
20 KiB
PHP
477 lines
20 KiB
PHP
<?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('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',
|
||
];
|
||
}
|
||
}
|