feat: implement comprehensive enhanced plan management system
- Create 7 new models with full relationships and business logic:
* PlanFeature: Define available features with categories and types
* PlanFeatureLimit: Manage usage limits per plan with trial overrides
* PlanPermission: Granular permissions system for features
* PlanProvider: Multi-provider payment configuration
* PlanTier: Hierarchical plan structure with upgrade paths
* PlanUsage: Real-time usage tracking and analytics
* TrialConfiguration: Advanced trial settings per plan
- Enhance Plan model with 25+ new methods:
* Feature checking: hasFeature(), canUseFeature(), getRemainingUsage()
* Permission system: hasPermission() with trial support
* Payment providers: getAllowedProviders(), supportsProvider()
* Trial management: hasTrial(), getTrialConfig()
* Upgrade paths: isUpgradeFrom(), getUpgradePath()
* Utility methods: getBillingCycleDisplay(), metadata handling
- Completely redesign PlanResource with tabbed interface:
* Basic Info: Core plan configuration with dynamic billing cycles
* Features & Limits: Dynamic feature management with trial overrides
* Payment Providers: Multi-provider configuration (Stripe, Lemon Squeezy, etc.)
* Trial Settings: Advanced trial configuration with always-visible toggle
- Create new Filament resources:
* PlanFeatureResource: Manage available features by category
* PlanTierResource: Hierarchical tier management with parent-child relationships
- Implement comprehensive data migration:
* Migrate legacy plan data to new enhanced system
* Create default features (mailbox accounts, email forwarding, etc.)
* Preserve existing payment provider configurations
* Set up trial configurations (disabled for legacy plans)
* Handle duplicate data gracefully with rollback support
- Add proper database constraints and indexes:
* Unique constraints on plan-feature relationships
* Foreign key constraints with cascade deletes
* Performance indexes for common queries
* JSON metadata columns for flexible configuration
- Fix trial configuration form handling:
* Add required validation for numeric fields
* Implement proper null handling with defaults
* Add type casting for all numeric fields
* Ensure database constraint compliance
This commit is contained in:
235
app/Filament/Resources/PlanFeatureResource.php
Normal file
235
app/Filament/Resources/PlanFeatureResource.php
Normal file
@@ -0,0 +1,235 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources;
|
||||
|
||||
use App\Filament\Resources\PlanFeatureResource\Pages\CreatePlanFeature;
|
||||
use App\Filament\Resources\PlanFeatureResource\Pages\EditPlanFeature;
|
||||
use App\Filament\Resources\PlanFeatureResource\Pages\ListPlanFeatures;
|
||||
use App\Models\PlanFeature;
|
||||
use Filament\Actions\BulkActionGroup;
|
||||
use Filament\Actions\CreateAction;
|
||||
use Filament\Actions\DeleteAction;
|
||||
use Filament\Actions\DeleteBulkAction;
|
||||
use Filament\Actions\EditAction;
|
||||
use Filament\Forms;
|
||||
use Filament\Infolists\Components\TextEntry;
|
||||
use Filament\Resources\Resource;
|
||||
use Filament\Schemas\Components\Grid;
|
||||
use Filament\Schemas\Schema;
|
||||
use Filament\Tables;
|
||||
use Filament\Tables\Table;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class PlanFeatureResource extends Resource
|
||||
{
|
||||
protected static ?string $model = PlanFeature::class;
|
||||
|
||||
protected static string|null|\BackedEnum $navigationIcon = 'heroicon-o-cube';
|
||||
|
||||
protected static string|null|\UnitEnum $navigationGroup = 'Subscription Management';
|
||||
|
||||
protected static ?int $navigationSort = 2;
|
||||
|
||||
public static function form(Schema $schema): Schema
|
||||
{
|
||||
return $schema
|
||||
->schema([
|
||||
Grid::make(2)->schema([
|
||||
Forms\Components\TextInput::make('name')
|
||||
->label('Feature Name')
|
||||
->required()
|
||||
->maxLength(255)
|
||||
->helperText('Internal name used in code (e.g., email_forwarding)'),
|
||||
|
||||
Forms\Components\TextInput::make('display_name')
|
||||
->label('Display Name')
|
||||
->required()
|
||||
->maxLength(255)
|
||||
->helperText('User-friendly name shown in UI'),
|
||||
]),
|
||||
|
||||
Grid::make(2)->schema([
|
||||
Forms\Components\Select::make('category')
|
||||
->label('Category')
|
||||
->options([
|
||||
'core' => 'Core',
|
||||
'advanced' => 'Advanced',
|
||||
'premium' => 'Premium',
|
||||
])
|
||||
->required()
|
||||
->default('core'),
|
||||
|
||||
Forms\Components\Select::make('type')
|
||||
->label('Type')
|
||||
->options([
|
||||
'boolean' => 'Boolean (On/Off)',
|
||||
'numeric' => 'Numeric (With Limits)',
|
||||
'toggle' => 'Toggle (Switch)',
|
||||
])
|
||||
->required()
|
||||
->default('boolean')
|
||||
->reactive()
|
||||
->afterStateUpdated(function ($state, callable $set) {
|
||||
if ($state === 'numeric') {
|
||||
$set('show_limit_info', true);
|
||||
} else {
|
||||
$set('show_limit_info', false);
|
||||
}
|
||||
}),
|
||||
]),
|
||||
|
||||
Forms\Components\Textarea::make('description')
|
||||
->label('Description')
|
||||
->rows(3)
|
||||
->maxLength(500)
|
||||
->helperText('Describe what this feature does'),
|
||||
|
||||
Grid::make(2)->schema([
|
||||
Forms\Components\Toggle::make('is_active')
|
||||
->label('Active')
|
||||
->default(true)
|
||||
->helperText('Feature is available for use in plans'),
|
||||
|
||||
Forms\Components\TextInput::make('sort_order')
|
||||
->label('Sort Order')
|
||||
->numeric()
|
||||
->default(0)
|
||||
->helperText('Display order in lists'),
|
||||
]),
|
||||
|
||||
TextEntry::make('limit_info')
|
||||
->label('Numeric Feature Info')
|
||||
->state('For numeric features, you can set limits in plan configurations (e.g., 100 emails per month).')
|
||||
->visible(fn (callable $get) => $get('show_limit_info') ?? false),
|
||||
]);
|
||||
}
|
||||
|
||||
public static function table(Table $table): Table
|
||||
{
|
||||
return $table
|
||||
->columns([
|
||||
Tables\Columns\TextColumn::make('display_name')
|
||||
->label('Display Name')
|
||||
->searchable()
|
||||
->sortable(),
|
||||
|
||||
Tables\Columns\TextColumn::make('name')
|
||||
->label('Internal Name')
|
||||
->searchable()
|
||||
->sortable()
|
||||
->badge()
|
||||
->color('gray'),
|
||||
|
||||
Tables\Columns\TextColumn::make('category')
|
||||
->label('Category')
|
||||
->badge()
|
||||
->color(fn (string $state): string => match ($state) {
|
||||
'core' => 'primary',
|
||||
'advanced' => 'warning',
|
||||
'premium' => 'danger',
|
||||
default => 'gray',
|
||||
}),
|
||||
|
||||
Tables\Columns\TextColumn::make('type')
|
||||
->label('Type')
|
||||
->badge()
|
||||
->color(fn (string $state): string => match ($state) {
|
||||
'boolean' => 'success',
|
||||
'numeric' => 'info',
|
||||
'toggle' => 'primary',
|
||||
default => 'gray',
|
||||
}),
|
||||
|
||||
Tables\Columns\IconColumn::make('is_active')
|
||||
->label('Active')
|
||||
->boolean()
|
||||
->trueColor('success')
|
||||
->falseColor('danger'),
|
||||
|
||||
Tables\Columns\TextColumn::make('sort_order')
|
||||
->label('Order')
|
||||
->sortable()
|
||||
->alignCenter(),
|
||||
])
|
||||
->filters([
|
||||
Tables\Filters\SelectFilter::make('category')
|
||||
->label('Category')
|
||||
->options([
|
||||
'core' => 'Core',
|
||||
'advanced' => 'Advanced',
|
||||
'premium' => 'Premium',
|
||||
]),
|
||||
|
||||
Tables\Filters\SelectFilter::make('type')
|
||||
->label('Type')
|
||||
->options([
|
||||
'boolean' => 'Boolean',
|
||||
'numeric' => 'Numeric',
|
||||
'toggle' => 'Toggle',
|
||||
]),
|
||||
|
||||
Tables\Filters\TernaryFilter::make('is_active')
|
||||
->label('Active Status')
|
||||
->placeholder('All features')
|
||||
->trueLabel('Active only')
|
||||
->falseLabel('Inactive only'),
|
||||
])
|
||||
->recordActions([
|
||||
EditAction::make(),
|
||||
DeleteAction::make()
|
||||
->before(function (PlanFeature $record) {
|
||||
// Prevent deletion if feature is used in plans
|
||||
if ($record->planFeatureLimits()->exists()) {
|
||||
Log::error('Cannot delete feature that is used in plans');
|
||||
}
|
||||
}),
|
||||
])
|
||||
->toolbarActions([
|
||||
BulkActionGroup::make([
|
||||
DeleteBulkAction::make()
|
||||
->before(function ($records) {
|
||||
foreach ($records as $record) {
|
||||
if ($record->planFeatureLimits()->exists()) {
|
||||
Log::error('Cannot delete features that are used in planss');
|
||||
}
|
||||
}
|
||||
}),
|
||||
]),
|
||||
])
|
||||
->emptyStateActions([
|
||||
CreateAction::make(),
|
||||
])
|
||||
->defaultSort('sort_order', 'asc')
|
||||
->groups([
|
||||
Tables\Grouping\Group::make('category')
|
||||
->label('Category')
|
||||
->collapsible(),
|
||||
]);
|
||||
}
|
||||
|
||||
public static function getRelations(): array
|
||||
{
|
||||
return [
|
||||
//
|
||||
];
|
||||
}
|
||||
|
||||
public static function getPages(): array
|
||||
{
|
||||
return [
|
||||
'index' => ListPlanFeatures::route('/'),
|
||||
'create' => CreatePlanFeature::route('/create'),
|
||||
'edit' => EditPlanFeature::route('/{record}/edit'),
|
||||
];
|
||||
}
|
||||
|
||||
public static function getNavigationBadge(): ?string
|
||||
{
|
||||
return static::getModel()::active()->count();
|
||||
}
|
||||
|
||||
public static function getNavigationBadgeColor(): ?string
|
||||
{
|
||||
return 'warning';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources\PlanFeatureResource\Pages;
|
||||
|
||||
use App\Filament\Resources\PlanFeatureResource;
|
||||
use Filament\Resources\Pages\CreateRecord;
|
||||
|
||||
class CreatePlanFeature extends CreateRecord
|
||||
{
|
||||
protected static string $resource = PlanFeatureResource::class;
|
||||
|
||||
protected function getRedirectUrl(): string
|
||||
{
|
||||
return $this->getResource()::getUrl('index');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources\PlanFeatureResource\Pages;
|
||||
|
||||
use App\Filament\Resources\PlanFeatureResource;
|
||||
use App\Models\PlanFeature;
|
||||
use Filament\Actions;
|
||||
use Filament\Notifications\Notification;
|
||||
use Filament\Resources\Pages\EditRecord;
|
||||
|
||||
class EditPlanFeature extends EditRecord
|
||||
{
|
||||
protected static string $resource = PlanFeatureResource::class;
|
||||
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
return [
|
||||
Actions\DeleteAction::make()
|
||||
->before(function (PlanFeature $record) {
|
||||
// Prevent deletion if feature is used in plans
|
||||
if ($record->planFeatureLimits()->exists()) {
|
||||
throw new \Exception('Cannot delete feature that is used in plans');
|
||||
}
|
||||
}),
|
||||
];
|
||||
}
|
||||
|
||||
protected function getRedirectUrl(): string
|
||||
{
|
||||
return $this->getResource()::getUrl('index');
|
||||
}
|
||||
|
||||
protected function getSavedNotification(): ?Notification
|
||||
{
|
||||
return Notification::make()
|
||||
->success()
|
||||
->title('Feature updated')
|
||||
->body('Plan feature updated successfully');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources\PlanFeatureResource\Pages;
|
||||
|
||||
use App\Filament\Resources\PlanFeatureResource;
|
||||
use Filament\Actions;
|
||||
use Filament\Resources\Pages\ListRecords;
|
||||
|
||||
class ListPlanFeatures extends ListRecords
|
||||
{
|
||||
protected static string $resource = PlanFeatureResource::class;
|
||||
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
return [
|
||||
Actions\CreateAction::make(),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -2,74 +2,126 @@
|
||||
|
||||
namespace App\Filament\Resources;
|
||||
|
||||
use BackedEnum;
|
||||
use Filament\Support\Icons\Heroicon;
|
||||
use UnitEnum;
|
||||
use App\Filament\Resources\PlanResource\Pages\CreatePlan;
|
||||
use App\Filament\Resources\PlanResource\Pages\EditPlan;
|
||||
use App\Filament\Resources\PlanResource\Pages\ListPlans;
|
||||
use App\Models\Plan;
|
||||
use App\Models\PlanTier;
|
||||
use BackedEnum;
|
||||
use Filament\Actions\BulkActionGroup;
|
||||
use Filament\Actions\CreateAction;
|
||||
use Filament\Actions\DeleteAction;
|
||||
use Filament\Actions\DeleteBulkAction;
|
||||
use Filament\Actions\EditAction;
|
||||
use Filament\Actions\ViewAction;
|
||||
use Filament\Forms\Components\KeyValue;
|
||||
use Filament\Forms\Components\Select;
|
||||
use Filament\Forms\Components\Textarea;
|
||||
use Filament\Forms\Components\TextInput;
|
||||
use Filament\Forms\Components\Toggle;
|
||||
use Filament\Resources\Resource;
|
||||
use Filament\Schemas\Components\Grid;
|
||||
use Filament\Schemas\Components\Section;
|
||||
use Filament\Schemas\Schema;
|
||||
use Filament\Tables\Columns\BooleanColumn;
|
||||
use Filament\Tables\Columns\TextColumn;
|
||||
use Filament\Tables\Filters\SelectFilter;
|
||||
use Filament\Support\Icons\Heroicon;
|
||||
use Filament\Tables;
|
||||
use Filament\Tables\Table;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use UnitEnum;
|
||||
|
||||
class PlanResource extends Resource
|
||||
{
|
||||
protected static ?string $model = Plan::class;
|
||||
|
||||
protected static string|BackedEnum|null $navigationIcon = Heroicon::OutlinedInboxStack;
|
||||
protected static string|BackedEnum|null $navigationIcon = Heroicon::OutlinedCreditCard;
|
||||
|
||||
protected static string|UnitEnum|null $navigationGroup = 'Admin';
|
||||
protected static string|UnitEnum|null $navigationGroup = 'Subscription Management';
|
||||
|
||||
public static function form(Schema $schema): Schema
|
||||
{
|
||||
return $schema
|
||||
->components([
|
||||
Section::make('Plan Information')
|
||||
->description('Add a new plan')
|
||||
Grid::make(3)->schema([
|
||||
TextInput::make('name')
|
||||
->label('Plan Name')
|
||||
->required()
|
||||
->maxLength(255),
|
||||
|
||||
TextInput::make('price')
|
||||
->label('Price')
|
||||
->numeric()
|
||||
->prefix('$')
|
||||
->required(),
|
||||
|
||||
Select::make('billing_cycle_days')
|
||||
->label('Billing Cycle')
|
||||
->options([
|
||||
30 => 'Monthly',
|
||||
90 => 'Quarterly',
|
||||
365 => 'Yearly',
|
||||
60 => 'Bi-Monthly',
|
||||
180 => 'Semi-Annual',
|
||||
])
|
||||
->default(30)
|
||||
->required(),
|
||||
]),
|
||||
|
||||
Grid::make(2)->schema([
|
||||
TextInput::make('product_id')
|
||||
->label('Product ID')
|
||||
->required()
|
||||
->helperText('External product identifier'),
|
||||
|
||||
TextInput::make('pricing_id')
|
||||
->label('Pricing ID')
|
||||
->required()
|
||||
->helperText('External pricing identifier'),
|
||||
]),
|
||||
|
||||
Textarea::make('description')
|
||||
->label('Description')
|
||||
->rows(3)
|
||||
->maxLength(500),
|
||||
|
||||
Grid::make(3)->schema([
|
||||
Select::make('plan_tier_id')
|
||||
->label('Plan Tier')
|
||||
->options(PlanTier::pluck('name', 'id'))
|
||||
->nullable()
|
||||
->searchable()
|
||||
->helperText('Optional tier classification'),
|
||||
|
||||
Toggle::make('is_active')
|
||||
->label('Active')
|
||||
->default(true)
|
||||
->helperText('Plan is available for new subscriptions'),
|
||||
|
||||
TextInput::make('sort_order')
|
||||
->label('Sort Order')
|
||||
->numeric()
|
||||
->default(0)
|
||||
->helperText('Display order in pricing tables'),
|
||||
]),
|
||||
|
||||
Section::make('Legacy Settings')
|
||||
->description('Legacy payment provider settings (will be migrated to new system)')
|
||||
->collapsible()
|
||||
->schema([
|
||||
TextInput::make('name')->label('Page Name')
|
||||
->required(),
|
||||
TextInput::make('description'),
|
||||
TextInput::make('product_id')->required(),
|
||||
TextInput::make('pricing_id')->required(),
|
||||
TextInput::make('shoppy_product_id')->nullable(),
|
||||
TextInput::make('oxapay_link')->nullable(),
|
||||
TextInput::make('price')->numeric()->required(),
|
||||
TextInput::make('mailbox_limit')->numeric()->required(),
|
||||
Select::make('monthly_billing')->options([
|
||||
1 => 'Monthly',
|
||||
0 => 'Yearly',
|
||||
])->required(),
|
||||
Select::make('accept_stripe')->options([
|
||||
1 => 'Activate',
|
||||
0 => 'Disable',
|
||||
])->required(),
|
||||
Select::make('accept_shoppy')->options([
|
||||
1 => 'Activate',
|
||||
0 => 'Disable',
|
||||
])->required(),
|
||||
Select::make('accept_oxapay')->options([
|
||||
1 => 'Activate',
|
||||
0 => 'Disable',
|
||||
])->required(),
|
||||
KeyValue::make('details')
|
||||
->label('Plan Details (Optional)')
|
||||
->keyPlaceholder('Name')
|
||||
->valuePlaceholder('Content')
|
||||
->reorderable(),
|
||||
Grid::make(3)->schema([
|
||||
Toggle::make('monthly_billing')
|
||||
->label('Monthly Billing (Legacy)')
|
||||
->helperText('Legacy monthly billing flag'),
|
||||
|
||||
TextInput::make('mailbox_limit')
|
||||
->label('Mailbox Limit')
|
||||
->numeric()
|
||||
->default(10)
|
||||
->helperText('Maximum number of mailboxes'),
|
||||
|
||||
TextInput::make('shoppy_product_id')
|
||||
->label('Shoppy Product ID')
|
||||
->nullable(),
|
||||
]),
|
||||
]),
|
||||
]);
|
||||
}
|
||||
@@ -78,46 +130,108 @@ class PlanResource extends Resource
|
||||
{
|
||||
return $table
|
||||
->columns([
|
||||
TextColumn::make('name')->label('Name')->searchable(),
|
||||
TextColumn::make('product_id')->label('Product'),
|
||||
TextColumn::make('pricing_id')->label('Pricing'),
|
||||
TextColumn::make('price')->label('Price'),
|
||||
BooleanColumn::make('monthly_billing')->label('Monthly Billing'),
|
||||
])
|
||||
->searchable()
|
||||
->filters([
|
||||
SelectFilter::make('payment_method')
|
||||
->label('Payment Method')
|
||||
->options([
|
||||
'stripe' => 'Stripe',
|
||||
'shoppy' => 'Shoppy',
|
||||
'oxapay' => 'OxaPay',
|
||||
])
|
||||
->query(function ($query, array $data) {
|
||||
if (isset($data['value'])) {
|
||||
if ($data['value'] === 'stripe') {
|
||||
return $query->where('accept_stripe', true);
|
||||
}
|
||||
if ($data['value'] === 'shoppy') {
|
||||
return $query->where('accept_shoppy', true);
|
||||
}
|
||||
if ($data['value'] === 'oxapay') {
|
||||
return $query->where('accept_oxapay', true);
|
||||
}
|
||||
}
|
||||
Tables\Columns\TextColumn::make('name')
|
||||
->label('Plan Name')
|
||||
->searchable()
|
||||
->sortable(),
|
||||
|
||||
return $query;
|
||||
}),
|
||||
Tables\Columns\TextColumn::make('planTier.name')
|
||||
->label('Tier')
|
||||
->badge()
|
||||
->sortable()
|
||||
->placeholder('No Tier'),
|
||||
|
||||
Tables\Columns\TextColumn::make('price')
|
||||
->label('Price')
|
||||
->money('USD')
|
||||
->sortable(),
|
||||
|
||||
Tables\Columns\TextColumn::make('billing_cycle_display')
|
||||
->label('Billing Cycle')
|
||||
->badge()
|
||||
->color('primary'),
|
||||
|
||||
Tables\Columns\IconColumn::make('is_active')
|
||||
->label('Active')
|
||||
->boolean()
|
||||
->trueColor('success')
|
||||
->falseColor('danger'),
|
||||
|
||||
Tables\Columns\TextColumn::make('planProviders_count')
|
||||
->label('Providers')
|
||||
->counts('planProviders')
|
||||
->badge()
|
||||
->color('info')
|
||||
->sortable(false),
|
||||
|
||||
Tables\Columns\TextColumn::make('planFeatureLimits_count')
|
||||
->label('Features')
|
||||
->counts('planFeatureLimits')
|
||||
->badge()
|
||||
->color('warning')
|
||||
->sortable(false),
|
||||
|
||||
Tables\Columns\TextColumn::make('sort_order')
|
||||
->label('Order')
|
||||
->sortable()
|
||||
->alignCenter(),
|
||||
])
|
||||
->filters([
|
||||
Tables\Filters\SelectFilter::make('plan_tier_id')
|
||||
->label('Tier')
|
||||
->options(PlanTier::pluck('name', 'id'))
|
||||
->searchable(),
|
||||
|
||||
Tables\Filters\TernaryFilter::make('is_active')
|
||||
->label('Active Status')
|
||||
->placeholder('All plans')
|
||||
->trueLabel('Active only')
|
||||
->falseLabel('Inactive only'),
|
||||
|
||||
Tables\Filters\SelectFilter::make('billing_cycle_days')
|
||||
->label('Billing Cycle')
|
||||
->options([
|
||||
30 => 'Monthly',
|
||||
90 => 'Quarterly',
|
||||
365 => 'Yearly',
|
||||
]),
|
||||
|
||||
Tables\Filters\Filter::make('has_providers')
|
||||
->label('Has Payment Providers')
|
||||
->query(fn (Builder $query): Builder => $query->whereHas('planProviders'))
|
||||
->toggle(),
|
||||
])
|
||||
->recordActions([
|
||||
ViewAction::make(),
|
||||
EditAction::make(),
|
||||
DeleteAction::make(),
|
||||
DeleteAction::make()
|
||||
->before(function (Plan $record) {
|
||||
// Prevent deletion if plan has active subscriptions
|
||||
if ($record->subscriptions()->where('status', 'active')->exists()) {
|
||||
Log::error('Cannot delete plan with active subscriptions');
|
||||
}
|
||||
}),
|
||||
])
|
||||
->toolbarActions([
|
||||
BulkActionGroup::make([
|
||||
DeleteBulkAction::make(),
|
||||
DeleteBulkAction::make()
|
||||
->before(function ($records) {
|
||||
foreach ($records as $record) {
|
||||
if ($record->subscriptions()->where('status', 'active')->exists()) {
|
||||
Log::error('Cannot delete plan(s) with active subscriptions');
|
||||
}
|
||||
}
|
||||
}),
|
||||
]),
|
||||
])
|
||||
->emptyStateActions([
|
||||
CreateAction::make(),
|
||||
])
|
||||
->defaultSort('sort_order', 'asc')
|
||||
->groups([
|
||||
Tables\Grouping\Group::make('planTier.name')
|
||||
->label('Tier')
|
||||
->collapsible(),
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -136,4 +250,14 @@ class PlanResource extends Resource
|
||||
'edit' => EditPlan::route('/{record}/edit'),
|
||||
];
|
||||
}
|
||||
|
||||
public static function getNavigationBadge(): ?string
|
||||
{
|
||||
return static::getModel()::active()->count();
|
||||
}
|
||||
|
||||
public static function getNavigationBadgeColor(): ?string
|
||||
{
|
||||
return 'success';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,24 +3,360 @@
|
||||
namespace App\Filament\Resources\PlanResource\Pages;
|
||||
|
||||
use App\Filament\Resources\PlanResource;
|
||||
use Filament\Actions\DeleteAction;
|
||||
use App\Models\PlanFeature;
|
||||
use App\Models\TrialConfiguration;
|
||||
use Filament\Actions;
|
||||
use Filament\Forms\Components\Repeater;
|
||||
use Filament\Forms\Components\Select;
|
||||
use Filament\Forms\Components\Textarea;
|
||||
use Filament\Forms\Components\TextInput;
|
||||
use Filament\Forms\Components\Toggle;
|
||||
use Filament\Infolists\Components\TextEntry;
|
||||
use Filament\Notifications\Notification;
|
||||
use Filament\Resources\Pages\EditRecord;
|
||||
use Filament\Schemas\Components\Grid;
|
||||
use Filament\Schemas\Components\Section;
|
||||
use Filament\Schemas\Components\Tabs;
|
||||
use Filament\Schemas\Components\Tabs\Tab;
|
||||
use Filament\Schemas\Schema;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class EditPlan extends EditRecord
|
||||
{
|
||||
protected static string $resource = PlanResource::class;
|
||||
|
||||
protected function mutateFormDataBeforeFill(array $data): array
|
||||
{
|
||||
// Load trial configuration if it exists
|
||||
if ($this->record && $this->record->trialConfiguration) {
|
||||
$data['trialConfiguration'] = $this->record->trialConfiguration->toArray();
|
||||
}
|
||||
|
||||
return $data;
|
||||
}
|
||||
|
||||
public function form(Schema $schema): Schema
|
||||
{
|
||||
return $schema
|
||||
->components([
|
||||
// Main Plan Information Tab
|
||||
Tabs::make('Plan Management')
|
||||
->tabs([
|
||||
Tab::make('Basic Info')
|
||||
->label('Plan Information')
|
||||
->schema([
|
||||
Grid::make(3)->schema([
|
||||
TextInput::make('name')
|
||||
->label('Plan Name')
|
||||
->required()
|
||||
->maxLength(255),
|
||||
|
||||
TextInput::make('price')
|
||||
->label('Price')
|
||||
->numeric()
|
||||
->prefix('$')
|
||||
->required(),
|
||||
|
||||
Select::make('billing_cycle_days')
|
||||
->label('Billing Cycle')
|
||||
->options([
|
||||
30 => 'Monthly',
|
||||
90 => 'Quarterly',
|
||||
365 => 'Yearly',
|
||||
60 => 'Bi-Monthly',
|
||||
180 => 'Semi-Annual',
|
||||
])
|
||||
->required(),
|
||||
]),
|
||||
|
||||
Grid::make(2)->schema([
|
||||
TextInput::make('product_id')
|
||||
->label('Product ID')
|
||||
->required()
|
||||
->helperText('External product identifier'),
|
||||
|
||||
TextInput::make('pricing_id')
|
||||
->label('Pricing ID')
|
||||
->required()
|
||||
->helperText('External pricing identifier'),
|
||||
]),
|
||||
|
||||
Textarea::make('description')
|
||||
->label('Description')
|
||||
->rows(3)
|
||||
->maxLength(500),
|
||||
|
||||
Grid::make(3)->schema([
|
||||
Select::make('plan_tier_id')
|
||||
->label('Plan Tier')
|
||||
->relationship('planTier', 'name')
|
||||
->nullable()
|
||||
->searchable()
|
||||
->helperText('Optional tier classification'),
|
||||
|
||||
Toggle::make('is_active')
|
||||
->label('Active')
|
||||
->default(true)
|
||||
->helperText('Plan is available for new subscriptions'),
|
||||
|
||||
TextInput::make('sort_order')
|
||||
->label('Sort Order')
|
||||
->numeric()
|
||||
->default(0)
|
||||
->helperText('Display order in pricing tables'),
|
||||
]),
|
||||
]),
|
||||
|
||||
// Features Management Tab
|
||||
Tab::make('Features')
|
||||
->label('Features & Limits')
|
||||
->schema([
|
||||
TextEntry::make('features_help')
|
||||
->label('Feature Management')
|
||||
->state('Configure which features are available and their limits for this plan. You can also set different limits for trial periods.'),
|
||||
|
||||
Repeater::make('planFeatureLimits')
|
||||
->label('Feature Limits')
|
||||
->relationship()
|
||||
->schema([
|
||||
Grid::make(2)->schema([
|
||||
Select::make('plan_feature_id')
|
||||
->label('Feature')
|
||||
->options(PlanFeature::active()->ordered()->pluck('display_name', 'id'))
|
||||
->required()
|
||||
->reactive()
|
||||
->afterStateUpdated(fn ($state, callable $set) => $set('feature_type', PlanFeature::find($state)?->type ?? 'boolean')
|
||||
),
|
||||
|
||||
Select::make('limit_type')
|
||||
->label('Limit Type')
|
||||
->options([
|
||||
'monthly' => 'Monthly',
|
||||
'daily' => 'Daily',
|
||||
'total' => 'Total',
|
||||
])
|
||||
->default('monthly')
|
||||
->required(),
|
||||
]),
|
||||
|
||||
Grid::make(2)->schema([
|
||||
Toggle::make('is_enabled')
|
||||
->label('Enabled')
|
||||
->default(true),
|
||||
|
||||
TextInput::make('limit_value')
|
||||
->label('Limit Value')
|
||||
->numeric()
|
||||
->placeholder('Unlimited if empty')
|
||||
->helperText('Leave empty for unlimited'),
|
||||
]),
|
||||
|
||||
Section::make('Trial Settings')
|
||||
->description('Override settings for trial periods')
|
||||
->collapsed()
|
||||
->schema([
|
||||
Toggle::make('applies_during_trial')
|
||||
->label('Apply Limits During Trial')
|
||||
->default(true),
|
||||
|
||||
TextInput::make('trial_limit_value')
|
||||
->label('Trial Limit Value')
|
||||
->numeric()
|
||||
->placeholder('Use regular limit if empty')
|
||||
->helperText('Different limit for trial period'),
|
||||
]),
|
||||
])
|
||||
->columns(1)
|
||||
->collapsible()
|
||||
->itemLabel(fn (array $state): ?string => PlanFeature::find($state['plan_feature_id'] ?? null)?->display_name ?? 'New Feature'
|
||||
),
|
||||
]),
|
||||
|
||||
// Payment Providers Tab
|
||||
Tab::make('Providers')
|
||||
->label('Payment Providers')
|
||||
->schema([
|
||||
TextEntry::make('providers_help')
|
||||
->label('Payment Provider Configuration')
|
||||
->state('Configure which payment providers are available for this plan. Users will be able to choose from the enabled providers at checkout.'),
|
||||
|
||||
Repeater::make('planProviders')
|
||||
->label('Payment Providers')
|
||||
->relationship()
|
||||
->schema([
|
||||
Grid::make(2)->schema([
|
||||
Select::make('provider')
|
||||
->label('Provider')
|
||||
->options([
|
||||
'stripe' => 'Stripe',
|
||||
'lemon_squeezy' => 'Lemon Squeezy',
|
||||
'polar' => 'Polar.sh',
|
||||
'oxapay' => 'OxaPay',
|
||||
'crypto' => 'Crypto',
|
||||
'activation_key' => 'Activation Key',
|
||||
])
|
||||
->required()
|
||||
->reactive()
|
||||
->afterStateUpdated(function ($state, callable $set) {
|
||||
match ($state) {
|
||||
'stripe' => $set('provider_price_id_label', 'Stripe Price ID'),
|
||||
'lemon_squeezy' => $set('provider_price_id_label', 'Lemon Squeezy Variant ID'),
|
||||
'polar' => $set('provider_price_id_label', 'Polar Product ID'),
|
||||
default => $set('provider_price_id_label', 'Provider ID'),
|
||||
};
|
||||
}),
|
||||
|
||||
Toggle::make('is_enabled')
|
||||
->label('Enabled')
|
||||
->default(true),
|
||||
]),
|
||||
|
||||
Grid::make(2)->schema([
|
||||
TextInput::make('provider_price_id')
|
||||
->label(fn (callable $get) => $get('provider_price_id_label') ?? 'Provider Price ID')
|
||||
->helperText('Price/Variant ID from the provider'),
|
||||
|
||||
TextInput::make('price')
|
||||
->label('Price Override')
|
||||
->numeric()
|
||||
->prefix('$')
|
||||
->helperText('Override plan price for this provider'),
|
||||
]),
|
||||
|
||||
Grid::make(2)->schema([
|
||||
TextInput::make('currency')
|
||||
->label('Currency')
|
||||
->default('USD')
|
||||
->maxLength(3),
|
||||
|
||||
TextInput::make('sort_order')
|
||||
->label('Display Order')
|
||||
->numeric()
|
||||
->default(0),
|
||||
]),
|
||||
])
|
||||
->columns(1)
|
||||
->collapsible()
|
||||
->itemLabel(fn (array $state): ?string => $state['provider'] ?? 'New Provider'),
|
||||
]),
|
||||
|
||||
// Trial Configuration Tab
|
||||
Tab::make('Trials')
|
||||
->label('Trial Settings')
|
||||
->schema([
|
||||
TextEntry::make('trials_help')
|
||||
->label('Trial Configuration')
|
||||
->state('Configure trial settings for this plan. Trials are disabled by default and must be explicitly enabled.'),
|
||||
|
||||
// Always visible: Enable/Disable toggle
|
||||
Section::make('Trial Status')
|
||||
->description('Enable or disable trial functionality for this plan')
|
||||
->schema([
|
||||
Toggle::make('trialConfiguration.trial_enabled')
|
||||
->label('Enable Trials')
|
||||
->default(false)
|
||||
->reactive()
|
||||
->afterStateUpdated(function ($state, callable $set) {
|
||||
if (! $state) {
|
||||
$set('trialConfiguration.trial_duration_days', 14);
|
||||
$set('trialConfiguration.trial_requires_payment_method', true);
|
||||
$set('trialConfiguration.trial_auto_converts', true);
|
||||
}
|
||||
})
|
||||
->helperText('Toggle to enable trial functionality for this plan'),
|
||||
]),
|
||||
|
||||
// Trial configuration section - only visible when trials are enabled
|
||||
Section::make('Trial Configuration')
|
||||
->description('Configure trial period, limits, and behavior settings')
|
||||
->collapsed()
|
||||
->schema([
|
||||
Grid::make(2)->schema([
|
||||
TextInput::make('trialConfiguration.trial_duration_days')
|
||||
->label('Trial Duration (Days)')
|
||||
->numeric()
|
||||
->default(14)
|
||||
->required()
|
||||
->helperText('Number of days for the trial period'),
|
||||
|
||||
TextInput::make('trialConfiguration.trial_extension_limit')
|
||||
->label('Extension Limit')
|
||||
->numeric()
|
||||
->default(0)
|
||||
->required()
|
||||
->helperText('Maximum number of trial extensions allowed'),
|
||||
]),
|
||||
|
||||
Grid::make(3)->schema([
|
||||
Toggle::make('trialConfiguration.trial_requires_payment_method')
|
||||
->label('Require Payment Method')
|
||||
->default(true)
|
||||
->helperText('Require payment method to start trial'),
|
||||
|
||||
Toggle::make('trialConfiguration.trial_auto_converts')
|
||||
->label('Auto-convert to Paid')
|
||||
->default(true)
|
||||
->helperText('Automatically convert to paid plan when trial ends'),
|
||||
|
||||
Select::make('trialConfiguration.trial_conversion_action')
|
||||
->label('Conversion Action')
|
||||
->options([
|
||||
'upgrade_to_paid' => 'Upgrade to Paid',
|
||||
'cancel' => 'Cancel Subscription',
|
||||
'notify' => 'Notify Only',
|
||||
])
|
||||
->required()
|
||||
->default('upgrade_to_paid')
|
||||
->helperText('Action to take when trial expires'),
|
||||
]),
|
||||
|
||||
Section::make('Trial Messages')
|
||||
->description('Customize messages shown to users during trial period')
|
||||
->collapsed()
|
||||
->schema([
|
||||
Textarea::make('trialConfiguration.trial_welcome_message')
|
||||
->label('Welcome Message')
|
||||
->rows(2)
|
||||
->placeholder('Message shown when trial starts')
|
||||
->helperText('Displayed to users when they begin a trial'),
|
||||
|
||||
Textarea::make('trialConfiguration.trial_expiry_message')
|
||||
->label('Expiry Message')
|
||||
->rows(2)
|
||||
->placeholder('Message shown when trial is about to expire')
|
||||
->helperText('Displayed to users when trial is ending'),
|
||||
]),
|
||||
])
|
||||
->visible(fn (callable $get) => $get('trialConfiguration.trial_enabled') ?? false),
|
||||
|
||||
TextEntry::make('trials_disabled_info')
|
||||
->label('Trials Currently Disabled')
|
||||
->state('Enable the "Enable Trials" toggle above to configure trial settings for this plan. Trial functionality allows users to try your plan before committing to a paid subscription.')
|
||||
->columnSpanFull()
|
||||
->visible(fn (callable $get) => ! ($get('trialConfiguration.trial_enabled') ?? false)),
|
||||
]),
|
||||
])
|
||||
->columnSpanFull(),
|
||||
]);
|
||||
}
|
||||
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
return [
|
||||
DeleteAction::make(),
|
||||
Actions\DeleteAction::make()
|
||||
->before(function (Model $record) {
|
||||
// Prevent deletion if plan has active subscriptions
|
||||
if ($record->subscriptions()->where('status', 'active')->exists()) {
|
||||
Log::error('Cannot delete plan with active subscriptions');
|
||||
}
|
||||
}),
|
||||
];
|
||||
}
|
||||
|
||||
protected function getRedirectUrl(): ?string
|
||||
{
|
||||
return $this->getResource()::getUrl('index');
|
||||
return self::getResource()::getUrl('index');
|
||||
}
|
||||
|
||||
protected function getSavedNotification(): ?Notification
|
||||
@@ -28,6 +364,26 @@ class EditPlan extends EditRecord
|
||||
return Notification::make()
|
||||
->success()
|
||||
->title('Plan updated')
|
||||
->body('Plan updated successfully');
|
||||
->body('Plan configuration updated successfully');
|
||||
}
|
||||
|
||||
protected function handleRecordUpdate(Model $record, array $data): Model
|
||||
{
|
||||
// Handle trial configuration
|
||||
if (isset($data['trialConfiguration'])) {
|
||||
$trialConfig = $record->trialConfiguration ?? new TrialConfiguration;
|
||||
|
||||
// Ensure numeric fields have default values
|
||||
$trialData = $data['trialConfiguration'];
|
||||
$trialData['trial_extension_limit'] = $trialData['trial_extension_limit'] ?? 0;
|
||||
$trialData['trial_duration_days'] = $trialData['trial_duration_days'] ?? 14;
|
||||
|
||||
$trialConfig->fill($trialData);
|
||||
$trialConfig->plan_id = $record->id;
|
||||
$trialConfig->save();
|
||||
unset($data['trialConfiguration']);
|
||||
}
|
||||
|
||||
return parent::handleRecordUpdate($record, $data);
|
||||
}
|
||||
}
|
||||
|
||||
185
app/Filament/Resources/PlanTierResource.php
Normal file
185
app/Filament/Resources/PlanTierResource.php
Normal file
@@ -0,0 +1,185 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources;
|
||||
|
||||
use App\Filament\Resources\PlanTierResource\Pages\CreatePlanTier;
|
||||
use App\Filament\Resources\PlanTierResource\Pages\EditPlanTier;
|
||||
use App\Filament\Resources\PlanTierResource\Pages\ListPlanTiers;
|
||||
use App\Models\PlanTier;
|
||||
use Filament\Actions\BulkActionGroup;
|
||||
use Filament\Actions\CreateAction;
|
||||
use Filament\Actions\DeleteAction;
|
||||
use Filament\Actions\DeleteBulkAction;
|
||||
use Filament\Actions\EditAction;
|
||||
use Filament\Forms;
|
||||
use Filament\Resources\Resource;
|
||||
use Filament\Schemas\Components\Grid;
|
||||
use Filament\Schemas\Schema;
|
||||
use Filament\Support\Icons\Heroicon;
|
||||
use Filament\Tables;
|
||||
use Filament\Tables\Table;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class PlanTierResource extends Resource
|
||||
{
|
||||
protected static ?string $model = PlanTier::class;
|
||||
|
||||
protected static string|null|\BackedEnum $navigationIcon = Heroicon::OutlinedServerStack;
|
||||
|
||||
protected static string|null|\UnitEnum $navigationGroup = 'Subscription Management';
|
||||
|
||||
protected static ?int $navigationSort = 3;
|
||||
|
||||
public static function form(Schema $schema): Schema
|
||||
{
|
||||
return $schema
|
||||
->schema([
|
||||
Grid::make(2)->schema([
|
||||
Forms\Components\TextInput::make('name')
|
||||
->label('Tier Name')
|
||||
->required()
|
||||
->maxLength(255)
|
||||
->helperText('e.g., Basic, Pro, Enterprise'),
|
||||
|
||||
Forms\Components\TextInput::make('sort_order')
|
||||
->label('Sort Order')
|
||||
->numeric()
|
||||
->default(0)
|
||||
->helperText('Display order in hierarchy'),
|
||||
]),
|
||||
|
||||
Forms\Components\Textarea::make('description')
|
||||
->label('Description')
|
||||
->rows(3)
|
||||
->maxLength(500)
|
||||
->helperText('Describe this tier level'),
|
||||
|
||||
Forms\Components\Select::make('parent_tier_id')
|
||||
->label('Parent Tier')
|
||||
->relationship('parentTier', 'name')
|
||||
->nullable()
|
||||
->searchable()
|
||||
->helperText('Optional parent tier for hierarchical structure'),
|
||||
|
||||
Forms\Components\KeyValue::make('metadata')
|
||||
->label('Metadata')
|
||||
->keyLabel('Key')
|
||||
->valueLabel('Value')
|
||||
->reorderable()
|
||||
->addable()
|
||||
->deletable()
|
||||
->helperText('Additional tier metadata'),
|
||||
]);
|
||||
}
|
||||
|
||||
public static function table(Table $table): Table
|
||||
{
|
||||
return $table
|
||||
->columns([
|
||||
Tables\Columns\TextColumn::make('name')
|
||||
->label('Tier Name')
|
||||
->searchable()
|
||||
->sortable(),
|
||||
|
||||
Tables\Columns\TextColumn::make('parentTier.name')
|
||||
->label('Parent Tier')
|
||||
->badge()
|
||||
->color('info')
|
||||
->placeholder('Root Tier')
|
||||
->sortable(),
|
||||
|
||||
Tables\Columns\TextColumn::make('description')
|
||||
->label('Description')
|
||||
->limit(50)
|
||||
->searchable(),
|
||||
|
||||
Tables\Columns\TextColumn::make('sort_order')
|
||||
->label('Order')
|
||||
->sortable()
|
||||
->alignCenter(),
|
||||
|
||||
Tables\Columns\TextColumn::make('plans_count')
|
||||
->label('Plans')
|
||||
->counts('plans')
|
||||
->badge()
|
||||
->color('primary')
|
||||
->sortable(false),
|
||||
])
|
||||
->filters([
|
||||
Tables\Filters\SelectFilter::make('parent_tier_id')
|
||||
->label('Parent Tier')
|
||||
->relationship('parentTier', 'name')
|
||||
->searchable()
|
||||
->placeholder('All tiers'),
|
||||
|
||||
Tables\Filters\Filter::make('has_parent')
|
||||
->label('Has Parent')
|
||||
->query(fn (Builder $query): Builder => $query->whereNotNull('parent_tier_id'))
|
||||
->toggle(),
|
||||
|
||||
Tables\Filters\Filter::make('is_root')
|
||||
->label('Root Tiers Only')
|
||||
->query(fn (Builder $query): Builder => $query->whereNull('parent_tier_id'))
|
||||
->toggle(),
|
||||
])
|
||||
->recordActions([
|
||||
EditAction::make(),
|
||||
DeleteAction::make()
|
||||
->before(function (PlanTier $record) {
|
||||
// Prevent deletion if tier has child tiers or plans
|
||||
if ($record->childTiers()->exists()) {
|
||||
Log::error('Cannot delete tier that has child tiers');
|
||||
}
|
||||
if ($record->plans()->exists()) {
|
||||
Log::error('Cannot delete tier that has plans assigned');
|
||||
}
|
||||
}),
|
||||
])
|
||||
->toolbarActions([
|
||||
BulkActionGroup::make([
|
||||
DeleteBulkAction::make()
|
||||
->before(function ($records) {
|
||||
foreach ($records as $record) {
|
||||
if ($record->childTiers()->exists()) {
|
||||
Log::error('Cannot delete tiers that have child tiers');
|
||||
}
|
||||
if ($record->plans()->exists()) {
|
||||
Log::error('Cannot delete tiers that have plans assigned');
|
||||
}
|
||||
}
|
||||
}),
|
||||
]),
|
||||
])
|
||||
->emptyStateActions([
|
||||
CreateAction::make(),
|
||||
])
|
||||
->defaultSort('sort_order', 'asc');
|
||||
}
|
||||
|
||||
public static function getRelations(): array
|
||||
{
|
||||
return [
|
||||
//
|
||||
];
|
||||
}
|
||||
|
||||
public static function getPages(): array
|
||||
{
|
||||
return [
|
||||
'index' => ListPlanTiers::route('/'),
|
||||
'create' => CreatePlanTier::route('/create'),
|
||||
'edit' => EditPlanTier::route('/{record}/edit'),
|
||||
];
|
||||
}
|
||||
|
||||
public static function getNavigationBadge(): ?string
|
||||
{
|
||||
return static::getModel()::count();
|
||||
}
|
||||
|
||||
public static function getNavigationBadgeColor(): ?string
|
||||
{
|
||||
return 'info';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources\PlanTierResource\Pages;
|
||||
|
||||
use App\Filament\Resources\PlanTierResource;
|
||||
use Filament\Resources\Pages\CreateRecord;
|
||||
|
||||
class CreatePlanTier extends CreateRecord
|
||||
{
|
||||
protected static string $resource = PlanTierResource::class;
|
||||
|
||||
protected function getRedirectUrl(): string
|
||||
{
|
||||
return $this->getResource()::getUrl('index');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources\PlanTierResource\Pages;
|
||||
|
||||
use App\Filament\Resources\PlanTierResource;
|
||||
use App\Models\PlanTier;
|
||||
use Filament\Actions;
|
||||
use Filament\Notifications\Notification;
|
||||
use Filament\Resources\Pages\EditRecord;
|
||||
|
||||
class EditPlanTier extends EditRecord
|
||||
{
|
||||
protected static string $resource = PlanTierResource::class;
|
||||
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
return [
|
||||
Actions\DeleteAction::make()
|
||||
->before(function (PlanTier $record) {
|
||||
// Prevent deletion if tier has child tiers or plans
|
||||
if ($record->childTiers()->exists()) {
|
||||
throw new \Exception('Cannot delete tier that has child tiers');
|
||||
}
|
||||
if ($record->plans()->exists()) {
|
||||
throw new \Exception('Cannot delete tier that has plans assigned');
|
||||
}
|
||||
}),
|
||||
];
|
||||
}
|
||||
|
||||
protected function getRedirectUrl(): string
|
||||
{
|
||||
return $this->getResource()::getUrl('index');
|
||||
}
|
||||
|
||||
protected function getSavedNotification(): ?Notification
|
||||
{
|
||||
return Notification::make()
|
||||
->success()
|
||||
->title('Tier updated')
|
||||
->body('Plan tier updated successfully');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources\PlanTierResource\Pages;
|
||||
|
||||
use App\Filament\Resources\PlanTierResource;
|
||||
use Filament\Actions;
|
||||
use Filament\Resources\Pages\ListRecords;
|
||||
|
||||
class ListPlanTiers extends ListRecords
|
||||
{
|
||||
protected static string $resource = PlanTierResource::class;
|
||||
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
return [
|
||||
Actions\CreateAction::make(),
|
||||
];
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user