Files
fotospiel-app/app/Filament/Resources/PackageResource.php
Codex Agent 10c99de1e2
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
Migrate billing from Paddle to Lemon Squeezy
2026-02-03 10:59:54 +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\PullPackageFromLemonSqueezy;
use App\Jobs\SyncPackageToLemonSqueezy;
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('Lemon Squeezy Billing')
->columns(2)
->schema([
TextInput::make('lemonsqueezy_product_id')
->label('Lemon Squeezy Produkt-ID')
->maxLength(191)
->helperText('Produkt aus Lemon Squeezy. Leer lassen, wenn noch nicht synchronisiert.')
->placeholder('nicht verknüpft'),
TextInput::make('lemonsqueezy_variant_id')
->label('Lemon Squeezy Variant-ID')
->maxLength(191)
->helperText('Variant-ID aus Lemon Squeezy, verknüpft mit diesem Paket.')
->placeholder('nicht verknüpft'),
Placeholder::make('lemonsqueezy_sync_status')
->label('Sync-Status')
->content(fn (?Package $record) => $record?->lemonsqueezy_sync_status ? Str::headline($record->lemonsqueezy_sync_status) : '')
->columnSpanFull(),
Placeholder::make('lemonsqueezy_synced_at')
->label('Zuletzt synchronisiert')
->content(fn (?Package $record) => $record?->lemonsqueezy_synced_at ? $record->lemonsqueezy_synced_at->diffForHumans() : '')
->columnSpanFull(),
Placeholder::make('lemonsqueezy_sync_error')
->label('Letzter Fehler')
->content(fn (?Package $record) => $record?->lemonsqueezy_sync_error_message ?? '')
->visible(fn (?Package $record) => filled($record?->lemonsqueezy_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('lemonsqueezy_product_id')
->label('Lemon Squeezy Produkt')
->toggleable(isToggledHiddenByDefault: true)
->formatStateUsing(fn ($state) => $state ?: '-'),
TextColumn::make('lemonsqueezy_variant_id')
->label('Lemon Squeezy Variant')
->toggleable(isToggledHiddenByDefault: true)
->formatStateUsing(fn ($state) => $state ?: '-'),
BadgeColumn::make('lemonsqueezy_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('lemonsqueezy_synced_at')
->label('Sync am')
->dateTime()
->toggleable(isToggledHiddenByDefault: true),
TextColumn::make('lemonsqueezy_sync_error_message')
->label('Sync-Fehler')
->getStateUsing(fn (Package $record) => $record->lemonsqueezy_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('syncLemonSqueezy')
->label('Mit Lemon Squeezy abgleichen')
->icon('heroicon-o-cloud-arrow-up')
->color('success')
->requiresConfirmation()
->disabled(fn (Package $record) => $record->lemonsqueezy_sync_status === 'syncing')
->action(function (Package $record) {
SyncPackageToLemonSqueezy::dispatch($record->id);
Notification::make()
->success()
->title('Lemon Squeezy-Sync gestartet')
->body('Das Paket wird im Hintergrund mit Lemon Squeezy abgeglichen.')
->send();
}),
Actions\Action::make('linkLemonSqueezy')
->label('Lemon Squeezy verknüpfen')
->icon('heroicon-o-link')
->color('info')
->form([
TextInput::make('lemonsqueezy_product_id')
->label('Lemon Squeezy Produkt-ID')
->required()
->maxLength(191),
TextInput::make('lemonsqueezy_variant_id')
->label('Lemon Squeezy Variant-ID')
->required()
->maxLength(191),
])
->fillForm(fn (Package $record) => [
'lemonsqueezy_product_id' => $record->lemonsqueezy_product_id,
'lemonsqueezy_variant_id' => $record->lemonsqueezy_variant_id,
])
->action(function (Package $record, array $data): void {
$record->linkLemonSqueezyIds($data['lemonsqueezy_product_id'], $data['lemonsqueezy_variant_id']);
PullPackageFromLemonSqueezy::dispatch($record->id);
app(SuperAdminAuditLogger::class)->recordModelMutation(
'linked',
$record,
SuperAdminAuditLogger::fieldsMetadata($data),
static::class
);
Notification::make()
->success()
->title('Lemon Squeezy-Verknüpfung gespeichert')
->body('Die IDs wurden gespeichert und ein Pull wurde angestoßen.')
->send();
}),
Actions\Action::make('pullLemonSqueezy')
->label('Status von Lemon Squeezy holen')
->icon('heroicon-o-cloud-arrow-down')
->disabled(fn (Package $record) => ! $record->lemonsqueezy_product_id && ! $record->lemonsqueezy_variant_id)
->requiresConfirmation()
->action(function (Package $record) {
PullPackageFromLemonSqueezy::dispatch($record->id);
Notification::make()
->info()
->title('Lemon Squeezy-Abgleich angefordert')
->body('Der aktuelle Stand aus Lemon Squeezy 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',
];
}
}