feat(packages): implement package-based business model

This commit is contained in:
Codex Agent
2025-09-26 22:13:56 +02:00
parent 6fc36ebaf4
commit 0a643c3e4d
54 changed files with 3301 additions and 282 deletions

View File

@@ -15,12 +15,14 @@ use Filament\Forms\Form;
use Filament\Schemas\Schema;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Components\DatePicker;
use Filament\Forms\Components\Select;
use Filament\Forms\Components\Toggle;
use Filament\Forms\Components\KeyValue;
use Filament\Forms\Components\Select;
use UnitEnum;
use BackedEnum;
use App\Filament\Resources\EventResource\RelationManagers\EventPackagesRelationManager;
class EventResource extends Resource
{
protected static ?string $model = Event::class;
@@ -57,6 +59,12 @@ class EventResource extends Resource
->label(__('admin.events.fields.type'))
->options(EventType::all()->pluck('name', 'id'))
->searchable(),
Select::make('package_id')
->label(__('admin.events.fields.package'))
->options(\App\Models\Package::where('type', 'endcustomer')->pluck('name', 'id'))
->searchable()
->preload()
->required(),
TextInput::make('default_locale')
->label(__('admin.events.fields.default_locale'))
->default('de')
@@ -82,6 +90,18 @@ class EventResource extends Resource
Tables\Columns\TextColumn::make('date')->date(),
Tables\Columns\IconColumn::make('is_active')->boolean(),
Tables\Columns\TextColumn::make('default_locale'),
Tables\Columns\TextColumn::make('eventPackage.package.name')
->label(__('admin.events.table.package'))
->badge()
->color('success'),
Tables\Columns\TextColumn::make('eventPackage.used_photos')
->label(__('admin.events.table.used_photos'))
->badge(),
Tables\Columns\TextColumn::make('eventPackage.remaining_photos')
->label(__('admin.events.table.remaining_photos'))
->badge()
->color(fn ($state) => $state < 1 ? 'danger' : 'success')
->getStateUsing(fn ($record) => $record->eventPackage?->remaining_photos ?? 0),
Tables\Columns\TextColumn::make('join')->label(__('admin.events.table.join'))
->getStateUsing(fn($record) => url("/e/{$record->slug}"))
->copyable()
@@ -117,4 +137,11 @@ class EventResource extends Resource
'edit' => Pages\EditEvent::route('/{record}/edit'),
];
}
public static function getRelations(): array
{
return [
EventPackagesRelationManager::class,
];
}
}

View File

@@ -0,0 +1,129 @@
<?php
namespace App\Filament\Resources\EventResource\RelationManagers;
use Filament\Forms;
use Filament\Forms\Form;
use Filament\Resources\RelationManagers\RelationManager;
use Filament\Tables;
use Filament\Tables\Table;
use Filament\Actions\CreateAction;
use Filament\Actions\EditAction;
use Filament\Actions\DeleteAction;
use Filament\Actions\BulkActionGroup;
use Filament\Actions\DeleteBulkAction;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\Relation;
use App\Models\EventPackage;
use Filament\Actions\Action;
use Filament\Forms\Components\Select;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Components\DateTimePicker;
use Filament\Tables\Columns\TextColumn;
use Filament\Schemas\Schema;
class EventPackagesRelationManager extends RelationManager
{
protected static string $relationship = 'eventPackages';
public function form(Schema $schema): Schema
{
return $schema->schema([
Select::make('package_id')
->label('Package')
->relationship('package', 'name')
->searchable()
->preload()
->required(),
TextInput::make('purchased_price')
->label('Kaufpreis')
->prefix('€')
->numeric()
->step(0.01)
->required(),
TextInput::make('used_photos')
->label('Verwendete Fotos')
->numeric()
->default(0)
->readOnly(),
TextInput::make('used_guests')
->label('Verwendete Gäste')
->numeric()
->default(0)
->readOnly(),
]);
}
public function table(Table $table): Table
{
return $table
->recordTitleAttribute('package.name')
->columns([
TextColumn::make('package.name')
->label('Package')
->badge()
->color('success'),
TextColumn::make('used_photos')
->label('Verwendete Fotos')
->badge(),
TextColumn::make('remaining_photos')
->label('Verbleibende Fotos')
->badge()
->color(fn ($state) => $state < 1 ? 'danger' : 'success')
->getStateUsing(fn (EventPackage $record) => $record->remaining_photos),
TextColumn::make('used_guests')
->label('Verwendete Gäste')
->badge(),
TextColumn::make('remaining_guests')
->label('Verbleibende Gäste')
->badge()
->color(fn ($state) => $state < 1 ? 'danger' : 'success')
->getStateUsing(fn (EventPackage $record) => $record->remaining_guests),
TextColumn::make('expires_at')
->label('Ablauf')
->dateTime()
->badge()
->color(fn ($state) => $state && $state->isPast() ? 'danger' : 'success'),
TextColumn::make('purchased_price')
->label('Preis')
->money('EUR')
->sortable(),
])
->filters([
//
])
->headerActions([
CreateAction::make(),
])
->actions([
EditAction::make(),
DeleteAction::make(),
])
->bulkActions([
BulkActionGroup::make([
DeleteBulkAction::make(),
]),
]);
}
public function getRelationExistenceQuery(
Builder $query,
string $relationshipName,
?string $ownerKeyName,
mixed $ownerKeyValue,
): Builder {
return $query;
}
public static function getTitle(Model $ownerRecord, string $pageClass): string
{
return __('admin.events.relation_managers.event_packages.title');
}
public function getTableQuery(): Builder | Relation
{
return parent::getTableQuery()
->with('package');
}
}

