diff --git a/app/Filament/Resources/EventResource.php b/app/Filament/Resources/EventResource.php index 9e32e4d..031070a 100644 --- a/app/Filament/Resources/EventResource.php +++ b/app/Filament/Resources/EventResource.php @@ -3,45 +3,46 @@ namespace App\Filament\Resources; use App\Filament\Resources\EventResource\Pages; -use App\Support\JoinTokenLayoutRegistry; +use App\Filament\Resources\EventResource\RelationManagers\EventPackagesRelationManager; use App\Models\Event; -use App\Models\Tenant; use App\Models\EventType; -use Filament\Resources\Resource; -use Filament\Tables; -use Filament\Tables\Table; +use App\Models\Tenant; +use App\Support\JoinTokenLayoutRegistry; +use BackedEnum; use Filament\Actions; -use Filament\Forms; -use Filament\Forms\Form; -use Filament\Schemas\Schema; -use Filament\Forms\Components\TextInput; use Filament\Forms\Components\DatePicker; -use Filament\Forms\Components\Toggle; use Filament\Forms\Components\KeyValue; use Filament\Forms\Components\Select; +use Filament\Forms\Components\TextInput; +use Filament\Forms\Components\Toggle; +use Filament\Forms\Form; +use Filament\Resources\Resource; +use Filament\Schemas\Schema; +use Filament\Tables; +use Filament\Tables\Table; use UnitEnum; -use BackedEnum; - -use App\Filament\Resources\EventResource\RelationManagers\EventPackagesRelationManager; class EventResource extends Resource { protected static ?string $model = Event::class; + protected static BackedEnum|string|null $navigationIcon = 'heroicon-o-calendar'; + protected static UnitEnum|string|null $navigationGroup = null; + protected static ?int $navigationSort = 20; + public static function getNavigationGroup(): UnitEnum|string|null { return __('admin.nav.platform'); } - protected static ?int $navigationSort = 20; public static function form(Schema $form): Schema { return $form->schema([ Select::make('tenant_id') ->label(__('admin.events.fields.tenant')) - ->options(Tenant::all()->pluck('name', 'id')) + ->options(Tenant::query()->pluck('name', 'id')) ->searchable() ->required(), TextInput::make('name') @@ -58,11 +59,11 @@ class EventResource extends Resource ->required(), Select::make('event_type_id') ->label(__('admin.events.fields.type')) - ->options(EventType::all()->pluck('name', 'id')) + ->options(EventType::query()->pluck('name', 'id')) ->searchable(), Select::make('package_id') ->label(__('admin.events.fields.package')) - ->options(\App\Models\Package::where('type', 'endcustomer')->pluck('name', 'id')) + ->options(\App\Models\Package::query()->where('type', 'endcustomer')->pluck('name', 'id')) ->searchable() ->preload() ->required(), @@ -85,7 +86,7 @@ class EventResource extends Resource return $table ->columns([ Tables\Columns\TextColumn::make('id')->sortable(), - Tables\Columns\TextColumn::make('tenant_id')->label(__('admin.events.table.tenant'))->sortable(), + Tables\Columns\TextColumn::make('tenant.name')->label(__('admin.events.table.tenant'))->searchable(), Tables\Columns\TextColumn::make('name')->limit(30), Tables\Columns\TextColumn::make('slug')->searchable(), Tables\Columns\TextColumn::make('date')->date(), @@ -106,9 +107,9 @@ class EventResource extends Resource Tables\Columns\TextColumn::make('primary_join_token') ->label(__('admin.events.table.join')) ->getStateUsing(function ($record) { - $token = $record->joinTokens()->orderByDesc('created_at')->first(); + $token = $record->joinTokens()->latest()->first(); - return $token ? url('/e/'.$token->token) : __('admin.events.table.no_join_tokens'); + return $token ? url('/e/' . $token->token) : __('admin.events.table.no_join_tokens'); }) ->description(function ($record) { $total = $record->joinTokens()->count(); @@ -127,7 +128,7 @@ class EventResource extends Resource Actions\Action::make('toggle') ->label(__('admin.events.actions.toggle_active')) ->icon('heroicon-o-power') - ->action(fn($record) => $record->update(['is_active' => !$record->is_active])), + ->action(fn ($record) => $record->update(['is_active' => ! $record->is_active])), Actions\Action::make('join_tokens') ->label(__('admin.events.actions.join_link_qr')) ->icon('heroicon-o-qr-code') @@ -152,7 +153,7 @@ class EventResource extends Resource 'id' => $token->id, 'label' => $token->label, 'token' => $token->token, - 'url' => url('/e/'.$token->token), + 'url' => url('/e/' . $token->token), 'usage_limit' => $token->usage_limit, 'usage_count' => $token->usage_count, 'expires_at' => optional($token->expires_at)->toIso8601String(), @@ -178,19 +179,20 @@ class EventResource extends Resource ]); } - public static function getPages(): array - { - return [ - 'index' => Pages\ListEvents::route('/'), - 'view' => Pages\ViewEvent::route('/{record}'), - 'edit' => Pages\EditEvent::route('/{record}/edit'), - ]; - } - public static function getRelations(): array { return [ EventPackagesRelationManager::class, ]; } + + public static function getPages(): array + { + return [ + 'index' => Pages\ListEvents::route('/'), + 'create' => Pages\CreateEvent::route('/create'), + 'view' => Pages\ViewEvent::route('/{record}'), + 'edit' => Pages\EditEvent::route('/{record}/edit'), + ]; + } } diff --git a/app/Filament/Resources/EventResource/Pages/CreateEvent.php b/app/Filament/Resources/EventResource/Pages/CreateEvent.php new file mode 100644 index 0000000..c1031e9 --- /dev/null +++ b/app/Filament/Resources/EventResource/Pages/CreateEvent.php @@ -0,0 +1,11 @@ +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(), - ]); + 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(), + DateTimePicker::make('expires_at') + ->label('Ablauf') + ->required(), + ]); } public function table(Table $table): Table @@ -90,9 +91,7 @@ class EventPackagesRelationManager extends RelationManager ->money('EUR') ->sortable(), ]) - ->filters([ - // - ]) + ->filters([]) ->headerActions([ CreateAction::make(), ]) @@ -121,9 +120,8 @@ class EventPackagesRelationManager extends RelationManager return __('admin.events.relation_managers.event_packages.title'); } - public function getTableQuery(): Builder | Relation + public function getTableQuery(): Builder|Relation { - return parent::getTableQuery() - ->with('package'); + return parent::getTableQuery()->with('package'); } -} \ No newline at end of file +} diff --git a/app/Filament/Resources/PhotoResource.php b/app/Filament/Resources/PhotoResource.php index 3d2c5ef..37dd767 100644 --- a/app/Filament/Resources/PhotoResource.php +++ b/app/Filament/Resources/PhotoResource.php @@ -3,46 +3,47 @@ namespace App\Filament\Resources; use App\Filament\Resources\PhotoResource\Pages; -use App\Models\Photo; use App\Models\Event; -use Filament\Resources\Resource; -use Filament\Tables; -use Filament\Tables\Table; +use App\Models\Photo; +use BackedEnum; use Filament\Actions; -use Filament\Forms; -use Filament\Forms\Form; -use Filament\Schemas\Schema; -use Filament\Forms\Components\TextInput; -use Filament\Forms\Components\Select; -use Filament\Forms\Components\Toggle; use Filament\Forms\Components\FileUpload; use Filament\Forms\Components\KeyValue; +use Filament\Forms\Components\Select; +use Filament\Forms\Components\Toggle; +use Filament\Forms\Form; +use Filament\Resources\Resource; +use Filament\Schemas\Schema; +use Filament\Tables; +use Filament\Tables\Table; use UnitEnum; -use BackedEnum; class PhotoResource extends Resource { protected static ?string $model = Photo::class; + protected static BackedEnum|string|null $navigationIcon = 'heroicon-o-photo'; + protected static UnitEnum|string|null $navigationGroup = null; + protected static ?int $navigationSort = 30; + public static function getNavigationGroup(): UnitEnum|string|null { return __('admin.nav.content'); } - protected static ?int $navigationSort = 30; public static function form(Schema $form): Schema { return $form->schema([ Select::make('event_id') ->label(__('admin.photos.fields.event')) - ->options(Event::all()->pluck('name', 'id')) + ->options(Event::query()->orderBy('name')->pluck('name', 'id')) ->searchable() ->required(), FileUpload::make('file_path') ->label(__('admin.photos.fields.photo')) - ->image() // enable FilePond image preview + ->image() ->disk('public') ->directory('photos') ->visibility('public') @@ -61,9 +62,14 @@ class PhotoResource extends Resource { return $table ->columns([ - Tables\Columns\ImageColumn::make('file_path')->label(__('admin.photos.table.photo'))->disk('public')->visibility('public'), + Tables\Columns\ImageColumn::make('file_path') + ->label(__('admin.photos.table.photo')) + ->disk('public') + ->visibility('public'), Tables\Columns\TextColumn::make('id')->sortable(), - Tables\Columns\TextColumn::make('event_id')->label(__('admin.photos.table.event')), + Tables\Columns\TextColumn::make('event.name') + ->label(__('admin.photos.table.event')) + ->searchable(), Tables\Columns\TextColumn::make('likes_count')->label(__('admin.photos.table.likes')), Tables\Columns\IconColumn::make('is_featured')->boolean(), Tables\Columns\TextColumn::make('created_at')->since(), @@ -73,13 +79,13 @@ class PhotoResource extends Resource Actions\EditAction::make(), Actions\Action::make('feature') ->label(__('admin.photos.actions.feature')) - ->visible(fn($record) => !$record->is_featured) - ->action(fn($record) => $record->update(['is_featured' => true])) + ->visible(fn (Photo $record) => ! $record->is_featured) + ->action(fn (Photo $record) => $record->update(['is_featured' => true])) ->icon('heroicon-o-star'), Actions\Action::make('unfeature') ->label(__('admin.photos.actions.unfeature')) - ->visible(fn($record) => $record->is_featured) - ->action(fn($record) => $record->update(['is_featured' => false])) + ->visible(fn (Photo $record) => $record->is_featured) + ->action(fn (Photo $record) => $record->update(['is_featured' => false])) ->icon('heroicon-o-star'), Actions\DeleteAction::make(), ]) @@ -87,11 +93,11 @@ class PhotoResource extends Resource Actions\BulkAction::make('feature') ->label(__('admin.photos.actions.feature_selected')) ->icon('heroicon-o-star') - ->action(fn($records) => $records->each->update(['is_featured' => true])), + ->action(fn ($records) => $records->each->update(['is_featured' => true])), Actions\BulkAction::make('unfeature') ->label(__('admin.photos.actions.unfeature_selected')) ->icon('heroicon-o-star') - ->action(fn($records) => $records->each->update(['is_featured' => false])), + ->action(fn ($records) => $records->each->update(['is_featured' => false])), Actions\DeleteBulkAction::make(), ]); } diff --git a/app/Filament/Resources/TenantPackageResource.php b/app/Filament/Resources/TenantPackageResource.php index baa0242..01a31f1 100644 --- a/app/Filament/Resources/TenantPackageResource.php +++ b/app/Filament/Resources/TenantPackageResource.php @@ -4,28 +4,25 @@ namespace App\Filament\Resources; use App\Filament\Resources\TenantPackageResource\Pages; use App\Models\TenantPackage; -use Filament\Schemas\Schema; -use Filament\Forms\Components\DateTimePicker; -use Filament\Forms\Components\Section; -use Filament\Forms\Components\Select; -use Filament\Forms\Components\Toggle; -use Filament\Icons\Icon; -use Filament\Resources\Resource; -use Filament\Tables; +use BackedEnum; use Filament\Actions\ActionGroup; 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\DateTimePicker; +use Filament\Forms\Components\Select; +use Filament\Forms\Components\Toggle; +use Filament\Forms\Form; +use Filament\Resources\Resource; +use Filament\Schemas\Schema; +use Filament\Tables; use Filament\Tables\Columns\IconColumn; use Filament\Tables\Columns\TextColumn; use Filament\Tables\Table; -use Illuminate\Database\Eloquent\Builder; -use Illuminate\Database\Eloquent\SoftDeletingScope; -use Illuminate\Support\Facades\Auth; use UnitEnum; -use BackedEnum; class TenantPackageResource extends Resource { @@ -33,31 +30,23 @@ class TenantPackageResource extends Resource protected static BackedEnum|string|null $navigationIcon = 'heroicon-o-shopping-bag'; - protected static ?string $navigationLabel = 'Packages'; - protected static ?string $slug = 'tenant-packages'; public static function form(Schema $form): Schema { return $form ->schema([ - Section::make('Package Details') - ->schema([ - Select::make('package_id') - ->relationship('package', 'name') - ->required() - ->searchable(), - Select::make('tenant_id') - ->relationship('tenant', 'name') - ->required() - ->default(fn () => Auth::user()->tenant_id) - ->disabled(), - DateTimePicker::make('expires_at') - ->required(), - Toggle::make('is_active') - ->default(true), - ]) - ->columns(1), + Select::make('tenant_id') + ->relationship('tenant', 'name') + ->required() + ->searchable(), + Select::make('package_id') + ->relationship('package', 'name') + ->required() + ->searchable(), + DateTimePicker::make('purchased_at'), + DateTimePicker::make('expires_at'), + Toggle::make('active')->default(true), ]); } @@ -65,26 +54,13 @@ class TenantPackageResource extends Resource { return $table ->columns([ - TextColumn::make('package.name') - ->searchable() - ->sortable(), - TextColumn::make('tenant.name') - ->badge() - ->color('success'), - TextColumn::make('expires_at') - ->dateTime() - ->sortable() - ->toggleable(isToggledHiddenByDefault: true), - IconColumn::make('is_active') - ->boolean(), - TextColumn::make('created_at') - ->dateTime() - ->sortable() - ->toggleable(isToggledHiddenByDefault: true), - ]) - ->filters([ - // + TextColumn::make('tenant.name')->searchable()->sortable(), + TextColumn::make('package.name')->badge()->color('success'), + TextColumn::make('purchased_at')->dateTime()->sortable(), + TextColumn::make('expires_at')->dateTime()->sortable(), + IconColumn::make('active')->boolean(), ]) + ->filters([]) ->actions([ ActionGroup::make([ ViewAction::make(), @@ -96,15 +72,12 @@ class TenantPackageResource extends Resource BulkActionGroup::make([ DeleteBulkAction::make(), ]), - ]) - ->modifyQueryUsing(fn (Builder $query) => $query->where('tenant_id', Auth::user()->tenant_id)); + ]); } public static function getRelations(): array { - return [ - // - ]; + return []; } public static function getPages(): array @@ -116,4 +89,4 @@ class TenantPackageResource extends Resource 'edit' => Pages\EditTenantPackage::route('/{record}/edit'), ]; } -} \ No newline at end of file +} diff --git a/app/Filament/Resources/TenantPackageResource/Pages/CreateTenantPackage.php b/app/Filament/Resources/TenantPackageResource/Pages/CreateTenantPackage.php index b935768..73811b2 100644 --- a/app/Filament/Resources/TenantPackageResource/Pages/CreateTenantPackage.php +++ b/app/Filament/Resources/TenantPackageResource/Pages/CreateTenantPackage.php @@ -3,23 +3,9 @@ namespace App\Filament\Resources\TenantPackageResource\Pages; use App\Filament\Resources\TenantPackageResource; -use Filament\Actions; use Filament\Resources\Pages\CreateRecord; -use Illuminate\Support\Facades\Auth; class CreateTenantPackage extends CreateRecord { protected static string $resource = TenantPackageResource::class; - - protected function mutateFormDataBeforeCreate(array $data): array - { - $data['tenant_id'] = Auth::user()->tenant_id; - - return $data; - } - - protected function getRedirectUrl(): string - { - return $this->getResource()::getUrl('index'); - } -} \ No newline at end of file +} diff --git a/app/Filament/Resources/TenantPackageResource/Pages/EditTenantPackage.php b/app/Filament/Resources/TenantPackageResource/Pages/EditTenantPackage.php index 73deb49..c6bb61d 100644 --- a/app/Filament/Resources/TenantPackageResource/Pages/EditTenantPackage.php +++ b/app/Filament/Resources/TenantPackageResource/Pages/EditTenantPackage.php @@ -17,9 +17,4 @@ class EditTenantPackage extends EditRecord Actions\DeleteAction::make(), ]; } - - protected function getRedirectUrl(): string - { - return $this->getResource()::getUrl('index'); - } -} \ No newline at end of file +} diff --git a/app/Filament/Resources/TenantPackageResource/Pages/ListTenantPackages.php b/app/Filament/Resources/TenantPackageResource/Pages/ListTenantPackages.php index d0ec3f3..60f2179 100644 --- a/app/Filament/Resources/TenantPackageResource/Pages/ListTenantPackages.php +++ b/app/Filament/Resources/TenantPackageResource/Pages/ListTenantPackages.php @@ -5,8 +5,6 @@ namespace App\Filament\Resources\TenantPackageResource\Pages; use App\Filament\Resources\TenantPackageResource; use Filament\Actions; use Filament\Resources\Pages\ListRecords; -use Illuminate\Database\Eloquent\Builder; -use Illuminate\Support\Facades\Auth; class ListTenantPackages extends ListRecords { @@ -18,9 +16,4 @@ class ListTenantPackages extends ListRecords Actions\CreateAction::make(), ]; } - - protected function getTableQuery(): Builder - { - return parent::getTableQuery()->where('tenant_id', Auth::user()->tenant_id); - } -} \ No newline at end of file +} diff --git a/app/Filament/Resources/TenantPackageResource/Pages/ViewTenantPackage.php b/app/Filament/Resources/TenantPackageResource/Pages/ViewTenantPackage.php index bad7d57..09811ef 100644 --- a/app/Filament/Resources/TenantPackageResource/Pages/ViewTenantPackage.php +++ b/app/Filament/Resources/TenantPackageResource/Pages/ViewTenantPackage.php @@ -17,4 +17,4 @@ class ViewTenantPackage extends ViewRecord Actions\DeleteAction::make(), ]; } -} \ No newline at end of file +} diff --git a/app/Filament/Resources/UserResource.php b/app/Filament/Resources/UserResource.php index c42a22c..daa05d3 100644 --- a/app/Filament/Resources/UserResource.php +++ b/app/Filament/Resources/UserResource.php @@ -4,36 +4,30 @@ namespace App\Filament\Resources; use App\Filament\Resources\UserResource\Pages; use App\Models\User; -use Filament\Schemas\Schema; -use Filament\Forms\Form; -use Filament\Forms\Components\Section; -use Filament\Forms\Components\TextInput; -use Filament\Forms\Components\Textarea; -use Filament\Icons\Icon; -use Filament\Resources\Resource; -use Filament\Tables; +use BackedEnum; use Filament\Actions\ActionGroup; use Filament\Actions\BulkActionGroup; use Filament\Actions\DeleteBulkAction; use Filament\Actions\EditAction; use Filament\Actions\ViewAction; +use Filament\Forms\Components\Section; +use Filament\Forms\Components\Textarea; +use Filament\Forms\Components\TextInput; +use Filament\Forms\Form; +use Filament\Resources\Resource; +use Filament\Schemas\Schema; +use Filament\Tables; use Filament\Tables\Columns\IconColumn; use Filament\Tables\Columns\TextColumn; use Filament\Tables\Table; -use Illuminate\Database\Eloquent\Builder; -use Illuminate\Database\Eloquent\SoftDeletingScope; -use Illuminate\Support\Facades\Auth; -use Illuminate\Support\Facades\Hash; -use Illuminate\Support\Facades\Request; -use BackedEnum; +use UnitEnum; + class UserResource extends Resource { protected static ?string $model = User::class; protected static BackedEnum|string|null $navigationIcon = 'heroicon-o-user-circle'; - protected static ?string $navigationLabel = 'Users'; - protected static ?string $slug = 'users'; public static function form(Schema $form): Schema @@ -76,8 +70,7 @@ class UserResource extends Resource ->required(fn (string $operation): bool => $operation === 'create') ->dehydrated(false), ]) - ->columns(1) - ->visible(fn (): bool => Auth::user()?->id === Request::route('record')), + ->columns(1), ]); } @@ -85,22 +78,18 @@ class UserResource extends Resource { return $table ->columns([ - TextColumn::make('fullName') - ->searchable(), - TextColumn::make('email') - ->searchable(), - TextColumn::make('username') - ->searchable(), - TextColumn::make('phone'), + TextColumn::make('fullName')->sortable()->searchable(), + TextColumn::make('email')->searchable(), + TextColumn::make('username')->searchable(), TextColumn::make('tenant.name') - ->label('Tenant'), - TextColumn::make('email_verified_at') - ->dateTime() - ->sortable(), - ]) - ->filters([ - // + ->label(__('admin.common.tenant')) + ->badge(), + TextColumn::make('phone'), + IconColumn::make('email_verified_at') + ->label(__('admin.users.fields.verified')) + ->boolean(), ]) + ->filters([]) ->actions([ ActionGroup::make([ ViewAction::make(), @@ -111,23 +100,20 @@ class UserResource extends Resource BulkActionGroup::make([ DeleteBulkAction::make(), ]), - ]) - ->modifyQueryUsing(fn (Builder $query) => $query->where('tenant_id', Auth::user()->tenant_id)); + ]); } public static function getRelations(): array { - return [ - // - ]; + return []; } public static function getPages(): array { return [ 'index' => Pages\ListUsers::route('/'), + 'create' => Pages\CreateUser::route('/create'), 'edit' => Pages\EditUser::route('/{record}/edit'), ]; } } - diff --git a/app/Filament/Resources/UserResource/Pages/CreateUser.php b/app/Filament/Resources/UserResource/Pages/CreateUser.php new file mode 100644 index 0000000..575a620 --- /dev/null +++ b/app/Filament/Resources/UserResource/Pages/CreateUser.php @@ -0,0 +1,21 @@ +getResource()::getUrl('index'); - } -} \ No newline at end of file +} diff --git a/app/Filament/Resources/UserResource/Pages/ListUsers.php b/app/Filament/Resources/UserResource/Pages/ListUsers.php index 5e787e4..0766ffe 100644 --- a/app/Filament/Resources/UserResource/Pages/ListUsers.php +++ b/app/Filament/Resources/UserResource/Pages/ListUsers.php @@ -5,8 +5,6 @@ namespace App\Filament\Resources\UserResource\Pages; use App\Filament\Resources\UserResource; use Filament\Actions; use Filament\Resources\Pages\ListRecords; -use Filament\Tables; -use Filament\Tables\Table; class ListUsers extends ListRecords { @@ -18,11 +16,4 @@ class ListUsers extends ListRecords Actions\CreateAction::make(), ]; } - - public function table(Table $table): Table - { - return $table - ->recordClasses(fn (User $record) => $record->id === auth()->id() ? 'border-2 border-blue-500' : '') - ->poll('30s'); - } -} \ No newline at end of file +} diff --git a/app/Filament/Tenant/Pages/InviteStudio.php b/app/Filament/Tenant/Pages/InviteStudio.php new file mode 100644 index 0000000..e673fce --- /dev/null +++ b/app/Filament/Tenant/Pages/InviteStudio.php @@ -0,0 +1,186 @@ +redirect(TenantOnboarding::getUrl()); + + return; + } + + $firstEventId = $tenant->events()->orderBy('date')->value('id'); + + $this->selectedEventId = $firstEventId; + $this->layouts = $this->buildLayouts(); + + if ($this->selectedEventId) { + $this->loadEventContext(); + } + } + + public static function shouldRegisterNavigation(): bool + { + return TenantOnboardingState::completed(); + } + + public function updatedSelectedEventId(): void + { + $this->loadEventContext(); + } + + public function createInvite(EventJoinTokenService $service): void + { + $this->validate([ + 'selectedEventId' => ['required', 'exists:events,id'], + 'tokenLabel' => ['nullable', 'string', 'max:120'], + ]); + + $tenant = TenantOnboardingState::tenant(); + + abort_if(! $tenant, 403); + + $event = $tenant->events()->whereKey($this->selectedEventId)->first(); + + if (! $event) { + Notification::make() + ->title('Event konnte nicht gefunden werden') + ->danger() + ->send(); + + return; + } + + $label = $this->tokenLabel ?: 'Einladung ' . now()->format('d.m.'); + + $layoutPreference = Arr::get($tenant->settings ?? [], 'branding.preferred_invite_layout'); + + $service->createToken($event, [ + 'label' => $label, + 'metadata' => [ + 'preferred_layout' => $layoutPreference, + ], + 'created_by' => auth()->id(), + ]); + + $this->tokenLabel = ''; + + $this->loadEventContext(); + + Notification::make() + ->title('Neuer Einladungslink erstellt') + ->success() + ->send(); + } + + protected function loadEventContext(): void + { + $tenant = TenantOnboardingState::tenant(); + + if (! $tenant || ! $this->selectedEventId) { + $this->tokens = []; + + return; + } + + $event = $tenant->events()->whereKey($this->selectedEventId)->first(); + + if (! $event) { + $this->tokens = []; + + return; + } + + $this->tokens = $event->joinTokens() + ->orderByDesc('created_at') + ->get() + ->map(fn (EventJoinToken $token) => $this->mapToken($event, $token)) + ->toArray(); + } + + protected function mapToken(Event $event, EventJoinToken $token): array + { + $downloadUrls = JoinTokenLayoutRegistry::toResponse(function (string $layoutId, string $format) use ($event, $token) { + return route('tenant.events.join-tokens.layouts.download', [ + 'event' => $event->slug, + 'joinToken' => $token->getKey(), + 'layout' => $layoutId, + 'format' => $format, + ]); + }); + + return [ + 'id' => $token->getKey(), + 'label' => $token->label ?? 'Einladungslink', + 'url' => URL::to('/e/' . $token->token), + 'created_at' => optional($token->created_at)->format('d.m.Y H:i'), + 'usage_count' => $token->usage_count, + 'usage_limit' => $token->usage_limit, + 'active' => $token->isActive(), + 'downloads' => $downloadUrls, + ]; + } + + protected function buildLayouts(): array + { + return collect(JoinTokenLayoutRegistry::all()) + ->map(fn (array $layout) => [ + 'id' => $layout['id'], + 'name' => $layout['name'], + 'subtitle' => $layout['subtitle'] ?? '', + 'description' => $layout['description'] ?? '', + ]) + ->toArray(); + } + + public function getEventsProperty(): Collection + { + $tenant = TenantOnboardingState::tenant(); + + if (! $tenant) { + return collect(); + } + + return $tenant->events()->orderBy('date')->get(); + } +} diff --git a/app/Filament/Tenant/Pages/TenantOnboarding.php b/app/Filament/Tenant/Pages/TenantOnboarding.php new file mode 100644 index 0000000..a1aff3a --- /dev/null +++ b/app/Filament/Tenant/Pages/TenantOnboarding.php @@ -0,0 +1,311 @@ +status = TenantOnboardingState::status($tenant); + + if (TenantOnboardingState::completed($tenant)) { + $this->redirect(EventResource::getUrl()); + + return; + } + + $this->eventDate = Carbon::now()->addWeeks(2)->format('Y-m-d'); + $this->eventTypeId = $this->getDefaultEventTypeId(); + } + + public static function shouldRegisterNavigation(): bool + { + $tenant = TenantOnboardingState::tenant(); + + return ! TenantOnboardingState::completed($tenant); + } + + public function start(): void + { + $this->step = 'packages'; + } + + public function savePackages(): void + { + $this->validate([ + 'selectedPackages' => ['required', 'array', 'min:1'], + 'selectedPackages.*' => ['integer', 'exists:task_collections,id'], + ], [ + 'selectedPackages.required' => 'Bitte wählt mindestens ein Aufgabenpaket aus.', + ]); + + $this->step = 'event'; + } + + public function saveEvent(): void + { + $this->validate([ + 'eventName' => ['required', 'string', 'max:255'], + 'eventDate' => ['required', 'date'], + 'eventTypeId' => ['required', 'exists:event_types,id'], + ]); + + $this->step = 'palette'; + } + + public function savePalette(): void + { + $this->validate([ + 'palette' => ['required', 'string'], + ]); + + $this->step = 'invite'; + } + + public function finish( + TaskCollectionImportService $importService, + EventJoinTokenService $joinTokenService + ): void { + $this->validate([ + 'inviteLayout' => ['required', 'string'], + ], [ + 'inviteLayout.required' => 'Bitte wählt ein Layout aus.', + ]); + + $tenant = TenantOnboardingState::tenant(); + + abort_if(! $tenant, 403); + + $this->isProcessing = true; + + try { + DB::transaction(function () use ($tenant, $importService, $joinTokenService) { + $event = $this->createEvent($tenant); + $this->importPackages($importService, $this->selectedPackages, $event); + + $token = $joinTokenService->createToken($event, [ + 'label' => 'Fotospiel Einladung', + 'metadata' => [ + 'preferred_layout' => $this->inviteLayout, + ], + ]); + + $settings = $tenant->settings ?? []; + Arr::set($settings, 'branding.palette', $this->palette); + Arr::set($settings, 'branding.primary_event_id', $event->id); + Arr::set($settings, 'branding.preferred_invite_layout', $this->inviteLayout); + $tenant->forceFill(['settings' => $settings])->save(); + + TenantOnboardingState::markCompleted($tenant, [ + 'primary_event_id' => $event->id, + 'selected_packages' => $this->selectedPackages, + 'qr_layout' => $this->inviteLayout, + ]); + + $this->inviteDownloads = $this->buildInviteDownloads($event, $token); + $this->status = TenantOnboardingState::status($tenant); + + Notification::make() + ->title('Euer Setup ist bereit!') + ->body('Wir haben euer Event erstellt, Aufgaben importiert und euren Einladungslink vorbereitet.') + ->success() + ->send(); + + $this->redirect(EventResource::getUrl('view', ['record' => $event])); + }); + } catch (Throwable $exception) { + report($exception); + + Notification::make() + ->title('Setup konnte nicht abgeschlossen werden') + ->body('Bitte prüft eure Eingaben oder versucht es später erneut.') + ->danger() + ->send(); + } finally { + $this->isProcessing = false; + } + } + + protected function createEvent($tenant): Event + { + $slugBase = Str::slug($this->eventName) ?: 'event'; + + do { + $slug = Str::of($slugBase)->append('-', Str::random(6))->lower(); + } while (Event::where('slug', $slug)->exists()); + + return Event::create([ + 'tenant_id' => $tenant->id, + 'name' => [ + app()->getLocale() => $this->eventName, + 'de' => $this->eventName, + ], + 'description' => null, + 'date' => $this->eventDate, + 'slug' => (string) $slug, + 'event_type_id' => $this->eventTypeId, + 'is_active' => true, + 'default_locale' => app()->getLocale(), + 'status' => 'draft', + 'settings' => [ + 'appearance' => [ + 'palette' => $this->palette, + ], + ], + ]); + } + + protected function importPackages( + TaskCollectionImportService $importService, + array $packageIds, + Event $event + ): void { + if (empty($packageIds)) { + return; + } + + /** @var EloquentCollection $collections */ + $collections = TaskCollection::query() + ->whereIn('id', $packageIds) + ->get(); + + $collections->each(function (TaskCollection $collection) use ($importService, $event) { + $importService->import($collection, $event); + }); + } + + protected function buildInviteDownloads(Event $event, $token): array + { + return JoinTokenLayoutRegistry::toResponse(function (string $layoutId, string $format) use ($event, $token) { + return route('tenant.events.join-tokens.layouts.download', [ + 'event' => $event->slug, + 'joinToken' => $token->getKey(), + 'layout' => $layoutId, + 'format' => $format, + ]); + }); + } + + public function getPackageListProperty(): array + { + return TaskCollection::query() + ->whereNull('tenant_id') + ->orderBy('position') + ->get() + ->map(fn (TaskCollection $collection) => [ + 'id' => $collection->getKey(), + 'name' => $collection->name, + 'description' => $collection->description, + ]) + ->toArray(); + } + + public function getEventTypeOptionsProperty(): array + { + return EventType::query() + ->orderBy('name->' . app()->getLocale()) + ->get() + ->mapWithKeys(function (EventType $type) { + $name = $type->name[app()->getLocale()] ?? $type->name['de'] ?? Arr::first($type->name); + + return [$type->getKey() => $name]; + }) + ->toArray(); + } + + public function getPaletteOptionsProperty(): array + { + return [ + 'romance' => [ + 'label' => 'Rosé & Gold', + 'description' => 'Warme Rosé-Töne mit goldenen Akzenten – romantisch und elegant.', + ], + 'sunset' => [ + 'label' => 'Sonnenuntergang', + 'description' => 'Leuchtende Orange- und Pink-Verläufe für lebhafte Partys.', + ], + 'evergreen' => [ + 'label' => 'Evergreen', + 'description' => 'Sanfte Grüntöne und Naturakzente für Boho- & Outdoor-Events.', + ], + 'midnight' => [ + 'label' => 'Midnight', + 'description' => 'Tiefes Navy und Flieder – perfekt für elegante Abendveranstaltungen.', + ], + ]; + } + + public function getLayoutOptionsProperty(): array + { + return collect(JoinTokenLayoutRegistry::all()) + ->map(fn ($layout) => [ + 'id' => $layout['id'], + 'name' => $layout['name'], + 'subtitle' => $layout['subtitle'] ?? '', + 'description' => $layout['description'] ?? '', + ]) + ->toArray(); + } + + protected function getDefaultEventTypeId(): ?int + { + return EventType::query()->orderBy('name->' . app()->getLocale())->value('id'); + } +} diff --git a/app/Filament/Tenant/Resources/EventResource.php b/app/Filament/Tenant/Resources/EventResource.php new file mode 100644 index 0000000..860553b --- /dev/null +++ b/app/Filament/Tenant/Resources/EventResource.php @@ -0,0 +1,209 @@ +tenant_id; + + return $form->schema([ + Hidden::make('tenant_id') + ->default($tenantId) + ->dehydrated(), + TextInput::make('name') + ->label(__('admin.events.fields.name')) + ->required() + ->maxLength(255), + TextInput::make('slug') + ->label(__('admin.events.fields.slug')) + ->required() + ->unique(ignoreRecord: true) + ->maxLength(255), + DatePicker::make('date') + ->label(__('admin.events.fields.date')) + ->required(), + Select::make('event_type_id') + ->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') + ->maxLength(5), + Toggle::make('is_active') + ->label(__('admin.events.fields.is_active')) + ->default(true), + KeyValue::make('settings') + ->label(__('admin.events.fields.settings')) + ->keyLabel(__('admin.common.key')) + ->valueLabel(__('admin.common.value')), + ])->columns(2); + } + + public static function table(Table $table): Table + { + return $table + ->columns([ + Tables\Columns\TextColumn::make('id')->sortable(), + Tables\Columns\TextColumn::make('eventPackage.package.name') + ->label(__('admin.events.table.package')) + ->badge() + ->color('success'), + Tables\Columns\TextColumn::make('name')->limit(30), + Tables\Columns\TextColumn::make('slug')->searchable(), + Tables\Columns\TextColumn::make('date')->date(), + Tables\Columns\IconColumn::make('is_active')->boolean(), + Tables\Columns\TextColumn::make('default_locale'), + 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('primary_join_token') + ->label(__('admin.events.table.join')) + ->getStateUsing(function ($record) { + $token = $record->joinTokens()->orderByDesc('created_at')->first(); + + return $token ? url('/e/'.$token->token) : __('admin.events.table.no_join_tokens'); + }) + ->description(function ($record) { + $total = $record->joinTokens()->count(); + + return $total > 0 + ? __('admin.events.table.join_tokens_total', ['count' => $total]) + : __('admin.events.table.join_tokens_missing'); + }) + ->copyable() + ->copyMessage(__('admin.events.messages.join_link_copied')), + Tables\Columns\TextColumn::make('created_at')->since(), + ]) + ->modifyQueryUsing(function (Builder $query) { + if ($tenantId = Auth::user()?->tenant_id) { + $query->where('tenant_id', $tenantId); + } + }) + ->filters([]) + ->actions([ + Actions\EditAction::make(), + Actions\Action::make('toggle') + ->label(__('admin.events.actions.toggle_active')) + ->icon('heroicon-o-power') + ->action(fn($record) => $record->update(['is_active' => !$record->is_active])), + Actions\Action::make('join_tokens') + ->label(__('admin.events.actions.join_link_qr')) + ->icon('heroicon-o-qr-code') + ->modalHeading(__('admin.events.modal.join_link_heading')) + ->modalSubmitActionLabel(__('admin.common.close')) + ->modalWidth('xl') + ->modalContent(function ($record) { + $tokens = $record->joinTokens() + ->orderByDesc('created_at') + ->get() + ->map(function ($token) use ($record) { + $layouts = JoinTokenLayoutRegistry::toResponse(function (string $layoutId, string $format) use ($record, $token) { + return route('tenant.events.join-tokens.layouts.download', [ + 'event' => $record->slug, + 'joinToken' => $token->getKey(), + 'layout' => $layoutId, + 'format' => $format, + ]); + }); + + return [ + 'id' => $token->id, + 'label' => $token->label, + 'token' => $token->token, + 'url' => url('/e/'.$token->token), + 'usage_limit' => $token->usage_limit, + 'usage_count' => $token->usage_count, + 'expires_at' => optional($token->expires_at)->toIso8601String(), + 'revoked_at' => optional($token->revoked_at)->toIso8601String(), + 'is_active' => $token->isActive(), + 'created_at' => optional($token->created_at)->toIso8601String(), + 'layouts' => $layouts, + 'layouts_url' => route('tenant.events.join-tokens.layouts.index', [ + 'event' => $record->slug, + 'joinToken' => $token->getKey(), + ]), + ]; + }); + + return view('filament.events.join-link', [ + 'event' => $record, + 'tokens' => $tokens, + ]); + }), + ]) + ->bulkActions([ + Actions\DeleteBulkAction::make(), + ]); + } + + public static function getPages(): array + { + return [ + 'index' => Pages\ListEvents::route('/'), + 'create' => Pages\CreateEvent::route('/create'), + 'view' => Pages\ViewEvent::route('/{record}'), + 'edit' => Pages\EditEvent::route('/{record}/edit'), + ]; + } + + public static function getRelations(): array + { + return [ + EventPackagesRelationManager::class, + ]; + } +} diff --git a/app/Filament/Tenant/Resources/EventResource/Pages/CreateEvent.php b/app/Filament/Tenant/Resources/EventResource/Pages/CreateEvent.php new file mode 100644 index 0000000..c27d96b --- /dev/null +++ b/app/Filament/Tenant/Resources/EventResource/Pages/CreateEvent.php @@ -0,0 +1,11 @@ +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('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'); + } +} diff --git a/app/Filament/Tenant/Resources/PhotoResource.php b/app/Filament/Tenant/Resources/PhotoResource.php new file mode 100644 index 0000000..d96d1b8 --- /dev/null +++ b/app/Filament/Tenant/Resources/PhotoResource.php @@ -0,0 +1,126 @@ +tenant_id; + + return $form->schema([ + Select::make('event_id') + ->label(__('admin.photos.fields.event')) + ->options( + Event::query() + ->when($tenantId, fn ($query) => $query->where('tenant_id', $tenantId)) + ->pluck('name', 'id') + ) + ->searchable() + ->required(), + FileUpload::make('file_path') + ->label(__('admin.photos.fields.photo')) + ->image() // enable FilePond image preview + ->disk('public') + ->directory('photos') + ->visibility('public') + ->required(), + Toggle::make('is_featured') + ->label(__('admin.photos.fields.is_featured')) + ->default(false), + KeyValue::make('metadata') + ->label(__('admin.photos.fields.metadata')) + ->keyLabel(__('admin.common.key')) + ->valueLabel(__('admin.common.value')), + ])->columns(2); + } + + public static function table(Table $table): Table + { + return $table + ->columns([ + Tables\Columns\ImageColumn::make('file_path')->label(__('admin.photos.table.photo'))->disk('public')->visibility('public'), + Tables\Columns\TextColumn::make('id')->sortable(), + Tables\Columns\TextColumn::make('event.name')->label(__('admin.photos.table.event'))->searchable(), + Tables\Columns\TextColumn::make('likes_count')->label(__('admin.photos.table.likes')), + Tables\Columns\IconColumn::make('is_featured')->boolean(), + Tables\Columns\TextColumn::make('created_at')->since(), + ]) + ->modifyQueryUsing(function (Builder $query) { + if ($tenantId = Auth::user()?->tenant_id) { + $query->whereHas('event', fn (Builder $eventQuery) => $eventQuery->where('tenant_id', $tenantId)); + } + }) + ->filters([]) + ->actions([ + Actions\EditAction::make(), + Actions\Action::make('feature') + ->label(__('admin.photos.actions.feature')) + ->visible(fn($record) => !$record->is_featured) + ->action(fn($record) => $record->update(['is_featured' => true])) + ->icon('heroicon-o-star'), + Actions\Action::make('unfeature') + ->label(__('admin.photos.actions.unfeature')) + ->visible(fn($record) => $record->is_featured) + ->action(fn($record) => $record->update(['is_featured' => false])) + ->icon('heroicon-o-star'), + Actions\DeleteAction::make(), + ]) + ->bulkActions([ + Actions\BulkAction::make('feature') + ->label(__('admin.photos.actions.feature_selected')) + ->icon('heroicon-o-star') + ->action(fn($records) => $records->each->update(['is_featured' => true])), + Actions\BulkAction::make('unfeature') + ->label(__('admin.photos.actions.unfeature_selected')) + ->icon('heroicon-o-star') + ->action(fn($records) => $records->each->update(['is_featured' => false])), + Actions\DeleteBulkAction::make(), + ]); + } + + public static function getPages(): array + { + return [ + 'index' => Pages\ListPhotos::route('/'), + 'view' => Pages\ViewPhoto::route('/{record}'), + 'edit' => Pages\EditPhoto::route('/{record}/edit'), + ]; + } +} diff --git a/app/Filament/Tenant/Resources/PhotoResource/Pages/EditPhoto.php b/app/Filament/Tenant/Resources/PhotoResource/Pages/EditPhoto.php new file mode 100644 index 0000000..5f2da5b --- /dev/null +++ b/app/Filament/Tenant/Resources/PhotoResource/Pages/EditPhoto.php @@ -0,0 +1,11 @@ +user()?->tenant_id; + + return $schema->components([ + Section::make(__('Task Collection Details')) + ->schema([ + TextInput::make('name_translations.de') + ->label(__('Name (DE)')) + ->required() + ->maxLength(255) + ->disabled(fn (?TaskCollection $record) => $record?->tenant_id !== $tenantId && $record !== null), + TextInput::make('name_translations.en') + ->label(__('Name (EN)')) + ->maxLength(255) + ->disabled(fn (?TaskCollection $record) => $record?->tenant_id !== $tenantId && $record !== null), + Select::make('event_type_id') + ->label(__('Event Type')) + ->options(fn () => EventType::orderBy('name->' . app()->getLocale()) + ->get() + ->mapWithKeys(function (EventType $type) { + $name = $type->name[app()->getLocale()] ?? $type->name['de'] ?? reset($type->name); + + return [$type->id => $name]; + })->toArray()) + ->searchable() + ->required() + ->disabled(fn (?TaskCollection $record) => $record?->tenant_id !== $tenantId && $record !== null), + Textarea::make('description_translations.de') + ->label(__('Description (DE)')) + ->rows(3) + ->disabled(fn (?TaskCollection $record) => $record?->tenant_id !== $tenantId && $record !== null), + Textarea::make('description_translations.en') + ->label(__('Description (EN)')) + ->rows(3) + ->disabled(fn (?TaskCollection $record) => $record?->tenant_id !== $tenantId && $record !== null), + ])->columns(2), + ]); + } + + public static function table(Table $table): Table + { + return $table + ->columns([ + TextColumn::make('name') + ->label(__('Name')) + ->searchable(['name_translations->de', 'name_translations->en']) + ->sortable(), + BadgeColumn::make('eventType.name') + ->label(__('Event Type')) + ->color('info'), + IconColumn::make('tenant_id') + ->label(__('Scope')) + ->boolean() + ->trueIcon('heroicon-o-user-group') + ->falseIcon('heroicon-o-globe-alt') + ->state(fn (TaskCollection $record) => $record->tenant_id !== null) + ->tooltip(fn (TaskCollection $record) => $record->tenant_id ? __('Tenant-only') : __('Global template')), + TextColumn::make('tasks_count') + ->label(__('Tasks')) + ->counts('tasks') + ->sortable(), + ]) + ->filters([ + SelectFilter::make('event_type_id') + ->label(__('Event Type')) + ->relationship('eventType', 'name->' . app()->getLocale()), + SelectFilter::make('scope') + ->options([ + 'global' => __('Global template'), + 'tenant' => __('Tenant-owned'), + ]) + ->query(function ($query, $value) { + $tenantId = auth()->user()?->tenant_id; + + if ($value === 'global') { + $query->whereNull('tenant_id'); + } + + if ($value === 'tenant') { + $query->where('tenant_id', $tenantId); + } + }), + ]) + ->actions([ + \Filament\Actions\Action::make('import') + ->label(__('Import to Event')) + ->icon('heroicon-o-cloud-arrow-down') + ->form([ + Select::make('event_slug') + ->label(__('Select Event')) + ->options(function () { + $tenantId = auth()->user()?->tenant_id; + + return Event::where('tenant_id', $tenantId) + ->orderBy('date', 'desc') + ->get() + ->mapWithKeys(function (Event $event) { + $name = $event->name[app()->getLocale()] ?? $event->name['de'] ?? reset($event->name); + + return [ + $event->slug => sprintf('%s (%s)', $name, $event->date?->format('d.m.Y')), + ]; + })->toArray(); + }) + ->required() + ->searchable(), + ]) + ->action(function (TaskCollection $record, array $data) { + $event = Event::where('slug', $data['event_slug']) + ->where('tenant_id', auth()->user()?->tenant_id) + ->firstOrFail(); + + /** @var TaskCollectionImportService $service */ + $service = app(TaskCollectionImportService::class); + $service->import($record, $event); + + Notification::make() + ->title(__('Task collection imported')) + ->body(__('The collection :name has been imported.', ['name' => $record->name])) + ->success() + ->send(); + }), + Actions\EditAction::make() + ->label(__('Edit')) + ->visible(fn (TaskCollection $record) => $record->tenant_id === auth()->user()?->tenant_id), + ]) + ->headerActions([ + Actions\CreateAction::make() + ->label(__('Create Task Collection')) + ->mutateFormDataUsing(function (array $data) { + $tenantId = auth()->user()?->tenant_id; + + $data['tenant_id'] = $tenantId; + $data['slug'] = static::generateSlug($data['name_translations']['en'] ?? $data['name_translations']['de'] ?? 'collection', $tenantId); + + return $data; + }), + ]) + ->bulkActions([ + Actions\DeleteBulkAction::make() + ->visible(fn () => false), + ]); + } + + public static function getPages(): array + { + return [ + 'index' => Pages\ListTaskCollections::route('/'), + 'create' => Pages\CreateTaskCollection::route('/create'), + 'edit' => Pages\EditTaskCollection::route('/{record}/edit'), + ]; + } + + public static function getEloquentQuery(): Builder + { + $tenantId = auth()->user()?->tenant_id; + + return parent::getEloquentQuery() + ->forTenant($tenantId) + ->with('eventType') + ->withCount('tasks'); + } + + public static function getGloballySearchableAttributes(): array + { + return ['name_translations->de', 'name_translations->en']; + } + + public static function generateSlug(string $base, int $tenantId): string + { + $slugBase = Str::slug($base) ?: 'collection'; + + do { + $candidate = $slugBase . '-' . $tenantId . '-' . Str::random(4); + } while (TaskCollection::where('slug', $candidate)->exists()); + + return $candidate; + } + + public static function scopeEloquentQueryToTenant(Builder $query, ?Model $tenant): Builder + { + $tenant ??= Filament::getTenant(); + + if (! $tenant) { + return $query; + } + + return $query->where(function (Builder $innerQuery) use ($tenant) { + $innerQuery->whereNull('tenant_id') + ->orWhere('tenant_id', $tenant->getKey()); + }); + } +} diff --git a/app/Filament/Tenant/Resources/TaskCollectionResource/Pages/CreateTaskCollection.php b/app/Filament/Tenant/Resources/TaskCollectionResource/Pages/CreateTaskCollection.php new file mode 100644 index 0000000..e537467 --- /dev/null +++ b/app/Filament/Tenant/Resources/TaskCollectionResource/Pages/CreateTaskCollection.php @@ -0,0 +1,26 @@ +tenant_id; + + $data['tenant_id'] = $tenantId; + $data['slug'] = TaskCollectionResource::generateSlug( + $data['name_translations']['en'] ?? $data['name_translations']['de'] ?? 'collection', + $tenantId + ); + + return $data; + } +} diff --git a/app/Filament/Tenant/Resources/TaskCollectionResource/Pages/EditTaskCollection.php b/app/Filament/Tenant/Resources/TaskCollectionResource/Pages/EditTaskCollection.php new file mode 100644 index 0000000..92b29da --- /dev/null +++ b/app/Filament/Tenant/Resources/TaskCollectionResource/Pages/EditTaskCollection.php @@ -0,0 +1,23 @@ +getRecord(); + + if ($record->tenant_id !== Auth::user()?->tenant_id) { + abort(403); + } + } +} diff --git a/app/Filament/Tenant/Resources/TaskCollectionResource/Pages/ListTaskCollections.php b/app/Filament/Tenant/Resources/TaskCollectionResource/Pages/ListTaskCollections.php new file mode 100644 index 0000000..b65f77e --- /dev/null +++ b/app/Filament/Tenant/Resources/TaskCollectionResource/Pages/ListTaskCollections.php @@ -0,0 +1,11 @@ +tenant_id; + + return $form->schema([ + Select::make('emotion_id') + ->relationship('emotion', 'name') + ->required() + ->searchable() + ->preload(), + Select::make('event_type_id') + ->relationship('eventType', 'name') + ->searchable() + ->preload() + ->label(__('admin.tasks.fields.event_type_optional')), + SchemaTabs::make('content_tabs') + ->label(__('admin.tasks.fields.content_localization')) + ->tabs([ + SchemaTab::make(__('admin.common.german')) + ->icon('heroicon-o-language') + ->schema([ + TextInput::make('title.de') + ->label(__('admin.tasks.fields.title_de')) + ->required(), + MarkdownEditor::make('description.de') + ->label(__('admin.tasks.fields.description_de')) + ->columnSpanFull(), + MarkdownEditor::make('example_text.de') + ->label(__('admin.tasks.fields.example_de')) + ->columnSpanFull(), + ]), + SchemaTab::make(__('admin.common.english')) + ->icon('heroicon-o-language') + ->schema([ + TextInput::make('title.en') + ->label(__('admin.tasks.fields.title_en')) + ->required(), + MarkdownEditor::make('description.en') + ->label(__('admin.tasks.fields.description_en')) + ->columnSpanFull(), + MarkdownEditor::make('example_text.en') + ->label(__('admin.tasks.fields.example_en')) + ->columnSpanFull(), + ]), + ]) + ->columnSpanFull(), + Select::make('difficulty') + ->label(__('admin.tasks.fields.difficulty.label')) + ->options([ + 'easy' => __('admin.tasks.fields.difficulty.easy'), + 'medium' => __('admin.tasks.fields.difficulty.medium'), + 'hard' => __('admin.tasks.fields.difficulty.hard'), + ]) + ->default('easy'), + TextInput::make('sort_order') + ->numeric() + ->default(0), + Toggle::make('is_active') + ->default(true), + Select::make('assigned_events') + ->label(__('admin.tasks.fields.events')) + ->multiple() + ->relationship( + 'assignedEvents', + 'name', + fn (Builder $query) => $tenantId + ? $query->where('tenant_id', $tenantId) + : $query + ) + ->searchable() + ->preload() + ->getOptionLabelFromRecordUsing(fn (Event $record) => $record->name) + ->helperText(__('admin.tasks.fields.events_helper')), + ])->columns(2); + } + + public static function table(Table $table): Table + { + $tenantId = Auth::user()?->tenant_id; + + return $table + ->columns([ + Tables\Columns\TextColumn::make('id') + ->label('#') + ->sortable(), + Tables\Columns\TextColumn::make('title') + ->label(__('admin.tasks.table.title')) + ->getStateUsing(function ($record) { + $value = $record->title; + if (is_array($value)) { + $loc = app()->getLocale(); + return $value[$loc] ?? ($value['de'] ?? ($value['en'] ?? '')); + } + + return (string) $value; + }) + ->limit(60) + ->searchable(['title->de', 'title->en']), + Tables\Columns\TextColumn::make('emotion.name') + ->label(__('admin.tasks.fields.emotion')) + ->toggleable(), + Tables\Columns\TextColumn::make('eventType.name') + ->label(__('admin.tasks.fields.event_type')) + ->toggleable(), + Tables\Columns\TextColumn::make('assignedEvents.name') + ->label(__('admin.tasks.table.events')) + ->badge() + ->separator(', ') + ->limitList(2), + Tables\Columns\TextColumn::make('difficulty') + ->label(__('admin.tasks.fields.difficulty.label')) + ->badge(), + Tables\Columns\IconColumn::make('is_active') + ->label(__('admin.tasks.table.is_active')) + ->boolean(), + Tables\Columns\TextColumn::make('sort_order') + ->label(__('admin.tasks.table.sort_order')) + ->sortable(), + ]) + ->filters([]) + ->actions([ + Actions\EditAction::make(), + Actions\DeleteAction::make(), + ]) + ->bulkActions([ + Actions\DeleteBulkAction::make(), + ]) + ->modifyQueryUsing(function (Builder $query) use ($tenantId) { + if (! $tenantId) { + return $query; + } + + $query->forTenant($tenantId); + + return $query; + }); + } + + public static function getPages(): array + { + return [ + 'index' => Pages\ListTasks::route('/'), + 'create' => Pages\CreateTask::route('/create'), + 'edit' => Pages\EditTask::route('/{record}/edit'), + ]; + } + + public static function scopeEloquentQueryToTenant(Builder $query, ?Model $tenant): Builder + { + $tenant ??= Filament::getTenant(); + + if (! $tenant) { + return $query; + } + + return $query->where(function (Builder $innerQuery) use ($tenant) { + $innerQuery->whereNull('tenant_id') + ->orWhere('tenant_id', $tenant->getKey()); + }); + } +} diff --git a/app/Filament/Tenant/Resources/TaskResource/Pages/CreateTask.php b/app/Filament/Tenant/Resources/TaskResource/Pages/CreateTask.php new file mode 100644 index 0000000..0b06513 --- /dev/null +++ b/app/Filament/Tenant/Resources/TaskResource/Pages/CreateTask.php @@ -0,0 +1,11 @@ +tenant->id; + + $query = Emotion::query() + ->whereNull('tenant_id') + ->orWhere('tenant_id', $tenantId) + ->with('eventTypes'); + + if ($request->boolean('only_tenant')) { + $query->where('tenant_id', $tenantId); + } + + if ($request->boolean('only_global')) { + $query->whereNull('tenant_id'); + } + + $query->orderByRaw('tenant_id is null desc')->orderBy('sort_order')->orderBy('id'); + + $emotions = $query->paginate($request->integer('per_page', 25)); + + return EmotionResource::collection($emotions); + } + + public function store(EmotionStoreRequest $request): JsonResponse + { + $data = $request->validated(); + + $payload = [ + 'tenant_id' => $request->tenant->id, + 'name' => $this->localizeValue($data['name']), + 'description' => $this->localizeValue($data['description'] ?? null, allowNull: true), + 'icon' => $data['icon'] ?? 'lucide-smile', + 'color' => $this->normalizeColor($data['color'] ?? '#6366f1'), + 'sort_order' => $data['sort_order'] ?? 0, + 'is_active' => $data['is_active'] ?? true, + ]; + + $emotion = null; + + DB::transaction(function () use (&$emotion, $payload, $data) { + $emotion = Emotion::create($payload); + + if (! empty($data['event_type_ids'])) { + $emotion->eventTypes()->sync($data['event_type_ids']); + } + }); + + return response()->json([ + 'message' => __('Emotion erfolgreich erstellt.'), + 'data' => new EmotionResource($emotion->fresh('eventTypes')), + ], 201); + } + + public function update(EmotionUpdateRequest $request, Emotion $emotion): JsonResponse + { + if ($emotion->tenant_id && $emotion->tenant_id !== $request->tenant->id) { + abort(403, 'Emotion gehört nicht zu diesem Tenant.'); + } + + if (is_null($emotion->tenant_id) && $request->hasAny(['name', 'description', 'icon', 'color', 'sort_order'])) { + abort(403, 'Globale Emotions können nicht bearbeitet werden.'); + } + + $data = $request->validated(); + + DB::transaction(function () use ($emotion, $data) { + $update = []; + + if (array_key_exists('name', $data)) { + $update['name'] = $this->localizeValue($data['name'], allowNull: false, fallback: $emotion->name); + } + + if (array_key_exists('description', $data)) { + $update['description'] = $this->localizeValue($data['description'], allowNull: true, fallback: $emotion->description); + } + + if (array_key_exists('icon', $data)) { + $update['icon'] = $data['icon'] ?? $emotion->icon; + } + + if (array_key_exists('color', $data)) { + $update['color'] = $this->normalizeColor($data['color'] ?? $emotion->color); + } + + if (array_key_exists('sort_order', $data)) { + $update['sort_order'] = $data['sort_order'] ?? 0; + } + + if (array_key_exists('is_active', $data)) { + $update['is_active'] = $data['is_active']; + } + + if (! empty($update)) { + $emotion->update($update); + } + + if (array_key_exists('event_type_ids', $data)) { + $emotion->eventTypes()->sync($data['event_type_ids'] ?? []); + } + }); + + return response()->json([ + 'message' => __('Emotion aktualisiert.'), + 'data' => new EmotionResource($emotion->fresh('eventTypes')), + ]); + } + + protected function localizeValue(mixed $value, bool $allowNull = false, ?array $fallback = null): ?array + { + if ($allowNull && ($value === null || $value === '')) { + return null; + } + + if (is_array($value)) { + $filtered = array_filter($value, static fn ($text) => is_string($text) && $text !== ''); + if (! empty($filtered)) { + return $filtered; + } + + return $allowNull ? null : ($fallback ?? []); + } + + if (is_string($value) && $value !== '') { + $locale = app()->getLocale() ?: 'de'; + return [$locale => $value]; + } + + return $allowNull ? null : $fallback; + } + + protected function normalizeColor(string $color): string + { + $normalized = ltrim($color, '#'); + if (strlen($normalized) === 6) { + return '#' . strtolower($normalized); + } + + return '#6366f1'; + } +} diff --git a/app/Http/Controllers/Api/Tenant/TaskCollectionController.php b/app/Http/Controllers/Api/Tenant/TaskCollectionController.php new file mode 100644 index 0000000..1ebad1b --- /dev/null +++ b/app/Http/Controllers/Api/Tenant/TaskCollectionController.php @@ -0,0 +1,94 @@ +tenant->id; + + $query = TaskCollection::query() + ->forTenant($tenantId) + ->with('eventType') + ->withCount('tasks') + ->orderBy('position') + ->orderBy('id'); + + if ($search = $request->query('search')) { + $query->where(function ($inner) use ($search) { + $inner->where('name_translations->de', 'like', "%{$search}%") + ->orWhere('name_translations->en', 'like', "%{$search}%"); + }); + } + + if ($eventTypeSlug = $request->query('event_type')) { + $query->whereHas('eventType', fn ($q) => $q->where('slug', $eventTypeSlug)); + } + + if ($request->boolean('only_global')) { + $query->whereNull('tenant_id'); + } + + if ($request->boolean('only_tenant')) { + $query->where('tenant_id', $tenantId); + } + + $perPage = $request->integer('per_page', 15); + + return TaskCollectionResource::collection( + $query->paginate($perPage) + ); + } + + public function show(Request $request, TaskCollection $collection): JsonResponse + { + $this->authorizeAccess($request, $collection); + + $collection->load(['eventType', 'tasks' => fn ($query) => $query->with('assignedEvents')]); + + return response()->json(new TaskCollectionResource($collection)); + } + + public function activate( + Request $request, + TaskCollection $collection, + TaskCollectionImportService $importService + ): JsonResponse { + $this->authorizeAccess($request, $collection); + + $data = $request->validate([ + 'event_slug' => ['required', 'string', Rule::exists('events', 'slug')->where('tenant_id', $request->tenant->id)], + ]); + + $event = Event::where('slug', $data['event_slug']) + ->where('tenant_id', $request->tenant->id) + ->firstOrFail(); + + $result = $importService->import($collection, $event); + + return response()->json([ + 'message' => __('Task-Collection erfolgreich importiert.'), + 'collection' => new TaskCollectionResource($result['collection']->load('eventType')->loadCount('tasks')), + 'created_task_ids' => $result['created_task_ids'], + 'attached_task_ids' => $result['attached_task_ids'], + ]); + } + + protected function authorizeAccess(Request $request, TaskCollection $collection): void + { + if ($collection->tenant_id && $collection->tenant_id !== $request->tenant->id) { + abort(404); + } + } +} diff --git a/app/Http/Controllers/Api/Tenant/TaskController.php b/app/Http/Controllers/Api/Tenant/TaskController.php index 122fbde..a7e24d3 100644 --- a/app/Http/Controllers/Api/Tenant/TaskController.php +++ b/app/Http/Controllers/Api/Tenant/TaskController.php @@ -23,14 +23,27 @@ class TaskController extends Controller */ public function index(Request $request): AnonymousResourceCollection { - $query = Task::where('tenant_id', $request->tenant->id) + $tenantId = $request->tenant->id; + + $query = Task::query() + ->where(function ($inner) use ($tenantId) { + $inner->whereNull('tenant_id') + ->orWhere('tenant_id', $tenantId); + }) ->with(['taskCollection', 'assignedEvents']) + ->orderByRaw('tenant_id is null desc') + ->orderBy('sort_order') ->orderBy('created_at', 'desc'); // Search and filters if ($search = $request->get('search')) { - $query->where('title', 'like', "%{$search}%") - ->orWhere('description', 'like', "%{$search}%"); + $query->where(function ($inner) use ($search) { + $like = '%' . $search . '%'; + $inner->where('title->de', 'like', $like) + ->orWhere('title->en', 'like', $like) + ->orWhere('description->de', 'like', $like) + ->orWhere('description->en', 'like', $like); + }); } if ($collectionId = $request->get('collection_id')) { @@ -55,15 +68,19 @@ class TaskController extends Controller */ public function store(TaskStoreRequest $request): JsonResponse { - $task = Task::create(array_merge($request->validated(), [ - 'tenant_id' => $request->tenant->id, - ])); + $collectionId = $request->input('collection_id'); + $collection = $collectionId ? $this->resolveAccessibleCollection($request, $collectionId) : null; - if ($collectionId = $request->input('collection_id')) { - $task->taskCollection()->associate(TaskCollection::findOrFail($collectionId)); - $task->save(); + $payload = $this->prepareTaskPayload($request->validated(), $request->tenant->id); + $payload['tenant_id'] = $request->tenant->id; + + if ($collection) { + $payload['collection_id'] = $collection->id; + $payload['source_collection_id'] = $collection->source_collection_id ?? $collection->id; } + $task = Task::create($payload); + $task->load(['taskCollection', 'assignedEvents']); return response()->json([ @@ -81,7 +98,7 @@ class TaskController extends Controller */ public function show(Request $request, Task $task): JsonResponse { - if ($task->tenant_id !== $request->tenant->id) { + if ($task->tenant_id && $task->tenant_id !== $request->tenant->id) { abort(404, 'Task nicht gefunden.'); } @@ -103,13 +120,18 @@ class TaskController extends Controller abort(404, 'Task nicht gefunden.'); } - $task->update($request->validated()); + $collectionId = $request->input('collection_id'); + $collection = $collectionId ? $this->resolveAccessibleCollection($request, $collectionId) : null; - if ($collectionId = $request->input('collection_id')) { - $task->taskCollection()->associate(TaskCollection::findOrFail($collectionId)); - $task->save(); + $payload = $this->prepareTaskPayload($request->validated(), $request->tenant->id, $task); + + if ($collection) { + $payload['collection_id'] = $collection->id; + $payload['source_collection_id'] = $collection->source_collection_id ?? $collection->id; } + $task->update($payload); + $task->load(['taskCollection', 'assignedEvents']); return response()->json([ @@ -228,7 +250,7 @@ class TaskController extends Controller */ public function fromCollection(Request $request, TaskCollection $collection): AnonymousResourceCollection { - if ($collection->tenant_id !== $request->tenant->id) { + if ($collection->tenant_id && $collection->tenant_id !== $request->tenant->id) { abort(404); } @@ -239,4 +261,98 @@ class TaskController extends Controller return TaskResource::collection($tasks); } -} \ No newline at end of file + + protected function resolveAccessibleCollection(Request $request, int|string $collectionId): TaskCollection + { + return TaskCollection::where('id', $collectionId) + ->where(function ($query) use ($request) { + $query->whereNull('tenant_id'); + + if ($request->tenant?->id) { + $query->orWhere('tenant_id', $request->tenant->id); + } + }) + ->firstOrFail(); + } + + protected function prepareTaskPayload(array $data, int $tenantId, ?Task $original = null): array + { + if (array_key_exists('title', $data)) { + $data['title'] = $this->normalizeTranslations($data['title'], $original?->title); + } elseif (array_key_exists('title_translations', $data)) { + $data['title'] = $this->normalizeTranslations($data['title_translations'], $original?->title); + } + + if (array_key_exists('description', $data)) { + $data['description'] = $this->normalizeTranslations($data['description'], $original?->description, true); + } elseif (array_key_exists('description_translations', $data)) { + $data['description'] = $this->normalizeTranslations( + $data['description_translations'], + $original?->description, + true + ); + } + + if (array_key_exists('example_text', $data)) { + $data['example_text'] = $this->normalizeTranslations($data['example_text'], $original?->example_text, true); + } elseif (array_key_exists('example_text_translations', $data)) { + $data['example_text'] = $this->normalizeTranslations( + $data['example_text_translations'], + $original?->example_text, + true + ); + } + + unset( + $data['title_translations'], + $data['description_translations'], + $data['example_text_translations'] + ); + + if (! array_key_exists('difficulty', $data) || $data['difficulty'] === null) { + $data['difficulty'] = $original?->difficulty ?? 'easy'; + } + + if (! array_key_exists('priority', $data) || $data['priority'] === null) { + $data['priority'] = $original?->priority ?? 'medium'; + } + + return $data; + } + + /** + * @param mixed $value + * @param array|null $fallback + * + * @return array|null + */ + protected function normalizeTranslations(mixed $value, ?array $fallback = null, bool $allowNull = false): ?array + { + if ($allowNull && ($value === null || $value === '')) { + return null; + } + + if (is_array($value)) { + $filtered = array_filter( + $value, + static fn ($text) => is_string($text) && $text !== '' + ); + + if (! empty($filtered)) { + return $filtered; + } + + return $allowNull ? null : ($fallback ?? []); + } + + if (is_string($value) && $value !== '') { + $locale = app()->getLocale() ?: 'de'; + + return [ + $locale => $value, + ]; + } + + return $allowNull ? null : $fallback; + } +} diff --git a/app/Http/Controllers/MarketingController.php b/app/Http/Controllers/MarketingController.php index a4d85e0..f93de4d 100644 --- a/app/Http/Controllers/MarketingController.php +++ b/app/Http/Controllers/MarketingController.php @@ -22,7 +22,13 @@ use App\Models\TenantPackage; use App\Models\PackagePurchase; use Illuminate\Support\Facades\Auth; use Inertia\Inertia; -use League\CommonMark\CommonMarkConverter; +use League\CommonMark\Environment\Environment; +use League\CommonMark\Extension\CommonMark\CommonMarkCoreExtension; +use League\CommonMark\Extension\Table\TableExtension; +use League\CommonMark\Extension\Autolink\AutolinkExtension; +use League\CommonMark\Extension\Strikethrough\StrikethroughExtension; +use League\CommonMark\Extension\TaskList\TaskListExtension; +use League\CommonMark\MarkdownConverter; class MarketingController extends Controller { @@ -429,7 +435,15 @@ class MarketingController extends Controller // Transform to array with translated strings for the current locale $markdown = $postModel->getTranslation('content', $locale) ?? $postModel->getTranslation('content', 'de') ?? ''; - $converter = new CommonMarkConverter(); + + $environment = new Environment(); + $environment->addExtension(new CommonMarkCoreExtension()); + $environment->addExtension(new TableExtension()); + $environment->addExtension(new AutolinkExtension()); + $environment->addExtension(new StrikethroughExtension()); + $environment->addExtension(new TaskListExtension()); + + $converter = new MarkdownConverter($environment); $contentHtml = (string) $converter->convert($markdown); // Debug log for content_html diff --git a/app/Http/Requests/Tenant/EmotionStoreRequest.php b/app/Http/Requests/Tenant/EmotionStoreRequest.php new file mode 100644 index 0000000..e83862e --- /dev/null +++ b/app/Http/Requests/Tenant/EmotionStoreRequest.php @@ -0,0 +1,27 @@ + ['required', 'string', 'max:255'], + 'description' => ['nullable', 'string'], + 'icon' => ['nullable', 'string', 'max:50'], + 'color' => ['nullable', 'string', 'regex:/^#?[0-9a-fA-F]{6}$/'], + 'sort_order' => ['nullable', 'integer'], + 'is_active' => ['nullable', 'boolean'], + 'event_type_ids' => ['nullable', 'array'], + 'event_type_ids.*' => ['integer', 'exists:event_types,id'], + ]; + } +} diff --git a/app/Http/Requests/Tenant/EmotionUpdateRequest.php b/app/Http/Requests/Tenant/EmotionUpdateRequest.php new file mode 100644 index 0000000..8b6b88f --- /dev/null +++ b/app/Http/Requests/Tenant/EmotionUpdateRequest.php @@ -0,0 +1,27 @@ + ['sometimes', 'nullable', 'string', 'max:255'], + 'description' => ['sometimes', 'nullable', 'string'], + 'icon' => ['sometimes', 'nullable', 'string', 'max:50'], + 'color' => ['sometimes', 'nullable', 'string', 'regex:/^#?[0-9a-fA-F]{6}$/'], + 'sort_order' => ['sometimes', 'nullable', 'integer'], + 'is_active' => ['sometimes', 'boolean'], + 'event_type_ids' => ['sometimes', 'array'], + 'event_type_ids.*' => ['integer', 'exists:event_types,id'], + ]; + } +} diff --git a/app/Http/Requests/Tenant/TaskStoreRequest.php b/app/Http/Requests/Tenant/TaskStoreRequest.php index 22983fb..5ea2bbf 100644 --- a/app/Http/Requests/Tenant/TaskStoreRequest.php +++ b/app/Http/Requests/Tenant/TaskStoreRequest.php @@ -27,7 +27,18 @@ class TaskStoreRequest extends FormRequest 'description' => ['nullable', 'string'], 'collection_id' => ['nullable', 'exists:task_collections,id', function ($attribute, $value, $fail) { $tenantId = request()->tenant?->id; - if ($tenantId && !\App\Models\TaskCollection::where('id', $value)->where('tenant_id', $tenantId)->exists()) { + + $accessible = \App\Models\TaskCollection::where('id', $value) + ->where(function ($query) use ($tenantId) { + $query->whereNull('tenant_id'); + + if ($tenantId) { + $query->orWhere('tenant_id', $tenantId); + } + }) + ->exists(); + + if (! $accessible) { $fail('Die TaskCollection gehört nicht zu diesem Tenant.'); } }], @@ -57,4 +68,4 @@ class TaskStoreRequest extends FormRequest 'assigned_to.exists' => 'Der zugewiesene Benutzer existiert nicht.', ]; } -} \ No newline at end of file +} diff --git a/app/Http/Requests/Tenant/TaskUpdateRequest.php b/app/Http/Requests/Tenant/TaskUpdateRequest.php index 871db13..f5cc3af 100644 --- a/app/Http/Requests/Tenant/TaskUpdateRequest.php +++ b/app/Http/Requests/Tenant/TaskUpdateRequest.php @@ -27,7 +27,18 @@ class TaskUpdateRequest extends FormRequest 'description' => ['sometimes', 'nullable', 'string'], 'collection_id' => ['sometimes', 'nullable', 'exists:task_collections,id', function ($attribute, $value, $fail) { $tenantId = request()->tenant?->id; - if ($tenantId && !\App\Models\TaskCollection::where('id', $value)->where('tenant_id', $tenantId)->exists()) { + + $accessible = \App\Models\TaskCollection::where('id', $value) + ->where(function ($query) use ($tenantId) { + $query->whereNull('tenant_id'); + + if ($tenantId) { + $query->orWhere('tenant_id', $tenantId); + } + }) + ->exists(); + + if (! $accessible) { $fail('Die TaskCollection gehört nicht zu diesem Tenant.'); } }], @@ -56,4 +67,4 @@ class TaskUpdateRequest extends FormRequest 'assigned_to.exists' => 'Der zugewiesene Benutzer existiert nicht.', ]; } -} \ No newline at end of file +} diff --git a/app/Http/Resources/Tenant/EmotionResource.php b/app/Http/Resources/Tenant/EmotionResource.php new file mode 100644 index 0000000..0841fde --- /dev/null +++ b/app/Http/Resources/Tenant/EmotionResource.php @@ -0,0 +1,64 @@ + $this->id, + 'tenant_id' => $this->tenant_id, + 'name' => $this->translatedText($this->name, 'Emotion'), + 'name_translations' => (array) $this->name, + 'description' => $this->description ? $this->translatedText($this->description, '') : null, + 'description_translations' => $this->description ? (array) $this->description : [], + 'icon' => $this->icon, + 'color' => $this->color, + 'sort_order' => $this->sort_order, + 'is_active' => (bool) $this->is_active, + 'is_global' => $this->tenant_id === null, + 'event_types' => $this->whenLoaded('eventTypes', function () { + return $this->eventTypes->map(fn ($eventType) => [ + 'id' => $eventType->id, + 'slug' => $eventType->slug, + 'name' => $this->translatedText($eventType->name, $eventType->slug ?? ''), + 'name_translations' => (array) $eventType->name, + ]); + }), + 'created_at' => optional($this->created_at)->toISOString(), + 'updated_at' => optional($this->updated_at)->toISOString(), + ]; + } + + protected function translatedText(mixed $value, string $fallback): string + { + if (is_string($value) && $value !== '') { + return $value; + } + + if (! is_array($value)) { + return $fallback; + } + + $locale = app()->getLocale(); + $locales = array_filter([ + $locale, + $locale && str_contains($locale, '-') ? explode('-', $locale)[0] : null, + 'de', + 'en', + ]); + + foreach ($locales as $code) { + if ($code && isset($value[$code]) && $value[$code] !== '') { + return $value[$code]; + } + } + + $first = reset($value); + return $first !== false ? (string) $first : $fallback; + } +} diff --git a/app/Http/Resources/Tenant/TaskCollectionResource.php b/app/Http/Resources/Tenant/TaskCollectionResource.php new file mode 100644 index 0000000..19dc75b --- /dev/null +++ b/app/Http/Resources/Tenant/TaskCollectionResource.php @@ -0,0 +1,42 @@ + + */ + public function toArray(Request $request): array + { + return [ + 'id' => $this->id, + 'slug' => $this->slug, + 'name' => $this->name, + 'name_translations' => $this->name_translations, + 'description' => $this->description, + 'description_translations' => $this->description_translations, + 'tenant_id' => $this->tenant_id, + 'is_global' => $this->tenant_id === null, + 'event_type' => $this->whenLoaded('eventType', function () { + return [ + 'id' => $this->eventType->id, + 'slug' => $this->eventType->slug, + 'name' => $this->eventType->name, + 'icon' => $this->eventType->icon, + ]; + }), + 'tasks_count' => $this->whenCounted('tasks'), + 'is_default' => (bool) ($this->is_default ?? false), + 'position' => $this->position, + 'source_collection_id' => $this->source_collection_id, + 'created_at' => $this->created_at?->toISOString(), + 'updated_at' => $this->updated_at?->toISOString(), + ]; + } +} diff --git a/app/Http/Resources/Tenant/TaskResource.php b/app/Http/Resources/Tenant/TaskResource.php index 5d084d6..19ad939 100644 --- a/app/Http/Resources/Tenant/TaskResource.php +++ b/app/Http/Resources/Tenant/TaskResource.php @@ -18,15 +18,27 @@ class TaskResource extends JsonResource ? $this->assignedEvents->count() : $this->assignedEvents()->count(); + $titleTranslations = $this->normalizeTranslations($this->title); + $descriptionTranslations = $this->normalizeTranslations($this->description, allowNull: true); + $exampleTranslations = $this->normalizeTranslations($this->example_text, allowNull: true); + return [ 'id' => $this->id, 'tenant_id' => $this->tenant_id, - 'title' => $this->title, - 'description' => $this->description, + 'slug' => $this->slug, + 'title' => $this->translatedText($titleTranslations, 'Untitled task'), + 'title_translations' => $titleTranslations, + 'description' => $descriptionTranslations ? $this->translatedText($descriptionTranslations, '') : null, + 'description_translations' => $descriptionTranslations ?? [], + 'example_text' => $exampleTranslations ? $this->translatedText($exampleTranslations, '') : null, + 'example_text_translations' => $exampleTranslations ?? [], 'priority' => $this->priority, + 'difficulty' => $this->difficulty, 'due_date' => $this->due_date?->toISOString(), 'is_completed' => (bool) $this->is_completed, 'collection_id' => $this->collection_id, + 'source_task_id' => $this->source_task_id, + 'source_collection_id' => $this->source_collection_id, 'assigned_events_count' => $assignedEventsCount, 'assigned_events' => $this->whenLoaded( 'assignedEvents', @@ -36,5 +48,60 @@ class TaskResource extends JsonResource 'updated_at' => $this->updated_at?->toISOString(), ]; } + + /** + * @return array|null + */ + protected function normalizeTranslations(mixed $value, bool $allowNull = false): ?array + { + if ($allowNull && ($value === null || $value === '')) { + return null; + } + + if (is_array($value)) { + $filtered = array_filter( + $value, + static fn ($text) => is_string($text) && $text !== '' + ); + + return ! empty($filtered) + ? $filtered + : ($allowNull ? null : []); + } + + if (is_string($value) && $value !== '') { + $locale = app()->getLocale() ?: 'de'; + + return [ + $locale => $value, + ]; + } + + return $allowNull ? null : []; + } + + /** + * @param array $translations + */ + protected function translatedText(array $translations, string $fallback): string + { + $locale = app()->getLocale(); + $locales = array_filter([ + $locale, + $locale && str_contains($locale, '-') ? explode('-', $locale)[0] : null, + 'de', + 'en', + ]); + + foreach ($locales as $code) { + if ($code && isset($translations[$code]) && $translations[$code] !== '') { + return $translations[$code]; + } + } + + $first = reset($translations); + + return $first !== false ? $first : $fallback; + } } diff --git a/app/Models/BlogPost.php b/app/Models/BlogPost.php index 32555c8..a592daf 100644 --- a/app/Models/BlogPost.php +++ b/app/Models/BlogPost.php @@ -10,7 +10,13 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\SoftDeletes; use Illuminate\Support\Facades\Storage; use Spatie\Translatable\HasTranslations; -use League\CommonMark\CommonMarkConverter; +use League\CommonMark\Environment\Environment; +use League\CommonMark\Extension\CommonMark\CommonMarkCoreExtension; +use League\CommonMark\Extension\Table\TableExtension; +use League\CommonMark\Extension\Autolink\AutolinkExtension; +use League\CommonMark\Extension\Strikethrough\StrikethroughExtension; +use League\CommonMark\Extension\TaskList\TaskListExtension; +use League\CommonMark\MarkdownConverter; class BlogPost extends Model { @@ -54,7 +60,15 @@ class BlogPost extends Model { return Attribute::get(function () { $markdown = $this->getTranslation('content', app()->getLocale()); - $converter = new CommonMarkConverter(); + + $environment = new Environment(); + $environment->addExtension(new CommonMarkCoreExtension()); + $environment->addExtension(new TableExtension()); + $environment->addExtension(new AutolinkExtension()); + $environment->addExtension(new StrikethroughExtension()); + $environment->addExtension(new TaskListExtension()); + + $converter = new MarkdownConverter($environment); return $converter->convert($markdown); }); } diff --git a/app/Models/Emotion.php b/app/Models/Emotion.php index 5e02dfd..639fd03 100644 --- a/app/Models/Emotion.php +++ b/app/Models/Emotion.php @@ -4,6 +4,7 @@ namespace App\Models; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; +use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\BelongsToMany; use Illuminate\Database\Eloquent\Relations\HasMany; @@ -18,6 +19,11 @@ class Emotion extends Model 'description' => 'array', ]; + public function tenant(): BelongsTo + { + return $this->belongsTo(Tenant::class); + } + public function eventTypes(): BelongsToMany { return $this->belongsToMany(EventType::class, 'emotion_event_type', 'emotion_id', 'event_type_id'); diff --git a/app/Models/Photo.php b/app/Models/Photo.php index 58e7563..d1e4b0a 100644 --- a/app/Models/Photo.php +++ b/app/Models/Photo.php @@ -6,10 +6,13 @@ use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\HasMany; +use Znck\Eloquent\Relations\BelongsToThrough as BelongsToThroughRelation; +use Znck\Eloquent\Traits\BelongsToThrough; class Photo extends Model { use HasFactory; + use BelongsToThrough; protected $table = 'photos'; protected $guarded = []; @@ -47,5 +50,12 @@ class Photo extends Model { return $this->hasMany(PhotoLike::class); } -} + public function tenant(): BelongsToThroughRelation + { + return $this->belongsToThrough( + Tenant::class, + Event::class + ); + } +} diff --git a/app/Models/Task.php b/app/Models/Task.php index 9741376..590fa87 100644 --- a/app/Models/Task.php +++ b/app/Models/Task.php @@ -2,11 +2,14 @@ namespace App\Models; +use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\BelongsToMany; +use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\SoftDeletes; +use Illuminate\Support\Str; class Task extends Model { @@ -38,6 +41,59 @@ class Task extends Model return $this->belongsTo(TaskCollection::class, 'collection_id'); } + public function tenant(): BelongsTo + { + return $this->belongsTo(Tenant::class); + } + + public function sourceTask(): BelongsTo + { + return $this->belongsTo(Task::class, 'source_task_id'); + } + + public function derivedTasks(): HasMany + { + return $this->hasMany(Task::class, 'source_task_id'); + } + + public function sourceCollection(): BelongsTo + { + return $this->belongsTo(TaskCollection::class, 'source_collection_id'); + } + + public function scopeForTenant(Builder $query, ?int $tenantId): Builder + { + return $query->where(function (Builder $innerQuery) use ($tenantId) { + $innerQuery->whereNull('tenant_id'); + + if ($tenantId) { + $innerQuery->orWhere('tenant_id', $tenantId); + } + }); + } + + protected static function booted(): void + { + static::creating(function (Task $task) { + if (! $task->slug) { + $task->slug = static::generateSlug( + $task->title['en'] ?? $task->title['de'] ?? 'task' + ); + } + }); + } + + protected static function generateSlug(string $base): string + { + $slugBase = Str::slug($base) ?: 'task'; + + do { + $slug = $slugBase . '-' . Str::random(6); + } while (static::where('slug', $slug)->exists()); + + return $slug; + } + public function assignedEvents(): BelongsToMany { return $this->belongsToMany(Event::class, 'event_task', 'task_id', 'event_id') diff --git a/app/Models/TaskCollection.php b/app/Models/TaskCollection.php index cfc1bb4..a6149f0 100644 --- a/app/Models/TaskCollection.php +++ b/app/Models/TaskCollection.php @@ -4,7 +4,10 @@ namespace App\Models; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; +use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\BelongsToMany; +use Illuminate\Database\Eloquent\Relations\HasMany; +use Illuminate\Support\Arr; class TaskCollection extends Model { @@ -14,10 +17,40 @@ class TaskCollection extends Model protected $fillable = [ 'tenant_id', - 'name', - 'description', + 'slug', + 'name_translations', + 'description_translations', + 'event_type_id', + 'source_collection_id', + 'is_default', + 'position', ]; + protected $casts = [ + 'name_translations' => 'array', + 'description_translations' => 'array', + ]; + + public function eventType(): BelongsTo + { + return $this->belongsTo(EventType::class); + } + + public function tenant(): BelongsTo + { + return $this->belongsTo(Tenant::class); + } + + public function sourceCollection(): BelongsTo + { + return $this->belongsTo(TaskCollection::class, 'source_collection_id'); + } + + public function derivedCollections(): HasMany + { + return $this->hasMany(TaskCollection::class, 'source_collection_id'); + } + public function tasks(): BelongsToMany { return $this->belongsToMany( @@ -25,7 +58,7 @@ class TaskCollection extends Model 'task_collection_task', 'task_collection_id', 'task_id' - ); + )->withPivot(['sort_order']); } public function events(): BelongsToMany @@ -35,7 +68,49 @@ class TaskCollection extends Model 'event_task_collection', 'task_collection_id', 'event_id' - ); + )->withPivot(['sort_order'])->withTimestamps(); + } + + public function scopeGlobal($query) + { + return $query->whereNull('tenant_id'); + } + + public function scopeForTenant($query, ?int $tenantId) + { + return $query->where(function ($inner) use ($tenantId) { + $inner->whereNull('tenant_id'); + + if ($tenantId) { + $inner->orWhere('tenant_id', $tenantId); + } + }); + } + + public function getNameAttribute(): string + { + return $this->resolveTranslation('name_translations'); + } + + public function getDescriptionAttribute(): ?string + { + $value = $this->resolveTranslation('description_translations'); + + return $value ?: null; + } + + protected function resolveTranslation(string $attribute, ?string $locale = null): string + { + $translations = $this->{$attribute} ?? []; + + if (is_string($translations)) { + $translations = json_decode($translations, true) ?: []; + } + + $locale = $locale ?? app()->getLocale(); + + return $translations[$locale] + ?? Arr::first($translations) + ?? ''; } } - diff --git a/app/Models/User.php b/app/Models/User.php index e52392a..731180a 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -116,7 +116,11 @@ class User extends Authenticatable implements MustVerifyEmail, HasName, Filament return false; } - return in_array($this->role, ['tenant_admin', 'super_admin'], true); + return match ($panel->getId()) { + 'superadmin' => $this->role === 'super_admin', + 'admin' => $this->role === 'tenant_admin', + default => false, + }; } public function canAccessTenant(Model $tenant): bool diff --git a/app/Providers/Filament/AdminPanelProvider.php b/app/Providers/Filament/AdminPanelProvider.php index e3e0e54..86eaa0f 100644 --- a/app/Providers/Filament/AdminPanelProvider.php +++ b/app/Providers/Filament/AdminPanelProvider.php @@ -17,34 +17,26 @@ use Illuminate\Routing\Middleware\SubstituteBindings; use Illuminate\Session\Middleware\AuthenticateSession; use Illuminate\Session\Middleware\StartSession; use Illuminate\View\Middleware\ShareErrorsFromSession; -use Stephenjude\FilamentBlog\Filament\Resources\CategoryResource; -use Stephenjude\FilamentBlog\Filament\Resources\PostResource; -use Stephenjude\FilamentBlog\Filament\Resources\TagResource; -use App\Models\BlogCategory; -use App\Models\BlogPost; -use App\Models\BlogTag; class AdminPanelProvider extends PanelProvider { public function panel(Panel $panel): Panel { return $panel - ->default() ->id('admin') ->path('admin') + ->brandName('Fotospiel Studio') ->login(\App\Filament\Pages\Auth\Login::class) ->colors([ 'primary' => Color::Pink, ]) - ->discoverResources(in: app_path('Filament/Resources'), for: 'App\\Filament\\Resources') - ->discoverPages(in: app_path('Filament/Admin/Pages'), for: 'App\\Filament\\Admin\\Pages') - ->pages([ - Pages\Dashboard::class, - ]) - ->discoverWidgets(in: app_path('Filament/Widgets'), for: 'App\\Filament\\Widgets') + ->homeUrl(fn () => \App\Filament\Tenant\Pages\TenantOnboarding::getUrl()) + ->discoverResources(in: app_path('Filament/Tenant/Resources'), for: 'App\\Filament\\Tenant\\Resources') + ->discoverPages(in: app_path('Filament/Tenant/Pages'), for: 'App\\Filament\\Tenant\\Pages') + ->pages([]) + ->discoverWidgets(in: app_path('Filament/Tenant/Widgets'), for: 'App\\Filament\\Tenant\\Widgets') ->widgets([ Widgets\AccountWidget::class, - Widgets\FilamentInfoWidget::class, ]) ->middleware([ EncryptCookies::class, @@ -60,12 +52,8 @@ class AdminPanelProvider extends PanelProvider ->authMiddleware([ Authenticate::class, ]) - ->resources([ - \App\Filament\Resources\UserResource::class, - \App\Filament\Resources\TenantPackageResource::class, - ]) ->tenant(\App\Models\Tenant::class) // Remove blog models as they are global and handled in SuperAdmin ; } -} \ No newline at end of file +} diff --git a/app/Providers/Filament/SuperAdminPanelProvider.php b/app/Providers/Filament/SuperAdminPanelProvider.php index 7280764..66756a4 100644 --- a/app/Providers/Filament/SuperAdminPanelProvider.php +++ b/app/Providers/Filament/SuperAdminPanelProvider.php @@ -10,6 +10,7 @@ use Filament\Panel; use Filament\PanelProvider; use Filament\Support\Colors\Color; use Filament\Widgets; +use App\Filament\Resources\LegalPageResource; use Illuminate\Cookie\Middleware\AddQueuedCookiesToResponse; use Illuminate\Cookie\Middleware\EncryptCookies; use Illuminate\Foundation\Http\Middleware\VerifyCsrfToken; @@ -17,21 +18,12 @@ use Illuminate\Routing\Middleware\SubstituteBindings; use Illuminate\Session\Middleware\AuthenticateSession; use Illuminate\Session\Middleware\StartSession; use Illuminate\View\Middleware\ShareErrorsFromSession; -use App\Filament\Resources\LegalPageResource; -use App\Filament\Resources\TenantResource; -use App\Models\User; -use App\Models\Tenant; -use App\Models\BlogPost; -use App\Models\BlogCategory; -use App\Models\BlogTag; use App\Filament\Widgets\PlatformStatsWidget; use App\Filament\Widgets\TopTenantsByUploads; use App\Filament\Blog\Resources\PostResource; use App\Filament\Blog\Resources\CategoryResource; use App\Filament\Blog\Resources\AuthorResource; -use Illuminate\Support\Facades\Log; - class SuperAdminPanelProvider extends PanelProvider { public function panel(Panel $panel): Panel @@ -49,7 +41,7 @@ class SuperAdminPanelProvider extends PanelProvider ->pages([ Pages\Dashboard::class, ]) - ->login(\App\Filament\SuperAdmin\Pages\Auth\Login::class) + ->login(\App\Filament\Pages\Auth\Login::class) /*->plugin( BlogPlugin::make() )*/ @@ -76,7 +68,11 @@ class SuperAdminPanelProvider extends PanelProvider Authenticate::class, ]) ->resources([ - + \App\Filament\Resources\EventResource::class, + \App\Filament\Resources\PhotoResource::class, + \App\Filament\Resources\UserResource::class, + \App\Filament\Resources\TenantPackageResource::class, + \App\Filament\Resources\TaskResource::class, PostResource::class, CategoryResource::class, LegalPageResource::class, diff --git a/app/Services/Tenant/TaskCollectionImportService.php b/app/Services/Tenant/TaskCollectionImportService.php new file mode 100644 index 0000000..32b9ce3 --- /dev/null +++ b/app/Services/Tenant/TaskCollectionImportService.php @@ -0,0 +1,161 @@ +, attached_task_ids: array} + */ + public function import(TaskCollection $collection, Event $event): array + { + if ($collection->tenant_id && $collection->tenant_id !== $event->tenant_id) { + throw new RuntimeException('Task collection is not accessible for this tenant.'); + } + + $collection->loadMissing('tasks'); + + return $this->db->transaction(function () use ($collection, $event) { + $tenantId = $event->tenant_id; + + $targetCollection = $this->resolveTenantCollection($collection, $tenantId); + + $createdTaskIds = []; + $attachedTaskIds = []; + + foreach ($collection->tasks as $task) { + $tenantTask = $this->resolveTenantTask($task, $targetCollection, $tenantId); + + if ($tenantTask->wasRecentlyCreated) { + $createdTaskIds[] = $tenantTask->id; + } + + if (! $tenantTask->assignedEvents()->where('event_id', $event->id)->exists()) { + $tenantTask->assignedEvents()->attach($event->id); + $attachedTaskIds[] = $tenantTask->id; + } + } + + $event->taskCollections()->syncWithoutDetaching([ + $targetCollection->id => ['sort_order' => $targetCollection->position ?? 0], + ]); + + return [ + 'collection' => $targetCollection->fresh(), + 'created_task_ids' => $createdTaskIds, + 'attached_task_ids' => $attachedTaskIds, + ]; + }); + } + + protected function resolveTenantCollection(TaskCollection $collection, int $tenantId): TaskCollection + { + if ($collection->tenant_id === $tenantId) { + return $collection; + } + + $existing = TaskCollection::query() + ->where('tenant_id', $tenantId) + ->where('source_collection_id', $collection->id) + ->first(); + + if ($existing) { + return $existing; + } + + return TaskCollection::create([ + 'tenant_id' => $tenantId, + 'source_collection_id' => $collection->id, + 'event_type_id' => $collection->event_type_id, + 'slug' => $this->buildCollectionSlug($collection->slug, $tenantId), + 'name_translations' => $collection->name_translations, + 'description_translations' => $collection->description_translations, + 'is_default' => false, + 'position' => $collection->position, + ]); + } + + protected function resolveTenantTask(Task $templateTask, TaskCollection $targetCollection, int $tenantId): Task + { + if ($templateTask->tenant_id === $tenantId) { + if ($templateTask->collection_id !== $targetCollection->id) { + $templateTask->update(['collection_id' => $targetCollection->id]); + } + + return $templateTask; + } + + $sourceId = $templateTask->source_task_id ?: $templateTask->id; + + $existing = Task::query() + ->where('tenant_id', $tenantId) + ->where('source_task_id', $sourceId) + ->first(); + + if ($existing) { + $existing->update([ + 'collection_id' => $targetCollection->id, + 'source_collection_id' => $templateTask->source_collection_id ?: $targetCollection->source_collection_id ?: $targetCollection->id, + ]); + + return tap($existing)->refresh(); + } + + $slugBase = $templateTask->slug ?: ($templateTask->title['en'] ?? $templateTask->title['de'] ?? 'task'); + $slug = $this->buildTaskSlug($slugBase); + + $cloned = Task::create([ + 'tenant_id' => $tenantId, + 'slug' => $slug, + 'emotion_id' => $templateTask->emotion_id, + 'event_type_id' => $templateTask->event_type_id, + 'title' => $templateTask->title, + 'description' => $templateTask->description, + 'example_text' => $templateTask->example_text, + 'due_date' => null, + 'is_completed' => false, + 'priority' => $templateTask->priority, + 'collection_id' => $targetCollection->id, + 'difficulty' => $templateTask->difficulty, + 'sort_order' => $templateTask->sort_order, + 'is_active' => true, + 'source_task_id' => $sourceId, + 'source_collection_id' => $templateTask->source_collection_id ?: $templateTask->collection_id, + ]); + + return $cloned; + } + + protected function buildCollectionSlug(?string $slug, int $tenantId): string + { + $base = Str::slug(($slug ?: 'collection') . '-' . $tenantId); + + do { + $candidate = $base . '-' . Str::random(4); + } while (TaskCollection::where('slug', $candidate)->exists()); + + return $candidate; + } + + protected function buildTaskSlug(string $base): string + { + $slugBase = Str::slug($base) ?: 'task'; + + do { + $candidate = $slugBase . '-' . Str::random(6); + } while (Task::where('slug', $candidate)->exists()); + + return $candidate; + } +} diff --git a/app/Support/TenantOnboardingState.php b/app/Support/TenantOnboardingState.php new file mode 100644 index 0000000..d6fa7b9 --- /dev/null +++ b/app/Support/TenantOnboardingState.php @@ -0,0 +1,87 @@ + false, + 'event' => false, + 'palette' => false, + 'invite' => false, + ]; + } + + $hasCustomCollections = TaskCollection::query() + ->where('tenant_id', $tenant->id) + ->exists(); + + $hasEvent = $tenant->events()->exists(); + + $palette = Arr::get($tenant->settings ?? [], 'branding.palette'); + + $hasInvite = EventJoinToken::query() + ->whereHas('event', fn ($query) => $query->where('tenant_id', $tenant->id)) + ->exists(); + + return [ + 'packages' => $hasCustomCollections, + 'event' => $hasEvent, + 'palette' => filled($palette), + 'invite' => $hasInvite, + ]; + } + + public static function completed(?Tenant $tenant = null): bool + { + $status = self::status($tenant); + + return collect($status)->every(fn ($done) => $done === true) + || Arr::has(self::tenant($tenant)?->settings ?? [], 'onboarding.completed_at'); + } + + public static function markCompleted(Tenant $tenant, array $data = []): void + { + $settings = $tenant->settings ?? []; + + Arr::set($settings, 'onboarding.completed_at', Carbon::now()->toIso8601String()); + + if (Arr::has($data, 'primary_event_id')) { + Arr::set($settings, 'onboarding.primary_event_id', Arr::get($data, 'primary_event_id')); + } + + if (Arr::has($data, 'selected_packages')) { + Arr::set($settings, 'onboarding.selected_packages', Arr::get($data, 'selected_packages')); + } + + if (Arr::has($data, 'qr_layout')) { + Arr::set($settings, 'onboarding.qr_layout', Arr::get($data, 'qr_layout')); + } + + $tenant->forceFill(['settings' => $settings])->save(); + } +} diff --git a/composer.json b/composer.json index 4e1da9e..eac4c45 100644 --- a/composer.json +++ b/composer.json @@ -19,6 +19,7 @@ "paypal/paypal-server-sdk": "^1.1", "simplesoftwareio/simple-qrcode": "^4.2", "spatie/laravel-translatable": "^6.11", + "staudenmeir/belongs-to-through": "^2.17", "stripe/stripe-php": "*" }, "require-dev": { diff --git a/composer.lock b/composer.lock index 6910fc8..2a2c9bc 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "2852435257a5672486892b814ff57bbf", + "content-hash": "7f7cd01c532ad63b7539234881b1169b", "packages": [ { "name": "anourvalar/eloquent-serialize", @@ -6373,6 +6373,73 @@ ], "time": "2025-02-21T14:16:57+00:00" }, + { + "name": "staudenmeir/belongs-to-through", + "version": "v2.17", + "source": { + "type": "git", + "url": "https://github.com/staudenmeir/belongs-to-through.git", + "reference": "e45460f8eecd882e5daea2af8f948d7596c20ba0" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/staudenmeir/belongs-to-through/zipball/e45460f8eecd882e5daea2af8f948d7596c20ba0", + "reference": "e45460f8eecd882e5daea2af8f948d7596c20ba0", + "shasum": "" + }, + "require": { + "illuminate/database": "^12.0", + "php": "^8.2" + }, + "require-dev": { + "barryvdh/laravel-ide-helper": "^3.0", + "larastan/larastan": "^3.0", + "laravel/framework": "^12.0", + "mockery/mockery": "^1.5.1", + "orchestra/testbench-core": "^10.0", + "phpunit/phpunit": "^11.0" + }, + "type": "library", + "extra": { + "laravel": { + "providers": [ + "Staudenmeir\\BelongsToThrough\\IdeHelperServiceProvider" + ] + } + }, + "autoload": { + "psr-4": { + "Znck\\Eloquent\\": "src/", + "Staudenmeir\\BelongsToThrough\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Rahul Kadyan", + "email": "hi@znck.me" + }, + { + "name": "Jonas Staudenmeir", + "email": "mail@jonas-staudenmeir.de" + } + ], + "description": "Laravel Eloquent BelongsToThrough relationships", + "support": { + "issues": "https://github.com/staudenmeir/belongs-to-through/issues", + "source": "https://github.com/staudenmeir/belongs-to-through/tree/v2.17" + }, + "funding": [ + { + "url": "https://paypal.me/JonasStaudenmeir", + "type": "custom" + } + ], + "time": "2025-02-20T19:24:03+00:00" + }, { "name": "stripe/stripe-php", "version": "v18.0.0", diff --git a/config/services.php b/config/services.php index 42ad520..cd37268 100644 --- a/config/services.php +++ b/config/services.php @@ -43,4 +43,27 @@ return [ 'sandbox' => env('PAYPAL_SANDBOX', true), ], + 'oauth' => [ + 'tenant_admin' => [ + 'id' => env('VITE_OAUTH_CLIENT_ID', 'tenant-admin-app'), + 'redirects' => (function (): array { + $redirects = []; + + $devServer = env('VITE_DEV_SERVER_URL'); + $redirects[] = rtrim($devServer ?: 'http://localhost:5173', '/') . '/event-admin/auth/callback'; + + $appUrl = env('APP_URL'); + if ($appUrl) { + $redirects[] = rtrim($appUrl, '/') . '/event-admin/auth/callback'; + } else { + $redirects[] = 'http://localhost:8000/event-admin/auth/callback'; + } + + $extra = array_filter(array_map('trim', explode(',', (string) env('TENANT_ADMIN_OAUTH_REDIRECTS', '')))); + + return array_values(array_unique(array_filter(array_merge($redirects, $extra)))); + })(), + ], + ], + ]; diff --git a/database/factories/TaskCollectionFactory.php b/database/factories/TaskCollectionFactory.php index 5c1d0f5..1b3c632 100644 --- a/database/factories/TaskCollectionFactory.php +++ b/database/factories/TaskCollectionFactory.php @@ -2,9 +2,12 @@ namespace Database\Factories; +use App\Models\EventType; +use App\Models\Task; use App\Models\TaskCollection; use App\Models\Tenant; use Illuminate\Database\Eloquent\Factories\Factory; +use Illuminate\Support\Str; class TaskCollectionFactory extends Factory { @@ -12,13 +15,21 @@ class TaskCollectionFactory extends Factory public function definition(): array { - $categories = ['Allgemein', 'Vorbereitung', 'Event-Tag', 'Aufräumen', 'Follow-up']; - $colors = ['#3B82F6', '#10B981', '#F59E0B', '#EF4444', '#8B5CF6']; + $label = ucfirst($this->faker->unique()->words(2, true)); + $description = $this->faker->sentence(12); return [ 'tenant_id' => Tenant::factory(), - 'name' => $this->faker->randomElement($categories), - 'description' => $this->faker->sentence(), + 'event_type_id' => EventType::factory(), + 'slug' => Str::slug($label . '-' . $this->faker->unique()->numberBetween(1, 9999)), + 'name_translations' => [ + 'de' => $label, + 'en' => $label, + ], + 'description_translations' => [ + 'de' => $description, + 'en' => $description, + ], 'is_default' => $this->faker->boolean(20), 'position' => $this->faker->numberBetween(1, 10), ]; @@ -28,7 +39,10 @@ class TaskCollectionFactory extends Factory { return $this->afterCreating(function (TaskCollection $collection) use ($count) { \App\Models\Task::factory($count) - ->create(['tenant_id' => $collection->tenant_id]) + ->create([ + 'tenant_id' => $collection->tenant_id, + 'event_type_id' => $collection->event_type_id, + ]) ->each(function ($task) use ($collection) { $task->taskCollection()->associate($collection); $task->save(); @@ -43,4 +57,4 @@ class TaskCollectionFactory extends Factory 'position' => 1, ]); } -} \ No newline at end of file +} diff --git a/database/factories/TaskFactory.php b/database/factories/TaskFactory.php index 6d99f0c..3b3ca00 100644 --- a/database/factories/TaskFactory.php +++ b/database/factories/TaskFactory.php @@ -6,6 +6,7 @@ use App\Models\Task; use App\Models\TaskCollection; use App\Models\Tenant; use Illuminate\Database\Eloquent\Factories\Factory; +use Illuminate\Support\Str; class TaskFactory extends Factory { @@ -13,10 +14,24 @@ class TaskFactory extends Factory public function definition(): array { + $title = ucfirst($this->faker->unique()->words(4, true)); + $description = $this->faker->paragraph(2); + return [ 'tenant_id' => Tenant::factory(), - 'title' => $this->faker->sentence(4), - 'description' => $this->faker->paragraph(), + 'slug' => Str::slug($title . '-' . $this->faker->unique()->numberBetween(1, 9999)), + 'title' => [ + 'de' => $title, + 'en' => $title, + ], + 'description' => [ + 'de' => $description, + 'en' => $description, + ], + 'example_text' => [ + 'de' => $this->faker->sentence(), + 'en' => $this->faker->sentence(), + ], 'due_date' => $this->faker->dateTimeBetween('now', '+1 month'), 'is_completed' => $this->faker->boolean(20), // 20% chance completed 'collection_id' => null, @@ -53,4 +68,4 @@ class TaskFactory extends Factory $task->assignedEvents()->attach($event); }); } -} \ No newline at end of file +} diff --git a/database/migrations/2024_10_13_000001_update_task_collections_and_tasks_for_localization.php b/database/migrations/2024_10_13_000001_update_task_collections_and_tasks_for_localization.php new file mode 100644 index 0000000..dacbc29 --- /dev/null +++ b/database/migrations/2024_10_13_000001_update_task_collections_and_tasks_for_localization.php @@ -0,0 +1,278 @@ +dropForeign(['tenant_id']); + }); + + Schema::table('task_collections', function (Blueprint $table) { + $table->unsignedBigInteger('tenant_id')->nullable()->change(); + }); + + Schema::table('task_collections', function (Blueprint $table) { + $table->foreign('tenant_id') + ->references('id') + ->on('tenants') + ->nullOnDelete(); + }); + } + + Schema::table('task_collections', function (Blueprint $table) { + if (! Schema::hasColumn('task_collections', 'slug')) { + $table->string('slug')->nullable()->after('tenant_id'); + } + + if (! Schema::hasColumn('task_collections', 'name_translations')) { + $table->json('name_translations')->nullable()->after('slug'); + } + + if (! Schema::hasColumn('task_collections', 'description_translations')) { + $table->json('description_translations')->nullable()->after('name_translations'); + } + + if (! Schema::hasColumn('task_collections', 'event_type_id')) { + $table->foreignId('event_type_id') + ->nullable() + ->after('description_translations') + ->constrained() + ->nullOnDelete(); + } + }); + + if (Schema::hasColumn('task_collections', 'name')) { + DB::table('task_collections') + ->select('id', 'name', 'description', 'slug') + ->orderBy('id') + ->chunk(100, function ($rows) { + foreach ($rows as $row) { + $name = $row->name; + $description = $row->description; + + $translations = [ + 'de' => $name, + ]; + + $descriptionTranslations = $description + ? [ + 'de' => $description, + ] + : null; + + $slugBase = Str::slug($name ?: ('collection-' . $row->id)); + + if (empty($slugBase)) { + $slugBase = 'collection-' . $row->id; + } + + $slug = $row->slug ?: ($slugBase . '-' . $row->id); + + DB::table('task_collections') + ->where('id', $row->id) + ->update([ + 'name_translations' => json_encode($translations, JSON_UNESCAPED_UNICODE), + 'description_translations' => $descriptionTranslations + ? json_encode($descriptionTranslations, JSON_UNESCAPED_UNICODE) + : null, + 'slug' => $slug, + ]); + } + }); + + Schema::table('task_collections', function (Blueprint $table) { + $table->dropColumn(['name', 'description']); + }); + + Schema::table('task_collections', function (Blueprint $table) { + $table->unique('slug'); + }); + } + } + + if (Schema::hasTable('tasks')) { + if (Schema::hasColumn('tasks', 'tenant_id')) { + Schema::table('tasks', function (Blueprint $table) { + $table->dropForeign(['tenant_id']); + }); + + Schema::table('tasks', function (Blueprint $table) { + $table->unsignedBigInteger('tenant_id')->nullable()->change(); + }); + + Schema::table('tasks', function (Blueprint $table) { + $table->foreign('tenant_id') + ->references('id') + ->on('tenants') + ->nullOnDelete(); + }); + } + + Schema::table('tasks', function (Blueprint $table) { + if (! Schema::hasColumn('tasks', 'slug')) { + $table->string('slug')->nullable()->after('id'); + } + }); + + if (! Schema::hasColumn('tasks', 'slug')) { + return; + } + + DB::table('tasks') + ->select('id', 'slug', 'title') + ->orderBy('id') + ->chunk(100, function ($rows) { + foreach ($rows as $row) { + if (! empty($row->slug)) { + continue; + } + + $titleData = $row->title; + + if (is_string($titleData)) { + $json = json_decode($titleData, true); + } else { + $json = $titleData; + } + + $base = $json['de'] + ?? $json['en'] + ?? ('task-' . $row->id); + + $slug = Str::slug($base); + + if (empty($slug)) { + $slug = 'task-' . $row->id; + } + + DB::table('tasks') + ->where('id', $row->id) + ->update([ + 'slug' => $slug . '-' . $row->id, + ]); + } + }); + + Schema::table('tasks', function (Blueprint $table) { + $table->unique('slug'); + }); + } + } + + public function down(): void + { + if (Schema::hasTable('tasks')) { + $fallbackTenantId = DB::table('tenants')->orderBy('id')->value('id'); + + if ($fallbackTenantId) { + DB::table('tasks')->whereNull('tenant_id')->update(['tenant_id' => $fallbackTenantId]); + } + + if (Schema::hasColumn('tasks', 'slug')) { + Schema::table('tasks', function (Blueprint $table) { + $table->dropUnique(['slug']); + $table->dropColumn('slug'); + }); + } + + if (Schema::hasColumn('tasks', 'tenant_id')) { + Schema::table('tasks', function (Blueprint $table) { + $table->dropForeign(['tenant_id']); + }); + + Schema::table('tasks', function (Blueprint $table) { + $table->unsignedBigInteger('tenant_id')->nullable(false)->change(); + }); + + Schema::table('tasks', function (Blueprint $table) { + $table->foreign('tenant_id') + ->references('id') + ->on('tenants') + ->cascadeOnDelete(); + }); + } + } + + if (Schema::hasTable('task_collections')) { + $fallbackTenantId = DB::table('tenants')->orderBy('id')->value('id'); + + if ($fallbackTenantId) { + DB::table('task_collections')->whereNull('tenant_id')->update(['tenant_id' => $fallbackTenantId]); + } + + if (Schema::hasColumn('task_collections', 'name_translations') && + ! Schema::hasColumn('task_collections', 'name')) { + Schema::table('task_collections', function (Blueprint $table) { + $table->string('name')->default('')->after('tenant_id'); + $table->text('description')->nullable()->after('name'); + }); + + DB::table('task_collections') + ->select('id', 'name_translations', 'description_translations') + ->orderBy('id') + ->chunk(100, function ($rows) { + foreach ($rows as $row) { + $names = is_string($row->name_translations) + ? json_decode($row->name_translations, true) ?: [] + : ($row->name_translations ?? []); + + $descriptions = is_string($row->description_translations) + ? json_decode($row->description_translations, true) ?: [] + : ($row->description_translations ?? []); + + DB::table('task_collections') + ->where('id', $row->id) + ->update([ + 'name' => $names['de'] ?? $names['en'] ?? 'Collection ' . $row->id, + 'description' => $descriptions['de'] ?? $descriptions['en'] ?? null, + ]); + } + }); + + Schema::table('task_collections', function (Blueprint $table) { + if (Schema::hasColumn('task_collections', 'description_translations')) { + $table->dropColumn('description_translations'); + } + + if (Schema::hasColumn('task_collections', 'name_translations')) { + $table->dropColumn('name_translations'); + } + }); + } + + if (Schema::hasColumn('task_collections', 'event_type_id')) { + Schema::table('task_collections', function (Blueprint $table) { + $table->dropForeign(['event_type_id']); + $table->dropColumn('event_type_id'); + }); + } + + if (Schema::hasColumn('task_collections', 'slug')) { + Schema::table('task_collections', function (Blueprint $table) { + $table->dropUnique(['slug']); + $table->dropColumn('slug'); + }); + } + + if (Schema::hasColumn('task_collections', 'tenant_id')) { + Schema::table('task_collections', function (Blueprint $table) { + $table->dropForeign(['tenant_id']); + $table->unsignedBigInteger('tenant_id')->nullable(false)->change(); + $table->foreign('tenant_id') + ->references('id') + ->on('tenants') + ->cascadeOnDelete(); + }); + } + } + } +}; diff --git a/database/migrations/2024_10_13_000002_add_source_columns_to_tasks_and_emotions.php b/database/migrations/2024_10_13_000002_add_source_columns_to_tasks_and_emotions.php new file mode 100644 index 0000000..88983ed --- /dev/null +++ b/database/migrations/2024_10_13_000002_add_source_columns_to_tasks_and_emotions.php @@ -0,0 +1,68 @@ +foreignId('source_task_id')->nullable()->after('tenant_id')->constrained('tasks')->nullOnDelete(); + } + + if (! Schema::hasColumn('tasks', 'source_collection_id')) { + $table->foreignId('source_collection_id')->nullable()->after('collection_id')->constrained('task_collections')->nullOnDelete(); + } + }); + } + + if (Schema::hasTable('task_collections') && ! Schema::hasColumn('task_collections', 'source_collection_id')) { + Schema::table('task_collections', function (Blueprint $table) { + $table->foreignId('source_collection_id')->nullable()->after('event_type_id')->constrained('task_collections')->nullOnDelete(); + }); + } + + if (Schema::hasTable('emotions') && ! Schema::hasColumn('emotions', 'tenant_id')) { + Schema::table('emotions', function (Blueprint $table) { + $table->foreignId('tenant_id')->nullable()->after('id')->constrained('tenants')->nullOnDelete(); + $table->index('tenant_id'); + }); + } + } + + public function down(): void + { + if (Schema::hasTable('tasks')) { + Schema::table('tasks', function (Blueprint $table) { + if (Schema::hasColumn('tasks', 'source_task_id')) { + $table->dropForeign(['source_task_id']); + $table->dropColumn('source_task_id'); + } + + if (Schema::hasColumn('tasks', 'source_collection_id')) { + $table->dropForeign(['source_collection_id']); + $table->dropColumn('source_collection_id'); + } + }); + } + + if (Schema::hasTable('task_collections') && Schema::hasColumn('task_collections', 'source_collection_id')) { + Schema::table('task_collections', function (Blueprint $table) { + $table->dropForeign(['source_collection_id']); + $table->dropColumn('source_collection_id'); + }); + } + + if (Schema::hasTable('emotions') && Schema::hasColumn('emotions', 'tenant_id')) { + Schema::table('emotions', function (Blueprint $table) { + $table->dropForeign(['tenant_id']); + $table->dropIndex(['tenant_id']); + $table->dropColumn('tenant_id'); + }); + } + } +}; diff --git a/database/migrations/2025_09_01_000300_create_events_tasks.php b/database/migrations/2025_09_01_000300_create_events_tasks.php index db4eb91..dffe503 100644 --- a/database/migrations/2025_09_01_000300_create_events_tasks.php +++ b/database/migrations/2025_09_01_000300_create_events_tasks.php @@ -43,7 +43,8 @@ return new class extends Migration if (!Schema::hasTable('tasks')) { Schema::create('tasks', function (Blueprint $table) { $table->id(); - $table->foreignId('tenant_id')->constrained()->onDelete('cascade'); + $table->foreignId('tenant_id')->nullable()->constrained()->nullOnDelete(); + $table->string('slug')->nullable()->unique(); $table->unsignedBigInteger('emotion_id')->nullable(); $table->unsignedBigInteger('event_type_id')->nullable(); $table->json('title'); @@ -75,9 +76,11 @@ return new class extends Migration if (!Schema::hasTable('task_collections')) { Schema::create('task_collections', function (Blueprint $table) { $table->id(); - $table->foreignId('tenant_id')->constrained()->onDelete('cascade'); - $table->string('name'); - $table->text('description')->nullable(); + $table->foreignId('tenant_id')->nullable()->constrained()->nullOnDelete(); + $table->string('slug')->nullable()->unique(); + $table->json('name_translations'); + $table->json('description_translations')->nullable(); + $table->foreignId('event_type_id')->nullable()->constrained()->nullOnDelete(); $table->boolean('is_default')->default(false); $table->integer('position')->default(0); $table->timestamps(); diff --git a/database/migrations/2025_09_26_000000_create_packages_system.php b/database/migrations/2025_09_26_000000_create_packages_system.php index 31c95a1..b8b5b1c 100644 --- a/database/migrations/2025_09_26_000000_create_packages_system.php +++ b/database/migrations/2025_09_26_000000_create_packages_system.php @@ -30,7 +30,7 @@ return new class extends Migration }); // Seed standard packages if empty - if (DB::table('packages')->count() == 0) { + /*if (DB::table('packages')->count() == 0) { DB::table('packages')->insert([ [ 'name' => 'Free/Test', @@ -82,7 +82,7 @@ return new class extends Migration ], // Add more as needed ]); - } + }*/ } // Event Packages diff --git a/database/migrations/2025_10_13_000003_add_slugs_to_tasks_and_collections.php b/database/migrations/2025_10_13_000003_add_slugs_to_tasks_and_collections.php new file mode 100644 index 0000000..b298263 --- /dev/null +++ b/database/migrations/2025_10_13_000003_add_slugs_to_tasks_and_collections.php @@ -0,0 +1,150 @@ +ensureCollectionSlugs(); + $this->ensureTaskSlugs(); + } + + public function down(): void + { + $this->rollbackCollectionSlugs(); + $this->rollbackTaskSlugs(); + } + + protected function ensureCollectionSlugs(): void + { + if (! Schema::hasTable('task_collections') || Schema::hasColumn('task_collections', 'slug')) { + return; + } + + Schema::table('task_collections', function (Blueprint $table) { + $table->string('slug')->nullable()->after('tenant_id'); + }); + + DB::table('task_collections') + ->select('id', 'slug', 'name_translations') + ->orderBy('id') + ->chunk(200, function ($rows) { + foreach ($rows as $row) { + if (! empty($row->slug)) { + continue; + } + + $translations = $this->decodeTranslations($row->name_translations); + $base = $translations['en'] ?? $translations['de'] ?? reset($translations) ?? ('collection-' . $row->id); + $slug = $this->buildUniqueSlug($base, 'collection-', function ($candidate) { + return DB::table('task_collections')->where('slug', $candidate)->exists(); + }); + + DB::table('task_collections') + ->where('id', $row->id) + ->update(['slug' => $slug]); + } + }); + + Schema::table('task_collections', function (Blueprint $table) { + $table->unique('slug'); + }); + } + + protected function ensureTaskSlugs(): void + { + if (! Schema::hasTable('tasks') || Schema::hasColumn('tasks', 'slug')) { + return; + } + + Schema::table('tasks', function (Blueprint $table) { + $table->string('slug')->nullable()->after('id'); + }); + + DB::table('tasks') + ->select('id', 'slug', 'title') + ->orderBy('id') + ->chunk(200, function ($rows) { + foreach ($rows as $row) { + if (! empty($row->slug)) { + continue; + } + + $translations = $this->decodeTranslations($row->title); + $base = $translations['en'] ?? $translations['de'] ?? reset($translations) ?? ('task-' . $row->id); + $slug = $this->buildUniqueSlug($base, 'task-', function ($candidate) { + return DB::table('tasks')->where('slug', $candidate)->exists(); + }); + + DB::table('tasks') + ->where('id', $row->id) + ->update(['slug' => $slug]); + } + }); + + Schema::table('tasks', function (Blueprint $table) { + $table->unique('slug'); + }); + } + + protected function rollbackCollectionSlugs(): void + { + if (! Schema::hasTable('task_collections') || ! Schema::hasColumn('task_collections', 'slug')) { + return; + } + + Schema::table('task_collections', function (Blueprint $table) { + $table->dropUnique('task_collections_slug_unique'); + $table->dropColumn('slug'); + }); + } + + protected function rollbackTaskSlugs(): void + { + if (! Schema::hasTable('tasks') || ! Schema::hasColumn('tasks', 'slug')) { + return; + } + + Schema::table('tasks', function (Blueprint $table) { + $table->dropUnique('tasks_slug_unique'); + $table->dropColumn('slug'); + }); + } + + /** + * @return array + */ + protected function decodeTranslations(mixed $value): array + { + if (is_array($value)) { + return $value; + } + + if (is_string($value)) { + $decoded = json_decode($value, true); + if (json_last_error() === JSON_ERROR_NONE && is_array($decoded)) { + return $decoded; + } + + return ['de' => $value]; + } + + return []; + } + + protected function buildUniqueSlug(string $base, string $prefix, callable $exists): string + { + $slugBase = Str::slug($base) ?: ($prefix . Str::random(4)); + + do { + $candidate = $slugBase . '-' . Str::random(4); + } while ($exists($candidate)); + + return $candidate; + } +}; diff --git a/database/seeders/OAuthClientSeeder.php b/database/seeders/OAuthClientSeeder.php index 6fc99e0..de0c1dc 100644 --- a/database/seeders/OAuthClientSeeder.php +++ b/database/seeders/OAuthClientSeeder.php @@ -5,6 +5,7 @@ namespace Database\Seeders; use App\Models\OAuthClient; use App\Models\Tenant; use Illuminate\Database\Seeder; +use Illuminate\Support\Arr; use Illuminate\Support\Str; class OAuthClientSeeder extends Seeder @@ -14,14 +15,19 @@ class OAuthClientSeeder extends Seeder */ public function run(): void { - $clientId = 'tenant-admin-app'; + $serviceConfig = config('services.oauth.tenant_admin', []); + + $clientId = $serviceConfig['id'] ?? 'tenant-admin-app'; $tenantId = Tenant::where('slug', 'demo')->value('id') ?? Tenant::query()->orderBy('id')->value('id'); - $redirectUris = [ - 'http://localhost:5174/auth/callback', - 'http://localhost:8000/auth/callback', - ]; + $redirectUris = Arr::wrap($serviceConfig['redirects'] ?? []); + if (empty($redirectUris)) { + $redirectUris = [ + 'http://localhost:5173/event-admin/auth/callback', + 'http://localhost:8000/event-admin/auth/callback', + ]; + } $scopes = [ 'tenant:read', diff --git a/database/seeders/TaskCollectionsSeeder.php b/database/seeders/TaskCollectionsSeeder.php index 3b7c6cd..686cb99 100644 --- a/database/seeders/TaskCollectionsSeeder.php +++ b/database/seeders/TaskCollectionsSeeder.php @@ -2,79 +2,293 @@ namespace Database\Seeders; +use App\Models\Emotion; +use App\Models\EventType; +use App\Models\Task; +use App\Models\TaskCollection; use Illuminate\Database\Seeder; -use App\Models\{Event, Task, TaskCollection, Tenant}; +use Illuminate\Support\Facades\DB; class TaskCollectionsSeeder extends Seeder { - /** - * Run the database seeds. - */ public function run(): void { - // Get demo tenant - $demoTenant = Tenant::where('slug', 'demo')->first(); - if (!$demoTenant) { - $this->command->info('Demo tenant not found, skipping task collections seeding'); - return; - } - - // Get demo event ID - $demoEvent = Event::where('slug', 'demo-wedding-2025')->first(); - if (!$demoEvent) { - $this->command->info('Demo event not found, skipping task collections seeding'); - return; - } - - // Get some task IDs for demo (assuming TasksSeeder was run) - $taskIds = Task::where('tenant_id', $demoTenant->id)->limit(6)->get('id')->pluck('id')->toArray(); - if (empty($taskIds)) { - $this->command->info('No tasks found, skipping task collections seeding'); - return; - } - - // Create Wedding Task Collection using Eloquent - $weddingCollection = TaskCollection::create([ - 'tenant_id' => $demoTenant->id, - 'name' => [ - 'de' => 'Hochzeitsaufgaben', - 'en' => 'Wedding Tasks' + $collections = [ + [ + 'slug' => 'wedding-classics', + 'event_type' => [ + 'slug' => 'wedding', + 'name' => [ + 'de' => 'Hochzeit', + 'en' => 'Wedding', + ], + 'icon' => 'lucide-heart', + ], + 'name' => [ + 'de' => 'Hochzeitsklassiker', + 'en' => 'Wedding Classics', + ], + 'description' => [ + 'de' => 'Kuratierte Aufgaben rund um Trauung, Emotionen und besondere Momente.', + 'en' => 'Curated prompts for vows, emotions, and memorable wedding highlights.', + ], + 'is_default' => true, + 'position' => 10, + 'tasks' => [ + [ + 'slug' => 'wedding-first-look', + 'title' => [ + 'de' => 'Erster Blick des Brautpaares festhalten', + 'en' => 'Capture the couple’s first look', + ], + 'description' => [ + 'de' => 'Halte den Moment fest, in dem sich Braut und Bräutigam zum ersten Mal sehen.', + 'en' => 'Capture the moment when the bride and groom see each other for the first time.', + ], + 'example' => [ + 'de' => 'Fotografiere die Reaktionen aus verschiedenen Blickwinkeln.', + 'en' => 'Photograph their reactions from different angles.', + ], + 'emotion' => [ + 'name' => [ + 'de' => 'Romantik', + 'en' => 'Romance', + ], + 'icon' => 'lucide-heart', + 'color' => '#ec4899', + 'sort_order' => 10, + ], + 'difficulty' => 'easy', + 'sort_order' => 10, + ], + [ + 'slug' => 'wedding-family-hug', + 'title' => [ + 'de' => 'Familienumarmung organisieren', + 'en' => 'Organise a family group hug', + ], + 'description' => [ + 'de' => 'Bitte die wichtigsten Menschen, das Paar gleichzeitig zu umarmen.', + 'en' => 'Ask the closest friends and family to hug the couple at the same time.', + ], + 'example' => [ + 'de' => 'Kombiniere die Umarmung mit einem Toast.', + 'en' => 'Combine the hug with a heartfelt toast.', + ], + 'emotion' => [ + 'name' => [ + 'de' => 'Freude', + 'en' => 'Joy', + ], + 'icon' => 'lucide-smile', + 'color' => '#f59e0b', + 'sort_order' => 20, + ], + 'difficulty' => 'medium', + 'sort_order' => 20, + ], + [ + 'slug' => 'wedding-midnight-sparkler', + 'title' => [ + 'de' => 'Mitternachtsfunkeln mit Wunderkerzen', + 'en' => 'Midnight sparkler moment', + ], + 'description' => [ + 'de' => 'Verteile Wunderkerzen und schafft ein leuchtendes Spalier für das Paar.', + 'en' => 'Hand out sparklers and form a glowing aisle for the couple.', + ], + 'example' => [ + 'de' => 'Koordiniere die Musik und kündige den Countdown an.', + 'en' => 'Coordinate music and announce a countdown.', + ], + 'emotion' => [ + 'name' => [ + 'de' => 'Ekstase', + 'en' => 'Euphoria', + ], + 'icon' => 'lucide-stars', + 'color' => '#6366f1', + 'sort_order' => 30, + ], + 'difficulty' => 'medium', + 'sort_order' => 30, + ], + ], ], - 'description' => [ - 'de' => 'Spezielle Aufgaben für Hochzeitsgäste', - 'en' => 'Special tasks for wedding guests' + [ + 'slug' => 'birthday-celebration', + 'event_type' => [ + 'slug' => 'birthday', + 'name' => [ + 'de' => 'Geburtstag', + 'en' => 'Birthday', + ], + 'icon' => 'lucide-cake', + ], + 'name' => [ + 'de' => 'Geburtstags-Highlights', + 'en' => 'Birthday Highlights', + ], + 'description' => [ + 'de' => 'Aufgaben für Überraschungen, Gratulationen und gemeinsames Feiern.', + 'en' => 'Prompts covering surprises, wishes, and shared celebrations.', + ], + 'is_default' => false, + 'position' => 20, + 'tasks' => [ + [ + 'slug' => 'birthday-surprise-wall', + 'title' => [ + 'de' => 'Überraschungswand mit Polaroids gestalten', + 'en' => 'Create a surprise wall filled with instant photos', + ], + 'description' => [ + 'de' => 'Sammle Schnappschüsse der Gäste und befestige sie als Fotowand.', + 'en' => 'Collect snapshots from guests and mount them on a photo wall.', + ], + 'example' => [ + 'de' => 'Schreibe zu jedem Bild einen kurzen Gruß.', + 'en' => 'Add a short message to each picture.', + ], + 'emotion' => [ + 'name' => [ + 'de' => 'Nostalgie', + 'en' => 'Nostalgia', + ], + 'icon' => 'lucide-images', + 'color' => '#f97316', + 'sort_order' => 40, + ], + 'difficulty' => 'easy', + 'sort_order' => 10, + ], + [ + 'slug' => 'birthday-toast-circle', + 'title' => [ + 'de' => 'Gratulationskreis mit kurzen Toasts', + 'en' => 'Circle of toasts', + ], + 'description' => [ + 'de' => 'Bildet einen Kreis und bittet jede Person um einen 10-Sekunden-Toast.', + 'en' => 'Form a circle and ask everyone for a 10-second toast.', + ], + 'example' => [ + 'de' => 'Nimm die Reaktionen als Video auf.', + 'en' => 'Record the reactions on video.', + ], + 'emotion' => [ + 'name' => [ + 'de' => 'Dankbarkeit', + 'en' => 'Gratitude', + ], + 'icon' => 'lucide-hands', + 'color' => '#22c55e', + 'sort_order' => 50, + ], + 'difficulty' => 'easy', + 'sort_order' => 20, + ], + ], ], - ]); + ]; - // Assign first 4 tasks to wedding collection using Eloquent - $weddingTasks = collect($taskIds)->take(4); - $weddingCollection->tasks()->attach($weddingTasks); + DB::transaction(function () use ($collections) { + foreach ($collections as $definition) { + $eventType = $this->ensureEventType($definition['event_type']); - // Link wedding collection to demo event using Eloquent - $demoEvent->taskCollections()->attach($weddingCollection, ['sort_order' => 1]); + $collection = TaskCollection::updateOrCreate( + ['slug' => $definition['slug']], + [ + 'tenant_id' => null, + 'event_type_id' => $eventType->id, + 'name_translations' => $definition['name'], + 'description_translations' => $definition['description'], + 'is_default' => $definition['is_default'] ?? false, + 'position' => $definition['position'] ?? 0, + ] + ); - // Create General Fun Tasks Collection (fallback) using Eloquent - $funCollection = TaskCollection::create([ - 'tenant_id' => $demoTenant->id, - 'name' => [ - 'de' => 'Spaß-Aufgaben', - 'en' => 'Fun Tasks' - ], - 'description' => [ - 'de' => 'Allgemeine unterhaltsame Aufgaben', - 'en' => 'General entertaining tasks' - ], - ]); + $syncPayload = []; - // Assign remaining tasks to fun collection using Eloquent - $funTasks = collect($taskIds)->slice(4); - $funCollection->tasks()->attach($funTasks); + foreach ($definition['tasks'] as $taskDefinition) { + $emotion = $this->ensureEmotion($taskDefinition['emotion'] ?? [], $eventType->id); - // Link fun collection to demo event as fallback using Eloquent - $demoEvent->taskCollections()->attach($funCollection, ['sort_order' => 2]); + $task = Task::updateOrCreate( + ['slug' => $taskDefinition['slug']], + [ + 'tenant_id' => null, + 'event_type_id' => $eventType->id, + 'collection_id' => $collection->id, + 'emotion_id' => $emotion?->id, + 'title' => $taskDefinition['title'], + 'description' => $taskDefinition['description'] ?? null, + 'example_text' => $taskDefinition['example'] ?? null, + 'difficulty' => $taskDefinition['difficulty'] ?? 'easy', + 'priority' => 'medium', + 'sort_order' => $taskDefinition['sort_order'] ?? 0, + 'is_active' => true, + 'is_completed' => false, + ] + ); - $this->command->info("✅ Created 2 task collections with " . count($taskIds) . " tasks for demo event"); - $this->command->info("Wedding Collection ID: {$weddingCollection->id}"); - $this->command->info("Fun Collection ID: {$funCollection->id}"); + $syncPayload[$task->id] = ['sort_order' => $taskDefinition['sort_order'] ?? 0]; + } + + if (! empty($syncPayload)) { + $collection->tasks()->sync($syncPayload); + } + } + }); } -} \ No newline at end of file + + protected function ensureEventType(array $definition): EventType + { + $payload = [ + 'name' => $definition['name'], + 'icon' => $definition['icon'] ?? null, + ]; + + return EventType::updateOrCreate( + ['slug' => $definition['slug']], + $payload + ); + } + + protected function ensureEmotion(array $definition, ?int $eventTypeId): ?Emotion + { + if (empty($definition)) { + return null; + } + + $query = Emotion::query(); + + $name = $definition['name'] ?? []; + + if (isset($name['en'])) { + $query->orWhere('name->en', $name['en']); + } + + if (isset($name['de'])) { + $query->orWhere('name->de', $name['de']); + } + + $emotion = $query->first(); + + if (! $emotion) { + $emotion = Emotion::create([ + 'name' => $name, + 'icon' => $definition['icon'] ?? 'lucide-smile', + 'color' => $definition['color'] ?? '#6366f1', + 'description' => $definition['description'] ?? null, + 'sort_order' => $definition['sort_order'] ?? 0, + 'is_active' => true, + ]); + } + + if ($eventTypeId && ! $emotion->eventTypes()->where('event_type_id', $eventTypeId)->exists()) { + $emotion->eventTypes()->attach($eventTypeId); + } + + return $emotion; + } +} diff --git a/database/seeders/TasksSeeder.php b/database/seeders/TasksSeeder.php index 254ccd9..5328cf3 100644 --- a/database/seeders/TasksSeeder.php +++ b/database/seeders/TasksSeeder.php @@ -3,6 +3,7 @@ namespace Database\Seeders; use Illuminate\Database\Seeder; +use Illuminate\Support\Str; use App\Models\{Emotion, Task, EventType}; class TasksSeeder extends Seeder @@ -43,10 +44,11 @@ class TasksSeeder extends Seeder $emotion = Emotion::where('name->de', $emotionNameDe)->first(); if (!$emotion) continue; foreach ($tasks as $t) { + $slugBase = Str::slug($t['title']['en'] ?? $t['title']['de']); + $slug = $slugBase ? $slugBase . '-' . $emotion->id : Str::uuid()->toString(); + Task::updateOrCreate([ - 'emotion_id' => $emotion->id, - 'title->de' => $t['title']['de'], - 'tenant_id' => $demoTenant->id + 'slug' => $slug, ], [ 'tenant_id' => $demoTenant->id, 'emotion_id' => $emotion->id, @@ -55,6 +57,7 @@ class TasksSeeder extends Seeder 'description' => $t['description'], 'difficulty' => $t['difficulty'], 'is_active' => true, + 'sort_order' => $t['sort_order'] ?? 0, ]); } } diff --git a/docs/prp/13-backend-authentication.md b/docs/prp/13-backend-authentication.md index 519df28..afe260a 100644 --- a/docs/prp/13-backend-authentication.md +++ b/docs/prp/13-backend-authentication.md @@ -308,6 +308,8 @@ VITE_API_URL=https://api.fotospiel.com VITE_OAUTH_CLIENT_ID=tenant-admin-app ``` +> **Hinweis:** Der Wert von `VITE_OAUTH_CLIENT_ID` dient jetzt als alleinige Quelle der Wahrheit für den Tenant-Admin-OAuth-Client. Der Seeder `OAuthClientSeeder` greift auf `config/services.php` zu, das wiederum diesen Env-Wert ausliest und passende Redirect-URIs generiert (`/event-admin/auth/callback` für DEV und APP_URL). Stimmt der Wert im Frontend nicht mit dem Seeder überein, schlägt der PKCE-Login mit `invalid_client` fehl. + ## Error Handling ### Common Error Responses diff --git a/docs/prp/tenant-app-specs/api-usage.md b/docs/prp/tenant-app-specs/api-usage.md index 491fa78..9dd0a94 100644 --- a/docs/prp/tenant-app-specs/api-usage.md +++ b/docs/prp/tenant-app-specs/api-usage.md @@ -258,7 +258,7 @@ curl -H "Authorization: Bearer {token}" \ ### Environment-Variablen - **VITE_API_URL**: Backend-API-URL (Pflicht) -- **VITE_OAUTH_CLIENT_ID**: OAuth-Client-ID (Pflicht) +- **VITE_OAUTH_CLIENT_ID**: OAuth-Client-ID (Pflicht, muss mit `config/services.php` übereinstimmen – der Seeder legt damit den Client in `oauth_clients` an) - **VITE_REVENUECAT_PUBLIC_KEY**: Optional fuer In-App-Kaeufe (RevenueCat) ### Build & Deploy diff --git a/docs/todo/tenant-admin-onboarding-fusion.md b/docs/todo/tenant-admin-onboarding-fusion.md index d8e31a0..a6ce189 100644 --- a/docs/todo/tenant-admin-onboarding-fusion.md +++ b/docs/todo/tenant-admin-onboarding-fusion.md @@ -40,5 +40,13 @@ Owner: Codex (handoff) - Validate whether onboarding flow must be localized at launch; coordinate mit den neuen i18n JSONs und fehlenden Übersetzungen. - Determine deprecation plan for fotospiel-tenant-app/tenant-admin-app once the merged flow ships. +## Priority: Immediate (Tenant admin refresh 2025-10-18) +- [x] Replace the `/event-admin/login` landing with a public welcome screen that explains Fotospiel for non-technical couples, keeps the login button, and updates `resources/js/admin/router.tsx`, `constants.ts`, and new `WelcomeTeaserPage`. +- [x] Align OAuth setup by reading `VITE_OAUTH_CLIENT_ID` in `OAuthClientSeeder`, updating redirect URIs to `/event-admin/auth/callback`, reseeding, and documenting the env expectation in `docs/prp/tenant-app-specs/api-usage.md` / `13-backend-authentication.md`. +- [x] Rebrand the Filament tenant panel away from “Admin” by adjusting `AdminPanelProvider` (brand name, home URL, navigation visibility) and registering a new onboarding home page. +- [x] Build the Filament onboarding wizard (welcome → task package selection → event name → color palette → QR layout) with persisted progress on the tenant record and guards that hide legacy resource menus until completion. +- [x] Expose QR invite generation in Filament via a dedicated page/component that reuses the join-token flow from `EventDetailPage.tsx`, ensuring tokens stay in sync between PWA and Filament. +- [ ] Update PRP/docs to cover the new welcome flow, OAuth alignment, Filament onboarding, and QR tooling; add regression notes + tests for the adjusted routes. + diff --git a/resources/js/admin/api.ts b/resources/js/admin/api.ts index 8e26182..72f5788 100644 --- a/resources/js/admin/api.ts +++ b/resources/js/admin/api.ts @@ -1,4 +1,5 @@ import { authorizedFetch } from './auth/tokens'; +import i18n from './i18n'; type JsonValue = Record; @@ -108,25 +109,93 @@ export type CreditLedgerEntry = { export type TenantTask = { id: number; + slug: string; title: string; + title_translations: Record; description: string | null; + description_translations: Record; + example_text: string | null; + example_text_translations: Record; priority: 'low' | 'medium' | 'high' | 'urgent' | null; + difficulty: 'easy' | 'medium' | 'hard' | null; due_date: string | null; is_completed: boolean; collection_id: number | null; + source_task_id: number | null; + source_collection_id: number | null; assigned_events_count: number; assigned_events?: TenantEvent[]; created_at: string | null; updated_at: string | null; }; +export type TenantTaskCollection = { + id: number; + slug: string; + name: string; + name_translations: Record; + description: string | null; + description_translations: Record; + tenant_id: number | null; + is_global: boolean; + event_type?: { + id: number; + slug: string; + name: string; + name_translations: Record; + icon: string | null; + } | null; + tasks_count: number; + position: number | null; + source_collection_id: number | null; + created_at: string | null; + updated_at: string | null; +}; + +export type TenantEmotion = { + id: number; + name: string; + name_translations: Record; + description: string | null; + description_translations: Record; + icon: string; + color: string; + sort_order: number; + is_active: boolean; + is_global: boolean; + tenant_id: number | null; + event_types: Array<{ + id: number; + slug: string; + name: string; + name_translations: Record; + }>; + created_at: string | null; + updated_at: string | null; +}; + export type TaskPayload = Partial<{ title: string; + title_translations: Record; description: string | null; + description_translations: Record; + example_text: string | null; + example_text_translations: Record; collection_id: number | null; priority: 'low' | 'medium' | 'high' | 'urgent'; due_date: string | null; is_completed: boolean; + difficulty: 'easy' | 'medium' | 'hard'; +}>; + +export type EmotionPayload = Partial<{ + name: string; + description: string | null; + icon: string; + color: string; + sort_order: number; + is_active: boolean; + event_type_ids: number[]; }>; export type EventMember = { @@ -197,6 +266,62 @@ function buildPagination(payload: JsonValue | null, defaultCount: number): Pagin }; } +function translationLocales(): string[] { + const locale = i18n.language; + const base = locale?.includes('-') ? locale.split('-')[0] : locale; + const fallback = ['de', 'en']; + return [locale, base, ...fallback].filter( + (value, index, self): value is string => Boolean(value) && self.indexOf(value) === index + ); +} + +function normalizeTranslationMap(value: unknown, fallback?: string, allowEmpty = false): Record { + if (typeof value === 'string') { + const map: Record = {}; + for (const locale of translationLocales()) { + map[locale] = value; + } + return map; + } + + if (value && typeof value === 'object' && !Array.isArray(value)) { + const entries: Record = {}; + for (const [key, entry] of Object.entries(value as Record)) { + if (typeof entry === 'string') { + entries[key] = entry; + } + } + + if (Object.keys(entries).length > 0) { + return entries; + } + } + + if (fallback) { + const locales = translationLocales(); + return locales.reduce>((acc, locale) => { + acc[locale] = fallback; + return acc; + }, {}); + } + + return allowEmpty ? {} : {}; +} + +function pickTranslatedText(translations: Record, fallback: string): string { + const locales = translationLocales(); + for (const locale of locales) { + if (translations[locale]) { + return translations[locale]!; + } + } + const first = Object.values(translations)[0]; + if (first) { + return first; + } + return fallback; +} + function normalizeEvent(event: TenantEvent): TenantEvent { return { ...event, @@ -260,14 +385,29 @@ function normalizeTenantPackage(pkg: JsonValue): TenantPackageSummary { } function normalizeTask(task: JsonValue): TenantTask { + const titleTranslations = normalizeTranslationMap(task.title_translations ?? task.title ?? {}); + const descriptionTranslations = normalizeTranslationMap(task.description_translations ?? task.description ?? {}); + const exampleTranslations = normalizeTranslationMap(task.example_text ?? {}); + return { id: Number(task.id ?? 0), - title: String(task.title ?? 'Ohne Titel'), - description: task.description ?? null, + tenant_id: task.tenant_id ?? null, + slug: String(task.slug ?? `task-${task.id ?? ''}`), + title: pickTranslatedText(titleTranslations, 'Ohne Titel'), + title_translations: titleTranslations, + description: Object.keys(descriptionTranslations).length + ? pickTranslatedText(descriptionTranslations, '') + : null, + description_translations: Object.keys(descriptionTranslations).length ? descriptionTranslations : {}, + example_text: Object.keys(exampleTranslations).length ? pickTranslatedText(exampleTranslations, '') : null, + example_text_translations: Object.keys(exampleTranslations).length ? exampleTranslations : {}, priority: (task.priority ?? null) as TenantTask['priority'], + difficulty: (task.difficulty ?? null) as TenantTask['difficulty'], due_date: task.due_date ?? null, is_completed: Boolean(task.is_completed ?? false), collection_id: task.collection_id ?? null, + source_task_id: task.source_task_id ?? null, + source_collection_id: task.source_collection_id ?? null, assigned_events_count: Number(task.assigned_events_count ?? 0), assigned_events: Array.isArray(task.assigned_events) ? task.assigned_events.map(normalizeEvent) : undefined, created_at: task.created_at ?? null, @@ -275,6 +415,75 @@ function normalizeTask(task: JsonValue): TenantTask { }; } +function normalizeTaskCollection(raw: JsonValue): TenantTaskCollection { + const nameTranslations = normalizeTranslationMap(raw.name_translations ?? raw.name ?? {}); + const descriptionTranslations = normalizeTranslationMap(raw.description_translations ?? raw.description ?? {}, true); + + const eventTypeRaw = raw.event_type ?? raw.eventType ?? null; + let eventType: TenantTaskCollection['event_type'] = null; + if (eventTypeRaw && typeof eventTypeRaw === 'object') { + const eventNameTranslations = normalizeTranslationMap(eventTypeRaw.name ?? {}); + eventType = { + id: Number(eventTypeRaw.id ?? 0), + slug: String(eventTypeRaw.slug ?? ''), + name: pickTranslatedText(eventNameTranslations, String(eventTypeRaw.slug ?? '')), + name_translations: eventNameTranslations, + icon: eventTypeRaw.icon ?? null, + }; + } + + return { + id: Number(raw.id ?? 0), + slug: String(raw.slug ?? `collection-${raw.id ?? ''}`), + name: pickTranslatedText(nameTranslations, 'Unbenannte Sammlung'), + name_translations: nameTranslations, + description: descriptionTranslations ? pickTranslatedText(descriptionTranslations, '') : null, + description_translations: descriptionTranslations ?? {}, + tenant_id: raw.tenant_id ?? null, + is_global: !raw.tenant_id, + event_type: eventType, + tasks_count: Number(raw.tasks_count ?? raw.tasksCount ?? 0), + position: raw.position !== undefined ? Number(raw.position) : null, + source_collection_id: raw.source_collection_id ?? null, + created_at: raw.created_at ?? null, + updated_at: raw.updated_at ?? null, + }; +} + +function normalizeEmotion(raw: JsonValue): TenantEmotion { + const nameTranslations = normalizeTranslationMap(raw.name_translations ?? raw.name ?? {}); + const descriptionTranslations = normalizeTranslationMap(raw.description_translations ?? raw.description ?? {}, true); + + const eventTypes = Array.isArray(raw.event_types ?? raw.eventTypes) + ? (raw.event_types ?? raw.eventTypes) + : []; + + return { + id: Number(raw.id ?? 0), + name: pickTranslatedText(nameTranslations, 'Emotion'), + name_translations: nameTranslations, + description: descriptionTranslations ? pickTranslatedText(descriptionTranslations, '') : null, + description_translations: descriptionTranslations ?? {}, + icon: String(raw.icon ?? 'lucide-smile'), + color: String(raw.color ?? '#6366f1'), + sort_order: Number(raw.sort_order ?? 0), + is_active: Boolean(raw.is_active ?? true), + is_global: !raw.tenant_id, + tenant_id: raw.tenant_id ?? null, + event_types: (eventTypes as JsonValue[]).map((eventType) => { + const translations = normalizeTranslationMap(eventType.name ?? {}); + return { + id: Number(eventType.id ?? 0), + slug: String(eventType.slug ?? ''), + name: pickTranslatedText(translations, String(eventType.slug ?? '')), + name_translations: translations, + }; + }), + created_at: raw.created_at ?? null, + updated_at: raw.updated_at ?? null, + }; +} + function normalizeMember(member: JsonValue): EventMember { return { id: Number(member.id ?? 0), @@ -479,6 +688,8 @@ type LedgerResponse = { type TaskCollectionResponse = { data?: JsonValue[]; + collection?: JsonValue; + message?: string; meta?: Partial; current_page?: number; last_page?: number; @@ -685,6 +896,102 @@ export async function syncCreditBalance(payload: { return jsonOrThrow(response, 'Failed to sync credit balance'); } +export async function getTaskCollections(params: { + page?: number; + per_page?: number; + search?: string; + event_type?: string; + scope?: 'global' | 'tenant'; +} = {}): Promise> { + const searchParams = new URLSearchParams(); + if (params.page) searchParams.set('page', String(params.page)); + if (params.per_page) searchParams.set('per_page', String(params.per_page)); + if (params.search) searchParams.set('search', params.search); + if (params.event_type) searchParams.set('event_type', params.event_type); + if (params.scope) searchParams.set('scope', params.scope); + + const queryString = searchParams.toString(); + const response = await authorizedFetch( + `/api/v1/tenant/task-collections${queryString ? `?${queryString}` : ''}` + ); + + if (!response.ok) { + const payload = await safeJson(response); + console.error('[API] Failed to load task collections', response.status, payload); + throw new Error('Failed to load task collections'); + } + + const json = (await response.json()) as TaskCollectionResponse; + const collections = Array.isArray(json.data) ? json.data.map(normalizeTaskCollection) : []; + + return { + data: collections, + meta: buildPagination(json as JsonValue, collections.length), + }; +} + +export async function getTaskCollection(collectionId: number): Promise { + const response = await authorizedFetch(`/api/v1/tenant/task-collections/${collectionId}`); + const json = await jsonOrThrow<{ data: JsonValue }>(response, 'Failed to load task collection'); + return normalizeTaskCollection(json.data); +} + +export async function importTaskCollection( + collectionId: number, + eventSlug: string +): Promise { + const response = await authorizedFetch(`/api/v1/tenant/task-collections/${collectionId}/activate`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ event_slug: eventSlug }), + }); + + const json = await jsonOrThrow(response, 'Failed to import task collection'); + if (json.collection) { + return normalizeTaskCollection(json.collection); + } + + if (json.data && json.data.length === 1) { + return normalizeTaskCollection(json.data[0]!); + } + + throw new Error('Missing collection payload'); +} + +export async function getEmotions(): Promise { + const response = await authorizedFetch('/api/v1/tenant/emotions'); + if (!response.ok) { + const payload = await safeJson(response); + console.error('[API] Failed to load emotions', response.status, payload); + throw new Error('Failed to load emotions'); + } + + const json = (await response.json()) as { data?: JsonValue[] }; + return Array.isArray(json.data) ? json.data.map(normalizeEmotion) : []; +} + +export async function createEmotion(payload: EmotionPayload): Promise { + const response = await authorizedFetch('/api/v1/tenant/emotions', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + }); + + const json = await jsonOrThrow<{ data: JsonValue }>(response, 'Failed to create emotion'); + return normalizeEmotion(json.data); +} + +export async function updateEmotion(emotionId: number, payload: EmotionPayload): Promise { + const response = await authorizedFetch(`/api/v1/tenant/emotions/${emotionId}`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + }); + + const json = await jsonOrThrow<{ data: JsonValue }>(response, 'Failed to update emotion'); + return normalizeEmotion(json.data); +} + export async function getTasks(params: { page?: number; per_page?: number; search?: string } = {}): Promise> { const searchParams = new URLSearchParams(); if (params.page) searchParams.set('page', String(params.page)); diff --git a/resources/js/admin/components/AdminLayout.tsx b/resources/js/admin/components/AdminLayout.tsx index 276dac3..0fde78f 100644 --- a/resources/js/admin/components/AdminLayout.tsx +++ b/resources/js/admin/components/AdminLayout.tsx @@ -8,6 +8,8 @@ import { ADMIN_SETTINGS_PATH, ADMIN_TASKS_PATH, ADMIN_BILLING_PATH, + ADMIN_TASK_COLLECTIONS_PATH, + ADMIN_EMOTIONS_PATH, } from '../constants'; import { LanguageSwitcher } from './LanguageSwitcher'; @@ -15,6 +17,8 @@ const navItems = [ { to: ADMIN_HOME_PATH, labelKey: 'navigation.dashboard', end: true }, { to: ADMIN_EVENTS_PATH, labelKey: 'navigation.events' }, { to: ADMIN_TASKS_PATH, labelKey: 'navigation.tasks' }, + { to: ADMIN_TASK_COLLECTIONS_PATH, labelKey: 'navigation.collections' }, + { to: ADMIN_EMOTIONS_PATH, labelKey: 'navigation.emotions' }, { to: ADMIN_BILLING_PATH, labelKey: 'navigation.billing' }, { to: ADMIN_SETTINGS_PATH, labelKey: 'navigation.settings' }, ]; diff --git a/resources/js/admin/constants.ts b/resources/js/admin/constants.ts index 5dfe589..c54b2de 100644 --- a/resources/js/admin/constants.ts +++ b/resources/js/admin/constants.ts @@ -2,12 +2,15 @@ export const ADMIN_BASE_PATH = '/event-admin'; export const adminPath = (suffix = ''): string => `${ADMIN_BASE_PATH}${suffix}`; -export const ADMIN_HOME_PATH = ADMIN_BASE_PATH; +export const ADMIN_PUBLIC_LANDING_PATH = ADMIN_BASE_PATH; +export const ADMIN_HOME_PATH = adminPath('/dashboard'); export const ADMIN_LOGIN_PATH = adminPath('/login'); export const ADMIN_AUTH_CALLBACK_PATH = adminPath('/auth/callback'); export const ADMIN_EVENTS_PATH = adminPath('/events'); export const ADMIN_SETTINGS_PATH = adminPath('/settings'); export const ADMIN_TASKS_PATH = adminPath('/tasks'); +export const ADMIN_TASK_COLLECTIONS_PATH = adminPath('/task-collections'); +export const ADMIN_EMOTIONS_PATH = adminPath('/emotions'); export const ADMIN_BILLING_PATH = adminPath('/billing'); export const ADMIN_PHOTOS_PATH = adminPath('/photos'); export const ADMIN_WELCOME_BASE_PATH = adminPath('/welcome'); diff --git a/resources/js/admin/i18n/locales/de/common.json b/resources/js/admin/i18n/locales/de/common.json index f80a067..71843fa 100644 --- a/resources/js/admin/i18n/locales/de/common.json +++ b/resources/js/admin/i18n/locales/de/common.json @@ -7,6 +7,8 @@ "dashboard": "Dashboard", "events": "Events", "tasks": "Aufgaben", + "collections": "Aufgabenvorlagen", + "emotions": "Emotionen", "billing": "Abrechnung", "settings": "Einstellungen" }, diff --git a/resources/js/admin/i18n/locales/de/management.json b/resources/js/admin/i18n/locales/de/management.json index 4a73a33..735a5cd 100644 --- a/resources/js/admin/i18n/locales/de/management.json +++ b/resources/js/admin/i18n/locales/de/management.json @@ -146,5 +146,100 @@ "urgent": "Dringend" } } + , + "collections": { + "title": "Aufgabenvorlagen", + "subtitle": "Durchstöbere kuratierte Vorlagen oder aktiviere sie für deine Events.", + "actions": { + "import": "Importieren", + "create": "Vorlage erstellen", + "openTasks": "Task-Bibliothek öffnen" + }, + "filters": { + "search": "Nach Vorlagen suchen", + "scope": "Bereich", + "allScopes": "Alle Bereiche", + "eventType": "Event-Typ", + "allEventTypes": "Alle Event-Typen", + "globalOnly": "Nur globale Vorlagen", + "tenantOnly": "Nur eigene Vorlagen" + }, + "scope": { + "global": "Globale Vorlage", + "tenant": "Eigene Vorlage" + }, + "empty": { + "title": "Noch keine Vorlagen", + "description": "Importiere eine Fotospiel-Kollektion oder erstelle dein eigenes Aufgabenpaket." + }, + "dialogs": { + "importTitle": "Vorlage importieren", + "collectionLabel": "Vorlage", + "selectEvent": "Event auswählen", + "submit": "Importieren", + "cancel": "Abbrechen" + }, + "notifications": { + "imported": "Vorlage erfolgreich importiert", + "error": "Vorlage konnte nicht importiert werden" + }, + "errors": { + "eventsLoad": "Events konnten nicht geladen werden.", + "selectEvent": "Bitte wähle ein Event aus.", + "noEvents": "Noch keine Events – lege eines an, um die Vorlage zu aktivieren." + }, + "labels": { + "taskCount": "{{count}} Tasks", + "updated": "Aktualisiert: {{date}}" + }, + "pagination": { + "prev": "Zurück", + "next": "Weiter", + "page": "Seite {{current}} von {{total}}" + } + }, + "emotions": { + "title": "Emotionen", + "subtitle": "Verwalte Stimmungen und Icons, die deine Events begleiten.", + "actions": { + "create": "Neue Emotion", + "enable": "Aktivieren", + "disable": "Deaktivieren" + }, + "scope": { + "global": "Global", + "tenant": "Eigen" + }, + "labels": { + "updated": "Aktualisiert: {{date}}", + "noEventType": "Alle Event-Typen" + }, + "status": { + "active": "Aktiv", + "inactive": "Inaktiv" + }, + "errors": { + "genericTitle": "Aktion fehlgeschlagen", + "load": "Emotionen konnten nicht geladen werden.", + "create": "Emotion konnte nicht erstellt werden.", + "toggle": "Status konnte nicht aktualisiert werden.", + "nameRequired": "Bitte gib einen Namen ein." + }, + "empty": { + "title": "Noch keine Emotionen", + "description": "Erstelle eine eigene Emotion oder verwende die Fotospiel-Vorlagen." + }, + "dialogs": { + "createTitle": "Eigene Emotion hinzufügen", + "name": "Name", + "description": "Beschreibung", + "icon": "Icon", + "color": "Farbe", + "activeLabel": "Aktiv", + "activeDescription": "In Task-Listen sichtbar", + "cancel": "Abbrechen", + "submit": "Emotion speichern" + } + } } diff --git a/resources/js/admin/i18n/locales/en/common.json b/resources/js/admin/i18n/locales/en/common.json index 7ac658a..5715d92 100644 --- a/resources/js/admin/i18n/locales/en/common.json +++ b/resources/js/admin/i18n/locales/en/common.json @@ -7,6 +7,8 @@ "dashboard": "Dashboard", "events": "Events", "tasks": "Tasks", + "collections": "Collections", + "emotions": "Emotions", "billing": "Billing", "settings": "Settings" }, diff --git a/resources/js/admin/i18n/locales/en/management.json b/resources/js/admin/i18n/locales/en/management.json index dfa6e0a..67957db 100644 --- a/resources/js/admin/i18n/locales/en/management.json +++ b/resources/js/admin/i18n/locales/en/management.json @@ -146,4 +146,99 @@ "urgent": "Urgent" } } + , + "collections": { + "title": "Task collections", + "subtitle": "Browse curated task bundles or activate them for your events.", + "actions": { + "import": "Import", + "create": "Create collection", + "openTasks": "Open task library" + }, + "filters": { + "search": "Search collections", + "scope": "Scope", + "allScopes": "All scopes", + "eventType": "Event type", + "allEventTypes": "All event types", + "globalOnly": "Global templates", + "tenantOnly": "Tenant collections" + }, + "scope": { + "global": "Global template", + "tenant": "Tenant-owned" + }, + "empty": { + "title": "No collections yet", + "description": "Import one of Fotospiel’s curated templates or create your own bundle to get started." + }, + "dialogs": { + "importTitle": "Import collection", + "collectionLabel": "Collection", + "selectEvent": "Select event", + "submit": "Import", + "cancel": "Cancel" + }, + "notifications": { + "imported": "Collection imported successfully", + "error": "Collection could not be imported" + }, + "errors": { + "eventsLoad": "Events could not be loaded.", + "selectEvent": "Please select an event.", + "noEvents": "No events yet – create one to activate this collection." + }, + "labels": { + "taskCount": "{{count}} tasks", + "updated": "Updated: {{date}}" + }, + "pagination": { + "prev": "Previous", + "next": "Next", + "page": "Page {{current}} of {{total}}" + } + }, + "emotions": { + "title": "Emotions", + "subtitle": "Manage the emotional tone available for your events.", + "actions": { + "create": "Add emotion", + "enable": "Enable", + "disable": "Disable" + }, + "scope": { + "global": "Global", + "tenant": "Tenant" + }, + "labels": { + "updated": "Updated: {{date}}", + "noEventType": "All event types" + }, + "status": { + "active": "Active", + "inactive": "Inactive" + }, + "errors": { + "genericTitle": "Action failed", + "load": "Emotions could not be loaded.", + "create": "Emotion could not be created.", + "toggle": "Emotion status could not be updated.", + "nameRequired": "Please provide a name." + }, + "empty": { + "title": "No emotions yet", + "description": "Create your own emotion or use the Fotospiel defaults." + }, + "dialogs": { + "createTitle": "Add custom emotion", + "name": "Name", + "description": "Description", + "icon": "Icon", + "color": "Color", + "activeLabel": "Active", + "activeDescription": "Visible in the task library", + "cancel": "Cancel", + "submit": "Save emotion" + } + } } diff --git a/resources/js/admin/pages/EmotionsPage.tsx b/resources/js/admin/pages/EmotionsPage.tsx new file mode 100644 index 0000000..4476a46 --- /dev/null +++ b/resources/js/admin/pages/EmotionsPage.tsx @@ -0,0 +1,384 @@ +import React from 'react'; +import { useTranslation } from 'react-i18next'; +import { format } from 'date-fns'; +import { de, enGB } from 'date-fns/locale'; +import type { Locale } from 'date-fns'; +import { Palette, Plus, Power, Smile } from 'lucide-react'; + +import { AdminLayout } from '../components/AdminLayout'; +import { + getEmotions, + createEmotion, + updateEmotion, + TenantEmotion, + EmotionPayload, +} from '../api'; +import { isAuthError } from '../auth/tokens'; +import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'; +import { Badge } from '@/components/ui/badge'; +import { Button } from '@/components/ui/button'; +import { + Card, + CardContent, + CardDescription, + CardFooter, + CardHeader, + CardTitle, +} from '@/components/ui/card'; +import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { Switch } from '@/components/ui/switch'; + +const DEFAULT_COLOR = '#6366f1'; + +type EmotionFormState = { + name: string; + description: string; + icon: string; + color: string; + is_active: boolean; + sort_order: number; +}; + +const INITIAL_FORM_STATE: EmotionFormState = { + name: '', + description: '', + icon: 'lucide-smile', + color: DEFAULT_COLOR, + is_active: true, + sort_order: 0, +}; + +export default function EmotionsPage(): JSX.Element { + const { t, i18n } = useTranslation('management'); + + const [emotions, setEmotions] = React.useState([]); + const [loading, setLoading] = React.useState(true); + const [error, setError] = React.useState(null); + + const [dialogOpen, setDialogOpen] = React.useState(false); + const [saving, setSaving] = React.useState(false); + const [form, setForm] = React.useState(INITIAL_FORM_STATE); + + React.useEffect(() => { + let cancelled = false; + async function load() { + setLoading(true); + setError(null); + try { + const data = await getEmotions(); + if (!cancelled) { + setEmotions(data); + } + } catch (err) { + if (!isAuthError(err)) { + setError(t('emotions.errors.load')); + } + } finally { + if (!cancelled) { + setLoading(false); + } + } + } + + void load(); + + return () => { + cancelled = true; + }; + }, [t]); + + function openCreateDialog() { + setForm(INITIAL_FORM_STATE); + setDialogOpen(true); + } + + async function handleCreate(event: React.FormEvent) { + event.preventDefault(); + if (!form.name.trim()) { + setError(t('emotions.errors.nameRequired')); + return; + } + + setSaving(true); + setError(null); + const payload: EmotionPayload = { + name: form.name.trim(), + description: form.description.trim() || null, + icon: form.icon.trim() || 'lucide-smile', + color: form.color.trim() || DEFAULT_COLOR, + is_active: form.is_active, + sort_order: form.sort_order, + }; + + try { + const created = await createEmotion(payload); + setEmotions((prev) => [created, ...prev]); + setDialogOpen(false); + } catch (err) { + if (!isAuthError(err)) { + setError(t('emotions.errors.create')); + } + } finally { + setSaving(false); + } + } + + async function toggleEmotion(emotion: TenantEmotion) { + try { + const updated = await updateEmotion(emotion.id, { is_active: !emotion.is_active }); + setEmotions((prev) => prev.map((item) => (item.id === updated.id ? updated : item))); + } catch (err) { + if (!isAuthError(err)) { + setError(t('emotions.errors.toggle')); + } + } + } + + const locale = i18n.language.startsWith('en') ? enGB : de; + + return ( + + + {t('emotions.actions.create')} + + } + > + {error && ( + + {t('emotions.errors.genericTitle')} + {error} + + )} + + + + {t('emotions.title')} + {t('emotions.subtitle')} + + + {loading ? ( + + ) : emotions.length === 0 ? ( + + ) : ( +
+ {emotions.map((emotion) => ( + toggleEmotion(emotion)} + locale={locale} + canToggle={!emotion.is_global} + /> + ))} +
+ )} +
+
+ + +
+ ); +} + +function EmotionCard({ + emotion, + onToggle, + locale, + canToggle, +}: { + emotion: TenantEmotion; + onToggle: () => void; + locale: Locale; + canToggle: boolean; +}) { + const { t } = useTranslation('management'); + const updatedLabel = emotion.updated_at + ? format(new Date(emotion.updated_at), 'PP', { locale }) + : null; + + return ( + + +
+ + {emotion.is_global ? t('emotions.scope.global') : t('emotions.scope.tenant')} + + + {emotion.event_types.map((type) => type.name).join(', ') || t('emotions.labels.noEventType')} + +
+ + + {emotion.name} + + {emotion.description && ( + {emotion.description} + )} +
+ +
+ + {emotion.color} +
+ {updatedLabel && {t('emotions.labels.updated', { date: updatedLabel })}} +
+ + + {emotion.is_active ? t('emotions.status.active') : t('emotions.status.inactive')} + + + +
+ ); +} + +function EmptyEmotionsState() { + const { t } = useTranslation('management'); + return ( +
+
+ +
+
+

{t('emotions.empty.title')}

+

{t('emotions.empty.description')}

+
+
+ ); +} + +function EmotionSkeleton() { + return ( +
+ {Array.from({ length: 4 }).map((_, index) => ( +
+
+
+
+
+ ))} +
+ ); +} + +function EmotionDialog({ + open, + onOpenChange, + form, + setForm, + saving, + onSubmit, +}: { + open: boolean; + onOpenChange: (open: boolean) => void; + form: EmotionFormState; + setForm: React.Dispatch>; + saving: boolean; + onSubmit: (event: React.FormEvent) => void; +}) { + const { t } = useTranslation('management'); + return ( + + + + {t('emotions.dialogs.createTitle')} + + +
+
+ + setForm((prev) => ({ ...prev, name: event.target.value }))} + required + /> +
+ +
+ +