diff --git a/README.md b/README.md index 894d8fe..b075df0 100644 --- a/README.md +++ b/README.md @@ -36,10 +36,12 @@ WIP - [ ] Add attachments to items - https://filamentphp.com/plugins/filament-spatie-media-library - [ ] Show related items in Location view - https://filamentphp.com/docs/3.x/panels/resources/relation-managers#creating-a-relation-manager - [ ] Add QR code to items - - [ ] Add multi-tenancy support - https://filamentphp.com/docs/3.x/panels/tenancy + - [x] Add multi-tenancy support - https://filamentphp.com/docs/3.x/panels/tenancy - [ ] Add better import/export of items with relation to locations + - [ ] Add Laravel Octane + - [ ] Add Laravel Pulse ## Contributing -Thank you for considering contributing to the project! All contributions are welcome. +Thank you for choosing to contribute to the project! Any contribution is welcome. diff --git a/app/Filament/Pages/Auth/Login.php b/app/Filament/Pages/Auth/Login.php new file mode 100644 index 0000000..34c03b2 --- /dev/null +++ b/app/Filament/Pages/Auth/Login.php @@ -0,0 +1,19 @@ +form->fill([ + 'email' => 'admin@filamentphp.com', + 'password' => 'password', + 'remember' => true, + ]); + } +} diff --git a/app/Filament/Pages/Tenancy/EditTeamProfile.php b/app/Filament/Pages/Tenancy/EditTeamProfile.php new file mode 100644 index 0000000..1d4c3a6 --- /dev/null +++ b/app/Filament/Pages/Tenancy/EditTeamProfile.php @@ -0,0 +1,24 @@ +schema([ + TextInput::make('name')->required(), + // ... + ]); + } +} diff --git a/app/Filament/Pages/Tenancy/RegisterTeam.php b/app/Filament/Pages/Tenancy/RegisterTeam.php new file mode 100644 index 0000000..bbd4bcd --- /dev/null +++ b/app/Filament/Pages/Tenancy/RegisterTeam.php @@ -0,0 +1,23 @@ +schema([ + TextInput::make('name')->required(), + ]); + } +} diff --git a/app/Filament/Resources/ItemResource.php b/app/Filament/Resources/ItemResource.php index ded3f1b..1e92894 100644 --- a/app/Filament/Resources/ItemResource.php +++ b/app/Filament/Resources/ItemResource.php @@ -50,7 +50,21 @@ public static function form(Form $form): Form ->createOptionForm([ TextInput::make('name') ->required(), - Textarea::make('description')->columnSpanFull()->rows(3)->autosize(), + + Textarea::make('description') + ->columnSpanFull() + ->rows(3) + ->autosize(), + + Select::make('parent_id') + ->label('Parent Location') + ->relationship( + name: 'parent', + titleAttribute: 'name', + ignoreRecord: true + ) + ->preload() + ->searchable(), ]) ->preload() ->searchable() diff --git a/app/Filament/Resources/LocationResource.php b/app/Filament/Resources/LocationResource.php index 6cf0b2a..5c42cf7 100644 --- a/app/Filament/Resources/LocationResource.php +++ b/app/Filament/Resources/LocationResource.php @@ -15,7 +15,6 @@ use Filament\Tables\Actions\DeleteAction; use Filament\Tables\Actions\DeleteBulkAction; use Filament\Tables\Actions\EditAction; -use Filament\Tables\Columns\Layout\Split; use Filament\Tables\Columns\TextColumn; use Filament\Tables\Table; @@ -36,11 +35,31 @@ public static function form(Form $form): Form ->required(), Select::make('parent_id')->label('Parent Location') - ->relationship(name: 'parent', titleAttribute: 'name', ignoreRecord: true) + ->relationship( + name: 'parent', + titleAttribute: 'name', + ignoreRecord: true + ) + ->preload() + ->searchable() ->createOptionForm([ TextInput::make('name') ->required(), - Textarea::make('description')->columnSpanFull()->rows(3)->autosize(), + + Textarea::make('description') + ->columnSpanFull() + ->rows(3) + ->autosize(), + + Select::make('parent_id') + ->label('Parent Location') + ->relationship( + name: 'parent', + titleAttribute: 'name', + ignoreRecord: true + ) + ->preload() + ->searchable(), ]), Textarea::make('description')->columnSpanFull()->rows(3)->autosize(), diff --git a/app/Http/Middleware/ApplyTenantScopes.php b/app/Http/Middleware/ApplyTenantScopes.php new file mode 100644 index 0000000..50829ae --- /dev/null +++ b/app/Http/Middleware/ApplyTenantScopes.php @@ -0,0 +1,31 @@ + $query->whereBelongsTo(Filament::getTenant()), + ); + Item::addGlobalScope( + fn (Builder $query) => $query->whereBelongsTo(Filament::getTenant()), + ); + + return $next($request); + } +} diff --git a/app/Models/Item.php b/app/Models/Item.php index a0b86cf..c42f39d 100644 --- a/app/Models/Item.php +++ b/app/Models/Item.php @@ -2,6 +2,7 @@ namespace App\Models; +use Filament\Facades\Filament; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; @@ -36,6 +37,7 @@ class Item extends Model 'sold_price', 'sold_notes', 'location_id', + 'team_id', ]; protected static function boot() @@ -44,6 +46,9 @@ protected static function boot() static::creating(function ($item) { $item->ulid = self::generateUlid(); + if (auth()->check()) { + $item->team_id = Filament::getTenant()?->id; + } }); } @@ -52,6 +57,11 @@ public function location(): BelongsTo return $this->belongsTo(Location::class); } + public function team(): BelongsTo + { + return $this->belongsTo(Team::class); + } + protected function casts(): array { return [ diff --git a/app/Models/Location.php b/app/Models/Location.php index 22ddbaa..6751603 100644 --- a/app/Models/Location.php +++ b/app/Models/Location.php @@ -2,8 +2,10 @@ namespace App\Models; +use Filament\Facades\Filament; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; +use Illuminate\Database\Eloquent\Relations\BelongsTo; class Location extends Model { @@ -14,12 +16,24 @@ class Location extends Model 'description', 'parent_id', 'is_active', + 'team_id', ]; protected $casts = [ 'is_active' => 'boolean', ]; + protected static function boot() + { + parent::boot(); + + static::creating(function ($item) { + if (auth()->check()) { + $item->team_id = Filament::getTenant()?->id; + } + }); + } + public function parent(): \Illuminate\Database\Eloquent\Relations\BelongsTo { return $this->belongsTo(Location::class); @@ -29,4 +43,9 @@ public function childrens(): \Illuminate\Database\Eloquent\Relations\HasMany { return $this->hasMany(Location::class, 'parent_id'); } + + public function team(): BelongsTo + { + return $this->belongsTo(Team::class); + } } diff --git a/app/Models/Team.php b/app/Models/Team.php index 7362cea..ac24d85 100644 --- a/app/Models/Team.php +++ b/app/Models/Team.php @@ -2,13 +2,14 @@ namespace App\Models; +use Filament\Models\Contracts\HasCurrentTenantLabel; use Illuminate\Database\Eloquent\Factories\HasFactory; use Laravel\Jetstream\Events\TeamCreated; use Laravel\Jetstream\Events\TeamDeleted; use Laravel\Jetstream\Events\TeamUpdated; use Laravel\Jetstream\Team as JetstreamTeam; -class Team extends JetstreamTeam +class Team extends JetstreamTeam implements HasCurrentTenantLabel { use HasFactory; @@ -44,4 +45,31 @@ protected function casts(): array 'personal_team' => 'boolean', ]; } + + protected static function boot() + { + parent::boot(); + + static::creating(function ($team) { + if (auth()->check()) { + $team->user_id = auth()->id(); + $team->personal_team = false; + } + }); + } + + public function getCurrentTenantLabel(): string + { + return 'Current team'; + } + + public function locations(): \Illuminate\Database\Eloquent\Relations\HasMany + { + return $this->hasMany(Location::class); + } + + public function items(): \Illuminate\Database\Eloquent\Relations\HasMany + { + return $this->hasMany(Item::class); + } } diff --git a/app/Models/User.php b/app/Models/User.php index 2b625ef..8553479 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -4,16 +4,19 @@ // use Illuminate\Contracts\Auth\MustVerifyEmail; use Filament\Models\Contracts\FilamentUser; +use Filament\Models\Contracts\HasTenants; use Filament\Panel; use Illuminate\Database\Eloquent\Factories\HasFactory; +use Illuminate\Database\Eloquent\Model; use Illuminate\Foundation\Auth\User as Authenticatable; use Illuminate\Notifications\Notifiable; +use Illuminate\Support\Collection; use Laravel\Fortify\TwoFactorAuthenticatable; use Laravel\Jetstream\HasProfilePhoto; use Laravel\Jetstream\HasTeams; use Laravel\Sanctum\HasApiTokens; -class User extends Authenticatable implements FilamentUser +class User extends Authenticatable implements FilamentUser, HasTenants //, MustVerifyEmail { use HasApiTokens; use HasFactory; @@ -71,4 +74,14 @@ public function canAccessPanel(Panel $panel): bool { return true; } + + public function getTenants(Panel $panel): Collection + { + return $this->allTeams(); + } + + public function canAccessTenant(Model $tenant): bool + { + return $this->allTeams()->count(); + } } diff --git a/app/Providers/Filament/AppPanelProvider.php b/app/Providers/Filament/AppPanelProvider.php index 5d5223f..7eb0464 100644 --- a/app/Providers/Filament/AppPanelProvider.php +++ b/app/Providers/Filament/AppPanelProvider.php @@ -2,9 +2,15 @@ namespace App\Providers\Filament; +use App\Filament\Pages\Auth\Login; +use App\Filament\Pages\Tenancy\EditTeamProfile; +use App\Filament\Pages\Tenancy\RegisterTeam; +use App\Http\Middleware\ApplyTenantScopes; +use App\Models\Team; use Filament\Http\Middleware\Authenticate; use Filament\Http\Middleware\DisableBladeIconComponents; use Filament\Http\Middleware\DispatchServingFilamentEvent; +use Filament\Navigation\MenuItem; use Filament\Pages; use Filament\Panel; use Filament\PanelProvider; @@ -26,7 +32,20 @@ public function panel(Panel $panel): Panel ->default() ->id('app') ->path('/app') - // ->login() + ->login(Login::class) + ->registration() + ->passwordReset() + // ->emailVerification() + ->tenant(Team::class) + ->tenantRegistration(RegisterTeam::class) + ->tenantProfile(EditTeamProfile::class) + ->tenantMenuItems([ + MenuItem::make() + ->label('Settings') + ->url(fn (): string => '/') + ->icon('heroicon-m-cog-8-tooth'), + // ... + ]) ->spa() ->databaseNotifications() ->colors([ @@ -42,6 +61,13 @@ public function panel(Panel $panel): Panel Widgets\AccountWidget::class, Widgets\FilamentInfoWidget::class, ]) + ->userMenuItems([ + MenuItem::make() + ->label('My Account') + ->url(fn (): string => route('profile.show')), + + ]) + ->unsavedChangesAlerts() ->middleware([ EncryptCookies::class, AddQueuedCookiesToResponse::class, @@ -53,6 +79,9 @@ public function panel(Panel $panel): Panel DisableBladeIconComponents::class, DispatchServingFilamentEvent::class, ]) + ->tenantMiddleware([ + ApplyTenantScopes::class, + ], isPersistent: true) ->authMiddleware([ Authenticate::class, ]); diff --git a/database/migrations/2024_06_18_204247_add_team_id_to_items_table.php b/database/migrations/2024_06_18_204247_add_team_id_to_items_table.php new file mode 100644 index 0000000..b7ab484 --- /dev/null +++ b/database/migrations/2024_06_18_204247_add_team_id_to_items_table.php @@ -0,0 +1,25 @@ +truncate(); + + Schema::table('items', function (Blueprint $table) { + $table->foreignId('team_id')->constrained()->cascadeOnDelete(); + }); + } + + public function down(): void + { + Schema::table('items', function (Blueprint $table) { + $table->dropConstrainedForeignId('team_id'); + }); + } +}; diff --git a/database/migrations/2024_06_18_204347_add_team_id_to_locations_table.php b/database/migrations/2024_06_18_204347_add_team_id_to_locations_table.php new file mode 100644 index 0000000..adc8779 --- /dev/null +++ b/database/migrations/2024_06_18_204347_add_team_id_to_locations_table.php @@ -0,0 +1,24 @@ +truncate(); + + Schema::table('locations', function (Blueprint $table) { + $table->foreignId('team_id')->constrained()->cascadeOnDelete(); + }); + } + + public function down(): void + { + Schema::table('locations', function (Blueprint $table) { + $table->dropConstrainedForeignId('team_id'); + }); + } +};