From d9be1894368f3d3a743fdf05a8dad6789033397e Mon Sep 17 00:00:00 2001 From: Remco Date: Sat, 24 Jan 2026 21:13:30 +0100 Subject: [PATCH] Add Catalog Editor functionality from commit 847879c4 --- .../CatalogEditors/CatalogEditorResource.php | 48 + .../Pages/CreateCatalogEditor.php | 11 + .../Pages/EditCatalogEditor.php | 20 + .../Pages/ListCatalogEditors.php | 19 + .../Pages/ManageCatalogEditor.php | 957 ++++++++++++++++++ .../Pages/ViewCatalogEditor.php | 19 + ...catalog_icons_path_to_website_settings.php | 25 + Updated_Cms/resources/css/global.scss | 9 +- .../forms/fields/catalog-icon-grid.blade.php | 39 + .../fields/catalog-icon-preview.blade.php | 13 + .../pages/manage-catalog-editor.blade.php | 283 ++++++ .../pages/partials/catalog-tree.blade.php | 175 ++++ .../columns/catalog-item-draggable.blade.php | 120 +++ .../columns/catalog-item-select.blade.php | 44 + 14 files changed, 1781 insertions(+), 1 deletion(-) create mode 100644 Updated_Cms/app/Filament/Resources/Hotel/CatalogEditors/CatalogEditorResource.php create mode 100644 Updated_Cms/app/Filament/Resources/Hotel/CatalogEditors/Pages/CreateCatalogEditor.php create mode 100644 Updated_Cms/app/Filament/Resources/Hotel/CatalogEditors/Pages/EditCatalogEditor.php create mode 100644 Updated_Cms/app/Filament/Resources/Hotel/CatalogEditors/Pages/ListCatalogEditors.php create mode 100644 Updated_Cms/app/Filament/Resources/Hotel/CatalogEditors/Pages/ManageCatalogEditor.php create mode 100644 Updated_Cms/app/Filament/Resources/Hotel/CatalogEditors/Pages/ViewCatalogEditor.php create mode 100644 Updated_Cms/database/migrations/2025_10_07_000000_add_catalog_icons_path_to_website_settings.php create mode 100644 Updated_Cms/resources/views/filament/forms/fields/catalog-icon-grid.blade.php create mode 100644 Updated_Cms/resources/views/filament/forms/fields/catalog-icon-preview.blade.php create mode 100644 Updated_Cms/resources/views/filament/resources/hotel/catalog-editors/pages/manage-catalog-editor.blade.php create mode 100644 Updated_Cms/resources/views/filament/resources/hotel/catalog-editors/pages/partials/catalog-tree.blade.php create mode 100644 Updated_Cms/resources/views/filament/tables/columns/catalog-item-draggable.blade.php create mode 100644 Updated_Cms/resources/views/filament/tables/columns/catalog-item-select.blade.php diff --git a/Updated_Cms/app/Filament/Resources/Hotel/CatalogEditors/CatalogEditorResource.php b/Updated_Cms/app/Filament/Resources/Hotel/CatalogEditors/CatalogEditorResource.php new file mode 100644 index 0000000000..a3c50c39e0 --- /dev/null +++ b/Updated_Cms/app/Filament/Resources/Hotel/CatalogEditors/CatalogEditorResource.php @@ -0,0 +1,48 @@ +columns([ + TextColumn::make('id')->label('ID')->sortable(), + TextColumn::make('caption')->label('Page Name')->searchable(), + TextColumn::make('parent_id')->label('Parent ID'), + TextColumn::make('order_num')->label('Order'), + IconColumn::make('visible')->boolean()->label('Visible'), + IconColumn::make('enabled')->boolean()->label('Enabled'), + ]) + ->recordActions([]) + ->toolbarActions([]); + } + + public static function form(Schema $schema): Schema + { + return $schema; + } + + public static function getPages(): array + { + return [ + 'index' => Pages\ManageCatalogEditor::route('/'), + ]; + } +} diff --git a/Updated_Cms/app/Filament/Resources/Hotel/CatalogEditors/Pages/CreateCatalogEditor.php b/Updated_Cms/app/Filament/Resources/Hotel/CatalogEditors/Pages/CreateCatalogEditor.php new file mode 100644 index 0000000000..3158f0d47a --- /dev/null +++ b/Updated_Cms/app/Filament/Resources/Hotel/CatalogEditors/Pages/CreateCatalogEditor.php @@ -0,0 +1,11 @@ +selectedPage = CatalogPage::find($pageId); + $this->selectedItemIds = []; + + if ($this->pageSearch !== '') { + $this->pageSearch = ''; + } + + $this->expandedPages = $this->collectParentIds($pageId); + + $this->resetTable(); + } + + protected function collectParentIds(int $pageId): array + { + $pages = CatalogPage::pluck('parent_id', 'id'); + $ids = [$pageId]; + $parentId = $pages[$pageId] ?? null; + while ($parentId && $parentId > 0) { + $ids[] = $parentId; + $parentId = $pages[$parentId] ?? null; + } + + return array_unique($ids); + } + + public function getMaxContentWidth(): ?string + { + return 'full'; + } + + public function resetView(): void + { + $this->pageSearch = ''; + $this->selectedPage = null; + $this->expandedPages = []; + $this->selectedItemIds = []; + $this->resetTable(); + + Notification::make() + ->title('View reset') + ->body('Catalog view restored to default.') + ->success() + ->send(); + } + + public function toggleExpand(int $pageId): void + { + if (in_array($pageId, $this->expandedPages, true)) { + $this->expandedPages = array_values(array_diff($this->expandedPages, [$pageId])); + } else { + $this->expandedPages[] = $pageId; + } + } + + public function isExpanded(int $pageId): bool + { + return in_array($pageId, $this->expandedPages, true); + } + + public function toggleSelectItem(int $itemId, bool $ctrl = false): void + { + if ($ctrl) { + if (in_array($itemId, $this->selectedItemIds, true)) { + $this->selectedItemIds = array_values(array_diff($this->selectedItemIds, [$itemId])); + } else { + $this->selectedItemIds[] = $itemId; + } + } else { + $this->selectedItemIds = [$itemId]; + } + + $this->resetTable(); + } + + public function updatedPageSearch(): void + { + $this->resetTable(); + + $needle = trim($this->pageSearch); + + if ($needle === '') { + return; + } + + $like = '%' . $this->escapeLike($needle) . '%'; + + $matchingPage = CatalogPage::query() + ->whereRaw("caption LIKE ? ESCAPE '\\\\'", [$like]) + ->first(); + + if ($matchingPage) { + $this->selectedPage = $matchingPage; + $this->expandedPages[] = $matchingPage->id; + $this->resetTable(); + $this->dispatch('scroll-to-page', id: $matchingPage->id); + + return; + } + + $matchingItem = CatalogItem::query() + ->whereRaw("catalog_name LIKE ? ESCAPE '\\\\'", [$like]) + ->orWhere('id', ctype_digit($needle) ? (int) $needle : -1) + ->first(); + + if ($matchingItem) { + $page = CatalogPage::find($matchingItem->page_id); + if ($page) { + $this->selectedPage = $page; + $this->expandedPages[] = $page->id; + $this->selectedItemIds = [$matchingItem->id]; + $this->resetTable(); + $this->dispatch('scroll-to-page', id: $page->id); + + Notification::make() + ->title('Item found') + ->body("Opened page: {$page->caption}") + ->success() + ->send(); + } + } + } + + public function getTableQuery() + { + if (! $this->selectedPage) { + return CatalogItem::query()->whereRaw('1=0'); + } + + $query = CatalogItem::query() + ->where('page_id', $this->selectedPage->id); + + if (filled($this->pageSearch)) { + $needle = trim($this->pageSearch); + $like = '%' . $this->escapeLike($needle) . '%'; + $isNumeric = ctype_digit($needle); + + $query->where(function ($q) use ($like, $needle, $isNumeric) { + // Text search (escaped) + $q->whereRaw("catalog_name LIKE ? ESCAPE '\\\\'", [$like]); + + // Numeric search: exact matches only (faster and avoids weird casts) + if ($isNumeric) { + $n = (int) $needle; + + $q->orWhere('id', $n) + ->orWhere('cost_credits', $n) + ->orWhere('cost_points', $n) + ->orWhere('points_type', $n); + } + }); + } + + if (! $this->getTableSortColumn()) { + $query->orderBy('order_number')->orderBy('catalog_name')->orderBy('id'); + } + + return $query; + } + + protected function findPrevNeighbor(CatalogItem $record): ?CatalogItem + { + return CatalogItem::query() + ->where('page_id', $record->page_id) + ->where('order_number', '!=', -1) + ->where(function ($q) use ($record) { + $q->where('order_number', '<', $record->order_number) + ->orWhere(function ($q2) use ($record) { + $q2->where('order_number', $record->order_number) + ->where('id', '<', $record->id); + }); + }) + ->orderBy('order_number', 'desc') + ->orderBy('id', 'desc') + ->first(); + } + + protected function findNextNeighbor(CatalogItem $record): ?CatalogItem + { + return CatalogItem::query() + ->where('page_id', $record->page_id) + ->where('order_number', '!=', -1) + ->where(function ($q) use ($record) { + $q->where('order_number', '>', $record->order_number) + ->orWhere(function ($q2) use ($record) { + $q2->where('order_number', $record->order_number) + ->where('id', '>', $record->id); + }); + }) + ->orderBy('order_number', 'asc') + ->orderBy('id', 'asc') + ->first(); + } + + protected function canMoveUp(CatalogItem $record): bool + { + if ($record->order_number === -1) { + return false; + } + + return (bool) $this->findPrevNeighbor($record); + } + + protected function canMoveDown(CatalogItem $record): bool + { + if ($record->order_number === -1) { + return false; + } + + return (bool) $this->findNextNeighbor($record); + } + + protected function nudgeRecord(CatalogItem $record, string $direction): void + { + if ($record->order_number === -1) { + Notification::make()->title('Locked')->body('This item is locked (order = -1).')->danger()->send(); + + return; + } + + $neighbor = $direction === 'up' + ? $this->findPrevNeighbor($record) + : $this->findNextNeighbor($record); + + if (! $neighbor) { + return; + } + + DB::transaction(function () use ($record, $neighbor) { + $a = $record->order_number; + $b = $neighbor->order_number; + + $record->update(['order_number' => $b]); + $neighbor->update(['order_number' => $a]); + }); + + $this->normalizeOrderForSelectedPage(); + + Notification::make()->title('Order updated')->success()->send(); + } + + protected function normalizeOrderForSelectedPage(): void + { + if (! $this->selectedPage?->id) { + return; + } + + $items = CatalogItem::query() + ->where('page_id', $this->selectedPage->id) + ->where('order_number', '!=', -1) + ->orderBy('order_number') + ->orderBy('id') + ->get(['id']); + + DB::transaction(function () use ($items) { + foreach ($items->values() as $index => $item) { + CatalogItem::whereKey($item->id) + ->update(['order_number' => ($index + 1) * 10]); + } + }); + + $this->resetTable(); + } + + public function pageHasLockedItems(): bool + { + if (! $this->selectedPage?->id) { + return false; + } + + return CatalogItem::query() + ->where('page_id', $this->selectedPage->id) + ->where('order_number', -1) + ->exists(); + } + + public function autoOrderItems(): void + { + if (! $this->selectedPage?->id) { + Notification::make()->title('Select a page first')->warning()->send(); + + return; + } + + if ($this->pageHasLockedItems()) { + Notification::make() + ->title('Action not allowed') + ->body('This page contains item(s) with order_number = -1. Remove or change them before auto-ordering.') + ->danger() + ->send(); + + return; + } + + $affected = CatalogItem::query() + ->where('page_id', $this->selectedPage->id) + ->where('order_number', '!=', -1) + ->update(['order_number' => 99]); + + $this->resetTable(); + + if ($affected > 0) { + Notification::make()->title('Items auto-ordered')->body("Updated {$affected} item(s).")->success()->send(); + } else { + Notification::make()->title('Nothing to update')->body('No items were changed (none on this page or all are set to -1).')->warning()->send(); + } + } + + public function manualOrderItems(): void + { + if (! $this->selectedPage?->id) { + Notification::make()->title('Select a page first')->warning()->send(); + + return; + } + + if ($this->pageHasLockedItems()) { + Notification::make()->title('Action not allowed')->body('This page contains item(s) with order_number = -1. Change/remove them before manual ordering.')->danger()->send(); + + return; + } + + $items = CatalogItem::query() + ->where('page_id', $this->selectedPage->id) + ->where('order_number', '!=', -1) + ->orderBy('catalog_name', 'asc') + ->orderBy('id', 'asc') + ->get(['id']); + + if ($items->isEmpty()) { + Notification::make()->title('Nothing to update')->body('No items on this page (or all are locked to -1).')->warning()->send(); + + return; + } + + DB::transaction(function () use ($items) { + foreach ($items->values() as $index => $item) { + CatalogItem::whereKey($item->id)->update(['order_number' => ($index + 1) * 10]); + } + }); + + $this->resetTable(); + + Notification::make()->title('Items manually ordered')->body('Items sorted AÔåÆZ and numbered 10, 20, 30, ÔǪ')->success()->send(); + } + + public function table(Table $table): Table + { + return $table->paginated(false); + } + + protected function getTableColumns(): array + { + return [ + Tables\Columns\ViewColumn::make('select_item') + ->label('') + ->view('filament.tables.columns.catalog-item-select') + ->viewData([ + 'itemId' => fn ($record) => $record->id, + 'isSelected' => fn ($record) => in_array($record->id, $this->selectedItemIds, true), + ]) + ->width('36px') + ->sortable(false) + ->searchable(false), + + Tables\Columns\ViewColumn::make('item_display') + ->label('Item') + ->view('filament.tables.columns.catalog-item-draggable') + ->viewData([ + 'icon' => fn ($record) => $this->buildFurniIconUrl($record->catalog_name), + 'name' => fn ($record) => $record->catalog_name, + 'itemId' => fn ($record) => $record->id, + 'isSelected' => fn ($record) => in_array($record->id, $this->selectedItemIds, true), + ]) + ->sortable(false) + ->searchable(false), + + Tables\Columns\TextColumn::make('cost_credits') + ->label('Credits') + ->sortable(), + + Tables\Columns\TextColumn::make('cost_points') + ->label('Points') + ->sortable(), + + Tables\Columns\TextColumn::make('points_type') + ->label('Type') + ->sortable(), + + Tables\Columns\TextColumn::make('amount') + ->label('Amount') + ->sortable(), + + Tables\Columns\TextColumn::make('order_number') + ->label('Order') + ->sortable() + ->toggleable(), + + Tables\Columns\IconColumn::make('club_only') + ->boolean() + ->label('Club Only') + ->sortable(), + ]; + } + + protected function getTableActions(): array + { + return [ + FilamentAction::make('move_up') + ->label('') + ->icon('heroicon-m-chevron-up') + ->color('gray') + ->tooltip('Move up') + ->action(fn (CatalogItem $record) => $this->nudgeRecord($record, 'up')) + ->visible(fn (CatalogItem $record) => $this->pageSearch === '' && $this->canMoveUp($record)) + ->size('sm'), + + FilamentAction::make('move_down') + ->label('') + ->icon('heroicon-m-chevron-down') + ->color('gray') + ->tooltip('Move down') + ->action(fn (CatalogItem $record) => $this->nudgeRecord($record, 'down')) + ->visible(fn (CatalogItem $record) => $this->pageSearch === '' && $this->canMoveDown($record)) + ->size('sm'), + + EditAction::make('edit') + ->label('Edit') + ->icon('heroicon-m-pencil-square') + ->modalHeading('Edit catalog item') + ->modalSubmitActionLabel('Save') + ->modalWidth('md') + ->form([ + Forms\Components\TextInput::make('cost_credits')->label('Credits')->numeric()->minValue(0)->required(), + Forms\Components\TextInput::make('cost_points')->label('Points')->numeric()->minValue(0)->required(), + Forms\Components\TextInput::make('points_type')->label('Type')->numeric()->minValue(0)->maxValue(999)->maxLength(50), + Forms\Components\TextInput::make('amount')->label('Amount')->numeric()->minValue(1)->default(1)->required(), + Forms\Components\TextInput::make('order_number') + ->label('Order') + ->numeric() + ->minValue(-1) + ->step(1) + ->helperText('Use -1 to lock, or a non-negative number to sort (lower = earlier).') + ->required(), + Forms\Components\Toggle::make('club_only')->label('Club only'), + ]) + ->fillForm(fn (CatalogItem $record) => [ + 'cost_credits' => $record->cost_credits, + 'cost_points' => $record->cost_points, + 'points_type' => $record->points_type, + 'amount' => $record->amount, + 'order_number' => $record->order_number, + 'club_only' => $record->club_only === '1', + ]) + ->action(function (CatalogItem $record, array $data): void { + $record->update([ + 'cost_credits' => (int) $data['cost_credits'], + 'cost_points' => (int) $data['cost_points'], + 'points_type' => $data['points_type'] ?? null, + 'amount' => (int) $data['amount'], + 'order_number' => (int) $data['order_number'], + 'club_only' => ! empty($data['club_only']) ? '1' : '0', + ]); + + $this->resetTable(); + + Notification::make()->title('Item updated')->success()->send(); + }), + ]; + } + + protected function getActions(): array + { + return [ + FilamentAction::make('editPage') + ->label('Edit page') + ->modalHeading(function (array $arguments): string { + $page = CatalogPage::find($arguments['pageId'] ?? null); + + return $page ? 'Edit: ' . $page->caption : 'Edit page'; + }) + ->modalSubmitActionLabel('Save') + ->modalWidth('3xl') + ->form([ + Forms\Components\TextInput::make('caption')->label('Name')->maxLength(128)->required(), + + Forms\Components\TextInput::make('caption_save') + ->label('Name TAG') + ->maxLength(25) + ->nullable() + ->extraInputAttributes([ + 'pattern' => '[a-z]*', + 'title' => 'Lowercase letters only (aÔÇôz); leave empty if you want.', + 'spellcheck' => 'false', + 'autocomplete' => 'off', + ]) + ->live(onBlur: true) + ->afterStateUpdated(function ($state, callable $set) { + $set('caption_save', $this->sanitizeCaptionSave($state)); + }) + ->rules(['nullable', 'regex:/^[a-z]*$/']) + ->validationMessages([ + 'regex' => 'Use lowercase letters only (aÔÇôz), no spaces or special characters.', + ]), + + Forms\Components\TextInput::make('order_num') + ->label('Order') + ->numeric() + ->minValue(0) + ->step(1) + ->required() + ->helperText('Lower number appears earlier in the menu.'), + + Forms\Components\TextInput::make('icon_image') + ->label('Icon number') + ->numeric() + ->minValue(1) + ->required() + ->default(1) + ->live() + ->helperText(function ($get) { + $id = (int) ($get('icon_image') ?: 1); + $url = $this->buildCatalogIconUrl($id); + $fallback = $this->buildCatalogIconUrl(1); + + $html = '
+ icon ' . e($id) . ' + Icon #' . e($id) . ' +
'; + + return new \Illuminate\Support\HtmlString($html); + }), + ]) + ->fillForm(function (array $arguments): array { + $page = CatalogPage::find($arguments['pageId'] ?? null); + + return [ + 'caption' => $page?->caption ?? '', + 'caption_save' => $page?->caption_save ?? '', + 'order_num' => $page?->order_num ?? 1, + 'icon_image' => $page?->icon_image ?? 1, + ]; + }) + ->action(function (array $data, array $arguments): void { + $page = CatalogPage::find($arguments['pageId'] ?? null); + if (! $page) { + Notification::make()->title('Page not found')->danger()->send(); + + return; + } + + $tag = $this->sanitizeCaptionSave($data['caption_save'] ?? ''); + + $icon = max(1, (int) ($data['icon_image'] ?: 1)); + + $page->update([ + 'caption' => $data['caption'], + 'caption_save' => $tag, + 'order_num' => (int) ($data['order_num'] ?? 1), + 'icon_image' => $icon, + ]); + + $this->selectPage($page->id); + Notification::make()->title('Page updated')->success()->send(); + }), + ]; + } + + protected function sanitizeCaptionSave(?string $value): string + { + $value = (string) ($value ?? ''); + if ($value === '') { + return ''; + } + + return strtolower(preg_replace('/[^a-z]/', '', $value)); + } + + public function reorderPage(int $pageId, int $targetPageId, string $position = 'after'): void + { + $page = CatalogPage::find($pageId); + $target = CatalogPage::find($targetPageId); + + if (! $page || ! $target) { + return; + } + + if ((int) $page->parent_id !== (int) $target->parent_id) { + return; + } + + if ($page->id === $target->id) { + return; + } + + $siblings = CatalogPage::query() + ->where('parent_id', $page->parent_id) + ->orderBy('order_num') + ->orderBy('id') + ->pluck('id') + ->toArray(); + + $siblings = array_values(array_filter($siblings, fn ($id) => (int) $id !== (int) $page->id)); + + $targetIndex = array_search($target->id, $siblings, true); + if ($targetIndex === false) { + return; + } + + if ($position === 'before') { + array_splice($siblings, $targetIndex, 0, [$page->id]); + } else { + array_splice($siblings, $targetIndex + 1, 0, [$page->id]); + } + + DB::transaction(function () use ($siblings) { + foreach ($siblings as $i => $id) { + CatalogPage::whereKey($id)->update(['order_num' => ($i + 1) * 10]); + } + }); + + if ($this->selectedPage?->id) { + $this->selectedPage = CatalogPage::find($this->selectedPage->id); + } + + Notification::make()->title('Menu order updated')->success()->send(); + } + + public function normalizePageOrder(int $parentId): void + { + $ids = CatalogPage::query() + ->where('parent_id', $parentId) + ->orderBy('order_num') + ->orderBy('id') + ->pluck('id') + ->toArray(); + + DB::transaction(function () use ($ids) { + foreach ($ids as $i => $id) { + CatalogPage::whereKey($id)->update(['order_num' => ($i + 1) * 10]); + } + }); + } + + public function openEditPage(int $pageId): void + { + $this->mountAction('editPage', ['pageId' => $pageId]); + } + + public function moveItemToPage(int $itemId, int $targetPageId): void + { + $this->moveItemsToPage((string) $itemId, $targetPageId); + } + + public function moveItemsToPage(string $itemIdsCsv, int $targetPageId): void + { + $raw = $itemIdsCsv; + + $target = CatalogPage::find($targetPageId); + + $ids = collect(explode(',', $itemIdsCsv)) + ->map(fn ($v) => (int) trim($v)) + ->filter(fn ($v) => $v > 0) + ->unique() + ->values() + ->all(); + + if (empty($ids) || ! $target) { + Notification::make()->title('Move failed')->body('No items selected or target page not found.')->danger()->send(); + + return; + } + + DB::transaction(function () use ($ids, $targetPageId) { + $maxOrder = (int) (CatalogItem::where('page_id', $targetPageId)->max('order_number') ?? 0); + + foreach ($ids as $i => $id) { + CatalogItem::whereKey($id)->update([ + 'page_id' => $targetPageId, + 'order_number' => $maxOrder + 1 + $i, + ]); + } + }); + + $this->resetTable(); + $this->selectedItemIds = []; + + $this->dispatch('$refresh'); + + Notification::make() + ->title('Items moved') + ->body('Moved ' . count($ids) . ' item(s) to: ' . ($target->caption ?? ('#' . $targetPageId))) + ->success() + ->send(); + } + + protected function buildFurniIconUrl(string $catalogName): string + { + $base = $this->getFurniIconBasePath(); + $safeName = str_replace('*', '_', $catalogName); + $path = rtrim($base, '/') . '/' . $safeName . '_icon.png'; + + if (preg_match('#^(https?:)?//#', $path)) { + return $path; + } + + return asset($path); + } + + protected function getFurniIconBasePath(): string + { + $setting = WebsiteSetting::where('key', 'furniture_icons_path')->first(); + + return $setting && $setting->value ? rtrim($setting->value, '/') : '/images/furniture'; + } + + protected function getCatalogIconBasePath(): string + { + $setting = WebsiteSetting::where('key', 'catalog_icons_path')->first(); + + return $setting && $setting->value ? rtrim($setting->value, '/') : '/gamedata/c_images/catalogue'; + } + + protected function buildCatalogIconUrl(int $iconImage): string + { + $iconImage = $iconImage > 0 ? $iconImage : 1; + $base = $this->getCatalogIconBasePath(); + $path = $base . '/icon_' . $iconImage . '.png'; + + if (preg_match('#^(https?:)?//#', $path)) { + return $path; + } + + return asset($path); + } + + public function reorderItems(array $orderedIds): void + { + if (filled($this->pageSearch)) { + Notification::make() + ->title('Ordering disabled in search mode') + ->body('You cannot reorder items while viewing search results.') + ->warning() + ->send(); + + return; + } + + if (! $this->selectedPage?->id) { + return; + } + + DB::transaction(function () use ($orderedIds) { + foreach ($orderedIds as $index => $id) { + CatalogItem::whereKey($id)->update([ + 'order_number' => ($index + 1) * 10, + ]); + } + }); + + $this->normalizeOrderForSelectedPage(); + + Notification::make() + ->title('Items reordered') + ->success() + ->send(); + + $this->resetTable(); + } + + protected function getTableHeaderActions(): array + { + return [ + FilamentAction::make('massEdit') + ->label('Mass edit selected') + ->icon('heroicon-m-pencil-square') + ->color('primary') + ->disabled(fn () => empty($this->selectedItemIds)) + ->modalHeading('Edit selected catalog items') + ->modalSubmitActionLabel('Apply changes') + ->modalWidth('lg') + ->form([ + Forms\Components\TextInput::make('cost_credits')->label('Credits')->numeric()->minValue(0)->nullable()->helperText('Leave empty to keep unchanged'), + Forms\Components\TextInput::make('cost_points')->label('Points')->numeric()->minValue(0)->nullable()->helperText('Leave empty to keep unchanged'), + Forms\Components\TextInput::make('points_type')->label('Type (points_type)')->numeric()->minValue(0)->maxValue(999)->nullable()->helperText('Leave empty to keep unchanged'), + Forms\Components\TextInput::make('amount')->label('Amount')->numeric()->minValue(1)->nullable()->helperText('Leave empty to keep unchanged'), + Forms\Components\TextInput::make('order_number')->label('Order')->numeric()->minValue(-1)->nullable()->helperText('Leave empty to keep unchanged'), + Forms\Components\Select::make('club_only') + ->label('Club only') + ->options(['' => 'ÔÇö No change ÔÇö', '1' => 'Yes', '0' => 'No']) + ->native(false) + ->nullable() + ->default('') + ->helperText('Choose Yes/No, or leave as "No change"'), + ]) + ->action(function (array $data): void { + $ids = collect($this->selectedItemIds) + ->filter(fn ($v) => (int) $v > 0) + ->map(fn ($v) => (int) $v) + ->values() + ->all(); + + if (empty($ids)) { + Notification::make()->title('No items selected')->warning()->send(); + + return; + } + + $updates = []; + + if ($data['cost_credits'] !== null && $data['cost_credits'] !== '') { + $updates['cost_credits'] = (int) $data['cost_credits']; + } + if ($data['cost_points'] !== null && $data['cost_points'] !== '') { + $updates['cost_points'] = (int) $data['cost_points']; + } + if ($data['points_type'] !== null && $data['points_type'] !== '') { + $updates['points_type'] = (int) $data['points_type']; + } + if ($data['amount'] !== null && $data['amount'] !== '') { + $updates['amount'] = (int) $data['amount']; + } + if ($data['order_number'] !== null && $data['order_number'] !== '') { + $updates['order_number'] = (int) $data['order_number']; + } + if ($data['club_only'] !== null && $data['club_only'] !== '') { + $updates['club_only'] = $data['club_only'] === '1' ? '1' : '0'; + } + + if (empty($updates)) { + Notification::make()->title('Nothing to update')->body('Fill at least one field to apply to the selected items.')->warning()->send(); + + return; + } + + CatalogItem::whereIn('id', $ids)->update($updates); + + $count = count($ids); + $this->resetTable(); + $this->selectedItemIds = []; + + Notification::make()->title('Updated items')->body("Applied changes to {$count} item(s).")->success()->send(); + }), + FilamentAction::make('updateOrder') + ->label('Update Order') + ->icon('heroicon-o-arrow-path') + ->color('secondary') + ->visible(fn () => $this->selectedPage && $this->pageSearch === '') + ->requiresConfirmation() + ->modalHeading('Confirm Update Order') + ->modalDescription('This will save the current item order (as currently sorted) into the database. Continue?') + ->modalSubmitActionLabel('Update Order') + ->action(function (): void { + if (! $this->selectedPage?->id) { + Notification::make()->title('No page selected')->warning()->send(); + + return; + } + + if ($this->pageSearch !== '') { + Notification::make() + ->title('Disabled in search mode') + ->body('Cannot update order while search results are active.') + ->warning() + ->send(); + + return; + } + + $sortColumn = $this->getTableSortColumn(); + $sortDirection = $this->getTableSortDirection() ?? 'asc'; + + $query = $this->getTableQuery(); + + if ($sortColumn) { + $query->orderBy($sortColumn, $sortDirection); + } else { + $query->orderBy('order_number')->orderBy('id'); + } + + $items = $query->get(['id']); + + if ($items->isEmpty()) { + Notification::make()->title('No items')->warning()->send(); + + return; + } + + DB::transaction(function () use ($items) { + foreach ($items->values() as $index => $item) { + CatalogItem::whereKey($item->id)->update([ + 'order_number' => ($index + 1) * 10, + ]); + } + }); + + $this->resetTable(); + + Notification::make()->title('Order updated')->success()->send(); + }), + ]; + } +} diff --git a/Updated_Cms/app/Filament/Resources/Hotel/CatalogEditors/Pages/ViewCatalogEditor.php b/Updated_Cms/app/Filament/Resources/Hotel/CatalogEditors/Pages/ViewCatalogEditor.php new file mode 100644 index 0000000000..a6172c23af --- /dev/null +++ b/Updated_Cms/app/Filament/Resources/Hotel/CatalogEditors/Pages/ViewCatalogEditor.php @@ -0,0 +1,19 @@ +updateOrInsert( + ['key' => 'catalog_icons_path'], + [ + 'value' => '/gamedata/c_images/catalogue', + 'comment' => 'Path to catalog icons', + ], + ); + } + + public function down(): void + { + DB::table('website_settings')->whereIn('key', [ + 'catalog_icons_path', + ])->delete(); + } +}; diff --git a/Updated_Cms/resources/css/global.scss b/Updated_Cms/resources/css/global.scss index e17e52536d..33a00244a0 100644 --- a/Updated_Cms/resources/css/global.scss +++ b/Updated_Cms/resources/css/global.scss @@ -1,4 +1,4 @@ -@tailwind base; +@tailwind base; @tailwind components; @tailwind utilities; @@ -271,3 +271,10 @@ html.dark .swiper-pagination-bullet:not(.swiper-pagination-bullet-active) { #article-content a { color: #53b2f8; } + +.cursor-grab { + cursor: grab !important; +} +.cursor-grab:active { + cursor: grabbing !important; +} diff --git a/Updated_Cms/resources/views/filament/forms/fields/catalog-icon-grid.blade.php b/Updated_Cms/resources/views/filament/forms/fields/catalog-icon-grid.blade.php new file mode 100644 index 0000000000..8084bb3758 --- /dev/null +++ b/Updated_Cms/resources/views/filament/forms/fields/catalog-icon-grid.blade.php @@ -0,0 +1,39 @@ +@props(['icons' => []]) + +
+
+

Icon picker

+ +
+ + +
diff --git a/Updated_Cms/resources/views/filament/forms/fields/catalog-icon-preview.blade.php b/Updated_Cms/resources/views/filament/forms/fields/catalog-icon-preview.blade.php new file mode 100644 index 0000000000..88699d9d1a --- /dev/null +++ b/Updated_Cms/resources/views/filament/forms/fields/catalog-icon-preview.blade.php @@ -0,0 +1,13 @@ +@props(['getUrl' => null, 'fallbackUrl' => null]) + +
+
Current icon:
+ +
diff --git a/Updated_Cms/resources/views/filament/resources/hotel/catalog-editors/pages/manage-catalog-editor.blade.php b/Updated_Cms/resources/views/filament/resources/hotel/catalog-editors/pages/manage-catalog-editor.blade.php new file mode 100644 index 0000000000..50ea45187f --- /dev/null +++ b/Updated_Cms/resources/views/filament/resources/hotel/catalog-editors/pages/manage-catalog-editor.blade.php @@ -0,0 +1,283 @@ + + + +
+
+ +
+ + + + + + + +
+ +@php +if ($pageSearch !== '') { + $search = trim($pageSearch); + + $matchedPages = \App\Models\Game\Furniture\CatalogPage::query() + ->where('caption', 'like', "%{$search}%") + ->get(); + + $matchedItems = \App\Models\Game\Furniture\CatalogItem::query() + ->where('catalog_name', 'like', "%{$search}%") + ->orWhere('id', (int) $search) + ->get(['page_id']); + + $visiblePageIds = collect() + ->merge($matchedPages->pluck('id')) + ->merge($matchedItems->pluck('page_id')) + ->filter() + ->unique(); + + $allPages = \App\Models\Game\Furniture\CatalogPage::all(['id', 'parent_id']); + $idToParent = $allPages->pluck('parent_id', 'id'); + foreach ($visiblePageIds as $pid) { + $parentId = $idToParent[$pid] ?? null; + while ($parentId && $parentId > 0) { + $visiblePageIds->push($parentId); + $parentId = $idToParent[$parentId] ?? null; + } + } + $visiblePageIds = $visiblePageIds->unique(); + + $rootPages = \App\Models\Game\Furniture\CatalogPage::query() + ->where('parent_id', -1) + ->where(function ($q) use ($visiblePageIds) { + $q->whereIn('id', $visiblePageIds) + ->orWhereIn('id', function ($sub) use ($visiblePageIds) { + $sub->select('parent_id') + ->from('catalog_pages') + ->whereIn('id', $visiblePageIds); + }); + }) + ->orderBy('order_num') + ->get(); + + $expanded = $visiblePageIds->values()->all(); + $this->expandedPages = array_unique(array_merge($this->expandedPages, $expanded)); + + if (! $this->selectedPage && $visiblePageIds->isNotEmpty()) { + $this->selectedPage = \App\Models\Game\Furniture\CatalogPage::find($visiblePageIds->first()); + $this->resetTable(); + } + + $visibleIdsForTree = $visiblePageIds->all(); +} else { + $rootPages = \App\Models\Game\Furniture\CatalogPage::query() + ->where('parent_id', -1) + ->orderBy('order_num') + ->get(); + + $visibleIdsForTree = null; +} +@endphp + +@include('filament.resources.hotel.catalog-editors.pages.partials.catalog-tree', [ + 'pages' => $rootPages, + 'depth' => 0, + 'selectedPage' => $selectedPage, + 'visibleIds' => $visibleIdsForTree, +]) + + +
+ +
+
+
+ +
+
+
+

+ @if($selectedPage) + Items for: {{ $selectedPage->caption }} + @else + Select a catalog page to view its items + @endif +

+ + @if($selectedPage && $pageSearch === '' && $selectedPage->parent_id !== -1 && ! $this->pageHasLockedItems()) +
+ + Auto Order Items + + + + Manual Order + +
+ @endif +
+ + @if($selectedPage && $selectedPage->parent_id === -1) +

+ This is a root menu entry. Select a subpage to order its items. +

+ @elseif($selectedPage && $this->pageHasLockedItems()) +

+ This page contains item(s) with + order_number = -1. + Change or remove them to enable ordering. +

+ @endif +
+ + {{-- Table --}} + +
+
+ + @if($pageSearch !== '') +
+ + ­ƒöì Search mode active ÔÇö ordering disabled + +
+ @endif + +
$pageSearch !== '' && ! $selectedPage + ]) + > + {{ $this->table }} +
+ + +
+
+ +
+
+
diff --git a/Updated_Cms/resources/views/filament/resources/hotel/catalog-editors/pages/partials/catalog-tree.blade.php b/Updated_Cms/resources/views/filament/resources/hotel/catalog-editors/pages/partials/catalog-tree.blade.php new file mode 100644 index 0000000000..4626f694c0 --- /dev/null +++ b/Updated_Cms/resources/views/filament/resources/hotel/catalog-editors/pages/partials/catalog-tree.blade.php @@ -0,0 +1,175 @@ + diff --git a/Updated_Cms/resources/views/filament/tables/columns/catalog-item-draggable.blade.php b/Updated_Cms/resources/views/filament/tables/columns/catalog-item-draggable.blade.php new file mode 100644 index 0000000000..3d263f5b44 --- /dev/null +++ b/Updated_Cms/resources/views/filament/tables/columns/catalog-item-draggable.blade.php @@ -0,0 +1,120 @@ +@props([ + 'icon' => '', + 'name' => '', + 'itemId' => null, + 'isSelected' => false, + 'reordering' => false, +]) + +@php + $record = isset($getRecord) ? $getRecord() : null; + $resolvedIcon = is_callable($icon) ? $icon($record) : $icon; + $resolvedName = is_callable($name) ? $name($record) : $name; + $resolvedItemId = (int) (is_callable($itemId) ? $itemId($record) : $itemId); +@endphp + +
+ + + + + + + {{ $resolvedName }} +
diff --git a/Updated_Cms/resources/views/filament/tables/columns/catalog-item-select.blade.php b/Updated_Cms/resources/views/filament/tables/columns/catalog-item-select.blade.php new file mode 100644 index 0000000000..d052fcd5ef --- /dev/null +++ b/Updated_Cms/resources/views/filament/tables/columns/catalog-item-select.blade.php @@ -0,0 +1,44 @@ +@props([ + 'itemId' => null, + 'isSelected' => false, +]) + +@php + $record = isset($getRecord) ? $getRecord() : null; + $resolvedItemId = (int) (is_callable($itemId) ? $itemId($record) : $itemId); + $checked = (bool) (is_callable($isSelected) ? $isSelected($record) : $isSelected); +@endphp + +
+ +