View File

@@ -0,0 +1,153 @@
<?php
namespace App\Filament\Resources;
use App\Filament\Resources\PackageResource\Pages;
use App\Models\Package;
use Filament\Forms;
use Filament\Resources\Resource;
use Filament\Tables;
use Filament\Tables\Table;
use Filament\Actions\Action;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Components\Select;
use Filament\Forms\Components\Toggle;
use Filament\Forms\Components\Repeater;
use Filament\Schemas\Schema;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Columns\IconColumn;
use Filament\Actions\EditAction;
use Filament\Actions\DeleteAction;
use Filament\Actions\BulkActionGroup;
use Filament\Actions\DeleteBulkAction;
use UnitEnum;
use BackedEnum;
class PackageResource extends Resource
{
protected static ?string $model = Package::class;
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-cube';
protected static string|UnitEnum|null $navigationGroup = null;
protected static ?int $navigationSort = 5;
public static function form(Schema $schema): Schema
{
return $schema->schema([
TextInput::make('name')
->label('Name')
->required()
->maxLength(255),
Select::make('type')
->label('Type')
->options([
'endcustomer' => 'Endcustomer',
'reseller' => 'Reseller',
])
->required(),
TextInput::make('price')
->label('Price')
->prefix('€')
->numeric()
->step(0.01)
->required()
->default(0),
TextInput::make('max_photos')
->label('Max Photos')
->numeric()
->nullable(),
TextInput::make('max_guests')
->label('Max Guests')
->numeric()
->nullable(),
TextInput::make('gallery_days')
->label('Gallery Days')
->numeric()
->nullable(),
TextInput::make('max_tasks')
->label('Max Tasks')
->numeric()
->nullable(),
Toggle::make('watermark_allowed')
->label('Watermark Allowed')
->default(true),
Toggle::make('branding_allowed')
->label('Branding Allowed')
->default(false),
TextInput::make('max_events_per_year')
->label('Max Events per Year')
->numeric()
->nullable(),
Repeater::make('features')
->label('Features')
->schema([
TextInput::make('key')
->label('Feature Key'),
TextInput::make('value')
->label('Feature Value'),
])
->columns(2)
->defaultItems(0),
]);
}
public static function table(Table $table): Table
{
return $table
->columns([
TextColumn::make('name')
->label('Name')
->searchable()
->sortable(),
TextColumn::make('type')
->label('Type')
->badge()
->color(fn (string $state): string => match ($state) {
'endcustomer' => 'info',
'reseller' => 'warning',
default => 'gray',
}),
TextColumn::make('price')
->label('Price')
->money('EUR')
->sortable(),
IconColumn::make('max_photos')
->label('Max Photos')
->icon('heroicon-o-photo')
->color('primary'),
TextColumn::make('features')
->label('Features')
->limit(50),
])
->filters([
//
])
->actions([
EditAction::make(),
DeleteAction::make(),
])
->bulkActions([
BulkActionGroup::make([
DeleteBulkAction::make(),
]),
]);
}
public static function getRelations(): array
{
return [
//
];
}
public static function getPages(): array
{
return [
'index' => Pages\ListPackages::route('/'),
'create' => Pages\CreatePackage::route('/create'),
'edit' => Pages\EditPackage::route('/{record}/edit'),
];
}
}

