tenant admin startseite schicker gestaltet und super-admin und tenant admin (filament) aufgesplittet.
Es gibt nun task collections und vordefinierte tasks für alle. Onboarding verfeinert und webseite-carousel gefixt (logging später entfernen!)
This commit is contained in:
@@ -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'),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
11
app/Filament/Resources/EventResource/Pages/CreateEvent.php
Normal file
11
app/Filament/Resources/EventResource/Pages/CreateEvent.php
Normal file
@@ -0,0 +1,11 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources\EventResource\Pages;
|
||||
|
||||
use App\Filament\Resources\EventResource;
|
||||
use Filament\Resources\Pages\CreateRecord;
|
||||
|
||||
class CreateEvent extends CreateRecord
|
||||
{
|
||||
protected static string $resource = EventResource::class;
|
||||
}
|
||||
@@ -2,26 +2,24 @@
|
||||
|
||||
namespace App\Filament\Resources\EventResource\RelationManagers;
|
||||
|
||||
use Filament\Forms;
|
||||
use Filament\Forms\Form;
|
||||
use Filament\Resources\RelationManagers\RelationManager;
|
||||
use Filament\Tables;
|
||||
use Filament\Tables\Table;
|
||||
use Filament\Actions\CreateAction;
|
||||
use Filament\Actions\EditAction;
|
||||
use Filament\Actions\DeleteAction;
|
||||
use App\Models\EventPackage;
|
||||
use Filament\Actions\BulkActionGroup;
|
||||
use Filament\Actions\CreateAction;
|
||||
use Filament\Actions\DeleteAction;
|
||||
use Filament\Actions\DeleteBulkAction;
|
||||
use Filament\Actions\EditAction;
|
||||
use Filament\Forms;
|
||||
use Filament\Forms\Components\DateTimePicker;
|
||||
use Filament\Forms\Components\Select;
|
||||
use Filament\Forms\Components\TextInput;
|
||||
use Filament\Resources\RelationManagers\RelationManager;
|
||||
use Filament\Schemas\Schema;
|
||||
use Filament\Tables;
|
||||
use Filament\Tables\Columns\TextColumn;
|
||||
use Filament\Tables\Table;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\Relation;
|
||||
use App\Models\EventPackage;
|
||||
use Filament\Actions\Action;
|
||||
use Filament\Forms\Components\Select;
|
||||
use Filament\Forms\Components\TextInput;
|
||||
use Filament\Forms\Components\DateTimePicker;
|
||||
use Filament\Tables\Columns\TextColumn;
|
||||
use Filament\Schemas\Schema;
|
||||
|
||||
class EventPackagesRelationManager extends RelationManager
|
||||
{
|
||||
@@ -52,6 +50,9 @@ class EventPackagesRelationManager extends RelationManager
|
||||
->numeric()
|
||||
->default(0)
|
||||
->readOnly(),
|
||||
DateTimePicker::make('expires_at')
|
||||
->label('Ablauf')
|
||||
->required(),
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -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('tenant_id')
|
||||
->relationship('tenant', 'name')
|
||||
->required()
|
||||
->searchable(),
|
||||
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),
|
||||
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
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -17,9 +17,4 @@ class EditTenantPackage extends EditRecord
|
||||
Actions\DeleteAction::make(),
|
||||
];
|
||||
}
|
||||
|
||||
protected function getRedirectUrl(): string
|
||||
{
|
||||
return $this->getResource()::getUrl('index');
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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'),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
21
app/Filament/Resources/UserResource/Pages/CreateUser.php
Normal file
21
app/Filament/Resources/UserResource/Pages/CreateUser.php
Normal file
@@ -0,0 +1,21 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources\UserResource\Pages;
|
||||
|
||||
use App\Filament\Resources\UserResource;
|
||||
use Filament\Resources\Pages\CreateRecord;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
|
||||
class CreateUser extends CreateRecord
|
||||
{
|
||||
protected static string $resource = UserResource::class;
|
||||
|
||||
protected function mutateFormDataBeforeCreate(array $data): array
|
||||
{
|
||||
if (isset($data['password'])) {
|
||||
$data['password'] = Hash::make($data['password']);
|
||||
}
|
||||
|
||||
return $data;
|
||||
}
|
||||
}
|
||||
@@ -26,9 +26,4 @@ class EditUser extends EditRecord
|
||||
|
||||
return $data;
|
||||
}
|
||||
|
||||
protected function getRedirectUrl(): string
|
||||
{
|
||||
return $this->getResource()::getUrl('index');
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
186
app/Filament/Tenant/Pages/InviteStudio.php
Normal file
186
app/Filament/Tenant/Pages/InviteStudio.php
Normal file
@@ -0,0 +1,186 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Tenant\Pages;
|
||||
use App\Models\Event;
|
||||
use App\Models\EventJoinToken;
|
||||
use App\Services\EventJoinTokenService;
|
||||
use App\Support\JoinTokenLayoutRegistry;
|
||||
use App\Support\TenantOnboardingState;
|
||||
use Filament\Notifications\Notification;
|
||||
use Filament\Pages\Page;
|
||||
use BackedEnum;
|
||||
use Illuminate\Support\Arr;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\URL;
|
||||
|
||||
class InviteStudio extends Page
|
||||
{
|
||||
protected static BackedEnum|string|null $navigationIcon = 'heroicon-o-qr-code';
|
||||
|
||||
protected string $view = 'filament.tenant.pages.invite-studio';
|
||||
|
||||
protected static ?string $navigationLabel = 'Einladungen & QR';
|
||||
|
||||
protected static ?string $slug = 'invite-studio';
|
||||
|
||||
protected static ?string $title = 'Einladungen & QR-Codes';
|
||||
|
||||
protected static ?int $navigationSort = 50;
|
||||
|
||||
public ?int $selectedEventId = null;
|
||||
|
||||
public string $tokenLabel = '';
|
||||
|
||||
public array $tokens = [];
|
||||
|
||||
public array $layouts = [];
|
||||
|
||||
protected static bool $shouldRegisterNavigation = true;
|
||||
|
||||
public function mount(): void
|
||||
{
|
||||
$tenant = TenantOnboardingState::tenant();
|
||||
|
||||
abort_if(! $tenant, 403);
|
||||
|
||||
if (! TenantOnboardingState::completed($tenant)) {
|
||||
$this->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();
|
||||
}
|
||||
}
|
||||
311
app/Filament/Tenant/Pages/TenantOnboarding.php
Normal file
311
app/Filament/Tenant/Pages/TenantOnboarding.php
Normal file
@@ -0,0 +1,311 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Tenant\Pages;
|
||||
|
||||
use App\Filament\Tenant\Resources\EventResource;
|
||||
use App\Models\Event;
|
||||
use App\Models\EventType;
|
||||
use App\Models\TaskCollection;
|
||||
use App\Services\EventJoinTokenService;
|
||||
use App\Services\Tenant\TaskCollectionImportService;
|
||||
use App\Support\JoinTokenLayoutRegistry;
|
||||
use App\Support\TenantOnboardingState;
|
||||
use Filament\Notifications\Notification;
|
||||
use Filament\Pages\Page;
|
||||
use BackedEnum;
|
||||
use UnitEnum;
|
||||
use Illuminate\Database\Eloquent\Collection as EloquentCollection;
|
||||
use Illuminate\Support\Arr;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Str;
|
||||
use Throwable;
|
||||
|
||||
class TenantOnboarding extends Page
|
||||
{
|
||||
protected static BackedEnum|string|null $navigationIcon = 'heroicon-o-sparkles';
|
||||
|
||||
protected string $view = 'filament.tenant.pages.onboarding';
|
||||
|
||||
protected static ?string $navigationLabel = 'Willkommen';
|
||||
|
||||
protected static ?string $slug = 'willkommen';
|
||||
|
||||
protected static ?string $title = 'Euer Start mit Fotospiel';
|
||||
|
||||
protected static UnitEnum|string|null $navigationGroup = null;
|
||||
|
||||
public string $step = 'intro';
|
||||
|
||||
public array $status = [];
|
||||
|
||||
public array $inviteDownloads = [];
|
||||
|
||||
public array $selectedPackages = [];
|
||||
|
||||
public string $eventName = '';
|
||||
|
||||
public ?string $eventDate = null;
|
||||
|
||||
public ?int $eventTypeId = null;
|
||||
|
||||
public ?string $palette = null;
|
||||
|
||||
public ?string $inviteLayout = null;
|
||||
|
||||
public bool $isProcessing = false;
|
||||
|
||||
protected static bool $shouldRegisterNavigation = true;
|
||||
|
||||
public function mount(): void
|
||||
{
|
||||
$tenant = TenantOnboardingState::tenant();
|
||||
|
||||
abort_if(! $tenant, 403);
|
||||
|
||||
$this->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<TaskCollection> $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');
|
||||
}
|
||||
}
|
||||
209
app/Filament/Tenant/Resources/EventResource.php
Normal file
209
app/Filament/Tenant/Resources/EventResource.php
Normal file
@@ -0,0 +1,209 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Tenant\Resources;
|
||||
|
||||
use App\Filament\Tenant\Resources\EventResource\Pages;
|
||||
use App\Support\JoinTokenLayoutRegistry;
|
||||
use App\Support\TenantOnboardingState;
|
||||
use App\Models\Event;
|
||||
use App\Models\EventType;
|
||||
use Filament\Resources\Resource;
|
||||
use Filament\Tables;
|
||||
use Filament\Tables\Table;
|
||||
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\Hidden;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use UnitEnum;
|
||||
use BackedEnum;
|
||||
|
||||
use App\Filament\Tenant\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;
|
||||
|
||||
public static function getNavigationGroup(): UnitEnum|string|null
|
||||
{
|
||||
return __('admin.nav.platform');
|
||||
}
|
||||
protected static ?int $navigationSort = 20;
|
||||
|
||||
public static function shouldRegisterNavigation(): bool
|
||||
{
|
||||
return TenantOnboardingState::completed();
|
||||
}
|
||||
|
||||
public static function form(Schema $form): Schema
|
||||
{
|
||||
$tenantId = Auth::user()?->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,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Tenant\Resources\EventResource\Pages;
|
||||
|
||||
use App\Filament\Tenant\Resources\EventResource;
|
||||
use Filament\Resources\Pages\CreateRecord;
|
||||
|
||||
class CreateEvent extends CreateRecord
|
||||
{
|
||||
protected static string $resource = EventResource::class;
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Tenant\Resources\EventResource\Pages;
|
||||
|
||||
use App\Filament\Tenant\Resources\EventResource;
|
||||
use Filament\Resources\Pages\EditRecord;
|
||||
|
||||
class EditEvent extends EditRecord
|
||||
{
|
||||
protected static string $resource = EventResource::class;
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Tenant\Resources\EventResource\Pages;
|
||||
|
||||
use App\Filament\Tenant\Resources\EventResource;
|
||||
use Filament\Resources\Pages\ListRecords;
|
||||
|
||||
class ListEvents extends ListRecords
|
||||
{
|
||||
protected static string $resource = EventResource::class;
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Tenant\Resources\EventResource\Pages;
|
||||
|
||||
use App\Filament\Tenant\Resources\EventResource;
|
||||
use Filament\Resources\Pages\ViewRecord;
|
||||
|
||||
class ViewEvent extends ViewRecord
|
||||
{
|
||||
protected static string $resource = EventResource::class;
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Tenant\Resources\EventResource\RelationManagers;
|
||||
|
||||
use Filament\Forms;
|
||||
use Filament\Forms\Form;
|
||||
use Filament\Resources\RelationManagers\RelationManager;
|
||||
use Filament\Tables;
|
||||
use Filament\Tables\Table;
|
||||
use Filament\Actions\CreateAction;
|
||||
use Filament\Actions\EditAction;
|
||||
use Filament\Actions\DeleteAction;
|
||||
use Filament\Actions\BulkActionGroup;
|
||||
use Filament\Actions\DeleteBulkAction;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\Relation;
|
||||
use App\Models\EventPackage;
|
||||
use Filament\Actions\Action;
|
||||
use Filament\Forms\Components\Select;
|
||||
use Filament\Forms\Components\TextInput;
|
||||
use Filament\Forms\Components\DateTimePicker;
|
||||
use Filament\Tables\Columns\TextColumn;
|
||||
use Filament\Schemas\Schema;
|
||||
|
||||
class EventPackagesRelationManager extends RelationManager
|
||||
{
|
||||
protected static string $relationship = 'eventPackages';
|
||||
|
||||
public function form(Schema $schema): Schema
|
||||
{
|
||||
return $schema->schema([
|
||||
Select::make('package_id')
|
||||
->label('Package')
|
||||
->relationship('package', 'name')
|
||||
->searchable()
|
||||
->preload()
|
||||
->required(),
|
||||
TextInput::make('purchased_price')
|
||||
->label('Kaufpreis')
|
||||
->prefix('€')
|
||||
->numeric()
|
||||
->step(0.01)
|
||||
->required(),
|
||||
TextInput::make('used_photos')
|
||||
->label('Verwendete Fotos')
|
||||
->numeric()
|
||||
->default(0)
|
||||
->readOnly(),
|
||||
TextInput::make('used_guests')
|
||||
->label('Verwendete Gäste')
|
||||
->numeric()
|
||||
->default(0)
|
||||
->readOnly(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function table(Table $table): Table
|
||||
{
|
||||
return $table
|
||||
->recordTitleAttribute('package.name')
|
||||
->columns([
|
||||
TextColumn::make('package.name')
|
||||
->label('Package')
|
||||
->badge()
|
||||
->color('success'),
|
||||
TextColumn::make('used_photos')
|
||||
->label('Verwendete Fotos')
|
||||
->badge(),
|
||||
TextColumn::make('remaining_photos')
|
||||
->label('Verbleibende Fotos')
|
||||
->badge()
|
||||
->color(fn ($state) => $state < 1 ? 'danger' : 'success')
|
||||
->getStateUsing(fn (EventPackage $record) => $record->remaining_photos),
|
||||
TextColumn::make('used_guests')
|
||||
->label('Verwendete Gäste')
|
||||
->badge(),
|
||||
TextColumn::make('remaining_guests')
|
||||
->label('Verbleibende Gäste')
|
||||
->badge()
|
||||
->color(fn ($state) => $state < 1 ? 'danger' : 'success')
|
||||
->getStateUsing(fn (EventPackage $record) => $record->remaining_guests),
|
||||
TextColumn::make('expires_at')
|
||||
->label('Ablauf')
|
||||
->dateTime()
|
||||
->badge()
|
||||
->color(fn ($state) => $state && $state->isPast() ? 'danger' : 'success'),
|
||||
TextColumn::make('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');
|
||||
}
|
||||
}
|
||||
126
app/Filament/Tenant/Resources/PhotoResource.php
Normal file
126
app/Filament/Tenant/Resources/PhotoResource.php
Normal file
@@ -0,0 +1,126 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Tenant\Resources;
|
||||
|
||||
use App\Filament\Tenant\Resources\PhotoResource\Pages;
|
||||
use App\Models\Photo;
|
||||
use App\Models\Event;
|
||||
use App\Support\TenantOnboardingState;
|
||||
use Filament\Resources\Resource;
|
||||
use Filament\Tables;
|
||||
use Filament\Tables\Table;
|
||||
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 Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
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;
|
||||
|
||||
public static function getNavigationGroup(): UnitEnum|string|null
|
||||
{
|
||||
return __('admin.nav.content');
|
||||
}
|
||||
protected static ?int $navigationSort = 30;
|
||||
|
||||
public static function shouldRegisterNavigation(): bool
|
||||
{
|
||||
return TenantOnboardingState::completed();
|
||||
}
|
||||
|
||||
public static function form(Schema $form): Schema
|
||||
{
|
||||
$tenantId = Auth::user()?->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'),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Tenant\Resources\PhotoResource\Pages;
|
||||
|
||||
use App\Filament\Tenant\Resources\PhotoResource;
|
||||
use Filament\Resources\Pages\EditRecord;
|
||||
|
||||
class EditPhoto extends EditRecord
|
||||
{
|
||||
protected static string $resource = PhotoResource::class;
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Tenant\Resources\PhotoResource\Pages;
|
||||
|
||||
use App\Filament\Tenant\Resources\PhotoResource;
|
||||
use Filament\Resources\Pages\ListRecords;
|
||||
|
||||
class ListPhotos extends ListRecords
|
||||
{
|
||||
protected static string $resource = PhotoResource::class;
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Tenant\Resources\PhotoResource\Pages;
|
||||
|
||||
use App\Filament\Tenant\Resources\PhotoResource;
|
||||
use Filament\Resources\Pages\ViewRecord;
|
||||
|
||||
class ViewPhoto extends ViewRecord
|
||||
{
|
||||
protected static string $resource = PhotoResource::class;
|
||||
}
|
||||
242
app/Filament/Tenant/Resources/TaskCollectionResource.php
Normal file
242
app/Filament/Tenant/Resources/TaskCollectionResource.php
Normal file
@@ -0,0 +1,242 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Tenant\Resources;
|
||||
|
||||
use App\Filament\Tenant\Resources\TaskCollectionResource\Pages;
|
||||
use App\Models\Event;
|
||||
use App\Models\EventType;
|
||||
use App\Models\TaskCollection;
|
||||
use App\Services\Tenant\TaskCollectionImportService;
|
||||
use Filament\Facades\Filament;
|
||||
use Filament\Schemas\Components\Section;
|
||||
use Filament\Forms\Components\Select;
|
||||
use Filament\Forms\Components\TextInput;
|
||||
use Filament\Forms\Components\Textarea;
|
||||
use Filament\Schemas\Schema;
|
||||
use Filament\Resources\Resource;
|
||||
use Filament\Tables;
|
||||
use Filament\Actions;
|
||||
use Filament\Tables\Columns\BadgeColumn;
|
||||
use Filament\Tables\Columns\IconColumn;
|
||||
use Filament\Tables\Columns\TextColumn;
|
||||
use Filament\Tables\Filters\SelectFilter;
|
||||
use Filament\Tables\Table;
|
||||
use Filament\Notifications\Notification;
|
||||
use Illuminate\Support\Str;
|
||||
use BackedEnum;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use App\Support\TenantOnboardingState;
|
||||
|
||||
class TaskCollectionResource extends Resource
|
||||
{
|
||||
protected static ?string $model = TaskCollection::class;
|
||||
|
||||
protected static BackedEnum|string|null $navigationIcon = 'heroicon-o-folder';
|
||||
|
||||
protected static ?int $navigationSort = 50;
|
||||
|
||||
public static function shouldRegisterNavigation(): bool
|
||||
{
|
||||
return TenantOnboardingState::completed();
|
||||
}
|
||||
|
||||
public static function getNavigationGroup(): string
|
||||
{
|
||||
return __('admin.nav.library');
|
||||
}
|
||||
|
||||
public static function form(Schema $schema): Schema
|
||||
{
|
||||
$tenantId = auth()->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());
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Tenant\Resources\TaskCollectionResource\Pages;
|
||||
|
||||
use App\Filament\Tenant\Resources\TaskCollectionResource;
|
||||
use Filament\Resources\Pages\CreateRecord;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class CreateTaskCollection extends CreateRecord
|
||||
{
|
||||
protected static string $resource = TaskCollectionResource::class;
|
||||
|
||||
protected function mutateFormDataBeforeCreate(array $data): array
|
||||
{
|
||||
$tenantId = Auth::user()?->tenant_id;
|
||||
|
||||
$data['tenant_id'] = $tenantId;
|
||||
$data['slug'] = TaskCollectionResource::generateSlug(
|
||||
$data['name_translations']['en'] ?? $data['name_translations']['de'] ?? 'collection',
|
||||
$tenantId
|
||||
);
|
||||
|
||||
return $data;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Tenant\Resources\TaskCollectionResource\Pages;
|
||||
|
||||
use App\Filament\Tenant\Resources\TaskCollectionResource;
|
||||
use Filament\Resources\Pages\EditRecord;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
|
||||
class EditTaskCollection extends EditRecord
|
||||
{
|
||||
protected static string $resource = TaskCollectionResource::class;
|
||||
|
||||
protected function authorizeAccess(): void
|
||||
{
|
||||
parent::authorizeAccess();
|
||||
|
||||
$record = $this->getRecord();
|
||||
|
||||
if ($record->tenant_id !== Auth::user()?->tenant_id) {
|
||||
abort(403);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Tenant\Resources\TaskCollectionResource\Pages;
|
||||
|
||||
use App\Filament\Tenant\Resources\TaskCollectionResource;
|
||||
use Filament\Resources\Pages\ListRecords;
|
||||
|
||||
class ListTaskCollections extends ListRecords
|
||||
{
|
||||
protected static string $resource = TaskCollectionResource::class;
|
||||
}
|
||||
206
app/Filament/Tenant/Resources/TaskResource.php
Normal file
206
app/Filament/Tenant/Resources/TaskResource.php
Normal file
@@ -0,0 +1,206 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Tenant\Resources;
|
||||
|
||||
use App\Filament\Tenant\Resources\TaskResource\Pages;
|
||||
use App\Models\Event;
|
||||
use App\Models\Task;
|
||||
use App\Support\TenantOnboardingState;
|
||||
use BackedEnum;
|
||||
use Filament\Facades\Filament;
|
||||
use Filament\Forms\Components\MarkdownEditor;
|
||||
use Filament\Forms\Components\Select;
|
||||
use Filament\Schemas\Components\Tabs as SchemaTabs;
|
||||
use Filament\Schemas\Components\Tabs\Tab as SchemaTab;
|
||||
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 Filament\Actions;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use UnitEnum;
|
||||
|
||||
class TaskResource extends Resource
|
||||
{
|
||||
protected static ?string $model = Task::class;
|
||||
|
||||
protected static BackedEnum|string|null $navigationIcon = 'heroicon-o-clipboard-document-check';
|
||||
|
||||
protected static ?int $navigationSort = 40;
|
||||
|
||||
public static function shouldRegisterNavigation(): bool
|
||||
{
|
||||
return TenantOnboardingState::completed();
|
||||
}
|
||||
|
||||
public static function getNavigationGroup(): UnitEnum|string|null
|
||||
{
|
||||
return __('admin.nav.library');
|
||||
}
|
||||
|
||||
public static function form(Schema $form): Schema
|
||||
{
|
||||
$tenantId = Auth::user()?->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());
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Tenant\Resources\TaskResource\Pages;
|
||||
|
||||
use App\Filament\Tenant\Resources\TaskResource;
|
||||
use Filament\Resources\Pages\CreateRecord;
|
||||
|
||||
class CreateTask extends CreateRecord
|
||||
{
|
||||
protected static string $resource = TaskResource::class;
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Tenant\Resources\TaskResource\Pages;
|
||||
|
||||
use App\Filament\Tenant\Resources\TaskResource;
|
||||
use Filament\Actions;
|
||||
use Filament\Resources\Pages\EditRecord;
|
||||
|
||||
class EditTask extends EditRecord
|
||||
{
|
||||
protected static string $resource = TaskResource::class;
|
||||
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
return [
|
||||
Actions\DeleteAction::make(),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Tenant\Resources\TaskResource\Pages;
|
||||
|
||||
use App\Filament\Tenant\Resources\TaskResource;
|
||||
use Filament\Actions;
|
||||
use Filament\Resources\Pages\ListRecords;
|
||||
|
||||
class ListTasks extends ListRecords
|
||||
{
|
||||
protected static string $resource = TaskResource::class;
|
||||
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
return [
|
||||
Actions\CreateAction::make(),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Widgets;
|
||||
namespace App\Filament\Tenant\Widgets;
|
||||
|
||||
use Filament\Tables;
|
||||
use Filament\Widgets\TableWidget as BaseWidget;
|
||||
@@ -1,6 +1,6 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Widgets;
|
||||
namespace App\Filament\Tenant\Widgets;
|
||||
|
||||
use Filament\Tables;
|
||||
use Filament\Widgets\TableWidget as BaseWidget;
|
||||
@@ -1,6 +1,6 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Widgets;
|
||||
namespace App\Filament\Tenant\Widgets;
|
||||
use Filament\Widgets\ChartWidget;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Carbon;
|
||||
157
app/Http/Controllers/Api/Tenant/EmotionController.php
Normal file
157
app/Http/Controllers/Api/Tenant/EmotionController.php
Normal file
@@ -0,0 +1,157 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api\Tenant;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\Tenant\EmotionStoreRequest;
|
||||
use App\Http\Requests\Tenant\EmotionUpdateRequest;
|
||||
use App\Http\Resources\Tenant\EmotionResource;
|
||||
use App\Models\Emotion;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class EmotionController extends Controller
|
||||
{
|
||||
public function index(Request $request): AnonymousResourceCollection
|
||||
{
|
||||
$tenantId = $request->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';
|
||||
}
|
||||
}
|
||||
94
app/Http/Controllers/Api/Tenant/TaskCollectionController.php
Normal file
94
app/Http/Controllers/Api/Tenant/TaskCollectionController.php
Normal file
@@ -0,0 +1,94 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api\Tenant;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Resources\Tenant\TaskCollectionResource;
|
||||
use App\Models\Event;
|
||||
use App\Models\TaskCollection;
|
||||
use App\Services\Tenant\TaskCollectionImportService;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
class TaskCollectionController extends Controller
|
||||
{
|
||||
public function index(Request $request): AnonymousResourceCollection
|
||||
{
|
||||
$tenantId = $request->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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
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<string, string>|null $fallback
|
||||
*
|
||||
* @return array<string, string>|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;
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
27
app/Http/Requests/Tenant/EmotionStoreRequest.php
Normal file
27
app/Http/Requests/Tenant/EmotionStoreRequest.php
Normal file
@@ -0,0 +1,27 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests\Tenant;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class EmotionStoreRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'name' => ['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'],
|
||||
];
|
||||
}
|
||||
}
|
||||
27
app/Http/Requests/Tenant/EmotionUpdateRequest.php
Normal file
27
app/Http/Requests/Tenant/EmotionUpdateRequest.php
Normal file
@@ -0,0 +1,27 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests\Tenant;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class EmotionUpdateRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'name' => ['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'],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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.');
|
||||
}
|
||||
}],
|
||||
|
||||
@@ -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.');
|
||||
}
|
||||
}],
|
||||
|
||||
64
app/Http/Resources/Tenant/EmotionResource.php
Normal file
64
app/Http/Resources/Tenant/EmotionResource.php
Normal file
@@ -0,0 +1,64 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Resources\Tenant;
|
||||
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Resources\Json\JsonResource;
|
||||
|
||||
class EmotionResource extends JsonResource
|
||||
{
|
||||
public function toArray(Request $request): array
|
||||
{
|
||||
return [
|
||||
'id' => $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;
|
||||
}
|
||||
}
|
||||
42
app/Http/Resources/Tenant/TaskCollectionResource.php
Normal file
42
app/Http/Resources/Tenant/TaskCollectionResource.php
Normal file
@@ -0,0 +1,42 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Resources\Tenant;
|
||||
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Resources\Json\JsonResource;
|
||||
|
||||
class TaskCollectionResource extends JsonResource
|
||||
{
|
||||
/**
|
||||
* Transform the resource into an array.
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
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(),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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<string, string>|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<string, string> $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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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)
|
||||
?? '';
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,10 +52,6 @@ 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
|
||||
;
|
||||
|
||||
@@ -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,
|
||||
|
||||
161
app/Services/Tenant/TaskCollectionImportService.php
Normal file
161
app/Services/Tenant/TaskCollectionImportService.php
Normal file
@@ -0,0 +1,161 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Tenant;
|
||||
|
||||
use App\Models\Event;
|
||||
use App\Models\Task;
|
||||
use App\Models\TaskCollection;
|
||||
use Illuminate\Database\DatabaseManager;
|
||||
use Illuminate\Support\Str;
|
||||
use RuntimeException;
|
||||
|
||||
class TaskCollectionImportService
|
||||
{
|
||||
public function __construct(private readonly DatabaseManager $db)
|
||||
{
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{collection: TaskCollection, created_task_ids: array<int>, attached_task_ids: array<int>}
|
||||
*/
|
||||
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;
|
||||
}
|
||||
}
|
||||
87
app/Support/TenantOnboardingState.php
Normal file
87
app/Support/TenantOnboardingState.php
Normal file
@@ -0,0 +1,87 @@
|
||||
<?php
|
||||
|
||||
namespace App\Support;
|
||||
|
||||
use App\Models\EventJoinToken;
|
||||
use App\Models\TaskCollection;
|
||||
use App\Models\Tenant;
|
||||
use Filament\Facades\Filament;
|
||||
use Illuminate\Support\Arr;
|
||||
use Illuminate\Support\Carbon;
|
||||
|
||||
class TenantOnboardingState
|
||||
{
|
||||
public static function tenant(?Tenant $tenant = null): ?Tenant
|
||||
{
|
||||
if ($tenant) {
|
||||
return $tenant;
|
||||
}
|
||||
|
||||
/** @var Tenant|null $tenant */
|
||||
$tenant = Filament::getTenant();
|
||||
|
||||
return $tenant;
|
||||
}
|
||||
|
||||
public static function status(?Tenant $tenant = null): array
|
||||
{
|
||||
$tenant = self::tenant($tenant);
|
||||
|
||||
if (! $tenant) {
|
||||
return [
|
||||
'packages' => 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();
|
||||
}
|
||||
}
|
||||
@@ -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": {
|
||||
|
||||
69
composer.lock
generated
69
composer.lock
generated
@@ -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",
|
||||
|
||||
@@ -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))));
|
||||
})(),
|
||||
],
|
||||
],
|
||||
|
||||
];
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -0,0 +1,278 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
if (Schema::hasTable('task_collections')) {
|
||||
if (Schema::hasColumn('task_collections', 'tenant_id')) {
|
||||
Schema::table('task_collections', function (Blueprint $table) {
|
||||
$table->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();
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,68 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
if (Schema::hasTable('tasks')) {
|
||||
Schema::table('tasks', function (Blueprint $table) {
|
||||
if (! Schema::hasColumn('tasks', 'source_task_id')) {
|
||||
$table->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');
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -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();
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
$this->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<string, string>
|
||||
*/
|
||||
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;
|
||||
}
|
||||
};
|
||||
@@ -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 = Arr::wrap($serviceConfig['redirects'] ?? []);
|
||||
if (empty($redirectUris)) {
|
||||
$redirectUris = [
|
||||
'http://localhost:5174/auth/callback',
|
||||
'http://localhost:8000/auth/callback',
|
||||
'http://localhost:5173/event-admin/auth/callback',
|
||||
'http://localhost:8000/event-admin/auth/callback',
|
||||
];
|
||||
}
|
||||
|
||||
$scopes = [
|
||||
'tenant:read',
|
||||
|
||||
@@ -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,
|
||||
$collections = [
|
||||
[
|
||||
'slug' => 'wedding-classics',
|
||||
'event_type' => [
|
||||
'slug' => 'wedding',
|
||||
'name' => [
|
||||
'de' => 'Hochzeitsaufgaben',
|
||||
'en' => 'Wedding Tasks'
|
||||
'de' => 'Hochzeit',
|
||||
'en' => 'Wedding',
|
||||
],
|
||||
'icon' => 'lucide-heart',
|
||||
],
|
||||
'name' => [
|
||||
'de' => 'Hochzeitsklassiker',
|
||||
'en' => 'Wedding Classics',
|
||||
],
|
||||
'description' => [
|
||||
'de' => 'Spezielle Aufgaben für Hochzeitsgäste',
|
||||
'en' => 'Special tasks for wedding guests'
|
||||
'de' => 'Kuratierte Aufgaben rund um Trauung, Emotionen und besondere Momente.',
|
||||
'en' => 'Curated prompts for vows, emotions, and memorable wedding highlights.',
|
||||
],
|
||||
]);
|
||||
|
||||
// Assign first 4 tasks to wedding collection using Eloquent
|
||||
$weddingTasks = collect($taskIds)->take(4);
|
||||
$weddingCollection->tasks()->attach($weddingTasks);
|
||||
|
||||
// Link wedding collection to demo event using Eloquent
|
||||
$demoEvent->taskCollections()->attach($weddingCollection, ['sort_order' => 1]);
|
||||
|
||||
// Create General Fun Tasks Collection (fallback) using Eloquent
|
||||
$funCollection = TaskCollection::create([
|
||||
'tenant_id' => $demoTenant->id,
|
||||
'name' => [
|
||||
'de' => 'Spaß-Aufgaben',
|
||||
'en' => 'Fun Tasks'
|
||||
'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' => 'Allgemeine unterhaltsame Aufgaben',
|
||||
'en' => 'General entertaining tasks'
|
||||
'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,
|
||||
],
|
||||
],
|
||||
],
|
||||
[
|
||||
'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,
|
||||
],
|
||||
],
|
||||
],
|
||||
];
|
||||
|
||||
DB::transaction(function () use ($collections) {
|
||||
foreach ($collections as $definition) {
|
||||
$eventType = $this->ensureEventType($definition['event_type']);
|
||||
|
||||
$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,
|
||||
]
|
||||
);
|
||||
|
||||
$syncPayload = [];
|
||||
|
||||
foreach ($definition['tasks'] as $taskDefinition) {
|
||||
$emotion = $this->ensureEmotion($taskDefinition['emotion'] ?? [], $eventType->id);
|
||||
|
||||
$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,
|
||||
]
|
||||
);
|
||||
|
||||
$syncPayload[$task->id] = ['sort_order' => $taskDefinition['sort_order'] ?? 0];
|
||||
}
|
||||
|
||||
if (! empty($syncPayload)) {
|
||||
$collection->tasks()->sync($syncPayload);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
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,
|
||||
]);
|
||||
}
|
||||
|
||||
// Assign remaining tasks to fun collection using Eloquent
|
||||
$funTasks = collect($taskIds)->slice(4);
|
||||
$funCollection->tasks()->attach($funTasks);
|
||||
if ($eventTypeId && ! $emotion->eventTypes()->where('event_type_id', $eventTypeId)->exists()) {
|
||||
$emotion->eventTypes()->attach($eventTypeId);
|
||||
}
|
||||
|
||||
// Link fun collection to demo event as fallback using Eloquent
|
||||
$demoEvent->taskCollections()->attach($funCollection, ['sort_order' => 2]);
|
||||
|
||||
$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}");
|
||||
return $emotion;
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { authorizedFetch } from './auth/tokens';
|
||||
import i18n from './i18n';
|
||||
|
||||
type JsonValue = Record<string, any>;
|
||||
|
||||
@@ -108,25 +109,93 @@ export type CreditLedgerEntry = {
|
||||
|
||||
export type TenantTask = {
|
||||
id: number;
|
||||
slug: string;
|
||||
title: string;
|
||||
title_translations: Record<string, string>;
|
||||
description: string | null;
|
||||
description_translations: Record<string, string | null>;
|
||||
example_text: string | null;
|
||||
example_text_translations: Record<string, string>;
|
||||
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<string, string>;
|
||||
description: string | null;
|
||||
description_translations: Record<string, string | null>;
|
||||
tenant_id: number | null;
|
||||
is_global: boolean;
|
||||
event_type?: {
|
||||
id: number;
|
||||
slug: string;
|
||||
name: string;
|
||||
name_translations: Record<string, string>;
|
||||
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<string, string>;
|
||||
description: string | null;
|
||||
description_translations: Record<string, string | null>;
|
||||
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<string, string>;
|
||||
}>;
|
||||
created_at: string | null;
|
||||
updated_at: string | null;
|
||||
};
|
||||
|
||||
export type TaskPayload = Partial<{
|
||||
title: string;
|
||||
title_translations: Record<string, string>;
|
||||
description: string | null;
|
||||
description_translations: Record<string, string | null>;
|
||||
example_text: string | null;
|
||||
example_text_translations: Record<string, string | null>;
|
||||
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<string, string> {
|
||||
if (typeof value === 'string') {
|
||||
const map: Record<string, string> = {};
|
||||
for (const locale of translationLocales()) {
|
||||
map[locale] = value;
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
if (value && typeof value === 'object' && !Array.isArray(value)) {
|
||||
const entries: Record<string, string> = {};
|
||||
for (const [key, entry] of Object.entries(value as Record<string, unknown>)) {
|
||||
if (typeof entry === 'string') {
|
||||
entries[key] = entry;
|
||||
}
|
||||
}
|
||||
|
||||
if (Object.keys(entries).length > 0) {
|
||||
return entries;
|
||||
}
|
||||
}
|
||||
|
||||
if (fallback) {
|
||||
const locales = translationLocales();
|
||||
return locales.reduce<Record<string, string>>((acc, locale) => {
|
||||
acc[locale] = fallback;
|
||||
return acc;
|
||||
}, {});
|
||||
}
|
||||
|
||||
return allowEmpty ? {} : {};
|
||||
}
|
||||
|
||||
function pickTranslatedText(translations: Record<string, string>, 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<PaginationMeta>;
|
||||
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<PaginatedResult<TenantTaskCollection>> {
|
||||
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<TenantTaskCollection> {
|
||||
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<TenantTaskCollection> {
|
||||
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<TaskCollectionResponse>(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<TenantEmotion[]> {
|
||||
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<TenantEmotion> {
|
||||
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<TenantEmotion> {
|
||||
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<PaginatedResult<TenantTask>> {
|
||||
const searchParams = new URLSearchParams();
|
||||
if (params.page) searchParams.set('page', String(params.page));
|
||||
|
||||
@@ -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' },
|
||||
];
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -7,6 +7,8 @@
|
||||
"dashboard": "Dashboard",
|
||||
"events": "Events",
|
||||
"tasks": "Aufgaben",
|
||||
"collections": "Aufgabenvorlagen",
|
||||
"emotions": "Emotionen",
|
||||
"billing": "Abrechnung",
|
||||
"settings": "Einstellungen"
|
||||
},
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -7,6 +7,8 @@
|
||||
"dashboard": "Dashboard",
|
||||
"events": "Events",
|
||||
"tasks": "Tasks",
|
||||
"collections": "Collections",
|
||||
"emotions": "Emotions",
|
||||
"billing": "Billing",
|
||||
"settings": "Settings"
|
||||
},
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
384
resources/js/admin/pages/EmotionsPage.tsx
Normal file
384
resources/js/admin/pages/EmotionsPage.tsx
Normal file
@@ -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<TenantEmotion[]>([]);
|
||||
const [loading, setLoading] = React.useState(true);
|
||||
const [error, setError] = React.useState<string | null>(null);
|
||||
|
||||
const [dialogOpen, setDialogOpen] = React.useState(false);
|
||||
const [saving, setSaving] = React.useState(false);
|
||||
const [form, setForm] = React.useState<EmotionFormState>(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<HTMLFormElement>) {
|
||||
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 (
|
||||
<AdminLayout
|
||||
title={t('emotions.title') ?? 'Emotions'}
|
||||
subtitle={t('emotions.subtitle') ?? ''}
|
||||
actions={
|
||||
<Button
|
||||
className="bg-gradient-to-r from-pink-500 via-fuchsia-500 to-purple-500 text-white shadow-lg shadow-pink-500/20"
|
||||
onClick={openCreateDialog}
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
{t('emotions.actions.create')}
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
{error && (
|
||||
<Alert variant="destructive">
|
||||
<AlertTitle>{t('emotions.errors.genericTitle')}</AlertTitle>
|
||||
<AlertDescription>{error}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Card className="border-0 bg-white/85 shadow-xl shadow-pink-100/60">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-xl text-slate-900">{t('emotions.title')}</CardTitle>
|
||||
<CardDescription className="text-sm text-slate-600">{t('emotions.subtitle')}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
{loading ? (
|
||||
<EmotionSkeleton />
|
||||
) : emotions.length === 0 ? (
|
||||
<EmptyEmotionsState />
|
||||
) : (
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
{emotions.map((emotion) => (
|
||||
<EmotionCard
|
||||
key={emotion.id}
|
||||
emotion={emotion}
|
||||
onToggle={() => toggleEmotion(emotion)}
|
||||
locale={locale}
|
||||
canToggle={!emotion.is_global}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<EmotionDialog
|
||||
open={dialogOpen}
|
||||
onOpenChange={setDialogOpen}
|
||||
form={form}
|
||||
setForm={setForm}
|
||||
saving={saving}
|
||||
onSubmit={handleCreate}
|
||||
/>
|
||||
</AdminLayout>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<Card className="border border-slate-100 bg-white/90 shadow-sm">
|
||||
<CardHeader className="space-y-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={emotion.is_global ? 'border-indigo-200 text-indigo-600' : 'border-pink-200 text-pink-600'}
|
||||
>
|
||||
{emotion.is_global ? t('emotions.scope.global') : t('emotions.scope.tenant')}
|
||||
</Badge>
|
||||
<Badge variant="secondary" className="bg-slate-100 text-slate-700">
|
||||
{emotion.event_types.map((type) => type.name).join(', ') || t('emotions.labels.noEventType')}
|
||||
</Badge>
|
||||
</div>
|
||||
<CardTitle className="flex items-center gap-2 text-lg text-slate-900">
|
||||
<Smile className="h-4 w-4" />
|
||||
{emotion.name}
|
||||
</CardTitle>
|
||||
{emotion.description && (
|
||||
<CardDescription className="text-sm text-slate-600">{emotion.description}</CardDescription>
|
||||
)}
|
||||
</CardHeader>
|
||||
<CardContent className="flex items-center justify-between text-xs text-slate-500">
|
||||
<div className="flex items-center gap-2">
|
||||
<Palette className="h-4 w-4" />
|
||||
<span>{emotion.color}</span>
|
||||
</div>
|
||||
{updatedLabel && <span>{t('emotions.labels.updated', { date: updatedLabel })}</span>}
|
||||
</CardContent>
|
||||
<CardFooter className="flex items-center justify-between border-t border-slate-100 bg-slate-50/60 px-4 py-3">
|
||||
<span className="text-xs text-slate-500">
|
||||
{emotion.is_active ? t('emotions.status.active') : t('emotions.status.inactive')}
|
||||
</span>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={onToggle}
|
||||
disabled={!canToggle}
|
||||
className={!canToggle ? 'pointer-events-none opacity-60' : ''}
|
||||
>
|
||||
<Power className="mr-2 h-4 w-4" />
|
||||
{emotion.is_active ? t('emotions.actions.disable') : t('emotions.actions.enable')}
|
||||
</Button>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function EmptyEmotionsState() {
|
||||
const { t } = useTranslation('management');
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center gap-4 rounded-2xl border border-dashed border-indigo-200 bg-indigo-50/40 p-12 text-center">
|
||||
<div className="rounded-full bg-white p-4 shadow-inner">
|
||||
<Smile className="h-8 w-8 text-indigo-500" />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<h3 className="text-lg font-semibold text-slate-800">{t('emotions.empty.title')}</h3>
|
||||
<p className="text-sm text-slate-600">{t('emotions.empty.description')}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function EmotionSkeleton() {
|
||||
return (
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
{Array.from({ length: 4 }).map((_, index) => (
|
||||
<div key={index} className="animate-pulse rounded-2xl border border-slate-100 bg-white/80 p-6 shadow-sm">
|
||||
<div className="mb-3 h-4 w-24 rounded bg-slate-200" />
|
||||
<div className="mb-2 h-5 w-40 rounded bg-slate-200" />
|
||||
<div className="h-16 rounded bg-slate-100" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function EmotionDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
form,
|
||||
setForm,
|
||||
saving,
|
||||
onSubmit,
|
||||
}: {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
form: EmotionFormState;
|
||||
setForm: React.Dispatch<React.SetStateAction<EmotionFormState>>;
|
||||
saving: boolean;
|
||||
onSubmit: (event: React.FormEvent<HTMLFormElement>) => void;
|
||||
}) {
|
||||
const { t } = useTranslation('management');
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t('emotions.dialogs.createTitle')}</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<form className="space-y-4" onSubmit={onSubmit}>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="emotion-name">{t('emotions.dialogs.name')}</Label>
|
||||
<Input
|
||||
id="emotion-name"
|
||||
value={form.name}
|
||||
onChange={(event) => setForm((prev) => ({ ...prev, name: event.target.value }))}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="emotion-description">{t('emotions.dialogs.description')}</Label>
|
||||
<textarea
|
||||
id="emotion-description"
|
||||
value={form.description}
|
||||
onChange={(event) => setForm((prev) => ({ ...prev, description: event.target.value }))}
|
||||
className="min-h-[80px] w-full rounded-md border border-slate-200 bg-white/80 p-2 text-sm shadow-sm focus:border-indigo-300 focus:outline-none focus:ring-2 focus:ring-indigo-200"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="emotion-icon">{t('emotions.dialogs.icon')}</Label>
|
||||
<Input
|
||||
id="emotion-icon"
|
||||
value={form.icon}
|
||||
onChange={(event) => setForm((prev) => ({ ...prev, icon: event.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="emotion-color">{t('emotions.dialogs.color')}</Label>
|
||||
<Input
|
||||
id="emotion-color"
|
||||
value={form.color}
|
||||
onChange={(event) => setForm((prev) => ({ ...prev, color: event.target.value }))}
|
||||
placeholder="#6366f1"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between rounded-lg border border-indigo-100 bg-indigo-50/60 p-3">
|
||||
<div>
|
||||
<Label htmlFor="emotion-active" className="text-sm font-medium text-slate-800">
|
||||
{t('emotions.dialogs.activeLabel')}
|
||||
</Label>
|
||||
<p className="text-xs text-slate-500">{t('emotions.dialogs.activeDescription')}</p>
|
||||
</div>
|
||||
<Switch
|
||||
id="emotion-active"
|
||||
checked={form.is_active}
|
||||
onCheckedChange={(checked) => setForm((prev) => ({ ...prev, is_active: Boolean(checked) }))}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<DialogFooter className="flex justify-end gap-2">
|
||||
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
|
||||
{t('emotions.dialogs.cancel')}
|
||||
</Button>
|
||||
<Button type="submit" disabled={saving}>
|
||||
{saving ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : null}
|
||||
{t('emotions.dialogs.submit')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
492
resources/js/admin/pages/TaskCollectionsPage.tsx
Normal file
492
resources/js/admin/pages/TaskCollectionsPage.tsx
Normal file
@@ -0,0 +1,492 @@
|
||||
import React from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { format } from 'date-fns';
|
||||
import { de, enGB } from 'date-fns/locale';
|
||||
import { Layers, Library, Loader2, Plus } from 'lucide-react';
|
||||
|
||||
import { AdminLayout } from '../components/AdminLayout';
|
||||
import {
|
||||
getTaskCollections,
|
||||
importTaskCollection,
|
||||
getEvents,
|
||||
PaginationMeta,
|
||||
TenantEvent,
|
||||
TenantTaskCollection,
|
||||
} from '../api';
|
||||
import { ADMIN_TASKS_PATH } from '../constants';
|
||||
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 {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
|
||||
const DEFAULT_PAGE_SIZE = 12;
|
||||
|
||||
type ScopeFilter = 'all' | 'global' | 'tenant';
|
||||
|
||||
type CollectionsState = {
|
||||
items: TenantTaskCollection[];
|
||||
meta: PaginationMeta | null;
|
||||
};
|
||||
|
||||
export default function TaskCollectionsPage(): JSX.Element {
|
||||
const navigate = useNavigate();
|
||||
const { t, i18n } = useTranslation('management');
|
||||
|
||||
const [collectionsState, setCollectionsState] = React.useState<CollectionsState>({ items: [], meta: null });
|
||||
const [page, setPage] = React.useState(1);
|
||||
const [search, setSearch] = React.useState('');
|
||||
const [scope, setScope] = React.useState<ScopeFilter>('all');
|
||||
const [loading, setLoading] = React.useState(true);
|
||||
const [error, setError] = React.useState<string | null>(null);
|
||||
const [successMessage, setSuccessMessage] = React.useState<string | null>(null);
|
||||
|
||||
const [dialogOpen, setDialogOpen] = React.useState(false);
|
||||
const [selectedCollection, setSelectedCollection] = React.useState<TenantTaskCollection | null>(null);
|
||||
const [events, setEvents] = React.useState<TenantEvent[]>([]);
|
||||
const [selectedEventSlug, setSelectedEventSlug] = React.useState('');
|
||||
const [importing, setImporting] = React.useState(false);
|
||||
const [eventsLoading, setEventsLoading] = React.useState(false);
|
||||
const [eventError, setEventError] = React.useState<string | null>(null);
|
||||
const [reloadToken, setReloadToken] = React.useState(0);
|
||||
|
||||
const scopeParam = React.useMemo(() => {
|
||||
if (scope === 'global') return 'global';
|
||||
if (scope === 'tenant') return 'tenant';
|
||||
return undefined;
|
||||
}, [scope]);
|
||||
|
||||
React.useEffect(() => {
|
||||
let cancelled = false;
|
||||
async function loadCollections() {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const result = await getTaskCollections({
|
||||
page,
|
||||
per_page: DEFAULT_PAGE_SIZE,
|
||||
search: search.trim() || undefined,
|
||||
scope: scopeParam,
|
||||
});
|
||||
if (cancelled) return;
|
||||
setCollectionsState({ items: result.data, meta: result.meta });
|
||||
} catch (err) {
|
||||
if (cancelled) return;
|
||||
if (!isAuthError(err)) {
|
||||
setError(t('collections.notifications.error'));
|
||||
}
|
||||
} finally {
|
||||
if (!cancelled) {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void loadCollections();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [page, search, scopeParam, reloadToken, t]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (successMessage) {
|
||||
const timeout = setTimeout(() => setSuccessMessage(null), 4000);
|
||||
return () => clearTimeout(timeout);
|
||||
}
|
||||
return undefined;
|
||||
}, [successMessage]);
|
||||
|
||||
async function ensureEventsLoaded() {
|
||||
if (events.length > 0 || eventsLoading) {
|
||||
return;
|
||||
}
|
||||
setEventsLoading(true);
|
||||
setEventError(null);
|
||||
try {
|
||||
const result = await getEvents();
|
||||
setEvents(result);
|
||||
} catch (err) {
|
||||
if (!isAuthError(err)) {
|
||||
setEventError(t('collections.errors.eventsLoad'));
|
||||
}
|
||||
} finally {
|
||||
setEventsLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
function openImportDialog(collection: TenantTaskCollection) {
|
||||
setSelectedCollection(collection);
|
||||
setSelectedEventSlug('');
|
||||
setDialogOpen(true);
|
||||
void ensureEventsLoaded();
|
||||
}
|
||||
|
||||
async function handleImport(event: React.FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
if (!selectedCollection || !selectedEventSlug) {
|
||||
setEventError(t('collections.errors.selectEvent'));
|
||||
return;
|
||||
}
|
||||
setImporting(true);
|
||||
setEventError(null);
|
||||
try {
|
||||
await importTaskCollection(selectedCollection.id, selectedEventSlug);
|
||||
setSuccessMessage(t('collections.notifications.imported'));
|
||||
setDialogOpen(false);
|
||||
setReloadToken((token) => token + 1);
|
||||
} catch (err) {
|
||||
if (!isAuthError(err)) {
|
||||
setEventError(t('collections.notifications.error'));
|
||||
}
|
||||
} finally {
|
||||
setImporting(false);
|
||||
}
|
||||
}
|
||||
|
||||
const showEmpty = !loading && collectionsState.items.length === 0;
|
||||
|
||||
return (
|
||||
<AdminLayout
|
||||
title={t('collections.title') ?? 'Task Collections'}
|
||||
subtitle={t('collections.subtitle') ?? ''}
|
||||
actions={
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button variant="outline" onClick={() => navigate(ADMIN_TASKS_PATH)}>
|
||||
<Library className="mr-2 h-4 w-4" />
|
||||
{t('collections.actions.openTasks')}
|
||||
</Button>
|
||||
<Button
|
||||
className="bg-gradient-to-r from-pink-500 via-fuchsia-500 to-purple-500 text-white shadow-lg shadow-pink-500/20"
|
||||
onClick={() => navigate(ADMIN_TASKS_PATH)}
|
||||
>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
{t('collections.actions.create')}
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
{error && (
|
||||
<Alert variant="destructive">
|
||||
<AlertTitle>{t('collections.notifications.error')}</AlertTitle>
|
||||
<AlertDescription>{error}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{successMessage && (
|
||||
<Alert className="border-l-4 border-green-500 bg-green-50 text-sm text-green-900">
|
||||
<AlertTitle>{t('collections.notifications.imported')}</AlertTitle>
|
||||
<AlertDescription>{successMessage}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Card className="border-0 bg-white/85 shadow-xl shadow-pink-100/60">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-xl text-slate-900">{t('collections.title')}</CardTitle>
|
||||
<CardDescription className="text-sm text-slate-600">{t('collections.subtitle')}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
<div className="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
|
||||
<Input
|
||||
value={search}
|
||||
onChange={(event) => {
|
||||
setPage(1);
|
||||
setSearch(event.target.value);
|
||||
}}
|
||||
placeholder={t('collections.filters.search') ?? 'Search collections'}
|
||||
className="w-full lg:max-w-md"
|
||||
/>
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<Select
|
||||
value={scope}
|
||||
onValueChange={(value) => {
|
||||
setScope(value as ScopeFilter);
|
||||
setPage(1);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="w-[220px]">
|
||||
<SelectValue placeholder={t('collections.filters.scope')} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">{t('collections.filters.allScopes')}</SelectItem>
|
||||
<SelectItem value="global">{t('collections.filters.globalOnly')}</SelectItem>
|
||||
<SelectItem value="tenant">{t('collections.filters.tenantOnly')}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<CollectionSkeleton />
|
||||
) : showEmpty ? (
|
||||
<EmptyCollectionsState onCreate={() => navigate(ADMIN_TASKS_PATH)} />
|
||||
) : (
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
{collectionsState.items.map((collection) => (
|
||||
<CollectionCard
|
||||
key={collection.id}
|
||||
collection={collection}
|
||||
onImport={() => openImportDialog(collection)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{collectionsState.meta && collectionsState.meta.last_page > 1 && (
|
||||
<div className="flex items-center justify-between">
|
||||
<Button
|
||||
variant="outline"
|
||||
disabled={page <= 1}
|
||||
onClick={() => setPage((prev) => Math.max(prev - 1, 1))}
|
||||
>
|
||||
{t('collections.pagination.prev')}
|
||||
</Button>
|
||||
<span className="text-xs text-slate-500">
|
||||
{t('collections.pagination.page', {
|
||||
current: collectionsState.meta.current_page,
|
||||
total: collectionsState.meta.last_page,
|
||||
})}
|
||||
</span>
|
||||
<Button
|
||||
variant="outline"
|
||||
disabled={page >= collectionsState.meta.last_page}
|
||||
onClick={() => setPage((prev) => Math.min(prev + 1, collectionsState.meta!.last_page))}
|
||||
>
|
||||
{t('collections.pagination.next')}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<ImportDialog
|
||||
open={dialogOpen}
|
||||
onOpenChange={setDialogOpen}
|
||||
collection={selectedCollection}
|
||||
events={events}
|
||||
eventError={eventError}
|
||||
eventsLoading={eventsLoading}
|
||||
selectedEventSlug={selectedEventSlug}
|
||||
onEventChange={setSelectedEventSlug}
|
||||
onSubmit={handleImport}
|
||||
importing={importing}
|
||||
/>
|
||||
</AdminLayout>
|
||||
);
|
||||
}
|
||||
|
||||
function CollectionCard({
|
||||
collection,
|
||||
onImport,
|
||||
}: {
|
||||
collection: TenantTaskCollection;
|
||||
onImport: () => void;
|
||||
}) {
|
||||
const { t, i18n } = useTranslation('management');
|
||||
const locale = i18n.language.startsWith('en') ? enGB : de;
|
||||
const updatedLabel = collection.updated_at
|
||||
? format(new Date(collection.updated_at), 'PP', { locale })
|
||||
: null;
|
||||
const scopeLabel = collection.is_global ? t('collections.scope.global') : t('collections.scope.tenant');
|
||||
const eventTypeLabel = collection.event_type?.name ?? t('collections.filters.allEventTypes');
|
||||
|
||||
return (
|
||||
<Card className="border border-slate-100 bg-white/90 shadow-sm">
|
||||
<CardHeader className="space-y-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={collection.is_global ? 'border-indigo-200 text-indigo-600' : 'border-pink-200 text-pink-600'}
|
||||
>
|
||||
{scopeLabel}
|
||||
</Badge>
|
||||
{eventTypeLabel && (
|
||||
<Badge variant="secondary" className="bg-slate-100 text-slate-700">
|
||||
{eventTypeLabel}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<CardTitle className="text-lg text-slate-900">{collection.name}</CardTitle>
|
||||
{collection.description && (
|
||||
<CardDescription className="text-sm text-slate-600">
|
||||
{collection.description}
|
||||
</CardDescription>
|
||||
)}
|
||||
</CardHeader>
|
||||
<CardContent className="flex items-center justify-between text-sm text-slate-500">
|
||||
<div className="flex items-center gap-2">
|
||||
<Layers className="h-4 w-4" />
|
||||
<span>{t('collections.labels.taskCount', { count: collection.tasks_count })}</span>
|
||||
</div>
|
||||
{updatedLabel && <span>{t('collections.labels.updated', { date: updatedLabel })}</span>}
|
||||
</CardContent>
|
||||
<CardFooter className="flex justify-end">
|
||||
<Button variant="outline" onClick={onImport}>
|
||||
{t('collections.actions.import')}
|
||||
</Button>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function EmptyCollectionsState({ onCreate }: { onCreate: () => void }) {
|
||||
const { t } = useTranslation('management');
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center gap-4 rounded-2xl border border-dashed border-pink-200 bg-pink-50/40 p-12 text-center">
|
||||
<div className="rounded-full bg-white p-4 shadow-inner">
|
||||
<Layers className="h-8 w-8 text-pink-500" />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<h3 className="text-lg font-semibold text-slate-800">{t('collections.empty.title')}</h3>
|
||||
<p className="text-sm text-slate-600">{t('collections.empty.description')}</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button onClick={onCreate}>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
{t('collections.actions.create')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CollectionSkeleton() {
|
||||
return (
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
{Array.from({ length: 4 }).map((_, index) => (
|
||||
<div key={index} className="animate-pulse rounded-2xl border border-slate-100 bg-white/80 p-6 shadow-sm">
|
||||
<div className="mb-3 h-4 w-24 rounded bg-slate-200" />
|
||||
<div className="mb-2 h-5 w-40 rounded bg-slate-200" />
|
||||
<div className="h-16 rounded bg-slate-100" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ImportDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
collection,
|
||||
events,
|
||||
eventsLoading,
|
||||
eventError,
|
||||
selectedEventSlug,
|
||||
onEventChange,
|
||||
onSubmit,
|
||||
importing,
|
||||
}: {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
collection: TenantTaskCollection | null;
|
||||
events: TenantEvent[];
|
||||
eventsLoading: boolean;
|
||||
eventError: string | null;
|
||||
selectedEventSlug: string;
|
||||
onEventChange: (value: string) => void;
|
||||
onSubmit: (event: React.FormEvent<HTMLFormElement>) => void;
|
||||
importing: boolean;
|
||||
}) {
|
||||
const { t, i18n } = useTranslation('management');
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t('collections.dialogs.importTitle')}</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<form className="space-y-4" onSubmit={onSubmit}>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="collection-name">{t('collections.dialogs.collectionLabel')}</Label>
|
||||
<Input id="collection-name" value={collection?.name ?? ''} readOnly disabled className="bg-slate-100" />
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="collection-event">{t('collections.dialogs.selectEvent')}</Label>
|
||||
<Select
|
||||
value={selectedEventSlug}
|
||||
onValueChange={onEventChange}
|
||||
disabled={eventsLoading || events.length === 0}
|
||||
>
|
||||
<SelectTrigger id="collection-event">
|
||||
<SelectValue placeholder={t('collections.dialogs.selectEvent') ?? 'Event auswählen'} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{events.map((event) => (
|
||||
<SelectItem key={event.slug} value={event.slug}>
|
||||
{formatEventLabel(event, i18n.language)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{events.length === 0 && !eventsLoading && (
|
||||
<p className="text-xs text-slate-500">
|
||||
{t('collections.errors.noEvents') ?? 'Noch keine Events vorhanden.'}
|
||||
</p>
|
||||
)}
|
||||
{eventError && <p className="text-xs text-red-500">{eventError}</p>}
|
||||
</div>
|
||||
|
||||
<DialogFooter className="flex justify-end gap-2">
|
||||
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
|
||||
{t('collections.dialogs.cancel')}
|
||||
</Button>
|
||||
<Button type="submit" disabled={importing || !selectedEventSlug}>
|
||||
{importing ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : null}
|
||||
{t('collections.dialogs.submit')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
function formatEventLabel(event: TenantEvent, language: string): string {
|
||||
const locales = [language, language?.split('-')[0], 'de', 'en'].filter(Boolean) as string[];
|
||||
|
||||
let name: string | undefined;
|
||||
if (typeof event.name === 'string') {
|
||||
name = event.name;
|
||||
} else if (event.name && typeof event.name === 'object') {
|
||||
for (const locale of locales) {
|
||||
const value = (event.name as Record<string, string>)[locale!];
|
||||
if (value) {
|
||||
name = value;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!name) {
|
||||
const first = Object.values(event.name as Record<string, string>)[0];
|
||||
if (first) {
|
||||
name = first;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const eventDate = event.event_date ? new Date(event.event_date) : null;
|
||||
if (!eventDate) {
|
||||
return name ?? event.slug;
|
||||
}
|
||||
|
||||
const locale = language.startsWith('en') ? enGB : de;
|
||||
return `${name ?? event.slug} (${format(eventDate, 'PP', { locale })})`;
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import React from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { CheckCircle2, Circle, Loader2, Pencil, Plus, Trash2 } from 'lucide-react';
|
||||
|
||||
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
|
||||
@@ -23,7 +24,7 @@ import {
|
||||
updateTask,
|
||||
} from '../api';
|
||||
import { isAuthError } from '../auth/tokens';
|
||||
import { ADMIN_EVENTS_PATH } from '../constants';
|
||||
import { ADMIN_EVENTS_PATH, ADMIN_TASK_COLLECTIONS_PATH } from '../constants';
|
||||
|
||||
type TaskFormState = {
|
||||
title: string;
|
||||
@@ -43,6 +44,7 @@ const INITIAL_FORM: TaskFormState = {
|
||||
|
||||
export default function TasksPage() {
|
||||
const navigate = useNavigate();
|
||||
const { t } = useTranslation('common');
|
||||
const [tasks, setTasks] = React.useState<TenantTask[]>([]);
|
||||
const [meta, setMeta] = React.useState<PaginationMeta | null>(null);
|
||||
const [page, setPage] = React.useState(1);
|
||||
@@ -150,6 +152,9 @@ export default function TasksPage() {
|
||||
}
|
||||
|
||||
async function toggleCompletion(task: TenantTask) {
|
||||
if (task.tenant_id === null) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const updated = await updateTask(task.id, { is_completed: !task.is_completed });
|
||||
setTasks((prev) => prev.map((entry) => (entry.id === updated.id ? updated : entry)));
|
||||
@@ -165,13 +170,18 @@ export default function TasksPage() {
|
||||
title="Task Bibliothek"
|
||||
subtitle="Weise Aufgaben zu und tracke Fortschritt rund um deine Events."
|
||||
actions={
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button variant="outline" onClick={() => navigate(ADMIN_TASK_COLLECTIONS_PATH)}>
|
||||
{t('navigation.collections')}
|
||||
</Button>
|
||||
<Button
|
||||
className="bg-gradient-to-r from-pink-500 via-fuchsia-500 to-purple-500 text-white shadow-lg shadow-pink-500/20"
|
||||
onClick={openCreate}
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
Neuer Task
|
||||
Neu
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
{error && (
|
||||
@@ -269,25 +279,34 @@ function TaskRow({
|
||||
}) {
|
||||
const assignedCount = task.assigned_events_count ?? task.assigned_events?.length ?? 0;
|
||||
const completed = task.is_completed;
|
||||
const isGlobal = task.tenant_id === null;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3 rounded-2xl border border-slate-100 bg-white/90 p-4 shadow-sm sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="flex flex-1 items-start gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onToggle}
|
||||
className="rounded-full border border-pink-200 bg-white/80 p-2 text-pink-600 shadow-sm transition hover:bg-pink-50"
|
||||
onClick={isGlobal ? undefined : onToggle}
|
||||
aria-disabled={isGlobal}
|
||||
className={`rounded-full border border-pink-200 bg-white/80 p-2 text-pink-600 shadow-sm transition ${
|
||||
isGlobal ? 'opacity-50 cursor-not-allowed' : 'hover:bg-pink-50'
|
||||
}`}
|
||||
>
|
||||
{completed ? <CheckCircle2 className="h-5 w-5" /> : <Circle className="h-5 w-5" />}
|
||||
</button>
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<p className={`text-sm font-semibold ${completed ? 'text-slate-500 line-through' : 'text-slate-900'}`}>
|
||||
{task.title}
|
||||
</p>
|
||||
<Badge variant="outline" className="border-pink-200 text-pink-600">
|
||||
{mapPriority(task.priority)}
|
||||
</Badge>
|
||||
{isGlobal && (
|
||||
<Badge variant="secondary" className="bg-slate-100 text-slate-600">
|
||||
Global
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
{task.description && <p className="text-xs text-slate-600">{task.description}</p>}
|
||||
<div className="flex flex-wrap items-center gap-3 text-xs text-slate-500">
|
||||
@@ -297,10 +316,16 @@ function TaskRow({
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Button variant="outline" size="sm" onClick={onEdit}>
|
||||
<Button variant="outline" size="sm" onClick={onEdit} disabled={isGlobal} className={isGlobal ? 'opacity-50' : ''}>
|
||||
<Pencil className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={onDelete} className="text-rose-600">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={onDelete}
|
||||
className={`text-rose-600 ${isGlobal ? 'opacity-50' : ''}`}
|
||||
disabled={isGlobal}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
115
resources/js/admin/pages/WelcomeTeaserPage.tsx
Normal file
115
resources/js/admin/pages/WelcomeTeaserPage.tsx
Normal file
@@ -0,0 +1,115 @@
|
||||
import React from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Sparkles, Users, Camera, Heart, ArrowRight } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { ADMIN_HOME_PATH } from '../constants';
|
||||
import { useAuth } from '../auth/context';
|
||||
|
||||
const highlights = [
|
||||
{
|
||||
icon: Sparkles,
|
||||
title: 'Momente lenken, nicht das Handy',
|
||||
description:
|
||||
'Fotospiel liefert euch spielerische Aufgaben, damit eure Gäste das Fest genießen und gleichzeitig emotionale Motive festhalten.',
|
||||
},
|
||||
{
|
||||
icon: Users,
|
||||
title: 'Alle Gäste auf einer Reise',
|
||||
description:
|
||||
'Einladungslinks und QR-Codes führen direkt in eure Event-Galerie. Kein Technik-Know-how nötig – nur teilen und loslegen.',
|
||||
},
|
||||
{
|
||||
icon: Camera,
|
||||
title: 'Live-Galerie und Moderation',
|
||||
description:
|
||||
'Sammelt Bilder in Echtzeit, markiert Highlights und entscheidet gemeinsam, welche Erinnerungen groß rauskommen.',
|
||||
},
|
||||
];
|
||||
|
||||
export default function WelcomeTeaserPage() {
|
||||
const navigate = useNavigate();
|
||||
const { login } = useAuth();
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-rose-50 via-white to-sky-50 text-slate-800">
|
||||
<header className="mx-auto flex w-full max-w-5xl flex-col gap-6 px-6 pb-12 pt-16 text-center md:pt-20">
|
||||
<div className="mx-auto w-fit rounded-full border border-rose-100 bg-white/80 px-4 py-1 text-sm font-medium text-rose-500 shadow-sm">
|
||||
Willkommen bei Fotospiel
|
||||
</div>
|
||||
<h1 className="font-display text-4xl font-semibold tracking-tight text-slate-900 md:text-5xl">
|
||||
Eure Gäste als Geschichtenerzähler – ohne Technikstress
|
||||
</h1>
|
||||
<p className="mx-auto max-w-2xl text-base leading-relaxed text-slate-600 md:text-lg">
|
||||
Dieses Kontrollzentrum zeigt euch, wie ihr Fotospiel für Hochzeit, Jubiläum oder Team-Event einsetzt.
|
||||
Wir führen euch Schritt für Schritt durch Aufgaben, Event-Setup und Einladungen.
|
||||
</p>
|
||||
<div className="flex flex-col justify-center gap-3 md:flex-row">
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="group inline-flex items-center justify-center gap-2 rounded-full border border-transparent bg-white px-6 py-3 text-base font-semibold text-rose-500 shadow-sm transition hover:border-rose-200 hover:text-rose-600"
|
||||
onClick={() => login(ADMIN_HOME_PATH)}
|
||||
>
|
||||
Ich habe bereits Zugang
|
||||
<ArrowRight className="h-4 w-4 transition group-hover:translate-x-0.5" />
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main className="mx-auto w-full max-w-5xl space-y-12 px-6 pb-16">
|
||||
<section className="grid gap-6 rounded-3xl border border-white/60 bg-white/80 p-6 shadow-xl shadow-rose-100/40 backdrop-blur-md md:grid-cols-3 md:p-8">
|
||||
{highlights.map((item) => (
|
||||
<article key={item.title} className="flex flex-col gap-4 rounded-2xl bg-white/70 p-5 shadow-sm">
|
||||
<div className="flex h-12 w-12 items-center justify-center rounded-full bg-rose-100 text-rose-500">
|
||||
<item.icon className="h-6 w-6" />
|
||||
</div>
|
||||
<h2 className="text-lg font-semibold text-slate-900">{item.title}</h2>
|
||||
<p className="text-sm leading-relaxed text-slate-600">{item.description}</p>
|
||||
</article>
|
||||
))}
|
||||
</section>
|
||||
|
||||
<section className="grid gap-8 rounded-3xl border border-sky-100 bg-gradient-to-br from-white via-sky-50 to-white p-6 shadow-lg md:grid-cols-2 md:p-10">
|
||||
<div className="space-y-5">
|
||||
<div className="inline-flex items-center gap-2 rounded-full bg-sky-100 px-3 py-1 text-xs font-semibold text-sky-700">
|
||||
<Heart className="h-4 w-4" />
|
||||
So startet ihr
|
||||
</div>
|
||||
<h2 className="text-2xl font-semibold text-slate-900 md:text-3xl">In drei Schritten zu eurer Story</h2>
|
||||
<ol className="space-y-4 text-sm leading-relaxed text-slate-600">
|
||||
<li>
|
||||
<span className="font-semibold text-slate-900">1. Aufgaben entdecken </span>
|
||||
Stellt euer erstes Aufgabenpaket zusammen, das zu eurer Feier passt.
|
||||
</li>
|
||||
<li>
|
||||
<span className="font-semibold text-slate-900">2. Event anlegen </span>
|
||||
Benennt euer Event, legt Farben fest und erstellt den QR-Einladungslink.
|
||||
</li>
|
||||
<li>
|
||||
<span className="font-semibold text-slate-900">3. Link teilen </span>
|
||||
Gäste scannen, laden Fotos hoch und ihr entscheidet, was in euer Album kommt.
|
||||
</li>
|
||||
</ol>
|
||||
</div>
|
||||
<aside className="space-y-4 rounded-2xl border border-rose-100 bg-white/90 p-6 text-sm leading-relaxed text-slate-600 shadow-md">
|
||||
<p>
|
||||
Ihr könnt jederzeit unterbrechen und später weiter machen. Falls ihr Fragen habt, meldet euch unter{' '}
|
||||
<a className="font-medium text-rose-500 underline" href="mailto:hallo@fotospiel.de">
|
||||
hallo@fotospiel.de
|
||||
</a>
|
||||
.
|
||||
</p>
|
||||
<p>
|
||||
Nach dem Login geleiten wir euch automatisch zur geführten Einrichtung. Dort entscheidet ihr auch,
|
||||
wann eure Gästegalerie sichtbar wird.
|
||||
</p>
|
||||
</aside>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<footer className="border-t border-white/60 bg-white/70 py-6 text-center text-xs text-slate-500">
|
||||
Fotospiel • Eure Gäste gestalten eure Lieblingsmomente
|
||||
</footer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -11,12 +11,16 @@ import EventMembersPage from './pages/EventMembersPage';
|
||||
import EventTasksPage from './pages/EventTasksPage';
|
||||
import BillingPage from './pages/BillingPage';
|
||||
import TasksPage from './pages/TasksPage';
|
||||
import TaskCollectionsPage from './pages/TaskCollectionsPage';
|
||||
import EmotionsPage from './pages/EmotionsPage';
|
||||
import AuthCallbackPage from './pages/AuthCallbackPage';
|
||||
import WelcomeTeaserPage from './pages/WelcomeTeaserPage';
|
||||
import { useAuth } from './auth/context';
|
||||
import {
|
||||
ADMIN_AUTH_CALLBACK_PATH,
|
||||
ADMIN_BASE_PATH,
|
||||
ADMIN_HOME_PATH,
|
||||
ADMIN_LOGIN_PATH,
|
||||
ADMIN_PUBLIC_LANDING_PATH,
|
||||
} from './constants';
|
||||
import WelcomeLandingPage from './onboarding/pages/WelcomeLandingPage';
|
||||
import WelcomePackagesPage from './onboarding/pages/WelcomePackagesPage';
|
||||
@@ -42,15 +46,36 @@ function RequireAuth() {
|
||||
return <Outlet />;
|
||||
}
|
||||
|
||||
function LandingGate() {
|
||||
const { status } = useAuth();
|
||||
|
||||
if (status === 'loading') {
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center text-sm text-muted-foreground">
|
||||
Bitte warten ...
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (status === 'authenticated') {
|
||||
return <Navigate to={ADMIN_HOME_PATH} replace />;
|
||||
}
|
||||
|
||||
return <WelcomeTeaserPage />;
|
||||
}
|
||||
|
||||
export const router = createBrowserRouter([
|
||||
{ path: ADMIN_LOGIN_PATH, element: <LoginPage /> },
|
||||
{ path: ADMIN_AUTH_CALLBACK_PATH, element: <AuthCallbackPage /> },
|
||||
{
|
||||
path: ADMIN_BASE_PATH,
|
||||
element: <Outlet />,
|
||||
children: [
|
||||
{ index: true, element: <LandingGate /> },
|
||||
{ path: 'login', element: <LoginPage /> },
|
||||
{ path: 'auth/callback', element: <AuthCallbackPage /> },
|
||||
{
|
||||
element: <RequireAuth />,
|
||||
children: [
|
||||
{ index: true, element: <DashboardPage /> },
|
||||
{ path: 'dashboard', element: <Navigate to={ADMIN_BASE_PATH} replace /> },
|
||||
{ path: 'dashboard', element: <DashboardPage /> },
|
||||
{ path: 'events', element: <EventsPage /> },
|
||||
{ path: 'events/new', element: <EventFormPage /> },
|
||||
{ path: 'events/:slug', element: <EventDetailPage /> },
|
||||
@@ -59,15 +84,22 @@ export const router = createBrowserRouter([
|
||||
{ path: 'events/:slug/members', element: <EventMembersPage /> },
|
||||
{ path: 'events/:slug/tasks', element: <EventTasksPage /> },
|
||||
{ path: 'tasks', element: <TasksPage /> },
|
||||
{ path: 'task-collections', element: <TaskCollectionsPage /> },
|
||||
{ path: 'emotions', element: <EmotionsPage /> },
|
||||
{ path: 'billing', element: <BillingPage /> },
|
||||
{ path: 'settings', element: <SettingsPage /> },
|
||||
{ path: 'welcome', element: <WelcomeLandingPage /> },
|
||||
{ path: 'welcome/packages', element: <WelcomePackagesPage /> },
|
||||
{ path: 'welcome/summary', element: <WelcomeOrderSummaryPage /> },
|
||||
{ path: 'welcome/event', element: <WelcomeEventSetupPage /> },
|
||||
{ path: '', element: <Navigate to="dashboard" replace /> },
|
||||
],
|
||||
},
|
||||
{ path: '*', element: <Navigate to={ADMIN_BASE_PATH} replace /> },
|
||||
],
|
||||
},
|
||||
{
|
||||
path: '*',
|
||||
element: <Navigate to={ADMIN_PUBLIC_LANDING_PATH} replace />,
|
||||
},
|
||||
]);
|
||||
|
||||
|
||||
|
||||
@@ -41,9 +41,18 @@ const Carousel = React.forwardRef<HTMLDivElement, CarouselProps>(
|
||||
return
|
||||
}
|
||||
|
||||
console.log('Embla API initialized:', api)
|
||||
console.log('Embla options:', opts)
|
||||
console.log('Embla plugins:', plugins)
|
||||
|
||||
setCount(api.slideNodes().length)
|
||||
api.on("reInit", setCount)
|
||||
api.on("slideChanged", ({ slide }: { slide: number }) => setCurrent(slide))
|
||||
api.on("slideChanged", ({ slide }: { slide: number }) => {
|
||||
console.log('Slide changed to:', slide)
|
||||
setCurrent(slide)
|
||||
})
|
||||
api.on("pointerDown", () => console.log('Pointer down event'))
|
||||
api.on("pointerUp", () => console.log('Pointer up event'))
|
||||
setApi?.(api)
|
||||
}, [api, setApi])
|
||||
|
||||
@@ -55,11 +64,15 @@ const Carousel = React.forwardRef<HTMLDivElement, CarouselProps>(
|
||||
"relative w-full",
|
||||
className
|
||||
)}
|
||||
onTouchStart={(e) => console.log('Carousel touch start:', e.touches.length)}
|
||||
onTouchMove={(e) => console.log('Carousel touch move:', e.touches.length)}
|
||||
onTouchEnd={(e) => console.log('Carousel touch end')}
|
||||
{...props}
|
||||
>
|
||||
<div
|
||||
className="overflow-hidden"
|
||||
ref={emblaRef}
|
||||
style={{ touchAction: 'pan-y pinch-zoom' }}
|
||||
>
|
||||
<div className="flex">{children}</div>
|
||||
</div>
|
||||
|
||||
@@ -3,6 +3,10 @@ import { Head, Link } from '@inertiajs/react';
|
||||
import { useLocalizedRoutes } from '@/hooks/useLocalizedRoutes';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import MarketingLayout from '@/layouts/mainWebsite';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import { Button } from '@/components/ui/button';
|
||||
|
||||
interface Props {
|
||||
posts: {
|
||||
@@ -44,38 +48,43 @@ const Blog: React.FC<Props> = ({ posts }) => {
|
||||
if (!posts.links || posts.links.length <= 3) return null;
|
||||
|
||||
return (
|
||||
<div className="mt-12 text-center">
|
||||
<div className="flex justify-center space-x-2">
|
||||
<div className="mt-12">
|
||||
<Card className="p-6">
|
||||
<div className="flex justify-center">
|
||||
<div className="flex flex-wrap justify-center gap-2">
|
||||
{posts.links.map((link, index) => {
|
||||
const href = resolvePaginationHref(link.url);
|
||||
|
||||
const baseClasses = `px-3 py-2 rounded ${
|
||||
link.active
|
||||
? 'bg-[#FFB6C1] text-white'
|
||||
: 'bg-gray-200 text-gray-700 hover:bg-gray-300'
|
||||
}`;
|
||||
|
||||
if (!href) {
|
||||
return (
|
||||
<span
|
||||
<Button
|
||||
key={index}
|
||||
className={`${baseClasses} cursor-default`}
|
||||
variant={link.active ? "default" : "outline"}
|
||||
disabled
|
||||
className={link.active ? "bg-[#FFB6C1] hover:bg-[#FF69B4]" : ""}
|
||||
dangerouslySetInnerHTML={{ __html: link.label }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Link
|
||||
<Button
|
||||
key={index}
|
||||
asChild
|
||||
variant={link.active ? "default" : "outline"}
|
||||
className={link.active ? "bg-[#FFB6C1] hover:bg-[#FF69B4]" : ""}
|
||||
>
|
||||
<Link
|
||||
href={href}
|
||||
className={baseClasses}
|
||||
dangerouslySetInnerHTML={{ __html: link.label }}
|
||||
/>
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -83,54 +92,91 @@ const Blog: React.FC<Props> = ({ posts }) => {
|
||||
<MarketingLayout title={t('blog.title')}>
|
||||
<Head title={t('blog.title')} />
|
||||
{/* Hero Section */}
|
||||
<section className="bg-aurora-enhanced text-gray-900 dark:text-gray-100 py-20 px-4">
|
||||
<div className="container mx-auto text-center">
|
||||
<h1 className="text-4xl md:text-6xl font-bold mb-4 font-display">{t('blog.hero_title')}</h1>
|
||||
<p className="text-xl md:text-2xl mb-8 max-w-3xl mx-auto font-sans-marketing">{t('blog.hero_description')}</p>
|
||||
<Link href="#posts" className="bg-white dark:bg-gray-800 text-[#FFB6C1] px-8 py-4 rounded-full font-semibold text-lg font-sans-marketing hover:bg-gray-100 dark:hover:bg-gray-700 transition">
|
||||
<section className="bg-aurora-enhanced py-20 px-4">
|
||||
<div className="container mx-auto max-w-4xl">
|
||||
<Card className="bg-white/10 backdrop-blur-sm border-white/20 shadow-xl">
|
||||
<CardContent className="p-8 md:p-12 text-center">
|
||||
<h1 className="text-4xl md:text-6xl font-bold mb-6 font-display text-gray-900 dark:text-gray-100">{t('blog.hero_title')}</h1>
|
||||
<p className="text-xl md:text-2xl mb-8 max-w-3xl mx-auto font-sans-marketing text-gray-800 dark:text-gray-200">{t('blog.hero_description')}</p>
|
||||
<Button
|
||||
asChild
|
||||
size="lg"
|
||||
className="bg-white text-[#FFB6C1] hover:bg-gray-100 px-8 py-4 rounded-full font-semibold text-lg font-sans-marketing"
|
||||
>
|
||||
<Link href="#posts">
|
||||
{t('blog.hero_cta')}
|
||||
</Link>
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Posts Section */}
|
||||
<section id="posts" className="py-20 px-4 bg-white dark:bg-gray-900">
|
||||
<div className="container mx-auto max-w-4xl">
|
||||
<h2 className="text-3xl font-bold text-center mb-12 font-display text-gray-900 dark:text-gray-100">{t('blog.posts_title')}</h2>
|
||||
<div className="container mx-auto max-w-6xl">
|
||||
<div className="text-center mb-12">
|
||||
<h2 className="text-3xl font-bold font-display text-gray-900 dark:text-gray-100 mb-4">{t('blog.posts_title')}</h2>
|
||||
<Separator className="w-24 mx-auto" />
|
||||
</div>
|
||||
|
||||
{posts.data.length > 0 ? (
|
||||
<>
|
||||
<div className="grid md:grid-cols-2 gap-8">
|
||||
{posts.data.map((post) => (
|
||||
<div key={post.id} className="bg-gray-50 dark:bg-gray-800 p-6 rounded-lg">
|
||||
<Card key={post.id} className="overflow-hidden hover:shadow-lg transition-shadow duration-300">
|
||||
{post.featured_image && (
|
||||
<div className="aspect-video overflow-hidden">
|
||||
<img
|
||||
src={post.featured_image}
|
||||
alt={post.title}
|
||||
className="w-full h-48 object-cover rounded mb-4"
|
||||
className="w-full h-full object-cover hover:scale-105 transition-transform duration-300"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<h3 className="text-xl font-semibold mb-2 font-sans-marketing text-gray-900 dark:text-gray-100">
|
||||
<Link href={`${localizedPath(`/blog/${post.slug}`)}`} className="hover:text-[#FFB6C1]">
|
||||
{post.title?.[locale] || post.title?.de || post.title || 'No Title'}
|
||||
</Link>
|
||||
</h3>
|
||||
<p className="mb-4 text-gray-700 dark:text-gray-300 font-serif-custom">{post.excerpt?.[locale] || post.excerpt?.de || post.excerpt || 'No Excerpt'}</p>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 mb-4 font-sans-marketing">
|
||||
{t('blog.by')} {post.author?.name?.[locale] || post.author?.name?.de || post.author?.name || t('blog.team')} | {t('blog.published_at')} {post.published_at}
|
||||
</p>
|
||||
<CardContent className="p-6">
|
||||
<CardTitle className="text-xl font-semibold mb-3 font-sans-marketing">
|
||||
<Link
|
||||
href={`${localizedPath(`/blog/${post.slug}`)}`}
|
||||
className="text-[#FFB6C1] font-semibold font-sans-marketing hover:underline"
|
||||
className="hover:text-[#FFB6C1] transition-colors"
|
||||
>
|
||||
{post.title?.[locale] || post.title?.de || post.title || 'No Title'}
|
||||
</Link>
|
||||
</CardTitle>
|
||||
|
||||
<p className="text-gray-700 dark:text-gray-300 font-serif-custom mb-4 leading-relaxed">
|
||||
{post.excerpt?.[locale] || post.excerpt?.de || post.excerpt || 'No Excerpt'}
|
||||
</p>
|
||||
|
||||
<div className="flex flex-col sm:flex-row items-start sm:items-center gap-3 mb-4">
|
||||
<Badge variant="secondary" className="bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300">
|
||||
{t('blog.by')} {post.author?.name?.[locale] || post.author?.name?.de || post.author?.name || t('blog.team')}
|
||||
</Badge>
|
||||
<Separator orientation="vertical" className="hidden sm:block h-4" />
|
||||
<Badge variant="outline" className="text-gray-500">
|
||||
{t('blog.published_at')} {new Date(post.published_at).toLocaleDateString('de-DE', {
|
||||
day: 'numeric',
|
||||
month: 'long',
|
||||
year: 'numeric'
|
||||
})}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<Button asChild variant="ghost" className="p-0 h-auto text-[#FFB6C1] hover:text-[#FF69B4] hover:bg-transparent">
|
||||
<Link href={`${localizedPath(`/blog/${post.slug}`)}`} className="font-semibold">
|
||||
{t('blog.read_more')}
|
||||
</Link>
|
||||
</div>
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
{renderPagination()}
|
||||
</>
|
||||
) : (
|
||||
<p className="text-center text-gray-600 dark:text-gray-400 font-serif-custom">{t('blog.empty')}</p>
|
||||
<Card className="p-8 text-center">
|
||||
<p className="text-gray-600 dark:text-gray-400 font-serif-custom">{t('blog.empty')}</p>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -3,6 +3,10 @@ import { Head, Link } from '@inertiajs/react';
|
||||
import { useLocalizedRoutes } from '@/hooks/useLocalizedRoutes';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import MarketingLayout from '@/layouts/mainWebsite';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import { Button } from '@/components/ui/button';
|
||||
|
||||
interface Props {
|
||||
post: {
|
||||
@@ -25,39 +29,82 @@ const BlogShow: React.FC<Props> = ({ post }) => {
|
||||
return (
|
||||
<MarketingLayout title={`${post.title} ${t('title_suffix')}`}>
|
||||
<Head title={`${post.title} ${t('title_suffix')}`} />
|
||||
|
||||
{/* Hero Section */}
|
||||
<section className="bg-gradient-to-r from-[#FFB6C1] via-[#FFD700] to-[#87CEEB] text-white py-20 px-4">
|
||||
<div className="container mx-auto text-center">
|
||||
<h1 className="text-4xl md:text-5xl font-bold mb-4">{post.title}</h1>
|
||||
<p className="text-lg mb-8">
|
||||
{t('by_author')} {post.author?.name || t('team')} | {t('published_on')} {new Date(post.published_at).toLocaleDateString('de-DE')}
|
||||
</p>
|
||||
<section className="bg-gradient-to-r from-[#FFB6C1] via-[#FFD700] to-[#87CEEB] py-20 px-4">
|
||||
<div className="container mx-auto max-w-4xl">
|
||||
<Card className="bg-white/10 backdrop-blur-sm border-white/20 text-white shadow-xl">
|
||||
<CardContent className="p-8 text-center">
|
||||
<h1 className="text-4xl md:text-5xl font-bold mb-6 leading-tight">{post.title}</h1>
|
||||
|
||||
<div className="flex flex-col sm:flex-row items-center justify-center gap-4 mb-8 text-lg">
|
||||
<Badge variant="secondary" className="bg-white/20 text-white border-white/30">
|
||||
{t('by_author')} {post.author?.name || t('team')}
|
||||
</Badge>
|
||||
<Separator orientation="vertical" className="hidden sm:block h-6 bg-white/30" />
|
||||
<Badge variant="secondary" className="bg-white/20 text-white border-white/30">
|
||||
{t('published_on')} {new Date(post.published_at).toLocaleDateString('de-DE', {
|
||||
day: 'numeric',
|
||||
month: 'long',
|
||||
year: 'numeric'
|
||||
})}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
{post.featured_image && (
|
||||
<div className="mt-8">
|
||||
<img
|
||||
src={post.featured_image}
|
||||
alt={post.title}
|
||||
className="mx-auto rounded-lg shadow-lg max-w-2xl"
|
||||
className="mx-auto rounded-lg shadow-lg max-w-2xl w-full object-cover"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Post Content */}
|
||||
<section className="py-20 px-4 bg-white">
|
||||
<div className="container mx-auto max-w-4xl prose prose-lg max-w-none">
|
||||
<div dangerouslySetInnerHTML={{ __html: post.content_html }} />
|
||||
<div className="container mx-auto max-w-4xl">
|
||||
<Card className="shadow-sm">
|
||||
<CardContent className="p-8 md:p-12">
|
||||
<div
|
||||
className="prose prose-lg prose-slate max-w-none
|
||||
prose-headings:text-slate-900 prose-headings:font-semibold
|
||||
prose-p:text-slate-700 prose-p:leading-relaxed
|
||||
prose-a:text-blue-600 prose-a:no-underline hover:prose-a:underline
|
||||
prose-strong:text-slate-900 prose-strong:font-semibold
|
||||
prose-code:text-slate-900 prose-code:bg-slate-100 prose-code:px-1 prose-code:py-0.5 prose-code:rounded
|
||||
prose-pre:bg-slate-900 prose-pre:text-slate-100
|
||||
prose-blockquote:border-l-4 prose-blockquote:border-blue-500 prose-blockquote:pl-6 prose-blockquote:italic
|
||||
prose-ul:text-slate-700 prose-ol:text-slate-700
|
||||
prose-li:text-slate-700"
|
||||
dangerouslySetInnerHTML={{ __html: post.content_html }}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Back to Blog */}
|
||||
<section className="py-10 px-4 bg-gray-50">
|
||||
<div className="container mx-auto text-center">
|
||||
<Link
|
||||
href={localizedPath('/blog')}
|
||||
className="bg-[#FFB6C1] text-white px-8 py-3 rounded-full font-semibold hover:bg-[#FF69B4] transition"
|
||||
<div className="container mx-auto max-w-4xl">
|
||||
<Card className="shadow-sm">
|
||||
<CardContent className="p-8 text-center">
|
||||
<Separator className="mb-6" />
|
||||
<Button
|
||||
asChild
|
||||
size="lg"
|
||||
className="bg-[#FFB6C1] hover:bg-[#FF69B4] text-white px-8 py-3 rounded-full font-semibold transition-colors"
|
||||
>
|
||||
<Link href={localizedPath('/blog')}>
|
||||
{t('back_to_blog')}
|
||||
</Link>
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</section>
|
||||
</MarketingLayout>
|
||||
|
||||
@@ -106,12 +106,15 @@ const Packages: React.FC<PackagesProps> = ({ endcustomerPackages, resellerPackag
|
||||
|
||||
{/* Mobile Carousel for Endcustomer Packages */}
|
||||
<div className="block md:hidden">
|
||||
<Carousel className="w-full max-w-md mx-auto">
|
||||
<Carousel className="w-full max-w-md mx-auto" opts={{ loop: true }}>
|
||||
<CarouselContent className="-ml-1">
|
||||
{endcustomerPackages.map((pkg) => (
|
||||
<CarouselItem key={pkg.id} className="pl-1 basis-full">
|
||||
<div
|
||||
className="bg-white dark:bg-gray-800 p-6 rounded-lg shadow-md border hover:shadow-lg transition-all duration-300"
|
||||
onTouchStart={(e) => console.log('Touch start on carousel item:', e.touches.length)}
|
||||
onTouchMove={(e) => console.log('Touch move on carousel item:', e.touches.length)}
|
||||
onTouchEnd={(e) => console.log('Touch end on carousel item')}
|
||||
>
|
||||
<div className="text-center mb-4">
|
||||
<ShoppingCart className="w-12 h-12 text-[#FFB6C1] mx-auto" />
|
||||
@@ -329,12 +332,15 @@ const Packages: React.FC<PackagesProps> = ({ endcustomerPackages, resellerPackag
|
||||
|
||||
{/* Mobile Carousel for Reseller Packages */}
|
||||
<div className="block md:hidden">
|
||||
<Carousel className="w-full max-w-md mx-auto">
|
||||
<Carousel className="w-full max-w-md mx-auto" opts={{ loop: true }}>
|
||||
<CarouselContent className="-ml-1">
|
||||
{resellerPackages.map((pkg) => (
|
||||
<CarouselItem key={pkg.id} className="pl-1 basis-full">
|
||||
<div
|
||||
className="bg-white dark:bg-gray-800 p-6 rounded-lg shadow-md border hover:shadow-lg transition-all duration-300"
|
||||
onTouchStart={(e) => console.log('Touch start on reseller carousel item:', e.touches.length)}
|
||||
onTouchMove={(e) => console.log('Touch move on reseller carousel item:', e.touches.length)}
|
||||
onTouchEnd={(e) => console.log('Touch end on reseller carousel item')}
|
||||
>
|
||||
<div className="text-center mb-4">
|
||||
<ShoppingCart className="w-12 h-12 text-[#FFD700] mx-auto" />
|
||||
|
||||
122
resources/views/filament/tenant/pages/invite-studio.blade.php
Normal file
122
resources/views/filament/tenant/pages/invite-studio.blade.php
Normal file
@@ -0,0 +1,122 @@
|
||||
<x-filament-panels::page>
|
||||
<div class="mx-auto w-full max-w-4xl space-y-10">
|
||||
<section class="rounded-3xl border border-white/70 bg-white/85 p-8 shadow-xl shadow-sky-100/50">
|
||||
<header class="space-y-3">
|
||||
<h1 class="text-3xl font-semibold text-slate-900">Einladungen & QR-Codes</h1>
|
||||
<p class="text-sm text-slate-600">
|
||||
Erstellt und verwaltet eure QR-Einladungen. Jeder Link enthält druckfertige Layouts als PDF & SVG.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<div class="mt-6 grid gap-6 md:grid-cols-[2fr,1fr]">
|
||||
<div>
|
||||
<label class="block text-sm font-semibold text-slate-700">
|
||||
Event auswählen
|
||||
<select
|
||||
wire:model="selectedEventId"
|
||||
class="mt-2 w-full rounded-xl border border-slate-200 bg-white px-4 py-3 text-sm text-slate-900 shadow-sm focus:border-rose-400 focus:ring-rose-400">
|
||||
<option value="">Bitte wählt ein Event</option>
|
||||
@foreach ($this->events as $event)
|
||||
<option value="{{ $event->id }}">{{ data_get($event->name, app()->getLocale()) ?? data_get($event->name, 'de') ?? 'Event #' . $event->id }}</option>
|
||||
@endforeach
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
<div class="rounded-2xl border border-rose-100 bg-rose-50/70 p-4 text-sm text-rose-700 shadow-inner">
|
||||
<p class="font-semibold">Tipp</p>
|
||||
<p class="mt-2">Druckt mehrere Layouts aus und verteilt sie am Eingang, am Gästebuch und beim DJ-Pult.</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="rounded-3xl border border-white/70 bg-white/85 p-8 shadow-xl shadow-rose-100/50">
|
||||
<form wire:submit.prevent="createInvite" class="grid gap-4 md:grid-cols-[2fr,1fr] md:items-end">
|
||||
<label class="block text-sm font-semibold text-slate-700">
|
||||
Bezeichnung des Links (optional)
|
||||
<input
|
||||
type="text"
|
||||
wire:model.defer="tokenLabel"
|
||||
class="mt-2 w-full rounded-xl border border-slate-200 bg-white px-4 py-3 text-sm text-slate-900 shadow-sm focus:border-rose-400 focus:ring-rose-400"
|
||||
placeholder="z. B. Empfang, Fotobox, Tanzfläche" />
|
||||
</label>
|
||||
<div class="flex justify-end">
|
||||
<button
|
||||
type="submit"
|
||||
class="inline-flex items-center justify-center gap-2 rounded-full bg-rose-500 px-6 py-3 text-sm font-semibold text-white shadow-lg shadow-rose-300/60 transition hover:bg-rose-600 disabled:cursor-not-allowed disabled:opacity-70"
|
||||
@disabled($this->events->isEmpty())
|
||||
wire:loading.attr="disabled"
|
||||
wire:target="createInvite">
|
||||
<span wire:loading.remove wire:target="createInvite">Neuen Einladungslink erzeugen</span>
|
||||
<span wire:loading wire:target="createInvite" class="flex items-center gap-2">
|
||||
<x-filament::loading-indicator class="h-4 w-4" />
|
||||
Wird erstellt …
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
@if ($this->events->isEmpty())
|
||||
<p class="mt-4 text-sm text-slate-600">Legt zunächst ein Event an, um Einladungslinks zu erstellen.</p>
|
||||
@endif
|
||||
</section>
|
||||
|
||||
<section class="rounded-3xl border border-white/70 bg-white/90 p-8 shadow-xl shadow-slate-100/60">
|
||||
<h2 class="text-2xl font-semibold text-slate-900">Aktive Einladungslinks</h2>
|
||||
|
||||
@if (empty($tokens))
|
||||
<p class="mt-4 text-sm text-slate-600">
|
||||
Noch keine Einladungen erstellt. Generiert euren ersten Link, um die QR-Codes als PDF oder SVG herunterzuladen.
|
||||
</p>
|
||||
@else
|
||||
<div class="mt-6 space-y-4">
|
||||
@foreach ($tokens as $token)
|
||||
<article class="rounded-2xl border border-slate-200 bg-white/80 p-5 shadow-sm">
|
||||
<div class="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
|
||||
<div>
|
||||
<h3 class="text-lg font-semibold text-slate-900">{{ $token['label'] }}</h3>
|
||||
<p class="text-sm text-slate-500">
|
||||
{{ $token['url'] }} · erstellt am {{ $token['created_at'] }} · Aufrufe: {{ $token['usage_count'] }}{{ $token['usage_limit'] ? ' / ' . $token['usage_limit'] : '' }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<a
|
||||
href="{{ $token['url'] }}"
|
||||
target="_blank"
|
||||
class="inline-flex items-center gap-2 rounded-full border border-slate-200 px-4 py-2 text-sm font-semibold text-slate-600 transition hover:border-rose-300 hover:text-rose-600">
|
||||
Link öffnen
|
||||
</a>
|
||||
<button
|
||||
type="button"
|
||||
x-data="{ copied: false }"
|
||||
x-on:click="navigator.clipboard.writeText('{{ $token['url'] }}').then(() => { copied = true; setTimeout(() => copied = false, 1500); })"
|
||||
class="inline-flex items-center gap-2 rounded-full border border-slate-200 px-4 py-2 text-sm font-semibold text-slate-600 transition hover:border-rose-300 hover:text-rose-600">
|
||||
<span x-show="!copied">Link kopieren</span>
|
||||
<span x-cloak x-show="copied" class="text-rose-500">Kopiert!</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 grid gap-3 md:grid-cols-2 lg:grid-cols-3">
|
||||
@foreach ($token['downloads'] as $layout)
|
||||
<div class="rounded-xl border border-slate-200 bg-white/70 p-4 text-sm text-slate-600">
|
||||
<p class="font-semibold text-slate-900">{{ $layout['name'] }}</p>
|
||||
<p class="text-xs text-slate-500">{{ $layout['subtitle'] }}</p>
|
||||
<div class="mt-3 flex flex-wrap gap-2">
|
||||
@foreach ($layout['download_urls'] as $format => $url)
|
||||
<a
|
||||
href="{{ $url }}"
|
||||
class="inline-flex items-center gap-2 rounded-full border border-slate-200 px-3 py-1 text-xs font-semibold text-slate-600 transition hover:border-rose-300 hover:text-rose-600"
|
||||
target="_blank">
|
||||
{{ strtoupper($format) }} herunterladen
|
||||
</a>
|
||||
@endforeach
|
||||
</div>
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
</article>
|
||||
@endforeach
|
||||
</div>
|
||||
@endif
|
||||
</section>
|
||||
</div>
|
||||
</x-filament-panels::page>
|
||||
278
resources/views/filament/tenant/pages/onboarding.blade.php
Normal file
278
resources/views/filament/tenant/pages/onboarding.blade.php
Normal file
@@ -0,0 +1,278 @@
|
||||
@php
|
||||
$steps = [
|
||||
'intro' => 'Willkommen',
|
||||
'packages' => 'Aufgaben wählen',
|
||||
'event' => 'Event benennen',
|
||||
'palette' => 'Farbwelt',
|
||||
'invite' => 'Einladungen',
|
||||
];
|
||||
@endphp
|
||||
|
||||
<x-filament-panels::page>
|
||||
<div class="mx-auto w-full max-w-4xl space-y-10">
|
||||
<section class="rounded-3xl border border-white/60 bg-white/80 p-8 shadow-xl shadow-rose-100/40 backdrop-blur-md">
|
||||
<header class="space-y-4 text-center">
|
||||
<p class="inline-flex items-center gap-2 rounded-full border border-rose-100 bg-white/70 px-4 py-1 text-sm font-medium text-rose-500 shadow-sm">
|
||||
Fotospiel Studio · Geführter Start
|
||||
</p>
|
||||
<h1 class="font-display text-4xl font-semibold tracking-tight text-slate-900">
|
||||
Eure Gäste werden zu Geschichtenerzähler:innen
|
||||
</h1>
|
||||
<p class="mx-auto max-w-2xl text-base leading-relaxed text-slate-600">
|
||||
Wir richten mit euch die Aufgaben, das Event und den ersten QR-Code ein. Alles ohne Technikstress – Schritt für Schritt.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<ol class="mt-8 grid gap-3 md:grid-cols-5">
|
||||
@foreach ($steps as $key => $label)
|
||||
<li class="flex flex-col items-center gap-2">
|
||||
<span
|
||||
@class([
|
||||
'flex h-10 w-10 items-center justify-center rounded-full text-sm font-semibold transition',
|
||||
'bg-rose-500 text-white shadow-lg shadow-rose-300/70' => $step === $key,
|
||||
'bg-white text-rose-500 border border-rose-200' => $step !== $key,
|
||||
])>
|
||||
{{ $loop->iteration }}
|
||||
</span>
|
||||
<span class="text-sm font-medium text-slate-600">
|
||||
{{ $label }}
|
||||
</span>
|
||||
</li>
|
||||
@endforeach
|
||||
</ol>
|
||||
</section>
|
||||
|
||||
@if ($step === 'intro')
|
||||
<section class="rounded-3xl border border-white/70 bg-gradient-to-br from-rose-50 via-white to-sky-50 p-10 shadow-lg shadow-rose-100/50">
|
||||
<div class="space-y-6 text-center">
|
||||
<h2 class="text-3xl font-semibold text-slate-900">So funktioniert Fotospiel</h2>
|
||||
<div class="grid gap-6 md:grid-cols-3">
|
||||
<div class="rounded-2xl bg-white/80 p-5 shadow-sm">
|
||||
<h3 class="font-semibold text-slate-900">1 · Aufgaben auswählen</h3>
|
||||
<p class="mt-2 text-sm text-slate-600">Kuratiere Aufgaben, die eure Gäste inspirieren – ohne schon Fotos zu sammeln.</p>
|
||||
</div>
|
||||
<div class="rounded-2xl bg-white/80 p-5 shadow-sm">
|
||||
<h3 class="font-semibold text-slate-900">2 · Event gestalten</h3>
|
||||
<p class="mt-2 text-sm text-slate-600">Name, Datum und Farben bestimmen das Look & Feel eurer Fotostory.</p>
|
||||
</div>
|
||||
<div class="rounded-2xl bg-white/80 p-5 shadow-sm">
|
||||
<h3 class="font-semibold text-slate-900">3 · QR-Code teilen</h3>
|
||||
<p class="mt-2 text-sm text-slate-600">Euer Einladungslink führt Gäste direkt in die Galerie – kein App-Download notwendig.</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
wire:click="start"
|
||||
type="button"
|
||||
class="inline-flex items-center justify-center gap-2 rounded-full bg-rose-500 px-8 py-3 text-base font-semibold text-white shadow-lg shadow-rose-300/60 transition hover:bg-rose-600">
|
||||
Jetzt loslegen
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
@endif
|
||||
|
||||
@if ($step === 'packages')
|
||||
<section class="rounded-3xl border border-white/70 bg-white/85 p-10 shadow-xl shadow-rose-100/50">
|
||||
<form wire:submit.prevent="savePackages" class="space-y-6">
|
||||
<div class="space-y-2 text-center">
|
||||
<h2 class="text-3xl font-semibold text-slate-900">Wählt euer erstes Aufgabenpaket</h2>
|
||||
<p class="text-sm text-slate-600">Jedes Paket enthält spielerische Prompts. Ihr könnt später weitere hinzufügen oder eigene Aufgaben erstellen.</p>
|
||||
</div>
|
||||
<div class="grid gap-4 md:grid-cols-2">
|
||||
@foreach ($this->packageList as $package)
|
||||
<label
|
||||
for="package-{{ $package['id'] }}"
|
||||
class="group flex cursor-pointer flex-col gap-3 rounded-2xl border border-rose-100 bg-white/80 p-5 shadow-sm transition hover:border-rose-300 hover:shadow-lg">
|
||||
<div class="flex items-start gap-3">
|
||||
<input
|
||||
id="package-{{ $package['id'] }}"
|
||||
type="checkbox"
|
||||
value="{{ $package['id'] }}"
|
||||
wire:model="selectedPackages"
|
||||
class="mt-1 h-4 w-4 rounded border-rose-200 text-rose-500 focus:ring-rose-400" />
|
||||
<div>
|
||||
<p class="text-base font-semibold text-slate-900">{{ $package['name'] }}</p>
|
||||
@if ($package['description'])
|
||||
<p class="mt-1 text-sm text-slate-600">{{ $package['description'] }}</p>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
</label>
|
||||
@endforeach
|
||||
</div>
|
||||
@error('selectedPackages')
|
||||
<p class="text-sm text-rose-600">{{ $message }}</p>
|
||||
@enderror
|
||||
<div class="flex justify-end">
|
||||
<button
|
||||
type="submit"
|
||||
class="inline-flex items-center justify-center gap-2 rounded-full bg-rose-500 px-6 py-3 text-sm font-semibold text-white shadow-lg shadow-rose-300/60 transition hover:bg-rose-600">
|
||||
Weiter zu Schritt 2
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
@endif
|
||||
|
||||
@if ($step === 'event')
|
||||
<section class="rounded-3xl border border-white/70 bg-white/90 p-10 shadow-xl shadow-sky-100/50">
|
||||
<form wire:submit.prevent="saveEvent" class="space-y-6">
|
||||
<div class="space-y-2">
|
||||
<h2 class="text-3xl font-semibold text-slate-900">Wie heißt euer Event?</h2>
|
||||
<p class="text-sm text-slate-600">Name und Anlass erscheinen in eurer Gästegalerie und auf den Einladungen.</p>
|
||||
</div>
|
||||
<div class="grid gap-5 md:grid-cols-2">
|
||||
<div class="md:col-span-2">
|
||||
<label class="block text-sm font-semibold text-slate-700">
|
||||
Event-Name
|
||||
<input
|
||||
type="text"
|
||||
wire:model.defer="eventName"
|
||||
class="mt-1 w-full rounded-xl border border-slate-200 bg-white px-4 py-3 text-sm text-slate-900 shadow-sm focus:border-rose-400 focus:ring-rose-400"
|
||||
placeholder="z. B. Hochzeit Anna & Lea" />
|
||||
</label>
|
||||
@error('eventName')
|
||||
<p class="mt-1 text-sm text-rose-600">{{ $message }}</p>
|
||||
@enderror
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-semibold text-slate-700">
|
||||
Datum
|
||||
<input
|
||||
type="date"
|
||||
wire:model.defer="eventDate"
|
||||
class="mt-1 w-full rounded-xl border border-slate-200 bg-white px-4 py-3 text-sm text-slate-900 shadow-sm focus:border-rose-400 focus:ring-rose-400" />
|
||||
</label>
|
||||
@error('eventDate')
|
||||
<p class="mt-1 text-sm text-rose-600">{{ $message }}</p>
|
||||
@enderror
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-semibold text-slate-700">
|
||||
Anlass
|
||||
<select
|
||||
wire:model.defer="eventTypeId"
|
||||
class="mt-1 w-full rounded-xl border border-slate-200 bg-white px-4 py-3 text-sm text-slate-900 shadow-sm focus:border-rose-400 focus:ring-rose-400">
|
||||
<option value="">Bitte wählen</option>
|
||||
@foreach ($this->eventTypeOptions as $id => $label)
|
||||
<option value="{{ $id }}">{{ $label }}</option>
|
||||
@endforeach
|
||||
</select>
|
||||
</label>
|
||||
@error('eventTypeId')
|
||||
<p class="mt-1 text-sm text-rose-600">{{ $message }}</p>
|
||||
@enderror
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<button type="button" wire:click="$set('step', 'packages')" class="text-sm font-semibold text-slate-500 hover:text-slate-700">
|
||||
Zurück
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
class="inline-flex items-center justify-center gap-2 rounded-full bg-rose-500 px-6 py-3 text-sm font-semibold text-white shadow-lg shadow-rose-300/60 transition hover:bg-rose-600">
|
||||
Weiter zu Schritt 3
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
@endif
|
||||
|
||||
@if ($step === 'palette')
|
||||
<section class="rounded-3xl border border-white/70 bg-white/90 p-10 shadow-xl shadow-emerald-100/50">
|
||||
<form wire:submit.prevent="savePalette" class="space-y-6">
|
||||
<div class="space-y-2">
|
||||
<h2 class="text-3xl font-semibold text-slate-900">Welche Farben spiegeln eure Story?</h2>
|
||||
<p class="text-sm text-slate-600">Wir wenden die Palette auf Karten, QR-Layouts und App-Elemente an. Ihr könnt sie später jederzeit ändern.</p>
|
||||
</div>
|
||||
<div class="grid gap-4 md:grid-cols-2">
|
||||
@foreach ($this->paletteOptions as $value => $data)
|
||||
<label
|
||||
for="palette-{{ $value }}"
|
||||
class="flex cursor-pointer flex-col gap-3 rounded-2xl border border-slate-200 bg-white/80 p-5 shadow-sm transition hover:border-rose-300 hover:shadow-lg">
|
||||
<div class="flex items-start gap-3">
|
||||
<input
|
||||
id="palette-{{ $value }}"
|
||||
type="radio"
|
||||
value="{{ $value }}"
|
||||
wire:model.defer="palette"
|
||||
class="mt-1 h-4 w-4 border-rose-300 text-rose-500 focus:ring-rose-400" />
|
||||
<div>
|
||||
<p class="text-base font-semibold text-slate-900">{{ $data['label'] }}</p>
|
||||
<p class="mt-1 text-sm text-slate-600">{{ $data['description'] }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</label>
|
||||
@endforeach
|
||||
</div>
|
||||
@error('palette')
|
||||
<p class="text-sm text-rose-600">{{ $message }}</p>
|
||||
@enderror
|
||||
<div class="flex justify-between">
|
||||
<button type="button" wire:click="$set('step', 'event')" class="text-sm font-semibold text-slate-500 hover:text-slate-700">
|
||||
Zurück
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
class="inline-flex items-center justify-center gap-2 rounded-full bg-rose-500 px-6 py-3 text-sm font-semibold text-white shadow-lg shadow-rose-300/60 transition hover:bg-rose-600">
|
||||
Weiter zu Schritt 4
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
@endif
|
||||
|
||||
@if ($step === 'invite')
|
||||
<section class="rounded-3xl border border-white/70 bg-white/90 p-10 shadow-xl shadow-indigo-100/50">
|
||||
<form wire:submit.prevent="finish" class="space-y-6">
|
||||
<div class="space-y-2">
|
||||
<h2 class="text-3xl font-semibold text-slate-900">Wie soll euer Einladungs-Layout aussehen?</h2>
|
||||
<p class="text-sm text-slate-600">Wir generieren sofort einen QR-Code samt PDF/SVG-Downloads.</p>
|
||||
</div>
|
||||
<div class="grid gap-4 md:grid-cols-2">
|
||||
@foreach ($this->layoutOptions as $layout)
|
||||
<label
|
||||
for="layout-{{ $layout['id'] }}"
|
||||
class="flex cursor-pointer flex-col gap-3 rounded-2xl border border-slate-200 bg-white/80 p-5 shadow-sm transition hover:border-rose-300 hover:shadow-lg">
|
||||
<div class="flex items-start gap-3">
|
||||
<input
|
||||
id="layout-{{ $layout['id'] }}"
|
||||
type="radio"
|
||||
value="{{ $layout['id'] }}"
|
||||
wire:model.defer="inviteLayout"
|
||||
class="mt-1 h-4 w-4 border-rose-300 text-rose-500 focus:ring-rose-400" />
|
||||
<div>
|
||||
<p class="text-base font-semibold text-slate-900">{{ $layout['name'] }}</p>
|
||||
<p class="mt-1 text-sm text-slate-600">{{ $layout['subtitle'] }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</label>
|
||||
@endforeach
|
||||
</div>
|
||||
@error('inviteLayout')
|
||||
<p class="text-sm text-rose-600">{{ $message }}</p>
|
||||
@enderror
|
||||
<div class="flex justify-between">
|
||||
<button type="button" wire:click="$set('step', 'palette')" class="text-sm font-semibold text-slate-500 hover:text-slate-700">
|
||||
Zurück
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
@class([
|
||||
'inline-flex items-center justify-center gap-2 rounded-full px-6 py-3 text-sm font-semibold text-white shadow-lg transition',
|
||||
'bg-rose-500 shadow-rose-300/60 hover:bg-rose-600' => ! $isProcessing,
|
||||
'bg-rose-400 opacity-70' => $isProcessing,
|
||||
])
|
||||
wire:loading.attr="disabled"
|
||||
wire:target="finish">
|
||||
<span wire:loading.remove wire:target="finish">Setup abschließen</span>
|
||||
<span wire:loading wire:target="finish" class="flex items-center gap-2">
|
||||
<x-filament::loading-indicator class="h-4 w-4" />
|
||||
Bitte warten …
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
@endif
|
||||
</div>
|
||||
</x-filament-panels::page>
|
||||
@@ -6,7 +6,9 @@ use App\Http\Controllers\Api\Tenant\EventJoinTokenController;
|
||||
use App\Http\Controllers\Api\Tenant\EventJoinTokenLayoutController;
|
||||
use App\Http\Controllers\Api\Tenant\SettingsController;
|
||||
use App\Http\Controllers\Api\Tenant\TaskController;
|
||||
use App\Http\Controllers\Api\Tenant\TaskCollectionController;
|
||||
use App\Http\Controllers\Api\Tenant\PhotoController;
|
||||
use App\Http\Controllers\Api\Tenant\EmotionController;
|
||||
use App\Http\Controllers\OAuthController;
|
||||
use App\Http\Controllers\RevenueCatWebhookController;
|
||||
use App\Http\Controllers\Api\PackageController;
|
||||
@@ -96,6 +98,17 @@ Route::prefix('v1')->name('api.v1.')->group(function () {
|
||||
Route::get('tasks/collection/{collection}', [TaskController::class, 'fromCollection'])
|
||||
->name('tenant.tasks.from-collection');
|
||||
|
||||
Route::get('task-collections', [TaskCollectionController::class, 'index'])
|
||||
->name('tenant.task-collections.index');
|
||||
Route::get('task-collections/{collection}', [TaskCollectionController::class, 'show'])
|
||||
->name('tenant.task-collections.show');
|
||||
Route::post('task-collections/{collection}/activate', [TaskCollectionController::class, 'activate'])
|
||||
->name('tenant.task-collections.activate');
|
||||
|
||||
Route::get('emotions', [EmotionController::class, 'index'])->name('tenant.emotions.index');
|
||||
Route::post('emotions', [EmotionController::class, 'store'])->name('tenant.emotions.store');
|
||||
Route::patch('emotions/{emotion}', [EmotionController::class, 'update'])->name('tenant.emotions.update');
|
||||
|
||||
Route::prefix('settings')->group(function () {
|
||||
Route::get('/', [SettingsController::class, 'index'])
|
||||
->name('tenant.settings.index');
|
||||
|
||||
Reference in New Issue
Block a user