From 7030e8b5b9b063195678d1a9423fb5703e5c7d60 Mon Sep 17 00:00:00 2001 From: Codex Agent Date: Mon, 19 Jan 2026 21:19:37 +0100 Subject: [PATCH] Add superadmin task collections resource --- .../Pages/ManageTaskCollections.php | 28 ++ .../RelationManagers/TasksRelationManager.php | 136 +++++++++ .../TaskCollectionResource.php | 276 ++++++++++++++++++ resources/lang/de/admin.php | 25 ++ resources/lang/en/admin.php | 25 ++ tests/Unit/SuperAdminNavigationGroupsTest.php | 2 + 6 files changed, 492 insertions(+) create mode 100644 app/Filament/Clusters/WeeklyOps/Resources/TaskCollections/Pages/ManageTaskCollections.php create mode 100644 app/Filament/Clusters/WeeklyOps/Resources/TaskCollections/RelationManagers/TasksRelationManager.php create mode 100644 app/Filament/Clusters/WeeklyOps/Resources/TaskCollections/TaskCollectionResource.php diff --git a/app/Filament/Clusters/WeeklyOps/Resources/TaskCollections/Pages/ManageTaskCollections.php b/app/Filament/Clusters/WeeklyOps/Resources/TaskCollections/Pages/ManageTaskCollections.php new file mode 100644 index 0000000..fdf27d8 --- /dev/null +++ b/app/Filament/Clusters/WeeklyOps/Resources/TaskCollections/Pages/ManageTaskCollections.php @@ -0,0 +1,28 @@ +mutateDataUsing(fn (array $data): array => TaskCollectionResource::normalizeData($data)) + ->after(fn (array $data, TaskCollection $record) => app(SuperAdminAuditLogger::class)->recordModelMutation( + 'created', + $record, + SuperAdminAuditLogger::fieldsMetadata($data), + static::class + )), + ]; + } +} diff --git a/app/Filament/Clusters/WeeklyOps/Resources/TaskCollections/RelationManagers/TasksRelationManager.php b/app/Filament/Clusters/WeeklyOps/Resources/TaskCollections/RelationManagers/TasksRelationManager.php new file mode 100644 index 0000000..8ecd6a2 --- /dev/null +++ b/app/Filament/Clusters/WeeklyOps/Resources/TaskCollections/RelationManagers/TasksRelationManager.php @@ -0,0 +1,136 @@ +columns([ + TextColumn::make('title') + ->label(__('admin.tasks.table.title')) + ->getStateUsing(fn (Task $record) => $this->formatTaskTitle($record->title)) + ->searchable(['title->de', 'title->en']) + ->limit(60), + TextColumn::make('emotion.name') + ->label(__('admin.tasks.fields.emotion')) + ->getStateUsing(function (Task $record) { + $value = optional($record->emotion)->name; + if (is_array($value)) { + $locale = app()->getLocale(); + + return $value[$locale] ?? ($value['de'] ?? ($value['en'] ?? '')); + } + + return (string) ($value ?? ''); + }) + ->sortable(), + TextColumn::make('difficulty') + ->label(__('admin.tasks.fields.difficulty.label')) + ->badge(), + IconColumn::make('is_active') + ->label(__('admin.tasks.table.is_active')) + ->boolean(), + TextColumn::make('sort_order') + ->label(__('admin.tasks.table.sort_order')) + ->sortable(), + ]) + ->headerActions([ + AttachAction::make() + ->recordTitle(fn (Task $record) => $this->formatTaskTitle($record->title)) + ->recordSelectOptionsQuery(function (Builder $query): Builder { + $collectionId = $this->getOwnerRecord()->getKey(); + + return $query + ->whereNull('tenant_id') + ->where(function (Builder $inner) use ($collectionId): void { + $inner->whereNull('collection_id') + ->orWhere('collection_id', $collectionId); + }); + }) + ->multiple() + ->after(function (array $data): void { + $collection = $this->getOwnerRecord(); + $recordIds = Arr::wrap($data['recordId'] ?? []); + + if ($recordIds === []) { + return; + } + + Task::query() + ->whereIn('id', $recordIds) + ->update(['collection_id' => $collection->getKey()]); + }), + ]) + ->recordActions([ + DetachAction::make() + ->after(function (?Task $record): void { + if (! $record) { + return; + } + + $collectionId = $this->getOwnerRecord()->getKey(); + + if ($record->collection_id === $collectionId) { + $record->update(['collection_id' => null]); + } + }), + ]) + ->toolbarActions([ + BulkActionGroup::make([ + DetachBulkAction::make() + ->after(function (Collection $records): void { + $collectionId = $this->getOwnerRecord()->getKey(); + + $ids = $records + ->filter(fn (Task $record) => $record->collection_id === $collectionId) + ->pluck('id') + ->all(); + + if ($ids === []) { + return; + } + + Task::query() + ->whereIn('id', $ids) + ->update(['collection_id' => null]); + }), + ]), + ]); + } + + /** + * @param array|string|null $value + */ + protected function formatTaskTitle(array|string|null $value): string + { + if (is_array($value)) { + $locale = app()->getLocale(); + + return $value[$locale] + ?? ($value['de'] ?? ($value['en'] ?? Arr::first($value) ?? '')); + } + + if (is_string($value)) { + return $value; + } + + return ''; + } +} diff --git a/app/Filament/Clusters/WeeklyOps/Resources/TaskCollections/TaskCollectionResource.php b/app/Filament/Clusters/WeeklyOps/Resources/TaskCollections/TaskCollectionResource.php new file mode 100644 index 0000000..bb5d515 --- /dev/null +++ b/app/Filament/Clusters/WeeklyOps/Resources/TaskCollections/TaskCollectionResource.php @@ -0,0 +1,276 @@ +schema([ + TextInput::make('slug') + ->label(__('admin.common.slug')) + ->maxLength(255) + ->unique(ignoreRecord: true) + ->required(), + Select::make('event_type_id') + ->relationship('eventType', 'name') + ->getOptionLabelFromRecordUsing(fn (EventType $record) => is_array($record->name) ? ($record->name['de'] ?? $record->name['en'] ?? __('admin.common.unnamed')) : $record->name) + ->searchable() + ->preload() + ->label(__('admin.task_collections.fields.event_type_optional')), + SchemaTabs::make('content_tabs') + ->label(__('admin.task_collections.fields.content_localization')) + ->tabs([ + SchemaTab::make(__('admin.common.german')) + ->icon('heroicon-o-language') + ->schema([ + TextInput::make('name_translations.de') + ->label(__('admin.task_collections.fields.name_de')) + ->required(), + MarkdownEditor::make('description_translations.de') + ->label(__('admin.task_collections.fields.description_de')) + ->columnSpanFull(), + ]), + SchemaTab::make(__('admin.common.english')) + ->icon('heroicon-o-language') + ->schema([ + TextInput::make('name_translations.en') + ->label(__('admin.task_collections.fields.name_en')) + ->required(), + MarkdownEditor::make('description_translations.en') + ->label(__('admin.task_collections.fields.description_en')) + ->columnSpanFull(), + ]), + ]) + ->columnSpanFull(), + Toggle::make('is_default') + ->label(__('admin.task_collections.fields.is_default')) + ->default(false), + TextInput::make('position') + ->label(__('admin.task_collections.fields.position')) + ->numeric() + ->default(0), + ]) + ->columns(2); + } + + public static function table(Table $table): Table + { + return $table + ->columns([ + TextColumn::make('id') + ->label('#') + ->sortable(), + TextColumn::make('name') + ->label(__('admin.task_collections.table.name')) + ->getStateUsing(fn (TaskCollection $record) => static::formatTranslation($record->name_translations)) + ->searchable(['name_translations->de', 'name_translations->en']) + ->limit(60), + TextColumn::make('eventType.name') + ->label(__('admin.task_collections.table.event_type')) + ->getStateUsing(function (TaskCollection $record) { + $value = optional($record->eventType)->name; + + if (is_array($value)) { + $locale = app()->getLocale(); + + return $value[$locale] ?? ($value['de'] ?? ($value['en'] ?? '')); + } + + return (string) ($value ?? ''); + }) + ->toggleable(), + TextColumn::make('slug') + ->label(__('admin.task_collections.table.slug')) + ->toggleable() + ->searchable(), + IconColumn::make('is_default') + ->label(__('admin.task_collections.table.is_default')) + ->boolean(), + TextColumn::make('position') + ->label(__('admin.task_collections.table.position')) + ->sortable(), + TextColumn::make('tasks_count') + ->label(__('admin.task_collections.table.tasks')) + ->sortable(), + TextColumn::make('events_count') + ->label(__('admin.task_collections.table.events')) + ->sortable(), + ]) + ->filters([ + SelectFilter::make('event_type_id') + ->label(__('admin.task_collections.table.event_type')) + ->relationship( + 'eventType', + 'name', + fn (Builder $query): Builder => $query->orderBy('name->de') + ) + ->getOptionLabelFromRecordUsing(fn (EventType $record) => is_array($record->name) ? ($record->name['de'] ?? $record->name['en'] ?? __('admin.common.unnamed')) : $record->name), + SelectFilter::make('is_default') + ->label(__('admin.task_collections.table.is_default')) + ->options([ + '1' => __('admin.common.yes'), + '0' => __('admin.common.no'), + ]), + ]) + ->recordActions([ + Actions\EditAction::make() + ->mutateDataUsing(fn (array $data, TaskCollection $record): array => static::normalizeData($data, $record)) + ->after(fn (array $data, TaskCollection $record) => app(SuperAdminAuditLogger::class)->recordModelMutation( + 'updated', + $record, + SuperAdminAuditLogger::fieldsMetadata($data), + static::class + )), + Actions\DeleteAction::make() + ->after(fn (TaskCollection $record) => app(SuperAdminAuditLogger::class)->recordModelMutation( + 'deleted', + $record, + source: static::class + )), + ]) + ->bulkActions([ + Actions\DeleteBulkAction::make() + ->after(function (Collection $records): void { + $logger = app(SuperAdminAuditLogger::class); + + foreach ($records as $record) { + $logger->recordModelMutation( + 'deleted', + $record, + source: static::class + ); + } + }), + ]); + } + + public static function getNavigationLabel(): string + { + return __('admin.task_collections.menu'); + } + + public static function getNavigationGroup(): UnitEnum|string|null + { + return __('admin.nav.curation'); + } + + public static function getEloquentQuery(): Builder + { + return parent::getEloquentQuery() + ->whereNull('tenant_id') + ->with('eventType') + ->withCount(['tasks', 'events']); + } + + /** + * @param array $data + */ + public static function normalizeData(array $data, ?TaskCollection $record = null): array + { + $data['tenant_id'] = null; + $data['slug'] = static::resolveSlug($data, $record); + + return $data; + } + + /** + * @param array $data + */ + protected static function resolveSlug(array $data, ?TaskCollection $record = null): string + { + $rawSlug = trim((string) ($data['slug'] ?? '')); + $translations = Arr::wrap($data['name_translations'] ?? []); + $fallbackName = (string) ($translations['en'] ?? $translations['de'] ?? ''); + + $base = $rawSlug !== '' ? $rawSlug : $fallbackName; + $slugBase = Str::slug($base) ?: 'collection'; + + $query = TaskCollection::query()->where('slug', $slugBase); + + if ($record) { + $query->whereKeyNot($record->getKey()); + } + + if (! $query->exists()) { + return $slugBase; + } + + do { + $candidate = $slugBase.'-'.Str::random(4); + $candidateQuery = TaskCollection::query()->where('slug', $candidate); + + if ($record) { + $candidateQuery->whereKeyNot($record->getKey()); + } + } while ($candidateQuery->exists()); + + return $candidate; + } + + /** + * @param array|null $translations + */ + protected static function formatTranslation(?array $translations): string + { + if (! is_array($translations)) { + return ''; + } + + $locale = app()->getLocale(); + + return $translations[$locale] + ?? ($translations['de'] ?? ($translations['en'] ?? Arr::first($translations) ?? '')); + } + + public static function getPages(): array + { + return [ + 'index' => ManageTaskCollections::route('/'), + ]; + } + + public static function getRelations(): array + { + return [ + TasksRelationManager::class, + ]; + } +} diff --git a/resources/lang/de/admin.php b/resources/lang/de/admin.php index ba6e541..f3f50a8 100644 --- a/resources/lang/de/admin.php +++ b/resources/lang/de/admin.php @@ -52,6 +52,8 @@ return [ 'unnamed' => 'Ohne Namen', 'from' => 'Von', 'until' => 'Bis', + 'yes' => 'Ja', + 'no' => 'Nein', ], 'photos' => [ @@ -503,6 +505,29 @@ return [ ], ], + 'task_collections' => [ + 'menu' => 'Aufgabensammlungen', + 'fields' => [ + 'event_type_optional' => 'Eventtyp (optional)', + 'content_localization' => 'Inhaltslokalisierung', + 'name_de' => 'Name (Deutsch)', + 'description_de' => 'Beschreibung (Deutsch)', + 'name_en' => 'Name (Englisch)', + 'description_en' => 'Beschreibung (Englisch)', + 'is_default' => 'Standard-Sammlung', + 'position' => 'Position', + ], + 'table' => [ + 'name' => 'Name', + 'event_type' => 'Eventtyp', + 'slug' => 'Slug', + 'is_default' => 'Standard', + 'position' => 'Position', + 'tasks' => 'Aufgaben', + 'events' => 'Events', + ], + ], + 'widgets' => [ 'events_active_today' => [ 'heading' => 'Heute aktive Events', diff --git a/resources/lang/en/admin.php b/resources/lang/en/admin.php index c3e530a..1582e7d 100644 --- a/resources/lang/en/admin.php +++ b/resources/lang/en/admin.php @@ -52,6 +52,8 @@ return [ 'unnamed' => 'Unnamed', 'from' => 'From', 'until' => 'Until', + 'yes' => 'Yes', + 'no' => 'No', ], 'photos' => [ @@ -489,6 +491,29 @@ return [ ], ], + 'task_collections' => [ + 'menu' => 'Task collections', + 'fields' => [ + 'event_type_optional' => 'Event Type (optional)', + 'content_localization' => 'Content Localization', + 'name_de' => 'Name (German)', + 'description_de' => 'Description (German)', + 'name_en' => 'Name (English)', + 'description_en' => 'Description (English)', + 'is_default' => 'Default collection', + 'position' => 'Position', + ], + 'table' => [ + 'name' => 'Name', + 'event_type' => 'Event Type', + 'slug' => 'Slug', + 'is_default' => 'Default', + 'position' => 'Position', + 'tasks' => 'Tasks', + 'events' => 'Events', + ], + ], + 'widgets' => [ 'events_active_today' => [ 'heading' => 'Events active today', diff --git a/tests/Unit/SuperAdminNavigationGroupsTest.php b/tests/Unit/SuperAdminNavigationGroupsTest.php index c523dba..14fee01 100644 --- a/tests/Unit/SuperAdminNavigationGroupsTest.php +++ b/tests/Unit/SuperAdminNavigationGroupsTest.php @@ -21,6 +21,7 @@ class SuperAdminNavigationGroupsTest extends TestCase \App\Filament\Resources\EventTypeResource::class => 'admin.nav.events', \App\Filament\Resources\PhotoResource::class => 'admin.nav.events', \App\Filament\Resources\TaskResource::class => 'admin.nav.curation', + \App\Filament\Clusters\WeeklyOps\Resources\TaskCollections\TaskCollectionResource::class => 'admin.nav.curation', \App\Filament\Resources\EmotionResource::class => 'admin.nav.curation', \App\Filament\Resources\LegalPageResource::class => 'admin.nav.content', \App\Filament\Blog\Resources\PostResource::class => 'admin.nav.content', @@ -62,6 +63,7 @@ class SuperAdminNavigationGroupsTest extends TestCase \App\Filament\SuperAdmin\Pages\IntegrationsHealthDashboard::class => DailyOpsCluster::class, \App\Filament\Clusters\DailyOps\Pages\JoinTokenAnalyticsDashboard::class => DailyOpsCluster::class, \App\Filament\Resources\TaskResource::class => WeeklyOpsCluster::class, + \App\Filament\Clusters\WeeklyOps\Resources\TaskCollections\TaskCollectionResource::class => WeeklyOpsCluster::class, \App\Filament\Resources\EmotionResource::class => WeeklyOpsCluster::class, \App\Filament\Resources\EventTypeResource::class => WeeklyOpsCluster::class, \App\Filament\Resources\UserResource::class => WeeklyOpsCluster::class,