View File

@@ -17,7 +17,8 @@ use Filament\Forms\Components\KeyValue;
use Filament\Forms\Components\Select;
use Filament\Forms\Components\DateTimePicker;
use Filament\Tables\Columns\IconColumn;
use App\Filament\Resources\TenantResource\RelationManagers\PurchasesRelationManager;
use App\Filament\Resources\TenantResource\RelationManagers\PackagePurchasesRelationManager;
use App\Filament\Resources\TenantResource\RelationManagers\TenantPackagesRelationManager;
use Filament\Resources\RelationManagers\RelationGroup;
use UnitEnum;
use BackedEnum;
@@ -52,28 +53,23 @@ class TenantResource extends Resource
->email()
->required()
->maxLength(255),
TextInput::make('event_credits_balance')
->label(__('admin.tenants.fields.event_credits_balance'))
->numeric()
->default(0),
Select::make('subscription_tier')
->label(__('admin.tenants.fields.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')
->label(__('admin.tenants.fields.subscription_expires_at')),
TextInput::make('total_revenue')
->label(__('admin.tenants.fields.total_revenue'))
->prefix('€')
->numeric()
->step(0.01)
->readOnly(),
Select::make('active_reseller_package_id')
->label(__('admin.tenants.fields.active_reseller_package'))
->relationship('activeResellerPackage', 'name')
->searchable()
->preload()
->nullable(),
TextInput::make('remaining_events')
->label(__('admin.tenants.fields.remaining_events'))
->readOnly()
->dehydrated(false)
->getStateUsing(fn (Tenant $record) => $record->activeResellerPackage?->remaining_events ?? 0),
Toggle::make('is_active')
->label(__('admin.tenants.fields.is_active'))
->default(true),
@@ -95,19 +91,21 @@ class TenantResource extends Resource
Tables\Columns\TextColumn::make('name')->searchable()->sortable(),
Tables\Columns\TextColumn::make('slug')->searchable(),
Tables\Columns\TextColumn::make('contact_email'),
Tables\Columns\TextColumn::make('event_credits_balance')
->label(__('admin.common.credits'))
Tables\Columns\TextColumn::make('activeResellerPackage.name')
->label(__('admin.tenants.fields.active_package'))
->badge()
->color(fn ($state) => $state < 5 ? 'warning' : 'success'),
Tables\Columns\TextColumn::make('subscription_tier')
->color('success'),
Tables\Columns\TextColumn::make('remaining_events')
->label(__('admin.tenants.fields.remaining_events'))
->badge()
->color(fn (string $state): string => match($state) {
'free' => 'gray',
'starter' => 'info',
'pro' => 'success',
'agency' => 'warning',
'lifetime' => 'danger',
}),
->color(fn ($state) => $state < 1 ? 'danger' : 'success')
->getStateUsing(fn (Tenant $record) => $record->activeResellerPackage?->remaining_events ?? 0),
Tables\Columns\TextColumn::make('activeResellerPackage.expires_at')
->dateTime()
->label(__('admin.tenants.fields.package_expires_at'))
->badge()
->color(fn ($state) => $state && $state->isPast() ? 'danger' : 'success')
->getStateUsing(fn (Tenant $record) => $record->activeResellerPackage?->expires_at),
Tables\Columns\TextColumn::make('total_revenue')
->money('EUR')
->sortable(),
@@ -120,23 +118,36 @@ class TenantResource extends Resource
->filters([])
->actions([
Actions\EditAction::make(),
Actions\Action::make('add_credits')
->label('Credits hinzufügen')
Actions\Action::make('add_package')
->label('Package hinzufügen')
->icon('heroicon-o-plus')
->form([
Forms\Components\TextInput::make('credits')->numeric()->required()->minValue(1),
Select::make('package_id')
->label('Package')
->options(\App\Models\Package::where('type', 'reseller')->pluck('name', 'id'))
->searchable()
->preload()
->required(),
Forms\Components\DateTimePicker::make('expires_at')
->label('Ablaufdatum')
->default(now()->addYear()),
Forms\Components\Textarea::make('reason')->label('Grund')->rows(3),
])
->action(function (Tenant $record, array $data) {
$record->increment('event_credits_balance', $data['credits']);
\App\Models\EventPurchase::create([
\App\Models\TenantPackage::create([
'tenant_id' => $record->id,
'package_id' => 'manual_adjustment',
'credits_added' => $data['credits'],
'price' => 0,
'platform' => 'manual',
'transaction_id' => null,
'reason' => $data['reason'],
'package_id' => $data['package_id'],
'expires_at' => $data['expires_at'],
'active' => true,
'reason' => $data['reason'] ?? null,
]);
\App\Models\PackagePurchase::create([
'tenant_id' => $record->id,
'package_id' => $data['package_id'],
'provider_id' => 'manual',
'type' => 'reseller_subscription',
'purchased_price' => 0,
'metadata' => ['reason' => $data['reason'] ?? 'manual assignment'],
]);
}),
Actions\Action::make('suspend')
@@ -164,4 +175,12 @@ class TenantResource extends Resource
'edit' => Pages\EditTenant::route('/{record}/edit'),
];
}
public static function getRelations(): array
{
return [
TenantPackagesRelationManager::class,
PackagePurchasesRelationManager::class,
];
}
}

View File

@@ -0,0 +1,143 @@
<?php
namespace App\Filament\Resources\TenantResource\RelationManagers;
use Filament\Forms;
use Filament\Forms\Components\Select;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Components\Toggle;
use Filament\Forms\Components\Textarea;
use Filament\Schemas\Schema;
use Filament\Resources\RelationManagers\RelationManager;
use Filament\Tables;
use Filament\Tables\Actions\BulkActionGroup;
use Filament\Tables\Actions\DeleteBulkAction;
use Filament\Tables\Actions\ViewAction;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Columns\IconColumn;
use Filament\Tables\Filters\SelectFilter;
use Filament\Tables\Table;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Relations\Relation;
class PackagePurchasesRelationManager extends RelationManager
{
protected static string $relationship = 'packagePurchases';
protected static ?string $title = 'Package-Käufe';
public function form(Schema $form): Schema
{
return $form
->schema([
Select::make('package_id')
->label('Paket')
->relationship('package', 'name')
->searchable()
->preload()
->required(),
Select::make('type')
->label('Typ')
->options([
'endcustomer_event' => 'Endkunden-Event',
'reseller_subscription' => 'Reseller-Abo',
])
->required(),
TextInput::make('purchased_price')
->label('Gekaufter Preis')
->numeric()
->step(0.01)
->prefix('€')
->required(),
Select::make('provider_id')
->label('Anbieter')
->options([
'stripe' => 'Stripe',
'paypal' => 'PayPal',
'manual' => 'Manuell',
'free' => 'Kostenlos',
])
->required(),
TextInput::make('transaction_id')
->label('Transaktions-ID')
->maxLength(255),
Toggle::make('refunded')
->label('Rückerstattet'),
Textarea::make('metadata')
->label('Metadaten')
->json()
->columnSpanFull(),
])
->columns(2);
}
public function table(Table $table): Table
{
return $table
->recordTitleAttribute('package.name')
->columns([
TextColumn::make('package.name')
->label('Paket')
->badge()
->color('success'),
TextColumn::make('type')
->badge()
->color(fn (string $state): string => match($state) {
'endcustomer_event' => 'info',
'reseller_subscription' => 'success',
default => 'gray',
}),
TextColumn::make('purchased_price')
->money('EUR')
->sortable(),
TextColumn::make('provider_id')
->badge()
->color(fn (string $state): string => match($state) {
'stripe' => 'info',
'paypal' => 'warning',
'manual' => 'gray',
'free' => 'success',
}),
TextColumn::make('transaction_id')
->copyable()
->toggleable(),
TextColumn::make('metadata')
->label('Metadaten')
->toggleable(),
IconColumn::make('refunded')
->boolean()
->color(fn (bool $state): string => $state ? 'danger' : 'success'),
TextColumn::make('created_at')
->dateTime()
->sortable(),
])
->filters([
SelectFilter::make('type')
->options([
'endcustomer_event' => 'Endkunden-Event',
'reseller_subscription' => 'Reseller-Abo',
]),
SelectFilter::make('provider_id')
->options([
'stripe' => 'Stripe',
'paypal' => 'PayPal',
'manual' => 'Manuell',
'free' => 'Kostenlos',
]),
SelectFilter::make('refunded')
->options([
'1' => 'Rückerstattet',
'0' => 'Nicht rückerstattet',
]),
])
->headerActions([])
->actions([
ViewAction::make(),
])
->bulkActions([
BulkActionGroup::make([
DeleteBulkAction::make(),
]),
]);
}
}

View File

@@ -0,0 +1,108 @@
<?php
namespace App\Filament\Resources\TenantResource\RelationManagers;
use Filament\Forms;
use Filament\Forms\Components\Select;
use Filament\Forms\Components\DateTimePicker;
use Filament\Forms\Components\Toggle;
use Filament\Forms\Components\Textarea;
use Filament\Schemas\Schema;
use Filament\Resources\RelationManagers\RelationManager;
use Filament\Tables;
use Filament\Actions\Action;
use Filament\Actions\BulkActionGroup;
use Filament\Actions\DeleteBulkAction;
use Filament\Actions\EditAction;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Columns\IconColumn;
use Filament\Tables\Filters\SelectFilter;
use Filament\Tables\Table;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Relations\Relation;
class TenantPackagesRelationManager extends RelationManager
{
protected static string $relationship = 'tenantPackages';
protected static ?string $title = 'Reseller-Pakete';
public function form(Schema $form): Schema
{
return $form
->schema([
Select::make('package_id')
->label('Paket')
->relationship('package', 'name')
->searchable()
->preload()
->required(),
DateTimePicker::make('expires_at')
->label('Ablaufdatum')
->required(),
Toggle::make('active')
->label('Aktiv'),
Textarea::make('reason')
->label('Grund')
->maxLength(65535)
->columnSpanFull(),
])
->columns(2);
}
public function table(Table $table): Table
{
return $table
->recordTitleAttribute('package.name')
->columns([
TextColumn::make('package.name')
->label('Paket')
->badge()
->color('success'),
TextColumn::make('used_events')
->label('Genutzte Events')
->badge(),
TextColumn::make('remaining_events')
->label('Verbleibende Events')
->badge()
->color(fn ($state) => $state < 1 ? 'danger' : 'success')
->getStateUsing(fn ($record) => $record->remaining_events),
TextColumn::make('expires_at')
->dateTime()
->sortable(),
IconColumn::make('active')
->boolean()
->color(fn (bool $state): string => $state ? 'success' : 'danger'),
TextColumn::make('created_at')
->dateTime()
->sortable(),
])
->filters([
SelectFilter::make('active')
->options([
'1' => 'Aktiv',
'0' => 'Inaktiv',
]),
])
->headerActions([])
->actions([
EditAction::make(),
Action::make('activate')
->label('Aktivieren')
->icon('heroicon-o-check-circle')
->color('success')
->action(fn ($record) => $record->update(['active' => true])),
Action::make('deactivate')
->label('Deaktivieren')
->icon('heroicon-o-x-circle')
->color('danger')
->requiresConfirmation()
->action(fn ($record) => $record->update(['active' => false])),
])
->bulkActions([
BulkActionGroup::make([
DeleteBulkAction::make(),
]),
]);
}
}