diff --git a/_docker/nginx/conf.d/nginx.conf b/_docker/nginx/conf.d/nginx.conf index 22767be..cfe5efd 100644 --- a/_docker/nginx/conf.d/nginx.conf +++ b/_docker/nginx/conf.d/nginx.conf @@ -9,12 +9,18 @@ server { try_files $uri /index.php?$args; # Обработка запросов } + + location /sveden/ { alias /var/www/public/sveden/; index index.html; try_files $uri $uri/ /sveden/index.html; # Обработка статических файлов } + location = /sveden { + return 301 /sveden/; + } + location ~ \.php$ { try_files $uri =404; # Если файл не найден, возвращаем 404 fastcgi_split_path_info ^(.+\.php)(/.+)$; # Разделение пути diff --git a/app/Containers/Post/Models/Post.php b/app/Containers/Post/Models/Post.php new file mode 100644 index 0000000..9c047bf --- /dev/null +++ b/app/Containers/Post/Models/Post.php @@ -0,0 +1,64 @@ +id); + Cache::forget('posts_' . $post->category_id . '_*'); // Очистка кеша для всех постов в категории + }); + + static::deleted(function ($post) { + Cache::forget('post_' . $post->id); + Cache::forget('posts_' . $post->category_id . '_*'); // Очистка кеша для всех постов в категории + }); + } + + public function category() : BelongsTo + { + return $this->belongsTo(Category::class); + } + + public function author() : BelongsTo + { + return $this->belongsTo(User::class, 'user_id'); + } + + public function seo() + { + return $this->morphOne(Seo::class, 'seoable'); + } + + public function mainSlider() + { + return $this->morphOne(MainSlider::class, 'slidable'); + } + + protected $casts = [ + 'content' => 'array', + 'authors' => 'array', + 'status' => PostStatus::class, + 'images' => 'array' + ]; +} diff --git a/app/Dto/MainSliderDTO.php b/app/Dto/MainSliderDTO.php index f36f798..37fcc24 100644 --- a/app/Dto/MainSliderDTO.php +++ b/app/Dto/MainSliderDTO.php @@ -17,6 +17,7 @@ class MainSliderDTO public ?Carbon $start_time, public ?Carbon $end_time, public ?int $sort, + public int $slider_id, ) {} // Опционально: метод для создания DTO из массива @@ -33,6 +34,7 @@ class MainSliderDTO start_time: isset($data['start_time']) ? Carbon::parse($data['start_time']) : null, end_time: isset($data['end_time']) ? Carbon::parse($data['end_time']) : null, sort: $data['sort'] ?? null, + slider_id: $data['slider_id'], ); } @@ -50,6 +52,7 @@ class MainSliderDTO 'start_time' => $this->start_time?->toDateTimeString(), 'end_time' => $this->end_time?->toDateTimeString(), 'sort' => $this->sort, + 'slider_id' => $this->slider_id, ]; } } \ No newline at end of file diff --git a/app/Enums/AdmissionCampaignStatus.php b/app/Enums/AdmissionCampaignStatus.php new file mode 100644 index 0000000..09c1207 --- /dev/null +++ b/app/Enums/AdmissionCampaignStatus.php @@ -0,0 +1,31 @@ + 'Активная', + self::ARCHIVED => 'Архивная', + self::HIDDEN => 'Скрытая', + }; + } + + public function getColor(): string|array|null + { + return match ($this) { + self::ACTIVE => 'success', + self::ARCHIVED => 'gray', + self::HIDDEN => 'danger', + }; + } +} \ No newline at end of file diff --git a/app/Enums/CacheKeys.php b/app/Enums/CacheKeys.php new file mode 100644 index 0000000..46c590d --- /dev/null +++ b/app/Enums/CacheKeys.php @@ -0,0 +1,57 @@ +schema([ - Section::make() + Section::make('Настройки формы') + ->description('Конфигурация пользовательской формы') + ->collapsible() ->schema([ - Tabs::make('Tabs') + Tabs::make('Конфигурация формы') + ->persistTabInQueryString() + ->columnSpanFull() ->tabs([ Tabs\Tab::make('Основная информация') + ->icon('heroicon-o-information-circle') ->schema([ - Forms\Components\Grid::make(2)->schema([ - TextInput::make('title')->label('Заголовок')->required() - ->live(onBlur: true) - ->afterStateUpdated(function (string $operation, string|null $state, Forms\Set $set) { - $set('form_id', Str::slug($state) . Carbon::now()->timestamp); - }), - TextInput::make('form_id')->label('ID формы')->unique(ignoreRecord: true)->required(), - ]), - Forms\Components\Textarea::make('description')->label('Описание формы')->required(), - Select::make('status')->label('Статус формы')->required() + Forms\Components\Grid::make(2) + ->schema([ + TextInput::make('title') + ->label('Название формы') + ->placeholder('Введите название формы') + ->helperText('Это название будет видно пользователям') + ->required() + ->maxLength(255) + ->live(onBlur: true) + ->afterStateUpdated(function (string $operation, string|null $state, Forms\Set $set) { + $set('form_id', Str::slug($state) . Carbon::now()->timestamp); + }), + + TextInput::make('form_id') + ->label('Уникальный ID формы') + ->helperText('Автоматически генерируется из названия') + ->required() + ->unique(ignoreRecord: true) + ->maxLength(255), + ]), + + Forms\Components\Textarea::make('description') + ->label('Описание формы') + ->placeholder('Опишите назначение этой формы') + ->helperText('Это описание будет видно пользователям') + ->required() + ->maxLength(2000) + ->columnSpanFull(), + + Select::make('status') + ->label('Статус формы') ->options(CustomFormStatus::class) + ->required() + ->native(false) + ->helperText('Определяет видимость формы на сайте') + ->columnSpanFull(), ]), - Tabs\Tab::make('Колонки') + + Tabs\Tab::make('Поля формы') + ->icon('heroicon-o-view-columns') ->schema([ - FormBuilderItem::getItem(), + FormBuilderItem::getItem() + ->columnSpanFull(), ]), + Tabs\Tab::make('Кнопка отправки') + ->icon('heroicon-o-paper-airplane') ->schema([ - TextInput::make('button')->label('Текст кнопки отправки')->required(), - Forms\Components\Textarea::make('send_message')->label('Текст после отправления письма')->required(), + TextInput::make('button') + ->label('Текст кнопки отправки') + ->placeholder('Например: Отправить заявку') + ->helperText('Текст, который будет отображаться на кнопке отправки формы') + ->required() + ->maxLength(255), + + Forms\Components\Textarea::make('send_message') + ->label('Сообщение после отправки') + ->placeholder('Спасибо! Ваша заявка принята.') + ->helperText('Это сообщение увидят пользователи после успешной отправки формы') + ->required() + ->maxLength(1000) + ->columnSpanFull(), ]), - Tabs\Tab::make('Настройка интеграции с почтой') + + Tabs\Tab::make('Настройки') + ->icon('heroicon-o-cog') + ->schema([ + Toggle::make('settings.personal_data') + ->label('Согласие на обработку данных') + ->helperText('Показывать checkbox для согласия на обработку персональных данных') + ->inline(false) + ->onColor('success') + ->offColor('gray'), + + Toggle::make('settings.captcha') + ->label('Защита CAPTCHA') + ->helperText('Включить защиту от спама с помощью CAPTCHA') + ->inline(false) + ->onColor('success') + ->offColor('gray'), + + Section::make('Ограничение по времени') + ->collapsible() + ->schema([ + Toggle::make('is_time_period') + ->label('Ограничить период работы формы') + ->helperText('Форма будет активна только в указанный период') + ->dehydrated(false) + ->live(true) + ->inline(false), + + Forms\Components\Grid::make(2) + ->schema([ + DateTimePicker::make('settings.period.start_time') + ->label('Дата начала') + ->native(false) + ->displayFormat('d/m/Y H:i') + ->seconds(false) + ->helperText('Когда форма станет доступна') + ->default(Carbon::now()) + ->minDate(Carbon::now()), + + DateTimePicker::make('settings.period.end_time') + ->label('Дата окончания') + ->native(false) + ->displayFormat('d/m/Y H:i') + ->seconds(false) + ->helperText('Когда форма перестанет быть доступна') + ->default(Carbon::now()->addWeeks(2)) + ->minDate(Carbon::now()), + ]) + ->hidden(fn(Forms\Get $get): bool => $get('is_time_period') !== true), + ]), + ]), + + Tabs\Tab::make('Настройки почты') + ->icon('heroicon-o-envelope') ->schema([ Forms\Components\Repeater::make('mail_settings') - ->label('') + ->label('Настройки уведомлений') ->addActionLabel('Добавить получателя') + ->helperText('Укажите, кому и какие уведомления отправлять') + ->collapsed() + ->itemLabel(fn (array $state): ?string => $state['target'] ?? 'Новый получатель') ->schema([ - TextInput::make('target')->label('Кому')->email()->required(), - TextInput::make('topic')->label('Тема')->required(), - Builder::make('data')->schema([ - Builder\Block::make('text')->schema([ - RichEditor::make('content')->required(), + TextInput::make('target') + ->label('Email получателя') + ->placeholder('email@example.com') + ->email() + ->required() + ->maxLength(255), + + TextInput::make('topic') + ->label('Тема письма') + ->placeholder('Новая заявка с формы') + ->required() + ->maxLength(255), + + Builder::make('data') + ->label('Содержимое письма') + ->blockNumbers(false) + ->collapsible() + ->schema([ + Builder\Block::make('text') + ->label('Текст письма') + ->schema([ + RichEditor::make('content') + ->label('') + ->required() + ->toolbarButtons([ + 'bold', 'italic', 'link', + 'orderedList', 'bulletList' + ]), + ]), + Builder\Block::make('answers') + ->label('Ответы формы') + ->schema([]), ]), - Builder\Block::make('answers')->schema([]), - ])->required(), ]) - ->collapsed(), + ->grid(2), ]), ]), - ]) + ]), ]); } } \ No newline at end of file diff --git a/app/Filament/Components/Forms/ItemForm/Blocks/BlockSchema.php b/app/Filament/Components/Forms/ItemForm/Blocks/BlockSchema.php new file mode 100644 index 0000000..0dda2df --- /dev/null +++ b/app/Filament/Components/Forms/ItemForm/Blocks/BlockSchema.php @@ -0,0 +1,8 @@ +label('Виджет контактов') + ->options(ContactWidget::query()->where('is_active', true)->pluck('title', 'slug')) + ->searchable() + ->required() + ->helperText('Выберите активный виджет контактов'), + ]; + } +} \ No newline at end of file diff --git a/app/Filament/Components/Forms/ItemForm/Blocks/CustomFormBlock.php b/app/Filament/Components/Forms/ItemForm/Blocks/CustomFormBlock.php new file mode 100644 index 0000000..be12347 --- /dev/null +++ b/app/Filament/Components/Forms/ItemForm/Blocks/CustomFormBlock.php @@ -0,0 +1,28 @@ +label('Форма') + ->options(CustomForm::query()->where('status', CustomFormStatus::PUBLISHED)->pluck('title', 'form_id')) + ->searchable() + ->required() + ->helperText('Выберите опубликованную форму'), + Section::make()->schema([ + Toggle::make('settings.in_modal')->label('Открывать в модальном окне')->default(false), + ]), + ]; + } +} \ No newline at end of file diff --git a/app/Filament/Components/Forms/ItemForm/Blocks/FilesBlock.php b/app/Filament/Components/Forms/ItemForm/Blocks/FilesBlock.php new file mode 100644 index 0000000..c586c61 --- /dev/null +++ b/app/Filament/Components/Forms/ItemForm/Blocks/FilesBlock.php @@ -0,0 +1,66 @@ +label('Файлы') + ->helperText('Загрузите один или несколько файлов') + ->schema([ + Hidden::make('expansion')->required(), + Hidden::make('size')->required(), + TextInput::make('title') + ->label('Название файла') + ->placeholder('Введите название файла') + ->required() + ->maxLength(255) + ->autofocus() + ->helperText('Это название будет отображаться пользователям'), + FileUpload::make('path') + ->label('Файл') + ->required() + ->helperText('Поддерживаются PDF, Word, Excel, PowerPoint и ZIP файлы (макс. 500KB)') + ->getUploadedFileNameForStorageUsing( + fn (TemporaryUploadedFile $file): string => + str(Str::slug(pathinfo($file->getClientOriginalName(), PATHINFO_FILENAME) . '-' . Carbon::now()->timestamp) . '.' . $file->getClientOriginalExtension()) + ) + ->acceptedFileTypes([ + 'application/pdf', + 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', + 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + 'application/vnd.openxmlformats-officedocument.presentationml.presentation', + 'application/zip' + ]) + ->maxSize(512000) + ->disk('public') + ->directory('files') + ->downloadable() + ->afterStateUpdated(function ($set, $state) { + $set('expansion', $state?->getClientOriginalExtension()); + $set('size', ByteConverter::bytesToHuman($state?->getSize())); + }) + ->visibility('public') + ->preserveFilenames() + ]) + ->itemLabel(fn (array $state): ?string => $state['title'] ?? null) + ->collapsible() + ->cloneable() + ->grid(2), + ]; + } +} \ No newline at end of file diff --git a/app/Filament/Components/Forms/ItemForm/Blocks/HeadingBlock.php b/app/Filament/Components/Forms/ItemForm/Blocks/HeadingBlock.php new file mode 100644 index 0000000..e3ace7a --- /dev/null +++ b/app/Filament/Components/Forms/ItemForm/Blocks/HeadingBlock.php @@ -0,0 +1,27 @@ +hidden() + ->integer() + ->default(rand(2335235, 324634264263426)), + TextInput::make('content') + ->label('Текст заголовка') + ->placeholder('Введите текст заголовка') + ->helperText('Основной заголовок раздела') + ->live(onBlur: true) + ->required() + ->maxLength(255), + ]; + } +} \ No newline at end of file diff --git a/app/Filament/Components/Forms/ItemForm/Blocks/ImageBlock.php b/app/Filament/Components/Forms/ItemForm/Blocks/ImageBlock.php new file mode 100644 index 0000000..ddaecf1 --- /dev/null +++ b/app/Filament/Components/Forms/ItemForm/Blocks/ImageBlock.php @@ -0,0 +1,38 @@ +label('Изображение') + ->image() + ->multiple() + ->reorderable() + ->maxFiles(5) + ->disk('public') + ->directory('images') + ->imageEditor() + ->required() + ->helperText('Можно загрузить до 5 изображений'), + TextInput::make('alt') + ->label('Альтернативный текст') + ->placeholder('Необязательно') + ->helperText('Описание изображения для SEO'), + ]; + } +} \ No newline at end of file diff --git a/app/Filament/Components/Forms/ItemForm/Blocks/ImagesBlock.php b/app/Filament/Components/Forms/ItemForm/Blocks/ImagesBlock.php new file mode 100644 index 0000000..be59793 --- /dev/null +++ b/app/Filament/Components/Forms/ItemForm/Blocks/ImagesBlock.php @@ -0,0 +1,38 @@ +label('Изображения') + ->image() + ->multiple() + ->reorderable() + ->maxFiles(5) + ->disk('public') + ->directory('images') + ->imageEditor() + ->required() + ->helperText('Максимум 5 изображений. Можно перетаскивать для изменения порядка'), + TextInput::make('alt') + ->label('Описание изображений') + ->placeholder('Необязательно') + ->helperText('Используется для SEO и доступности'), + ]; + } +} \ No newline at end of file diff --git a/app/Filament/Components/Forms/ItemForm/Blocks/PageItemBlock.php b/app/Filament/Components/Forms/ItemForm/Blocks/PageItemBlock.php new file mode 100644 index 0000000..148847e --- /dev/null +++ b/app/Filament/Components/Forms/ItemForm/Blocks/PageItemBlock.php @@ -0,0 +1,31 @@ +label('Страница') + ->options(Page::query()->where('title', '!=', null)->where('is_visible', true)->pluck('title', 'id')) + ->searchable() + ->required() + ->helperText('Выберите видимую страницу'), + ]; + } +} \ No newline at end of file diff --git a/app/Filament/Components/Forms/ItemForm/Blocks/PageResourceListBlock.php b/app/Filament/Components/Forms/ItemForm/Blocks/PageResourceListBlock.php new file mode 100644 index 0000000..d3b82ac --- /dev/null +++ b/app/Filament/Components/Forms/ItemForm/Blocks/PageResourceListBlock.php @@ -0,0 +1,31 @@ +label('Ресурс') + ->options(PageReferenceList::query()->where('is_active', true)->pluck('title', 'slug')) + ->searchable() + ->required() + ->helperText('Выберите активный ресурс'), + ]; + } +} \ No newline at end of file diff --git a/app/Filament/Components/Forms/ItemForm/Blocks/ParagraphBlock.php b/app/Filament/Components/Forms/ItemForm/Blocks/ParagraphBlock.php new file mode 100644 index 0000000..a65108a --- /dev/null +++ b/app/Filament/Components/Forms/ItemForm/Blocks/ParagraphBlock.php @@ -0,0 +1,51 @@ +label('Использовать блок как SEO-текст') + ->helperText('Этот текст будет использоваться для SEO-оптимизации') + ->live(onBlur: true) + ->required() + ->disabled(function ($state, Get $get) { + $data = $get('../../'); + return self::findSeoActive($data) && !$state; + }) + ->dehydrated(), + TinyEditor::make('content') + ->label('Текст') + ->placeholder('Начните вводить текст...') + ->profile('test') + ->required() + ->helperText('Основное текстовое содержимое блока'), + ]; + } + + private static function findSeoActive(array $data) : bool + { + $bool = false; + + foreach ($data as $item) { + if ($item['type'] !== 'paragraph') { + continue; + } + if ($item['data']['seo_active'] === true) { + $bool = true; + break; + } + } + return $bool; + } +} \ No newline at end of file diff --git a/app/Filament/Components/Forms/ItemForm/Blocks/PersonBlock.php b/app/Filament/Components/Forms/ItemForm/Blocks/PersonBlock.php new file mode 100644 index 0000000..6b0213c --- /dev/null +++ b/app/Filament/Components/Forms/ItemForm/Blocks/PersonBlock.php @@ -0,0 +1,63 @@ +label('Имя персоны') + ->placeholder('Введите имя') + ->required() + ->maxLength(255) + ->helperText('Полное имя персоны'), + FileUpload::make('photo') + ->label('Фотография') + ->image() + ->helperText('Рекомендуемый формат: WebP') + ->optimize('webp') + ->resize(50) + ->disk('public') + ->directory('images') + ->imageEditor() + ->required() + ->downloadable() + ->openable(), + Repeater::make('info') + ->label('Дополнительная информация') + ->helperText('Добавьте характеристики персоны') + ->schema([ + TextInput::make('column') + ->label('Название характеристики') + ->placeholder('Например: Должность') + ->required() + ->maxLength(255), + Textarea::make('content') + ->label('Значение') + ->placeholder('Например: Главный инженер') + ->required() + ->maxLength(1000) + ->columnSpanFull(), + ]) + ->minItems(1) + ->grid(2) + ->collapsible() + ->cloneable() + ->itemLabel(fn (array $state): ?string => $state['column'] ?? null), + ]; + } +} \ No newline at end of file diff --git a/app/Filament/Components/Forms/ItemForm/Blocks/PostItemBlock.php b/app/Filament/Components/Forms/ItemForm/Blocks/PostItemBlock.php new file mode 100644 index 0000000..f6855b9 --- /dev/null +++ b/app/Filament/Components/Forms/ItemForm/Blocks/PostItemBlock.php @@ -0,0 +1,23 @@ +label('Новость') + ->options(Post::query()->where('status', PostStatus::PUBLISHED)->pluck('title', 'id')) + ->searchable() + ->required() + ->helperText('Выберите опубликованную новость'), + ]; + } +} \ No newline at end of file diff --git a/app/Filament/Components/Forms/ItemForm/Blocks/PostListBlock.php b/app/Filament/Components/Forms/ItemForm/Blocks/PostListBlock.php new file mode 100644 index 0000000..c098713 --- /dev/null +++ b/app/Filament/Components/Forms/ItemForm/Blocks/PostListBlock.php @@ -0,0 +1,41 @@ +schema([ + TextInput::make('count') + ->label('Количество записей') + ->integer() + ->minValue(1) + ->maxValue(20) + ->default(5) + ->helperText('От 1 до 20 записей'), + Select::make('category') + ->label('Категория') + ->options(Category::all()->pluck('title', 'id')) + ->searchable() + ->helperText('Выберите категорию или оставьте пустым для всех'), + ]), + ]; + } +} \ No newline at end of file diff --git a/app/Filament/Components/Forms/ItemForm/Blocks/SliderBlock.php b/app/Filament/Components/Forms/ItemForm/Blocks/SliderBlock.php new file mode 100644 index 0000000..4410cc4 --- /dev/null +++ b/app/Filament/Components/Forms/ItemForm/Blocks/SliderBlock.php @@ -0,0 +1,31 @@ +label('Слайдер') + ->options(Slider::query()->whereHas('slides')->where('is_active', true)->pluck('title', 'slug')) + ->searchable() + ->required() + ->helperText('Выберите активный слайдер с изображениями'), + ]; + } +} \ No newline at end of file diff --git a/app/Filament/Components/Forms/ItemForm/Blocks/StepperBlock.php b/app/Filament/Components/Forms/ItemForm/Blocks/StepperBlock.php new file mode 100644 index 0000000..e2e9333 --- /dev/null +++ b/app/Filament/Components/Forms/ItemForm/Blocks/StepperBlock.php @@ -0,0 +1,55 @@ +label('Название процесса') + ->placeholder('Например: Процесс оформления') + ->required() + ->maxLength(255) + ->helperText('Общее название для всех шагов'), + Repeater::make('steps') + ->label('Шаги') + ->helperText('Добавьте шаги процесса') + ->schema([ + TextInput::make('title') + ->label('Название шага') + ->placeholder('Например: Шаг 1') + ->required() + ->maxLength(255) + ->columnSpanFull(), + RichEditor::make('content') + ->label('Описание шага') + ->required() + ->toolbarButtons([ + 'bold', + 'italic', + 'link', + 'orderedList', + 'bulletList', + ]), + ]) + ->minItems(1) + ->collapsible() + ->cloneable() + ->itemLabel(fn (array $state): ?string => $state['title'] ?? null), + ]; + } +} \ No newline at end of file diff --git a/app/Filament/Components/Forms/ItemForm/Blocks/TabBlock.php b/app/Filament/Components/Forms/ItemForm/Blocks/TabBlock.php new file mode 100644 index 0000000..4e32382 --- /dev/null +++ b/app/Filament/Components/Forms/ItemForm/Blocks/TabBlock.php @@ -0,0 +1,128 @@ +label('Вкладки') + ->helperText('Добавьте вкладки с контентом') + ->schema([ + TextInput::make('title') + ->label('Название вкладки') + ->placeholder('Введите название вкладки') + ->required() + ->maxLength(255) + ->columnSpanFull() + ->helperText('Это название будет отображаться в табе'), + Builder::make('content') + ->label('') + ->blocks([ + Builder\Block::make('heading') + ->label('Заголовок') + ->icon('heroicon-o-hashtag') + ->schema(HeadingBlock::schema()), + + Builder\Block::make('paragraph') + ->label('Текст') + ->icon('heroicon-o-document-text') + ->schema(ParagraphBlock::schema()), + + Builder\Block::make('files') + ->label('Файлы') + ->icon('heroicon-o-paper-clip') + ->schema(FilesBlock::schema()), + + Builder\Block::make('person') + ->label('Персона') + ->icon('heroicon-o-user') + ->schema(PersonBlock::schema()), + + Builder\Block::make('stepper') + ->label('Этапы') + ->icon('heroicon-o-list-bullet') + ->schema(StepperBlock::schema()), + + Builder\Block::make('images') + ->label('Слайдер изображений') + ->icon('heroicon-o-photo') + ->schema(ImagesBlock::schema()), + + Builder\Block::make('image') + ->label('Изображение') + ->icon('heroicon-o-photo') + ->schema(ImagesBlock::schema()), + + Builder\Block::make('video') + ->label('Видео') + ->icon('heroicon-o-film') + ->schema(VideoBlock::schema()), + + Builder\Block::make('postsList') + ->label('Список новостей') + ->icon('heroicon-o-newspaper') + ->schema(PostListBlock::schema()), + + Builder\Block::make('postItem') + ->label('Конкретная новость') + ->icon('heroicon-o-document-text') + ->schema(PostItemBlock::schema()), + + Builder\Block::make('pageItem') + ->label('Конкретная страница') + ->icon('heroicon-o-document') + ->schema(PageItemBlock::schema()), + + Builder\Block::make('customForm') + ->label('Пользовательская форма') + ->icon('heroicon-o-clipboard-document-list') + ->schema(CustomFormBlock::schema()), + + Builder\Block::make('pageResourceList') + ->label('Ресурсы') + ->icon('heroicon-o-archive-box') + ->schema(PageResourceListBlock::schema()), + + Builder\Block::make('contact') + ->label('Контакты') + ->icon('heroicon-o-phone') + ->schema(ContactBlock::schema()), + + Builder\Block::make('slider') + ->label('Слайдер') + ->icon('heroicon-o-presentation-chart-line') + ->schema(SliderBlock::schema()), + ]) + ->collapsed() + ->blockNumbers(false) + ->collapsible() + ->blockPickerColumns(3) + ->blockPickerWidth('2xl') + ->addActionLabel('Добавить новый блок') + ->cloneable() + ->reorderableWithButtons(), + ]) + ->minItems(1) + ->collapsible() + ->cloneable() + + ->itemLabel(fn (array $state): ?string => $state['title'] ?? null), + ]; + } +} \ No newline at end of file diff --git a/app/Filament/Components/Forms/ItemForm/Blocks/VideoBlock.php b/app/Filament/Components/Forms/ItemForm/Blocks/VideoBlock.php new file mode 100644 index 0000000..4addf8a --- /dev/null +++ b/app/Filament/Components/Forms/ItemForm/Blocks/VideoBlock.php @@ -0,0 +1,53 @@ +label('Тип видео') + ->readOnly() + ->helperText('Определяется автоматически'), + TextInput::make('title') + ->label('Название видео') + ->placeholder('Введите название видео') + ->required() + ->maxLength(255) + ->autofocus() + ->helperText('Это название будет отображаться перед видео'), + FileUpload::make('path') + ->label('Видеофайл') + ->required() + ->acceptedFileTypes([ + 'video/mp4', + 'video/quicktime', + 'video/x-msvideo', + 'video/x-ms-wmv', + 'video/avi', + 'video/webm', + 'video/ogg', + 'video/3gpp', + 'video/3gpp2', + 'video/x-m4v', + ]) + ->disk('public') + ->directory('videos') + ->helperText('Поддерживаются популярные видеоформаты (MP4, MOV, AVI и др.)') + ->afterStateUpdated(fn (callable $set, $state) => $set('mime', $state?->getMimeType())), + ]; + } +} \ No newline at end of file diff --git a/app/Filament/Components/Forms/ItemForm/CustomForm/FormBuilderItem.php b/app/Filament/Components/Forms/ItemForm/CustomForm/FormBuilderItem.php index 43326e4..8501b43 100644 --- a/app/Filament/Components/Forms/ItemForm/CustomForm/FormBuilderItem.php +++ b/app/Filament/Components/Forms/ItemForm/CustomForm/FormBuilderItem.php @@ -16,9 +16,11 @@ use Filament\Forms; use Filament\Forms\Components\Builder; use Filament\Forms\Components\FileUpload; use Filament\Forms\Components\Hidden; +use Filament\Forms\Components\Repeater; use Filament\Forms\Components\RichEditor; use Filament\Forms\Components\Section; use Filament\Forms\Components\Select; +use Filament\Forms\Components\Textarea; use Filament\Forms\Components\TextInput; use Filament\Forms\Components\Toggle; use Filament\Forms\Form; @@ -29,23 +31,44 @@ use Mohamedsabil83\FilamentFormsTinyeditor\Components\TinyEditor; class FormBuilderItem { - public static function getItem() + public static function getItem(): Builder { return Builder::make('columns') + ->label('Конструктор полей формы') + ->addActionLabel('Добавить новое поле') + ->blockPickerColumns(3) + ->blockPickerWidth('2xl') + ->collapsed() + ->collapsible() + ->cloneable() ->schema([ + // Email поле Builder\Block::make('email') - ->label('Почта') + ->icon('heroicon-o-envelope') + ->label('Поле Email') ->schema([ TextInput::make('title_field') - ->label('Заголовок поля') + ->label('Название поля') + ->placeholder('Например: Ваш Email') + ->helperText('Это название будет отображаться пользователям') + ->required() + ->maxLength(255) ->live(onBlur: true) ->afterStateUpdated(function (string $operation, string $state, Forms\Set $set) { - $set('name_field', Str::slug($state) . Carbon::now()->timestamp ); + $set('name_field', Str::slug($state) . Carbon::now()->timestamp); }), - Forms\Components\Hidden::make('name_field')->required(), - Forms\Components\Textarea::make('description')->label('Описание поля (опционально)'), - Section::make('Настройка') + Hidden::make('name_field')->required(), + + Textarea::make('description') + ->label('Подсказка для поля') + ->placeholder('Например: Введите действующий email') + ->helperText('Необязательное пояснение для пользователей') + ->maxLength(500) + ->columnSpanFull(), + + Section::make('Дополнительные настройки') + ->collapsible() ->collapsed() ->statePath('rules') ->schema([ @@ -54,18 +77,34 @@ class FormBuilderItem RuleLengthLimitComponent::getComponent(), ]), ]), + + // Phone поле Builder\Block::make('phone') - ->label('Телефон') + ->icon('heroicon-o-phone') + ->label('Поле Телефона') ->schema([ TextInput::make('title_field') - ->label('Заголовок поля') + ->label('Название поля') + ->placeholder('Например: Ваш телефон') + ->helperText('Укажите контактный номер для связи') + ->required() + ->maxLength(255) ->live(onBlur: true) ->afterStateUpdated(function (string $operation, string $state, Forms\Set $set) { - $set('name_field', Str::slug($state) . Carbon::now()->timestamp ); + $set('name_field', Str::slug($state) . Carbon::now()->timestamp); }), - Forms\Components\Hidden::make('name_field')->required(), - Forms\Components\Textarea::make('description')->label('Описание поля (опционально)'), - Section::make('Настройка') + + Hidden::make('name_field')->required(), + + Textarea::make('description') + ->label('Подсказка для поля') + ->placeholder('Например: +7 (XXX) XXX-XX-XX') + ->maxLength(500) + ->columnSpanFull(), + + Section::make('Дополнительные настройки') + ->collapsible() + ->collapsed() ->statePath('rules') ->schema([ RuleRequiredComponent::getComponent(), @@ -73,73 +112,135 @@ class FormBuilderItem RuleLengthLimitComponent::getComponent(), ]), ]), + + // Короткий текст Builder\Block::make('text') + ->icon('heroicon-o-pencil') ->label('Короткий текст') ->schema([ TextInput::make('title_field') - ->label('Заголовок поля') + ->label('Название поля') + ->placeholder('Например: Ваше имя') + ->helperText('Краткий текст (до 255 символов)') + ->required() + ->maxLength(255) ->live(onBlur: true) ->afterStateUpdated(function (string $operation, string $state, Forms\Set $set) { - $set('name_field', Str::slug($state) . Carbon::now()->timestamp ); + $set('name_field', Str::slug($state) . Carbon::now()->timestamp); }), - Forms\Components\Hidden::make('name_field')->required(), - Forms\Components\Textarea::make('description')->label('Описание поля (опционально)'), - Section::make('Настройка') - ->statePath('rules') - ->schema([ - RuleRequiredComponent::getComponent(), - RuleLengthLimitComponent::getComponent(), - ]), - ]), - Builder\Block::make('textarea') - ->label('Длинный текст текст') - ->schema([ - TextInput::make('title_field') - ->label('Заголовок поля') - ->live(onBlur: true) - ->afterStateUpdated(function (string $operation, string $state, Forms\Set $set) { - $set('name_field', Str::slug($state) . Carbon::now()->timestamp ); - }), - Forms\Components\Hidden::make('name_field')->required(), - Forms\Components\Textarea::make('description')->label('Описание поля (опционально)'), - Section::make('Настройка') + Hidden::make('name_field')->required(), + + Textarea::make('description') + ->label('Подсказка для поля') + ->placeholder('Например: Введите ваше полное имя') + ->maxLength(500) + ->columnSpanFull(), + + Section::make('Дополнительные настройки') + ->collapsible() + ->collapsed() ->statePath('rules') ->schema([ RuleRequiredComponent::getComponent(), RuleLengthLimitComponent::getComponent(), ]), ]), + + // Длинный текст + Builder\Block::make('textarea') + ->icon('heroicon-o-document-text') + ->label('Длинный текст') + ->schema([ + TextInput::make('title_field') + ->label('Название поля') + ->placeholder('Например: Ваш комментарий') + ->helperText('Расширенный текст (до 5000 символов)') + ->required() + ->maxLength(255) + ->live(onBlur: true) + ->afterStateUpdated(function (string $operation, string $state, Forms\Set $set) { + $set('name_field', Str::slug($state) . Carbon::now()->timestamp); + }), + + Hidden::make('name_field')->required(), + + Textarea::make('description') + ->label('Подсказка для поля') + ->placeholder('Например: Опишите вашу проблему подробно') + ->maxLength(500) + ->columnSpanFull(), + + Section::make('Дополнительные настройки') + ->collapsible() + ->collapsed() + ->statePath('rules') + ->schema([ + RuleRequiredComponent::getComponent(), + RuleLengthLimitComponent::getComponent(), + ]), + ]), + + // Дата Builder\Block::make('date') + ->icon('heroicon-o-calendar') ->label('Дата') ->schema([ TextInput::make('title_field') - ->label('Заголовок поля') + ->label('Название поля') + ->placeholder('Например: Дата рождения') + ->helperText('Выбор даты из календаря') + ->required() + ->maxLength(255) ->live(onBlur: true) ->afterStateUpdated(function (string $operation, string $state, Forms\Set $set) { - $set('name_field', Str::slug($state) . Carbon::now()->timestamp ); + $set('name_field', Str::slug($state) . Carbon::now()->timestamp); }), - Forms\Components\Hidden::make('name_field')->required(), - Forms\Components\Textarea::make('description')->label('Описание поля (опционально)'), - Section::make('Настройка') + + Hidden::make('name_field')->required(), + + Textarea::make('description') + ->label('Подсказка для поля') + ->placeholder('Например: Укажите вашу дату рождения') + ->maxLength(500) + ->columnSpanFull(), + + Section::make('Дополнительные настройки') + ->collapsible() + ->collapsed() ->statePath('rules') ->schema([ RuleRequiredComponent::getComponent(), ]), - ]), + + // Ссылка Builder\Block::make('url') + ->icon('heroicon-o-link') ->label('Ссылка') ->schema([ TextInput::make('title_field') - ->label('Заголовок поля') + ->label('Название поля') + ->placeholder('Например: Ваш сайт') + ->helperText('Введите корректный URL адрес') + ->required() + ->maxLength(255) ->live(onBlur: true) ->afterStateUpdated(function (string $operation, string $state, Forms\Set $set) { - $set('name_field', Str::slug($state) . Carbon::now()->timestamp ); + $set('name_field', Str::slug($state) . Carbon::now()->timestamp); }), - Forms\Components\Hidden::make('name_field')->required(), - Forms\Components\Textarea::make('description')->label('Описание поля (опционально)'), - Section::make('Настройка') + + Hidden::make('name_field')->required(), + + Textarea::make('description') + ->label('Подсказка для поля') + ->placeholder('Например: https://example.com') + ->maxLength(500) + ->columnSpanFull(), + + Section::make('Дополнительные настройки') + ->collapsible() + ->collapsed() ->statePath('rules') ->schema([ RuleRequiredComponent::getComponent(), @@ -147,140 +248,178 @@ class FormBuilderItem RuleLengthLimitComponent::getComponent(), ]), ]), + + // Множественный выбор Builder\Block::make('multiple_choice') - ->label('Несколько вариантов') + ->icon('heroicon-o-check-circle') + ->label('Множественный выбор') ->schema([ TextInput::make('title_field') - ->label('Заголовок поля') + ->label('Название группы') + ->placeholder('Например: Ваши интересы') + ->helperText('Несколько вариантов с возможностью выбора') + ->required() + ->maxLength(255) ->live(onBlur: true) ->afterStateUpdated(function (string $operation, string $state, Forms\Set $set) { - $set('name_field', Str::slug($state) . Carbon::now()->timestamp ); + $set('name_field', Str::slug($state) . Carbon::now()->timestamp); }), - Forms\Components\Hidden::make('name_field')->required(), Forms\Components\Repeater::make('columns')->schema([ - TextInput::make('title_field') - ->live(onBlur: true) - ->afterStateUpdated(function (string $operation, string $state, Forms\Set $set) { - $set('name_field', Str::slug($state) . Carbon::now()->timestamp ); - }), - Forms\Components\Hidden::make('name_field')->required(), - Forms\Components\Textarea::make('description')->label('Описание поля (опционально)'), - ])->collapsed(), - Section::make('Настройка') + Hidden::make('name_field')->required(), + + Repeater::make('columns') + ->label('Варианты выбора') + ->addActionLabel('Добавить вариант') + ->collapsible() + ->cloneable() + ->itemLabel(fn (array $state): ?string => $state['title_field'] ?? 'Новый вариант') + ->schema([ + TextInput::make('title_field') + ->label('Текст варианта') + ->placeholder('Например: Спорт') + ->required() + ->maxLength(255) + ->live(onBlur: true) + ->afterStateUpdated(function (string $operation, string $state, Forms\Set $set) { + $set('name_field', Str::slug($state) . Carbon::now()->timestamp); + }), + + Hidden::make('name_field')->required(), + + Textarea::make('description') + ->label('Описание варианта') + ->placeholder('Необязательное описание') + ->maxLength(500), + ]), + + Section::make('Дополнительные настройки') + ->collapsible() + ->collapsed() ->statePath('rules') ->schema([ RuleRequiredComponent::getComponent(), ]), ]), + + + // Одиночный выбор Builder\Block::make('single_choice') - ->label('Один вариант') + ->icon('heroicon-o-radio') + ->label('Одиночный выбор') ->schema([ TextInput::make('title_field') - ->label('Заголовок поля') + ->label('Название группы') + ->placeholder('Например: Ваш пол') + ->helperText('Один вариант из предложенных') + ->required() + ->maxLength(255) ->live(onBlur: true) ->afterStateUpdated(function (string $operation, string $state, Forms\Set $set) { - $set('name_field', Str::slug($state) . Carbon::now()->timestamp ); + $set('name_field', Str::slug($state) . Carbon::now()->timestamp); }), - Forms\Components\Hidden::make('name_field')->required(), Forms\Components\Repeater::make('columns')->schema([ - TextInput::make('title_field') - ->live(onBlur: true) - ->afterStateUpdated(function (string $operation, string $state, Forms\Set $set) { - $set('name_field', Str::slug($state) . Carbon::now()->timestamp ); - }), - Forms\Components\Hidden::make('name_field')->required(), - Forms\Components\Textarea::make('description')->label('Описание поля (опционально)'), - ])->collapsed(), - Section::make('Настройка') + Hidden::make('name_field')->required(), + + Repeater::make('columns') + ->label('Варианты выбора') + ->addActionLabel('Добавить вариант') + ->collapsible() + ->cloneable() + ->itemLabel(fn (array $state): ?string => $state['title_field'] ?? 'Новый вариант') + ->schema([ + TextInput::make('title_field') + ->label('Текст варианта') + ->placeholder('Например: Мужской') + ->required() + ->maxLength(255) + ->live(onBlur: true) + ->afterStateUpdated(function (string $operation, string $state, Forms\Set $set) { + $set('name_field', Str::slug($state) . Carbon::now()->timestamp); + }), + + Hidden::make('name_field')->required(), + + Textarea::make('description') + ->label('Описание варианта') + ->placeholder('Необязательное описание') + ->maxLength(500), + ]), + + Section::make('Дополнительные настройки') + ->collapsible() + ->collapsed() ->statePath('rules') ->schema([ RuleRequiredComponent::getComponent(), - ]), + ]), ]), + + // Дополнительное образование Builder\Block::make('additional_education_choice') - ->label('Выбрать дополнительное образование') + ->icon('heroicon-o-academic-cap') + ->label('Доп. образование') ->schema([ TextInput::make('title_field') - ->label('Заголовок поля') + ->label('Название поля') + ->placeholder('Например: Дополнительное образование') + ->helperText('Выбор из списка доп. образования') + ->required() + ->maxLength(255) ->live(onBlur: true) ->afterStateUpdated(function (string $operation, string $state, Forms\Set $set) { - $set('name_field', Str::slug($state) . Carbon::now()->timestamp ); + $set('name_field', Str::slug($state) . Carbon::now()->timestamp); }), - Forms\Components\Hidden::make('name_field')->required(), - Forms\Components\Textarea::make('description')->label('Описание поля (опционально)'), - Section::make('Настройка') + Hidden::make('name_field')->required(), + + Textarea::make('description') + ->label('Подсказка для поля') + ->placeholder('Например: Выберите интересующую программу') + ->maxLength(500) + ->columnSpanFull(), + + Section::make('Дополнительные настройки') + ->collapsible() + ->collapsed() ->statePath('rules') - ->schema([ -// RuleRequiredComponent::getComponent(), -// RuleLengthLimitComponent::getComponent(), - ]), + ->schema([]), ]) ->maxItems(1), + + // Образовательная программа Builder\Block::make('educational_program_choice') - ->label('Выбрать Образовательную программу') + ->icon('heroicon-o-book-open') + ->label('Образовательная программа') ->schema([ TextInput::make('title_field') - ->label('Заголовок поля') + ->label('Название поля') + ->placeholder('Например: Основная программа') + ->helperText('Выбор из списка образовательных программ') + ->required() + ->maxLength(255) ->live(onBlur: true) ->afterStateUpdated(function (string $operation, string $state, Forms\Set $set) { - $set('name_field', Str::slug($state) . Carbon::now()->timestamp ); + $set('name_field', Str::slug($state) . Carbon::now()->timestamp); }), - Forms\Components\Hidden::make('name_field')->required(), - Forms\Components\Textarea::make('description')->label('Описание поля (опционально)'), - Section::make('Настройка') + Hidden::make('name_field')->required(), + + Textarea::make('description') + ->label('Подсказка для поля') + ->placeholder('Например: Выберите основную программу обучения') + ->maxLength(500) + ->columnSpanFull(), + + Section::make('Дополнительные настройки') + ->collapsible() + ->collapsed() ->statePath('rules') ->schema([ RuleRequiredComponent::getComponent(), RuleLengthLimitComponent::getComponent(), ]), ]) - ->maxItems(1), - Builder\Block::make('captcha') - ->label('reCaptcha') - ->schema([ - TextInput::make('title_field') - ->label('Заголовок поля') - ->live(onBlur: true) - ->default('Капча') - ->disabled(true) - ->dehydrated(true), - Forms\Components\Hidden::make('name_field')->required()->default(Str::slug('reCaptcha') . Carbon::now()->timestamp), - Section::make('Настройка') - ->collapsed() - ->statePath('rules') - ->schema([ - RuleRequiredComponent::getComponent()->default(true), - ]), - ]), - Builder\Block::make('personal_data') - ->label('Соглашение на обработку персональных данных') - ->schema([ - TextInput::make('title_field') - ->label('Заголовок поля') - ->live(onBlur: true) - ->default('Соглашение на обработку персональных данных') - ->afterStateUpdated(function (string $operation, string $state, Forms\Set $set) { - $set('name_field', Str::slug($state) . Carbon::now()->timestamp ); - }), - Forms\Components\Hidden::make('name_field')->required()->default(Str::slug('Соглашение на обработку персональных данных') . Carbon::now()->timestamp), - - Section::make('Настройка') - ->collapsed() - ->statePath('rules') - ->schema([ - RuleRequiredComponent::getComponent()->default(true), - ]), - ]), - ]) - ->label('') - ->addActionLabel('Добавить поле') - ->blockPickerColumns(3) - ->blockPickerWidth('2xl') - ->collapsed(); - + ->maxItems(1) + ]); } - - } \ No newline at end of file diff --git a/app/Filament/Components/Forms/ItemForm/Defaults/ContentBuilderItem.php b/app/Filament/Components/Forms/ItemForm/Defaults/ContentBuilderItem.php index 4780d77..c88c244 100644 --- a/app/Filament/Components/Forms/ItemForm/Defaults/ContentBuilderItem.php +++ b/app/Filament/Components/Forms/ItemForm/Defaults/ContentBuilderItem.php @@ -13,10 +13,13 @@ use App\Models\Post; use Filament\Forms; use Filament\Forms\Components\Builder; use Filament\Forms\Components\FileUpload; +use Filament\Forms\Components\Grid; use Filament\Forms\Components\Hidden; +use Filament\Forms\Components\Repeater; use Filament\Forms\Components\RichEditor; use Filament\Forms\Components\Section; use Filament\Forms\Components\Select; +use Filament\Forms\Components\Textarea; use Filament\Forms\Components\TextInput; use Filament\Forms\Components\Toggle; use Filament\Forms\Form; @@ -29,202 +32,347 @@ class ContentBuilderItem { public static function getItem(string $name) { - return - Builder::make($name)->label('')->blocks([ - Builder\Block::make('heading')->label('Заголовок') + return Builder::make($name) + ->label('Конструктор содержимого') + ->blocks([ + // Заголовок + Builder\Block::make('heading') + ->icon('heroicon-o-title') + ->label('Заголовок') ->schema([ - TextInput::make('id')->hidden()->integer()->default(rand(2335235,324634264263426)), + TextInput::make('id') + ->hidden() + ->integer() + ->default(rand(2335235, 324634264263426)), + TextInput::make('content') - ->label('') - ->live(onBlur: true) - ->afterStateUpdated(function (string $operation, string $state, Forms\Set $set, $get) { - }), + ->label('Текст заголовка') + ->placeholder('Введите заголовок H2-H4') + ->hint('Рекомендуется 50-80 символов') + ->helperText('Используйте для семантической структуры') + ->minLength(10) + ->maxLength(120) + ->required() + ->live(onBlur: true), ]), + + // Текстовый блок Builder\Block::make('paragraph') + ->icon('heroicon-o-document-text') + ->label('Текстовый блок') ->schema([ TinyEditor::make('content') - ->label('') - ->profile('test') - ->required(), - ])->label('Текст'), - Builder\Block::make('files') - ->label('Файл(-ы)') - ->schema([ - Forms\Components\Repeater::make('file')->schema([ - Hidden::make('expansion')->required(), - Hidden::make('size')->required(), - TextInput::make('title') - ->required() - ->maxLength(255) - ->autofocus(), - FileUpload::make('path') - ->required() - ->getUploadedFileNameForStorageUsing( - fn (TemporaryUploadedFile $file): string => - str(Str::slug(pathinfo($file->getClientOriginalName(), PATHINFO_FILENAME) . '-' . Carbon::now()->timestamp ) . '.' . $file->getClientOriginalExtension()) - ) - ->acceptedFileTypes([ - 'application/pdf', - 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', - 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', - 'application/vnd.openxmlformats-officedocument.presentationml.presentation', - 'application/zip' - ]) - ->maxSize(512000) - ->disk('public') - ->directory('files') - ->downloadable() - ->afterStateUpdated(function ($set, $state) { - $set('expansion', $state?->getClientOriginalExtension()); - $set('size', ByteConverter::bytesToHuman($state?->getSize())); - }) - ->visibility('public') - ]), + ->label('Содержимое') + ->placeholder('Введите текст...') + ->hint('Поддерживается форматирование') + ->helperText('Для заголовков используйте стили H3-H4') + ->required() + ->columnSpanFull(), ]), + + // Файлы + Builder\Block::make('files') + ->icon('heroicon-o-paper-clip') + ->label('Файлы для скачивания') + ->schema([ + Repeater::make('file') + ->label('') + ->hint('Максимум 10 файлов') + ->schema([ + Hidden::make('expansion')->required(), + Hidden::make('size')->required(), + + TextInput::make('title') + ->label('Название файла') + ->placeholder('Годовой отчет 2023.pdf') + ->required() + ->maxLength(255), + + FileUpload::make('path') + ->label('Выберите файл') + ->helperText('Допустимы: PDF, DOCX, XLSX, PPTX, ZIP') + ->hint('Макс. размер 500KB') + ->required() + ->acceptedFileTypes([ + 'application/pdf', + 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', + 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + 'application/vnd.openxmlformats-officedocument.presentationml.presentation', + 'application/zip' + ]) + ->maxSize(512000) + ->disk('public') + ->directory('files') + ->downloadable() + ->preserveFilenames() + ->afterStateUpdated(function ($set, $state) { + $set('expansion', $state?->getClientOriginalExtension()); + $set('size', ByteConverter::bytesToHuman($state?->getSize())); + }), + ]) + ->maxItems(10) + ->collapsible() + ->itemLabel(fn (array $state): string => $state['title'] ?? 'Новый файл'), + ]), + + // Карточка персоны Builder\Block::make('person') - ->label('Персона') + ->icon('heroicon-o-user') + ->label('Карточка сотрудника') ->schema([ TextInput::make('name') - ->label('Имя') + ->label('ФИО') + ->placeholder('Иванов Иван Иванович') ->required() ->maxLength(255), + FileUpload::make('photo') ->label('Фотография') + ->hint('Оптимальный размер 500x500px') + ->helperText('Автоматическая конвертация в WebP') ->image() ->optimize('webp') ->resize(50) ->disk('public') - ->directory('images') - ->imageEditor(), - Forms\Components\Repeater::make('info')->schema([ + ->directory('personnel') + ->imageEditor() + ->required(), + + Repeater::make('info') + ->label('Характеристики') + ->hint('Добавьте 3-5 ключевых пунктов') + ->schema([ TextInput::make('column') - ->label('Название колонки') + ->label('Параметр') + ->placeholder('Стаж работы') ->required() - ->maxLength(255), - Forms\Components\Textarea::make('content') - ->label('Содержание') + ->maxLength(100), + + Textarea::make('content') + ->label('Значение') + ->placeholder('10 лет') ->required() - ->maxLength(1000), - ])->minItems(1)->label('Информация о персоне'), + ->maxLength(500), + ]) + ->minItems(1) + ->maxItems(10) + ->collapsible() + ->itemLabel(fn (array $state): string => $state['column'] ?? 'Новый параметр'), ]), + + // Этапы Builder\Block::make('stepper') - ->label('Строитель этапов') + ->icon('heroicon-o-list-bullet') + ->label('Пошаговый процесс') ->schema([ TextInput::make('step_name') - ->label('Название шага') + ->label('Название процесса') + ->placeholder('Процесс согласования') + ->required() + ->maxLength(100), + + Repeater::make('steps') + ->label('Этапы') + ->hint('Добавьте последовательные шаги') + ->schema([ + TextInput::make('title') + ->label('Шаг') + ->placeholder('1. Подготовка документов') + ->required() + ->maxLength(100), + + RichEditor::make('content') + ->label('Описание') + ->required() + ->maxLength(2000), + ]) + ->minItems(2) + ->collapsible() + ->itemLabel(fn (array $state): string => $state['title'] ?? 'Новый этап'), + ]), + + // Табы + Builder\Block::make('tabs') + ->icon('heroicon-o-rectangle-stack') + ->label('Табы') + ->schema([ + Repeater::make('tabs') + ->label('') + ->hint('Оптимально 3-5 вкладок') + ->schema([ + TextInput::make('title') + ->label('Название вкладки') + ->placeholder('Характеристики') + ->required() + ->maxLength(50), + + RichEditor::make('content') + ->label('Содержимое') + ->required(), + ]) + ->minItems(2) + ->maxItems(8) + ->collapsible() + ->itemLabel(fn (array $state): string => $state['title'] ?? 'Новая вкладка'), + ]), + + // Слайдер изображений + Builder\Block::make('images') + ->icon('heroicon-o-photo') + ->label('Галерея изображений') + ->schema([ + FileUpload::make('url') + ->label('Изображения') + ->hint('Оптимально 3-5 изображений') + ->helperText('Поддерживаются JPG, PNG, WEBP') + ->image() + ->multiple() + ->reorderable() + ->minFiles(1) + ->maxFiles(10) + ->disk('public') + ->directory('gallery') + ->imageEditor() + ->required(), + + TextInput::make('alt') + ->label('Описание для SEO') + ->placeholder('Наш офис в Москве') + ->hint('Краткое описание изображения') + ->maxLength(255), + ]), + + // Одиночное изображение + Builder\Block::make('image') + ->icon('heroicon-o-photo') + ->label('Изображение') + ->schema([ + FileUpload::make('url') + ->label('Выберите изображение') + ->helperText('Рекомендуемое соотношение 16:9') + ->image() + ->disk('public') + ->directory('images') + ->imageEditor() + ->required(), + + TextInput::make('alt') + ->label('ALT-текст') + ->placeholder('Описание изображения') ->required() ->maxLength(255), - Forms\Components\Repeater::make('steps')->schema([ - TextInput::make('title') - ->required() - ->maxLength(255)->columnSpanFull(), - RichEditor::make('content')->required(), - ])->minItems(1), ]), - TabBuilderItem::getItem(), - Builder\Block::make('images') - ->schema([ - FileUpload::make('url') - ->label('Изображение(-я)') - ->image() - ->multiple() - ->reorderable() - ->maxFiles(5) - ->disk('public') - ->directory('images') - ->imageEditor() - ->required(), - TextInput::make('alt') - ->label('Описание') - ->placeholder('Необязяательно') - ])->label('Слайдер изображений'), - Builder\Block::make('image') - ->schema([ - FileUpload::make('url') - ->label('Изображение(-я)') - ->image() - ->multiple() - ->reorderable() - ->maxFiles(5) - ->disk('public') - ->directory('images') - ->imageEditor() - ->required(), - TextInput::make('alt') - ->label('Описание') - ->placeholder('Необязяательно') - ])->label('Изображение'), + + // Видео Builder\Block::make('video') - ->label('Видео (Не стабильно)') + ->icon('heroicon-o-film') + ->label('Видео') ->schema([ - TextInput::make('mime')->readOnly(), + TextInput::make('mime') + ->label('Формат') + ->readOnly(), + TextInput::make('title') + ->label('Название видео') + ->placeholder('Обзор продукта') ->required() - ->maxLength(255) - ->autofocus(), + ->maxLength(255), + FileUpload::make('path') + ->label('Видеофайл') + ->hint('MP4, WebM, до 50MB') + ->helperText('Рекомендуемое разрешение 1080p') ->required() ->acceptedFileTypes([ 'video/mp4', - 'video/quicktime', - 'video/x-msvideo', - 'video/x-ms-wmv', - 'video/avi', 'video/webm', 'video/ogg', - 'video/3gpp', - 'video/3gpp2', - 'video/x-m4v', ]) + ->maxSize(51200) ->disk('public') - ->directory('videos') - ->afterStateUpdated(fn (callable $set, $state) => $set('mime', $state?->getMimeType())), + ->directory('videos'), ]), + + // Список новостей Builder\Block::make('postsList') + ->icon('heroicon-o-newspaper') + ->label('Лента новостей') ->schema([ - Forms\Components\Grid::make(2)->schema([ - TextInput::make('count') - ->label('Количество запией') - ->integer(), - Select::make('category') - ->options(Category::all()->pluck('title', 'id')) - ]), - ])->label('Список новостей'), + Grid::make(2) + ->schema([ + TextInput::make('count') + ->label('Количество') + ->numeric() + ->minValue(1) + ->maxValue(20) + ->default(5) + ->required(), + + Select::make('category') + ->label('Категория') + ->options(Category::all()->pluck('title', 'id')) + ->searchable() + ->placeholder('Все категории'), + ]), + ]), + + // Отдельная новость Builder\Block::make('postItem') + ->icon('heroicon-o-document-text') + ->label('Конкретная новость') ->schema([ Select::make('post') - ->options(Post::query()->where('status', PostStatus::PUBLISHED)->pluck('title', 'id')) + ->label('Выберите новость') + ->options(Post::published()->pluck('title', 'id')) ->searchable() - ->required(), - ])->label('Новость'), + ->required() + ->placeholder('Начните вводить название'), + ]), + + // Страница Builder\Block::make('pageItem') + ->icon('heroicon-o-document') + ->label('Ссылка на страницу') ->schema([ Select::make('page') - ->options(Page::query()->where('title', '!=' , null)->where('is_visible', true)->pluck('title', 'id')) + ->label('Страница') + ->options(Page::visible()->pluck('title', 'id')) ->searchable() ->required(), - ])->label('Страница'), + ]), + + // Форма Builder\Block::make('customForm') + ->icon('heroicon-o-clipboard-document') + ->label('Форма') ->schema([ Select::make('form') - ->options(CustomForm::query()->where('status', CustomFormStatus::PUBLISHED)->pluck('title', 'form_id')) + ->label('Выберите форму') + ->options(CustomForm::published()->pluck('title', 'form_id')) ->searchable() ->required(), - ])->label('Форма'), + ]), + + // Ресурсы Builder\Block::make('pageResourceList') + ->icon('heroicon-o-archive-box') + ->label('Список ресурсов') ->schema([ Select::make('resource') - ->options(PageReferenceList::query()->where('is_active', true)->pluck('title', 'slug')) + ->label('Ресурс') + ->options(PageReferenceList::active()->pluck('title', 'slug')) ->searchable() ->required(), - ])->label('Ресурсы'), + ]), ]) - ->collapsed() - ->blockNumbers(false) - ->collapsible() - ->blockPickerColumns(3) - ->blockPickerWidth('2xl') - ->addActionLabel('Добавить новый блок'); + ->collapsed() + ->blockNumbers(false) + ->collapsible() + ->blockPickerColumns(3) + ->blockPickerWidth('2xl') + ->addActionLabel('Добавить блок') + ->addBetweenActionLabel('Вставить блок между') + ->cloneActionLabel('Клонировать блок'); } diff --git a/app/Filament/Components/Forms/ItemForm/Defaults/TabBuilderItem.php b/app/Filament/Components/Forms/ItemForm/Defaults/TabBuilderItem.php index 2b9d07d..8a5901e 100644 --- a/app/Filament/Components/Forms/ItemForm/Defaults/TabBuilderItem.php +++ b/app/Filament/Components/Forms/ItemForm/Defaults/TabBuilderItem.php @@ -6,10 +6,12 @@ use App\Enums\CustomFormStatus; use App\Enums\PostStatus; use App\Helpers\ByteConverter; use App\Models\Category; +use App\Models\ContactWidget; use App\Models\CustomForm; use App\Models\Page; use App\Models\PageReferenceList; use App\Models\Post; +use App\Models\Slider; use Filament\Forms; use Filament\Forms\Components\Builder; use Filament\Forms\Components\FileUpload; @@ -29,210 +31,356 @@ class TabBuilderItem { public static function getItem() { - return - Builder\Block::make('tabs') - ->label('Вкладки') - ->schema([ - Forms\Components\Repeater::make('tab')->schema([ - TextInput::make('title') + return Builder::make('content') + ->label('Содержимое вкладки') + ->blocks([ + Builder\Block::make('heading') + ->label('Заголовок') + ->icon('heroicon-o-hashtag') + ->schema([ + TextInput::make('id') + ->hidden() + ->integer() + ->default(rand(2335235, 324634264263426)), + TextInput::make('content') + ->label('Текст заголовка') + ->placeholder('Введите текст заголовка') + ->helperText('Основной заголовок раздела') + ->live(onBlur: true) ->required() - ->maxLength(255)->columnSpanFull(), - Builder::make('content')->label('')->blocks([ - Builder\Block::make('heading')->label('Заголовок') - ->schema([ - TextInput::make('id')->hidden()->integer()->default(rand(2335235,324634264263426)), - TextInput::make('content') - ->label('') - ->live(onBlur: true) - ->afterStateUpdated(function (string $operation, string $state, Forms\Set $set, $get) { - }), - ]), - Builder\Block::make('paragraph') - ->schema([ - TinyEditor::make('content') - ->label('') - ->profile('test') - ->required(), - ])->label('Текст'), - Builder\Block::make('files') - ->label('Файл(-ы)') - ->schema([ - Forms\Components\Repeater::make('file')->schema([ - Hidden::make('expansion')->required(), - Hidden::make('size')->required(), - TextInput::make('title') - ->required() - ->maxLength(255) - ->autofocus(), - FileUpload::make('path') - ->required() - ->getUploadedFileNameForStorageUsing( - fn (TemporaryUploadedFile $file): string => - str(Str::slug(pathinfo($file->getClientOriginalName(), PATHINFO_FILENAME) . '-' . Carbon::now()->timestamp ) . '.' . $file->getClientOriginalExtension()) - ) - ->acceptedFileTypes([ - 'application/pdf', - 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', - 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', - 'application/vnd.openxmlformats-officedocument.presentationml.presentation', - 'application/zip' - ]) - ->maxSize(512000) - ->disk('public') - ->directory('files') - ->downloadable() - ->afterStateUpdated(function ($set, $state) { - $set('expansion', $state?->getClientOriginalExtension()); - $set('size', ByteConverter::bytesToHuman($state?->getSize())); - }) - ->visibility('public') - ]), - ]), - Builder\Block::make('person') - ->label('Персона') - ->schema([ - TextInput::make('name') - ->label('Имя') - ->required() - ->maxLength(255), - FileUpload::make('photo') - ->label('Фотография') - ->image() - ->optimize('webp') - ->resize(50) - ->disk('public') - ->directory('images') - ->imageEditor(), - Forms\Components\Repeater::make('info')->schema([ - TextInput::make('column') - ->label('Название колонки') - ->required() - ->maxLength(255), - Forms\Components\Textarea::make('content') - ->label('Содержание') - ->required() - ->maxLength(1000), - ])->minItems(1)->label('Информация о персоне'), - ]), - Builder\Block::make('stepper') - ->label('Строитель этапов') - ->schema([ - TextInput::make('step_name') - ->label('Название шага') - ->required() - ->maxLength(255), - Forms\Components\Repeater::make('steps')->schema([ - TextInput::make('title') - ->required() - ->maxLength(255)->columnSpanFull(), - RichEditor::make('content')->required(), - ])->minItems(1), - ]), - Builder\Block::make('images') - ->schema([ - FileUpload::make('url') - ->label('Изображение(-я)') - ->image() - ->multiple() - ->reorderable() - ->maxFiles(5) - ->disk('public') - ->directory('images') - ->imageEditor() - ->required(), - TextInput::make('alt') - ->label('Описание') - ->placeholder('Необязяательно') - ])->label('Слайдер изображений'), - Builder\Block::make('image') - ->schema([ - FileUpload::make('url') - ->label('Изображение(-я)') - ->image() - ->multiple() - ->reorderable() - ->maxFiles(5) - ->disk('public') - ->directory('images') - ->imageEditor() - ->required(), - TextInput::make('alt') - ->label('Описание') - ->placeholder('Необязяательно') - ])->label('Изображение'), - Builder\Block::make('video') - ->label('Видео (Не стабильно)') - ->schema([ - TextInput::make('mime')->readOnly(), - TextInput::make('title') - ->required() - ->maxLength(255) - ->autofocus(), - FileUpload::make('path') - ->required() - ->acceptedFileTypes([ - 'video/mp4', - 'video/quicktime', - 'video/x-msvideo', - 'video/x-ms-wmv', - 'video/avi', - 'video/webm', - 'video/ogg', - 'video/3gpp', - 'video/3gpp2', - 'video/x-m4v', - ]) - ->disk('public') - ->directory('videos') - ->afterStateUpdated(fn (callable $set, $state) => $set('mime', $state?->getMimeType())), - ]), - Builder\Block::make('postsList') - ->schema([ - Forms\Components\Grid::make(2)->schema([ - TextInput::make('count') - ->label('Количество запией') - ->integer(), - Select::make('category') - ->options(Category::all()->pluck('title', 'id')) - ]), - ])->label('Список новостей'), - Builder\Block::make('postItem') - ->schema([ - Select::make('post') - ->options(Post::query()->where('status', PostStatus::PUBLISHED)->pluck('title', 'id')) - ->searchable() - ->required(), - ])->label('Новость'), - Builder\Block::make('pageItem') - ->schema([ - Select::make('page') - ->options(Page::query()->where('title', '!=' , null)->where('is_visible', true)->pluck('title', 'id')) - ->searchable() - ->required(), - ])->label('Страница'), - Builder\Block::make('customForm') - ->schema([ - Select::make('form') - ->options(CustomForm::query()->where('status', CustomFormStatus::PUBLISHED)->pluck('title', 'form_id')) - ->searchable() - ->required(), - ])->label('Форма'), - Builder\Block::make('pageResourceList') - ->schema([ - Select::make('resource') - ->options(PageReferenceList::query()->where('is_active', true)->pluck('title', 'slug')) - ->searchable() - ->required(), - ])->label('Ресурсы'), - ]) - ->collapsed() - ->blockNumbers(false) + ->maxLength(255), + ]), + + Builder\Block::make('paragraph') + ->label('Текст') + ->icon('heroicon-o-document-text') + ->schema([ + Toggle::make('seo_active') + ->label('Использовать блок как SEO-текст') + ->helperText('Этот текст будет использоваться для SEO-оптимизации') + ->live(onBlur: true) + ->required() + ->disabled(function ($state, Forms\Get $get) { + $data = $get('../../'); + return self::findSeoActive($data) && !$state; + }) + ->dehydrated(), + TinyEditor::make('content') + ->label('Текст') + ->placeholder('Начните вводить текст...') + ->profile('test') + ->required() + ->helperText('Основное текстовое содержимое блока'), + ]), + + Builder\Block::make('files') + ->label('Файлы') + ->icon('heroicon-o-paper-clip') + ->schema([ + Forms\Components\Repeater::make('file') + ->label('Файлы') + ->helperText('Загрузите один или несколько файлов') + ->schema([ + Hidden::make('expansion')->required(), + Hidden::make('size')->required(), + TextInput::make('title') + ->label('Название файла') + ->placeholder('Введите название файла') + ->required() + ->maxLength(255) + ->autofocus() + ->helperText('Это название будет отображаться пользователям'), + FileUpload::make('path') + ->label('Файл') + ->required() + ->helperText('Поддерживаются PDF, Word, Excel, PowerPoint и ZIP файлы (макс. 500KB)') + ->getUploadedFileNameForStorageUsing( + fn (TemporaryUploadedFile $file): string => + str(Str::slug(pathinfo($file->getClientOriginalName(), PATHINFO_FILENAME) . '-' . Carbon::now()->timestamp) . '.' . $file->getClientOriginalExtension()) + ) + ->acceptedFileTypes([ + 'application/pdf', + 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', + 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + 'application/vnd.openxmlformats-officedocument.presentationml.presentation', + 'application/zip' + ]) + ->maxSize(512000) + ->disk('public') + ->directory('files') + ->downloadable() + ->afterStateUpdated(function ($set, $state) { + $set('expansion', $state?->getClientOriginalExtension()); + $set('size', ByteConverter::bytesToHuman($state?->getSize())); + }) + ->visibility('public') + ->preserveFilenames() + ]) + ->itemLabel(fn (array $state): ?string => $state['title'] ?? null) ->collapsible() - ->blockPickerColumns(3) - ->blockPickerWidth('2xl') - ->addActionLabel('Добавить новый блок'), - ])->minItems(1), - ]); + ->cloneable() + ->grid(2), + ]), + + Builder\Block::make('person') + ->label('Персона') + ->icon('heroicon-o-user') + ->schema([ + TextInput::make('name') + ->label('Имя персоны') + ->placeholder('Введите имя') + ->required() + ->maxLength(255) + ->helperText('Полное имя персоны'), + FileUpload::make('photo') + ->label('Фотография') + ->image() + ->helperText('Рекомендуемый формат: WebP') + ->optimize('webp') + ->resize(50) + ->disk('public') + ->directory('images') + ->imageEditor() + ->required() + ->downloadable() + ->openable(), + Forms\Components\Repeater::make('info') + ->label('Дополнительная информация') + ->helperText('Добавьте характеристики персоны') + ->schema([ + TextInput::make('column') + ->label('Название характеристики') + ->placeholder('Например: Должность') + ->required() + ->maxLength(255), + Forms\Components\Textarea::make('content') + ->label('Значение') + ->placeholder('Например: Главный инженер') + ->required() + ->maxLength(1000) + ->columnSpanFull(), + ]) + ->minItems(1) + ->grid(2) + ->collapsible() + ->cloneable() + ->itemLabel(fn (array $state): ?string => $state['column'] ?? null), + ]), + + Builder\Block::make('stepper') + ->label('Этапы') + ->icon('heroicon-o-list-bullet') + ->schema([ + TextInput::make('step_name') + ->label('Название процесса') + ->placeholder('Например: Процесс оформления') + ->required() + ->maxLength(255) + ->helperText('Общее название для всех шагов'), + Forms\Components\Repeater::make('steps') + ->label('Шаги') + ->helperText('Добавьте шаги процесса') + ->schema([ + TextInput::make('title') + ->label('Название шага') + ->placeholder('Например: Шаг 1') + ->required() + ->maxLength(255) + ->columnSpanFull(), + RichEditor::make('content') + ->label('Описание шага') + ->required() + ->toolbarButtons([ + 'bold', + 'italic', + 'link', + 'orderedList', + 'bulletList', + ]), + ]) + ->minItems(1) + ->collapsible() + ->cloneable() + ->itemLabel(fn (array $state): ?string => $state['title'] ?? null), + ]), + + Builder\Block::make('images') + ->label('Слайдер изображений') + ->icon('heroicon-o-photo') + ->schema([ + FileUpload::make('url') + ->label('Изображения') + ->image() + ->multiple() + ->reorderable() + ->maxFiles(5) + ->disk('public') + ->directory('images') + ->imageEditor() + ->required() + ->helperText('Максимум 5 изображений. Можно перетаскивать для изменения порядка'), + TextInput::make('alt') + ->label('Описание изображений') + ->placeholder('Необязательно') + ->helperText('Используется для SEO и доступности'), + ]), + + Builder\Block::make('image') + ->label('Изображение') + ->icon('heroicon-o-photo') + ->schema([ + FileUpload::make('url') + ->label('Изображение') + ->image() + ->multiple() + ->reorderable() + ->maxFiles(5) + ->disk('public') + ->directory('images') + ->imageEditor() + ->required() + ->helperText('Можно загрузить до 5 изображений'), + TextInput::make('alt') + ->label('Альтернативный текст') + ->placeholder('Необязательно') + ->helperText('Описание изображения для SEO'), + ]), + + Builder\Block::make('video') + ->label('Видео') + ->icon('heroicon-o-film') + ->schema([ + TextInput::make('mime') + ->label('Тип видео') + ->readOnly() + ->helperText('Определяется автоматически'), + TextInput::make('title') + ->label('Название видео') + ->placeholder('Введите название видео') + ->required() + ->maxLength(255) + ->autofocus() + ->helperText('Это название будет отображаться перед видео'), + FileUpload::make('path') + ->label('Видеофайл') + ->required() + ->acceptedFileTypes([ + 'video/mp4', + 'video/quicktime', + 'video/x-msvideo', + 'video/x-ms-wmv', + 'video/avi', + 'video/webm', + 'video/ogg', + 'video/3gpp', + 'video/3gpp2', + 'video/x-m4v', + ]) + ->disk('public') + ->directory('videos') + ->helperText('Поддерживаются популярные видеоформаты (MP4, MOV, AVI и др.)') + ->afterStateUpdated(fn (callable $set, $state) => $set('mime', $state?->getMimeType())), + ]), + + Builder\Block::make('postsList') + ->label('Список новостей') + ->icon('heroicon-o-newspaper') + ->schema([ + Forms\Components\Grid::make(2) + ->schema([ + TextInput::make('count') + ->label('Количество записей') + ->integer() + ->minValue(1) + ->maxValue(20) + ->default(5) + ->helperText('От 1 до 20 записей'), + Select::make('category') + ->label('Категория') + ->options(Category::all()->pluck('title', 'id')) + ->searchable() + ->helperText('Выберите категорию или оставьте пустым для всех'), + ]), + ]), + + Builder\Block::make('postItem') + ->label('Конкретная новость') + ->icon('heroicon-o-document-text') + ->schema([ + Select::make('post') + ->label('Новость') + ->options(Post::query()->where('status', PostStatus::PUBLISHED)->pluck('title', 'id')) + ->searchable() + ->required() + ->helperText('Выберите опубликованную новость'), + ]), + + Builder\Block::make('pageItem') + ->label('Конкретная страница') + ->icon('heroicon-o-document') + ->schema([ + Select::make('page') + ->label('Страница') + ->options(Page::query()->where('title', '!=', null)->where('is_visible', true)->pluck('title', 'id')) + ->searchable() + ->required() + ->helperText('Выберите видимую страницу'), + ]), + + Builder\Block::make('customForm') + ->label('Пользовательская форма') + ->icon('heroicon-o-clipboard-document-list') + ->schema([ + Select::make('form') + ->label('Форма') + ->options(CustomForm::query()->where('status', CustomFormStatus::PUBLISHED)->pluck('title', 'form_id')) + ->searchable() + ->required() + ->helperText('Выберите опубликованную форму'), + ]), + + Builder\Block::make('pageResourceList') + ->label('Ресурсы') + ->icon('heroicon-o-archive-box') + ->schema([ + Select::make('resource') + ->label('Ресурс') + ->options(PageReferenceList::query()->where('is_active', true)->pluck('title', 'slug')) + ->searchable() + ->required() + ->helperText('Выберите активный ресурс'), + ]), + + Builder\Block::make('contact') + ->label('Контакты') + ->icon('heroicon-o-phone') + ->schema([ + Select::make('contact') + ->label('Виджет контактов') + ->options(ContactWidget::query()->where('is_active', true)->pluck('title', 'slug')) + ->searchable() + ->required() + ->helperText('Выберите активный виджет контактов'), + ]), + + Builder\Block::make('slider') + ->label('Слайдер') + ->icon('heroicon-o-presentation-chart-line') + ->schema([ + Select::make('slider') + ->label('Слайдер') + ->options(Slider::query()->whereHas('slides')->where('is_active', true)->pluck('title', 'slug')) + ->searchable() + ->required() + ->helperText('Выберите активный слайдер с изображениями'), + ]), + ]) + ->collapsed() + ->blockPickerColumns(3) + ->blockPickerWidth('2xl') + ->blockNumbers(false) + ->collapsible() + ->addActionLabel('Добавить блок в вкладку'); } diff --git a/app/Filament/Components/Forms/ItemForm/Pages/ContentBuilderItem.php b/app/Filament/Components/Forms/ItemForm/Pages/ContentBuilderItem.php index 17646e5..8db9cf9 100644 --- a/app/Filament/Components/Forms/ItemForm/Pages/ContentBuilderItem.php +++ b/app/Filament/Components/Forms/ItemForm/Pages/ContentBuilderItem.php @@ -4,6 +4,22 @@ namespace App\Filament\Components\Forms\ItemForm\Pages; use App\Enums\CustomFormStatus; use App\Enums\PostStatus; +use App\Filament\Components\Forms\ItemForm\Blocks\ContactBlock; +use App\Filament\Components\Forms\ItemForm\Blocks\CustomFormBlock; +use App\Filament\Components\Forms\ItemForm\Blocks\FilesBlock; +use App\Filament\Components\Forms\ItemForm\Blocks\HeadingBlock; +use App\Filament\Components\Forms\ItemForm\Blocks\ImagesBlock; +use App\Filament\Components\Forms\ItemForm\Blocks\PageItemBlock; +use App\Filament\Components\Forms\ItemForm\Blocks\PageResourceListBlock; +use App\Filament\Components\Forms\ItemForm\Blocks\ParagraphBlock; +use App\Filament\Components\Forms\ItemForm\Blocks\PersonBlock; +use App\Filament\Components\Forms\ItemForm\Blocks\PostItemBlock; +use App\Filament\Components\Forms\ItemForm\Blocks\PostListBlock; +use App\Filament\Components\Forms\ItemForm\Blocks\SliderBlock; +use App\Filament\Components\Forms\ItemForm\Blocks\StepperBlock; +use App\Filament\Components\Forms\ItemForm\Blocks\TabBlock; +use App\Filament\Components\Forms\ItemForm\Blocks\VideoBlock; +use App\Filament\Components\Forms\ItemForm\Defaults\TabBuilderItem; use App\Helpers\ByteConverter; use App\Models\Category; use App\Models\ContactWidget; @@ -14,6 +30,7 @@ use App\Models\Post; use App\Models\Slider; use Filament\Forms; use Filament\Forms\Components\Builder; +use Filament\Forms\Components\Fieldset; use Filament\Forms\Components\FileUpload; use Filament\Forms\Components\Hidden; use Filament\Forms\Components\RichEditor; @@ -29,407 +46,99 @@ use Mohamedsabil83\FilamentFormsTinyeditor\Components\TinyEditor; class ContentBuilderItem { - private static function findSeoActive(array $data) : bool + public static function getItem(string $name): Builder { - $bool = false; + return Builder::make($name) + ->label('') + ->blocks([ + Builder\Block::make('heading') + ->label('Заголовок') + ->icon('heroicon-o-hashtag') + ->schema(HeadingBlock::schema()), - foreach ($data as $item) { - if ($item['type'] !== 'paragraph') { - continue; - } - if ($item['data']['seo_active'] === true) { - $bool = true; - break; - } - } - return $bool; - } - public static function getItem(string $name) - { - return - Builder::make($name)->label('')->blocks([ - Builder\Block::make('heading')->label('Заголовок') - ->schema([ - TextInput::make('id')->hidden()->integer()->default(rand(2335235,324634264263426)), - TextInput::make('content') - ->label('') - ->live(onBlur: true) - ->afterStateUpdated(function (string $operation, string $state, Forms\Set $set, $get) { - }), - ]), Builder\Block::make('paragraph') - ->schema([ - Toggle::make('seo_active')->label('Использовать блок как seo') - ->live(onBlur: true) - ->required() - ->disabled(function ($state, Forms\Get $get) { - $data = $get('../../'); - return self::findSeoActive($data) && !$state; - }) - ->dehydrated(), - TinyEditor::make('content') - ->label('') - ->profile('test') - ->required(), - ])->label('Текст'), + ->label('Текст') + ->icon('heroicon-o-document-text') + ->schema(ParagraphBlock::schema()), + Builder\Block::make('files') - ->label('Файл(-ы)') - ->schema([ - Forms\Components\Repeater::make('file')->schema([ - Hidden::make('expansion')->required(), - Hidden::make('size')->required(), - TextInput::make('title') - ->required() - ->maxLength(255) - ->autofocus(), - FileUpload::make('path') - ->required() - ->getUploadedFileNameForStorageUsing( - fn (TemporaryUploadedFile $file): string => - str(Str::slug(pathinfo($file->getClientOriginalName(), PATHINFO_FILENAME) . '-' . Carbon::now()->timestamp ) . '.' . $file->getClientOriginalExtension()) - ) - ->acceptedFileTypes([ - 'application/pdf', - 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', - 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', - 'application/vnd.openxmlformats-officedocument.presentationml.presentation', - 'application/zip' - ]) - ->maxSize(512000) - ->disk('public') - ->directory('files') - ->downloadable() - ->afterStateUpdated(function ($set, $state) { - $set('expansion', $state?->getClientOriginalExtension()); - $set('size', ByteConverter::bytesToHuman($state?->getSize())); - }) - ->visibility('public') - ]), - ]), + ->label('Файлы') + ->icon('heroicon-o-paper-clip') + ->schema(FilesBlock::schema()), + Builder\Block::make('person') ->label('Персона') - ->schema([ - TextInput::make('name') - ->label('Имя') - ->required() - ->maxLength(255), - FileUpload::make('photo') - ->label('Фотография') - ->image() - ->optimize('webp') - ->resize(50) - ->disk('public') - ->directory('images') - ->imageEditor(), - Forms\Components\Repeater::make('info')->schema([ - TextInput::make('column') - ->label('Название колонки') - ->required() - ->maxLength(255), - Forms\Components\Textarea::make('content') - ->label('Содержание') - ->required() - ->maxLength(1000), - ])->minItems(1)->label('Информация о персоне'), - ]), + ->icon('heroicon-o-user') + ->schema(PersonBlock::schema()), + Builder\Block::make('stepper') - ->label('Строитель этапов') - ->schema([ - TextInput::make('step_name') - ->label('Название шага') - ->required() - ->maxLength(255), - Forms\Components\Repeater::make('steps')->schema([ - TextInput::make('title') - ->required() - ->maxLength(255)->columnSpanFull(), - RichEditor::make('content')->required(), - ])->minItems(1), - ]), + ->label('Этапы') + ->icon('heroicon-o-list-bullet') + ->schema(StepperBlock::schema()), + Builder\Block::make('tabs') ->label('Вкладки') - ->schema([ - Forms\Components\Repeater::make('tab')->schema([ - TextInput::make('title') - ->required() - ->maxLength(255)->columnSpanFull(), - \Filament\Forms\Components\Builder::make('content')->label('')->blocks([ - Builder\Block::make('heading')->label('Заголовок') - ->schema([ - TextInput::make('id')->hidden()->integer()->default(rand(2335235,324634264263426)), - TextInput::make('content') - ->label('') - ->live(onBlur: true) - ->afterStateUpdated(function (string $operation, string $state, Forms\Set $set, $get) { - }), - ]), - Builder\Block::make('paragraph') - ->schema([ - RichEditor::make('content') - ->toolbarButtons([ - 'blockquote', - 'bold', - 'bulletList', - 'italic', - 'link', - 'orderedList', - 'redo', - 'strike', - 'underline', - 'undo', - ]) - ->label(''), - ])->label('Текст'), - Builder\Block::make('files') - ->schema([ - Forms\Components\Repeater::make('file')->schema([ - TextInput::make('title') - ->required() - ->maxLength(255) - ->autofocus(), - FileUpload::make('path') - ->required() - ->acceptedFileTypes([ - 'application/pdf', - 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', - 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', - 'application/vnd.openxmlformats-officedocument.presentationml.presentation', - 'application/zip' - ]) - ->maxSize(512000) - ->disk('public') - ->directory('files') - ->downloadable() - ->visibility('public') - ]), - ]), - Builder\Block::make('person') - ->schema([ - TextInput::make('name') - ->label('Имя') - ->required() - ->maxLength(255), - FileUpload::make('photo') - ->label('Фотография') - ->image() - ->disk('public') - ->directory('images') - ->imageEditor(), - Forms\Components\Repeater::make('info')->schema([ - Forms\Components\Grid::make(2)->schema([ - TextInput::make('column') - ->required() - ->maxLength(255), - TextInput::make('content') - ->required() - ->maxLength(255), - ]), - ])->minItems(1), - ]), - Builder\Block::make('stepper') - ->schema([ - TextInput::make('step_name') - ->label('Название шага') - ->required() - ->maxLength(255), - Forms\Components\Repeater::make('steps')->schema([ - TextInput::make('title') - ->required() - ->maxLength(255)->columnSpanFull(), - RichEditor::make('content')->required(), - ])->minItems(1), - ]), - Builder\Block::make('images') - ->schema([ - FileUpload::make('url') - ->label('Изображение(-я)') - ->image() - ->multiple() - ->reorderable() - ->maxFiles(5) - ->disk('public') - ->directory('images') - ->imageEditor() - ->required(), - TextInput::make('alt') - ->label('Описание') - ->placeholder('Необязяательно') - ])->label('Слайдер изображений'), - Builder\Block::make('image') - ->schema([ - FileUpload::make('url') - ->label('Изображение(-я)') - ->image() - ->multiple() - ->reorderable() - ->maxFiles(5) - ->disk('public') - ->directory('images') - ->imageEditor() - ->required(), - TextInput::make('alt') - ->label('Описание') - ->placeholder('Необязяательно') - ])->label('Изображение'), - Builder\Block::make('video') - ->schema([ - TextInput::make('mime')->readOnly(), - TextInput::make('title') - ->required() - ->maxLength(255) - ->autofocus(), - FileUpload::make('path') - ->required() - ->acceptedFileTypes([ - 'video/mp4', - 'video/quicktime', - 'video/x-msvideo', - 'video/x-ms-wmv', - 'video/avi', - 'video/webm', - 'video/ogg', - 'video/3gpp', - 'video/3gpp2', - 'video/x-m4v', - ]) - ->disk('public') - ->directory('videos') - ->afterStateUpdated(fn (callable $set, $state) => $set('mime', $state?->getMimeType())), - ]), - Builder\Block::make('postsList') - ->schema([ - Forms\Components\Grid::make(2)->schema([ - TextInput::make('count') - ->label('Количество запией') - ->integer(), - Select::make('category') - ->options(Category::all()->pluck('title', 'id')) - ]), - ])->label('Список новостей'), - ]) - ->collapsed() - ->blockNumbers(false) - ->collapsible() - ->addActionLabel('Добавить новый блок'), - ])->minItems(1), - ]), + ->icon('heroicon-o-rectangle-stack') + ->schema(TabBlock::schema()), + Builder\Block::make('images') - ->schema([ - FileUpload::make('url') - ->label('Изображение(-я)') - ->image() - ->multiple() - ->reorderable() - ->maxFiles(5) - ->disk('public') - ->directory('images') - ->imageEditor() - ->required(), - TextInput::make('alt') - ->label('Описание') - ->placeholder('Необязяательно') - ])->label('Слайдер изображений'), + ->label('Слайдер изображений') + ->icon('heroicon-o-photo') + ->schema(ImagesBlock::schema()), + Builder\Block::make('image') - ->schema([ - FileUpload::make('url') - ->label('Изображение(-я)') - ->image() - ->multiple() - ->reorderable() - ->maxFiles(5) - ->disk('public') - ->directory('images') - ->imageEditor() - ->required(), - TextInput::make('alt') - ->label('Описание') - ->placeholder('Необязяательно') - ])->label('Изображение'), + ->label('Изображение') + ->icon('heroicon-o-photo') + ->schema(ImagesBlock::schema()), + Builder\Block::make('video') - ->label('Видео (Не стабильно)') - ->schema([ - TextInput::make('mime')->readOnly(), - TextInput::make('title') - ->required() - ->maxLength(255) - ->autofocus(), - FileUpload::make('path') - ->required() - ->acceptedFileTypes([ - 'video/mp4', - 'video/quicktime', - 'video/x-msvideo', - 'video/x-ms-wmv', - 'video/avi', - 'video/webm', - 'video/ogg', - 'video/3gpp', - 'video/3gpp2', - 'video/x-m4v', - ]) - ->disk('public') - ->directory('videos') - ->afterStateUpdated(fn (callable $set, $state) => $set('mime', $state?->getMimeType())), - ]), + ->label('Видео') + ->icon('heroicon-o-film') + ->schema(VideoBlock::schema()), + Builder\Block::make('postsList') - ->schema([ - Forms\Components\Grid::make(2)->schema([ - TextInput::make('count') - ->label('Количество запией') - ->integer(), - Select::make('category') - ->options(Category::all()->pluck('title', 'id')) - ]), - ])->label('Список новостей'), + ->label('Список новостей') + ->icon('heroicon-o-newspaper') + ->schema(PostListBlock::schema()), + Builder\Block::make('postItem') - ->schema([ - Select::make('post') - ->options(Post::query()->where('status', PostStatus::PUBLISHED)->pluck('title', 'id')) - ->searchable() - ->required(), - ])->label('Новость'), + ->label('Конкретная новость') + ->icon('heroicon-o-document-text') + ->schema(PostItemBlock::schema()), + Builder\Block::make('pageItem') - ->schema([ - Select::make('page') - ->options(Page::query()->where('title', '!=' , null)->where('is_visible', true)->pluck('title', 'id')) - ->searchable() - ->required(), - ])->label('Страница'), + ->label('Конкретная страница') + ->icon('heroicon-o-document') + ->schema(PageItemBlock::schema()), + Builder\Block::make('customForm') - ->schema([ - Select::make('form') - ->options(CustomForm::query()->where('status', CustomFormStatus::PUBLISHED)->pluck('title', 'form_id')) - ->searchable() - ->required(), - ])->label('Форма'), + ->label('Пользовательская форма') + ->icon('heroicon-o-clipboard-document-list') + ->schema(CustomFormBlock::schema()), + Builder\Block::make('pageResourceList') - ->schema([ - Select::make('resource') - ->options(PageReferenceList::query()->where('is_active', true)->pluck('title', 'slug')) - ->searchable() - ->required(), - ])->label('Ресурсы'), + ->label('Ресурсы') + ->icon('heroicon-o-archive-box') + ->schema(PageResourceListBlock::schema()), + Builder\Block::make('contact') - ->schema([ - Select::make('contact') - ->options(ContactWidget::query()->where('is_active', true)->pluck('title', 'slug')) - ->searchable() - ->required(), - ])->label('Контакты'), + ->label('Контакты') + ->icon('heroicon-o-phone') + ->schema(ContactBlock::schema()), + Builder\Block::make('slider') - ->schema([ - Select::make('slider') - ->options(Slider::query()->whereHas('slides')->where('is_active', true)->pluck('title', 'slug')) - ->searchable() - ->required(), - ])->label('Слайдеры'), + ->label('Слайдер') + ->icon('heroicon-o-presentation-chart-line') + ->schema(SliderBlock::schema()), ]) - ->collapsed() - ->blockNumbers(false) - ->collapsible() - ->blockPickerColumns(3) - ->blockPickerWidth('2xl') - ->addActionLabel('Добавить новый блок'); + ->collapsed() + ->blockNumbers(false) + ->collapsible() + ->blockPickerColumns(3) + ->blockPickerWidth('2xl') + ->addActionLabel('Добавить новый блок') + ->cloneable() + ->reorderableWithButtons(); } diff --git a/app/Filament/Components/Forms/PageForm.php b/app/Filament/Components/Forms/PageForm.php index 721a0b9..42e09b5 100644 --- a/app/Filament/Components/Forms/PageForm.php +++ b/app/Filament/Components/Forms/PageForm.php @@ -6,6 +6,7 @@ use App\Enums\CustomFormStatus; use App\Enums\PostStatus; use App\Filament\Components\Forms\ItemForm\Pages\ContentBuilderItem; use Filament\Forms; +use Filament\Forms\Components\Actions\Action; use Filament\Forms\Components\Section; use Filament\Forms\Components\Select; use Filament\Forms\Components\TextInput; @@ -22,69 +23,129 @@ class PageForm ->schema([ Section::make() ->schema([ - Forms\Components\Tabs::make('')->schema([ - Forms\Components\Tabs\Tab::make('Основная информация')->schema([ - Forms\Components\Grid::make(2)->schema([ - TextInput::make('title')->label('Заголовок')->required() - ->live(onBlur: true) - ->afterStateUpdated(function (string $operation, string|null $state, Forms\Set $set) { - $set('slug', Str::slug($state)); - $set('path', Str::slug($state)); - }), - TextInput::make('slug')->label('Текстовый идентификатор страницы')->unique(ignoreRecord: true)->required() - ->live(onBlur: true) - ->afterStateUpdated(function (string $operation, string $state, Forms\Set $set, $get) { - $set('path', Str::slug($state)); - }), - ]), - Select::make('sub_section_id')->label('Подраздел') - ->relationship('section', 'title') - ->createOptionForm([ - Forms\Components\TextInput::make('title')->label('Название подраздела')->required() - ->live(onBlur: true) - ->afterStateUpdated(function (string $operation, string $state, Forms\Set $set) { - $set('slug', Str::slug($state)); - }), - TextInput::make('slug')->label('Slug')->unique(ignoreRecord: true)->readOnly()->required(), + Forms\Components\Tabs::make('Настройки страницы') + ->persistTabInQueryString() + ->columnSpanFull() + ->tabs([ + Forms\Components\Tabs\Tab::make('Основная информация') + ->icon('heroicon-o-information-circle') + ->schema([ + Forms\Components\Grid::make(2) + ->schema([ + TextInput::make('title') + ->label('Заголовок страницы') + ->required() + ->maxLength(255) + ->placeholder('Введите название страницы') + ->helperText('Этот заголовок будет отображаться в заголовке страницы и в навигации') + ->live(onBlur: true) + ->afterStateUpdated(function (string $operation, string|null $state, Forms\Set $set) { + $set('slug', Str::slug($state)); + $set('path', Str::slug($state)); + }), + TextInput::make('slug') + ->label('URL-адрес страницы') + ->required() + ->unique(ignoreRecord: true) + ->maxLength(255) + ->helperText('Человеко-понятный URL для страницы') + ->placeholder('example-page') +// ->prefix(fn ($record) => url('/') . '/' . substr($record->path, 0, strrpos($record->path, '/'))) + ->live(onBlur: true) + ->afterStateUpdated(function (string $operation, string $state, Forms\Set $set, $get) { + $set('path', Str::slug($state)); + }) + ->suffixAction( + Action::make('copy') + ->icon('heroicon-s-clipboard-document-check') + ->action(function ($livewire, $state, $record) { + $livewire->js( + 'window.navigator.clipboard.writeText("'. url('/') . '/' . substr($record->path, 0, strrpos($record->path, '/')) . '/' . $state.'"); + $tooltip("'.__('Copied to clipboard').'", { timeout: 1500 });' + ); + })), + + ]), + Select::make('sub_section_id') + ->label('Родительский подраздел') + ->relationship('section', 'title') + ->preload() + ->searchable() + ->placeholder('Выберите подраздел') + ->helperText('Выберите раздел, к которому принадлежит эта страница') + ->createOptionForm([ + Forms\Components\Grid::make(2) + ->schema([ + Forms\Components\TextInput::make('title') + ->label('Название подраздела') + ->required() + ->maxLength(255) + ->placeholder('Введите название подраздела') + ->live(onBlur: true) + ->afterStateUpdated(function (string $operation, string $state, Forms\Set $set) { + $set('slug', Str::slug($state)); + }), + TextInput::make('slug') + ->label('URL подраздела') + ->unique(ignoreRecord: true) + ->readOnly() + ->required() + ->maxLength(255) + ->helperText('Автоматически генерируется из названия'), + ]), + ]), + Select::make('code') + ->label('HTTP статус страницы') + ->options([ + '200' => 'Обычная страница (200 OK)', + '404' => 'Страница не найдена (404 Not Found)', + '500' => 'Технические работы (500 Server Error)', + ]) + ->required() + ->default('200') + ->helperText('Выберите HTTP статус, с которым будет отдаваться страница'), + Toggle::make('searchable') + ->label('Индексировать в поиске') + ->default(true) + ->inline(false) + ->helperText('Разрешить локальному поиску индексировать страницу'), + IconPicker::make('icon') + ->label('Иконка страницы') + ->default('heroicon-o-academic-cap') + ->helperText('Выберите иконку для отображения в навигации') + ->columns(6), + TextInput::make('search_data') + ->hidden(), + ]), + Forms\Components\Tabs\Tab::make('Содержание') + ->icon('heroicon-o-document-text') + ->schema([ + ContentBuilderItem::getItem('content') + ->helperText('Создайте содержимое страницы используя конструктор') + ]), + Forms\Components\Tabs\Tab::make('Дополнительные настройки') + ->icon('heroicon-o-cog') + ->schema([ + Section::make('Отображение элементов') + ->description('Управление видимостью элементов на странице') + ->collapsible() + ->schema([ + Toggle::make('settings.hide_page_sub_section_links') + ->label('Скрыть боковую панель с ссылками на страницы раздела') + ->helperText('Скрывает список страниц текущего раздела') + ->columnSpan(1), + Toggle::make('settings.hide_page_navigate_links') + ->label('Скрыть навигацию по странице') + ->helperText('Скрывает навигацию по заголовкам') + ->columnSpan(1), + Toggle::make('settings.hide_breadcrumbs') + ->label('Скрыть хлебные крошки') + ->helperText('Скрывает навигационную цепочку вверху страницы') + ->columnSpan(1), + ]) + ->columns(2), ]), - Select::make('code')->options([ - '200' => 'Открытая страница', - '404' => 'Не найдено', - '500' => 'Ведутся технические работы', - ])->label('Статус')->required()->default('200'), - Toggle::make('searchable')->default(true)->label('Индексируется поиском')->inline(false), - IconPicker::make('icon') - ->default('heroicon-o-academic-cap') - ->label('Icon'), - - - TextInput::make('search_data')->hidden(), ]), - Forms\Components\Tabs\Tab::make('Контент')->schema([ - ContentBuilderItem::getItem('content') - ]), - Forms\Components\Tabs\Tab::make('Настройки')->schema([ - Section::make()->schema([ - Toggle::make('settings.hide_page_sub_section_links') - ->label('Скрыть сайдбар смежных страниц') - ->columnSpan(1), - - Toggle::make('settings.hide_page_navigate_links') - ->label('Скрыть навигацию по страницу') - ->columnSpan(1), - - Toggle::make('settings.hide_breadcrumbs') - ->label('Скрыть хлебные крошки') - ->columnSpan(1), -// -// Toggle::make('settings.full_width_page') -// ->label('Страница на всю ширину') -// ->columnSpan(1)->default(true), - ]), - ]), - ]), - - ]) ]); } diff --git a/app/Filament/Components/Forms/PostForm.php b/app/Filament/Components/Forms/PostForm.php index 12fd48e..6220fab 100644 --- a/app/Filament/Components/Forms/PostForm.php +++ b/app/Filament/Components/Forms/PostForm.php @@ -11,6 +11,7 @@ use App\Models\CustomForm; use App\Models\Page; use App\Models\PageReferenceList; use App\Models\Post; +use App\Models\Slider; use Filament\Forms; use Filament\Forms\Components\Builder; use Filament\Forms\Components\ColorPicker; @@ -38,8 +39,6 @@ use Symfony\Component\Finder\Finder; class PostForm { - - public static function getForm(Form $form): Form { return $form @@ -49,155 +48,264 @@ class PostForm Tabs::make('Tabs') ->tabs([ Tabs\Tab::make('Основная информация') + ->icon('heroicon-o-information-circle') ->schema([ - Forms\Components\Grid::make(2)->schema([ - TextInput::make('title')->label('Заголовок')->required() - ->live(onBlur: true) - ->afterStateUpdated(function (string $operation, string|null $state, Forms\Set $set) { - $set('slug', Str::slug($state)); - $set('seo.title', $state); - }), - TextInput::make('slug')->label('Slug')->unique(ignoreRecord: true)->readOnly()->required(), - ]), - Select::make('status')->options(PostStatus::class) - ->label('Статус')->required() + Grid::make(2) + ->schema([ + TextInput::make('title') + ->label('Заголовок') + ->required() + ->maxLength(255) + ->placeholder('Введите заголовок новости') + ->helperText('Этот заголовок будет отображаться на сайте') + ->live(onBlur: true) + ->afterStateUpdated(function (string $operation, string|null $state, Forms\Set $set) { + $set('slug', Str::slug($state)); + $set('seo.title', $state); + }), + TextInput::make('slug') + ->label('URL-адрес') + ->unique(ignoreRecord: true) + ->readOnly() + ->required() + ->helperText('Этот URL будет использоваться для страницы новости') + ->maxLength(255), + ]), + Select::make('status') + ->label('Статус публикации') + ->options(PostStatus::class) + ->required() + ->default(PostStatus::VERIFICATION) + ->helperText('Выберите статус публикации новости') ->disableOptionWhen(fn (string $value): bool => $value == PostStatus::PUBLISHED->value && !auth()->user()->can('publish_post') - ) - ->default(PostStatus::VERIFICATION), + ), Select::make('category_id') + ->label('Категория') ->options(Category::all()->pluck('title', 'id')) + ->searchable() ->preload() - ->label('Категория'), - SpatieTagsInput::make('tags')->label('Тэги'), + ->placeholder('Выберите категорию') + ->helperText('Выберите категорию для новости'), + SpatieTagsInput::make('tags') + ->label('Теги') + ->placeholder('Добавьте теги') + ->helperText('Добавьте теги для лучшей классификации'), Forms\Components\TagsInput::make('authors') - ->label('Авторы')->placeholder('Добавить автора'), - Section::make('Отложенная публикация')->schema([ - Grid::make(2)->schema([ - Toggle::make('publish_setting.publish_after') - ->label('Включить') - ->inline(false) - ->default(false) - ->live(), - DateTimePicker::make('publish_setting.publish_at') - ->label('Дата публикации') - ->native() - ->displayFormat('d/m/Y') - - ->required(fn (Forms\Get $get) => $get('publish_setting.publish_after')) - ->disabled(fn (Forms\Get $get) => !$get('publish_setting.publish_after')) - ->minDate(Carbon::now()->subWeek()) - ->maxDate(Carbon::now()->addMonth()), + ->label('Авторы') + ->placeholder('Добавить автора') + ->helperText('Укажите авторов новости') + ->suggestions([ + 'Редакция', + 'Администратор', ]), - ]), - Section::make('Публикация в сервисах')->schema([ - Forms\Components\Grid::make()->schema([ - Toggle::make('publication.vk')->label('Публикация в VK')->default(true), - Toggle::make('publication.telegram')->label('Публикация в Telegram')->default(true), + Section::make('Отложенная публикация') + ->description('Настройте автоматическую публикацию новости в указанное время') + ->collapsible() + ->schema([ + Grid::make(2) + ->schema([ + Toggle::make('publish_setting.publish_after') + ->label('Включить отложенную публикацию') + ->inline(false) + ->default(false) + ->live() + ->helperText('Активируйте для публикации в указанное время'), + DateTimePicker::make('publish_setting.publish_at') + ->label('Дата и время публикации') + ->native(false) + ->displayFormat('d/m/Y H:i') + ->seconds(false) + ->minutesStep(15) + ->helperText('Выберите дату и время публикации') + ->required(fn (Forms\Get $get) => $get('publish_setting.publish_after')) + ->disabled(fn (Forms\Get $get) => !$get('publish_setting.publish_after')) + ->minDate(now()) + ->maxDate(now()->addMonth()), + ]), + ]), + Section::make('Публикация в соцсетях') + ->description('Управление автоматической публикацией в социальных сетях') + ->collapsible() + ->schema([ + Grid::make() + ->schema([ + Toggle::make('publication.vk') + ->label('Опубликовать в VK') + ->default(true) + ->helperText('Новость будет автоматически опубликована в VK'), + Toggle::make('publication.telegram') + ->label('Опубликовать в Telegram') + ->default(true) + ->helperText('Новость будет автоматически опубликована в Telegram'), + ]), ]), - ]), ]), - Tabs\Tab::make('Содержание новости') + Tabs\Tab::make('Содержание') + ->icon('heroicon-o-document-text') ->schema([ - ContentBuilderItem::getItem('content')->required(), + ContentBuilderItem::getItem('content') + ->required() + ->helperText('Создайте содержимое новости используя конструктор'), ]), - Tabs\Tab::make('Изображения') + Tabs\Tab::make('Медиа') + ->icon('heroicon-o-photo') ->schema([ - FileUpload::make('preview')->label('Превью новости') + FileUpload::make('preview') + ->label('Главное изображение') ->image() + ->directory('posts/previews') ->optimize('webp') ->resize(50) ->imageEditor() - ->directory('images'), - FileUpload::make('images')->label('Альбом') + ->helperText('Загрузите главное изображение для новости') + ->maxSize(2048) + ->acceptedFileTypes(['image/jpeg', 'image/png', 'image/webp']) + ->imagePreviewHeight('150') + ->panelLayout('integrated'), + FileUpload::make('images') + ->label('Галерея изображений') ->image() + ->directory('posts/gallery') ->optimize('jpg') ->resize(30) ->imageEditor() - ->panelLayout('grid') - ->reorderable() - ->imageEditor() ->multiple() - ->directory('images'), + ->reorderable() + ->panelLayout('grid') + ->helperText('Загрузите дополнительные изображения для галереи') + ->maxFiles(10) + ->maxSize(2048) + ->acceptedFileTypes(['image/jpeg', 'image/png']) + ->imagePreviewHeight('150'), ]), - Tabs\Tab::make('Добавление новости в слайдер') + Tabs\Tab::make('Слайдер') + ->icon('heroicon-o-view-columns') ->schema([ Toggle::make('is_slider_enabled') - ->label('Добавить новый слайд') + ->label('Добавить в слайдер') ->live() - ->hidden(fn (string $context): bool => $context === 'edit') + ->helperText('Активируйте для добавления новости в слайдер') + ->hidden(function (Forms\Get $get, string $context) { + if ($context === 'edit') { + return true; + } + return false; + }) ->dehydrated(false) ->default(false), - Section::make() + Section::make('Настройки слайда') + ->description('Настройте отображение новости в слайдере') + ->collapsible() + ->collapsed() ->schema([ - Forms\Components\Section::make('Информация слайда')->schema([ - Forms\Components\TextInput::make('slide.title') - ->label('Заголовок слайда'), - Forms\Components\Textarea::make('slide.content') - ->label('Текст слайда'), - Forms\Components\Grid::make()->schema([ + Select::make('slide.slider_id') + ->label('Выберите слайдер') + ->options(Slider::where('is_active', true)->pluck('title', 'id')) + ->required() + ->helperText('Выберите слайдер для размещения'), + TextInput::make('slide.title') + ->label('Заголовок слайда') + ->maxLength(100) + ->helperText('Короткий заголовок для слайда') + ->placeholder('Введите заголовок'), + Forms\Components\Textarea::make('slide.content') + ->label('Текст слайда') + ->maxLength(255) + ->helperText('Краткое описание для слайда') + ->placeholder('Введите текст слайда'), + Grid::make() + ->schema([ ColorPicker::make('slide.color_theme') ->label('Цвет текста') ->default('#ffffff') - ->required(), - Forms\Components\ToggleButtons::make('slide.settings.text_position') + ->required() + ->helperText('Выберите цвет текста на слайде'), + ToggleButtons::make('slide.settings.text_position') + ->label('Позиция текста') ->options([ - 'left' => 'Текст слева', - 'center' => 'Текст по середине', - 'right' => 'Текст справа' + 'left' => 'Слева', + 'center' => 'По центру', + 'right' => 'Справа', ]) - ->inline()->default('left')->grouped() - ->label('Позиция текста на слайде'), + ->inline() + ->grouped() + ->default('left') + ->helperText('Выберите расположение текста на слайде'), ]), - Forms\Components\Grid::make()->schema([ + Grid::make() + ->schema([ Toggle::make('active_button') - ->label('Использовать кнопку для ссылки (Ссылка будет открываться при нажатии на слайд)') + ->label('Добавить кнопку') ->inline(false) ->live() + ->helperText('Добавить кнопку со ссылкой на новость') ->afterStateHydrated(function (Toggle $component, $state, $get) { $component->state(true); }) ->dehydrated(false), - Forms\Components\TextInput::make('slide.settings.link_text') - ->default('Читать') + TextInput::make('slide.settings.link_text') ->label('Текст кнопки') + ->default('Читать') + ->maxLength(20) ->disabled(fn (Forms\Get $get) => !$get('active_button')) + ->helperText('Текст для кнопки перехода'), ]), - ]), - Forms\Components\Section::make('Изображение')->schema([ - FileUpload::make('slide.image.url') - ->label('Изображение') - ->image() - ->optimize('webp') - ->resize(50) - ->disk('public') - ->directory('images') - ->imageEditor() - ->required(), - ToggleButtons::make('slide.image.shading')->inline()->grouped()->label('Уровень затемнения изображения')->options([ - '1' => 'Без затемнения', - '0.7' => 'Слабое затемнение', - '0.5' => 'Среднее затемнение', - '0.3' => 'Сильное затемнение', + Section::make('Изображение слайда') + ->schema([ + FileUpload::make('slide.image.url') + ->label('Фоновое изображение') + ->image() + ->directory('sliders') + ->optimize('webp') + ->resize(50) + ->imageEditor() + ->required() + ->maxSize(2048) + ->helperText('Загрузите фоновое изображение для слайда') + ->acceptedFileTypes(['image/jpeg', 'image/png', 'image/webp']), + ToggleButtons::make('slide.image.shading') + ->label('Затемнение фона') + ->inline() + ->grouped() + ->options([ + '1' => 'Нет', + '0.7' => 'Слабое', + '0.5' => 'Среднее', + '0.3' => 'Сильное', + ]) + ->helperText('Выберите уровень затемнения фона'), ]), - ]), - Forms\Components\Section::make('Общая часть')->schema([ - Forms\Components\Grid::make()->schema([ + Section::make('Время показа') + ->schema([ DateTimePicker::make('slide.end_time') - ->label('Слайд действует до') - ->native() - ->displayFormat('d/m/Y') - ->minDate(Carbon::now()) - ->maxDate(Carbon::now()->addMonth()), + ->label('Дата окончания показа') + ->native(false) + ->displayFormat('d/m/Y H:i') + ->minDate(now()) + ->maxDate(now()->addMonth()) + ->helperText('Укажите до какого времени слайд будет активен'), ]), - ]), ]) - ->hidden(fn(Forms\Get $get) => !$get('is_slider_enabled')) - - - ]), - ]), - ]) + ->hidden(function (Forms\Get $get) { + if ($get('is_slider_enabled') === true) { + return false; + } + if ($get('slide')['slider_id'] !== null) { + return false; + } + return true; + }), + ]) + ->hidden(function (Forms\Get $get, string $context) { + if ($context === 'edit' && $get('slide')['slider_id'] === null) { + return true; + } + return false; + }), + ]) + ->persistTabInQueryString(), + ]), ]); } } \ No newline at end of file diff --git a/app/Filament/Pages/Backups.php b/app/Filament/Pages/Backups.php index 9b25468..503af9e 100644 --- a/app/Filament/Pages/Backups.php +++ b/app/Filament/Pages/Backups.php @@ -24,7 +24,7 @@ class Backups extends BaseBackups public static function getNavigationGroup(): ?string { - return 'Settings'; + return 'Настройки приложения'; } public static function getNavigationLabel(): string diff --git a/app/Filament/Pages/CheckpointSettingsPage.php b/app/Filament/Pages/CheckpointSettingsPage.php index 1ed9a1b..d3dc60d 100644 --- a/app/Filament/Pages/CheckpointSettingsPage.php +++ b/app/Filament/Pages/CheckpointSettingsPage.php @@ -15,6 +15,7 @@ class CheckpointSettingsPage extends SettingsPage { protected static ?string $slug = 'checkpoint/settings'; + protected static ?string $navigationIcon = 'heroicon-o-adjustments-horizontal'; protected static string $settings = CheckpointSettings::class; @@ -36,7 +37,7 @@ class CheckpointSettingsPage extends SettingsPage public static function getNavigationGroup(): ?string { - return 'Settings'; // Группа навигации + return 'Настройки приложения'; // Группа навигации } public function form(Form $form): Form diff --git a/app/Filament/Resources/AcademicJournalResource.php b/app/Filament/Resources/AcademicJournalResource.php index 48e5370..e00f8ac 100644 --- a/app/Filament/Resources/AcademicJournalResource.php +++ b/app/Filament/Resources/AcademicJournalResource.php @@ -4,6 +4,7 @@ namespace App\Filament\Resources; use App\Enums\CustomFormStatus; use App\Enums\PostStatus; +use App\Filament\Components\Forms\ItemForm\Pages\ContentBuilderItem; use App\Filament\Resources\AcademicJournalResource\Pages; use App\Filament\Resources\AcademicJournalResource\RelationManagers; use App\Filament\Resources\AcademicJournalResource\RelationManagers\JournalsRelationManager; @@ -14,10 +15,13 @@ use App\Models\CustomForm; use App\Models\Page; use App\Models\Post; use Filament\Forms; +use Filament\Forms\Components\Actions\Action; use Filament\Forms\Components\Builder; use Filament\Forms\Components\FileUpload; +use Filament\Forms\Components\Grid; use Filament\Forms\Components\Hidden; use Filament\Forms\Components\RichEditor; +use Filament\Forms\Components\Section; use Filament\Forms\Components\Select; use Filament\Forms\Components\Tabs; use Filament\Forms\Components\TextInput; @@ -33,780 +37,141 @@ use Mohamedsabil83\FilamentFormsTinyeditor\Components\TinyEditor; class AcademicJournalResource extends Resource { - protected static ?string $navigationGroup = 'Наука'; - public static ?string $label = 'Журнал'; - protected static ?string $pluralLabel = 'Научные журналы'; - protected static ?string $model = AcademicJournal::class; - protected static ?string $navigationIcon = 'heroicon-o-beaker'; public static function form(Form $form): Form { return $form ->schema([ - Forms\Components\Section::make()->schema([ - Forms\Components\Grid::make(2)->schema([ - TextInput::make('title')->label('Заголовок')->required() - ->live(onBlur: true) - ->afterStateUpdated(function (string $operation, string|null $state, Forms\Set $set) { - $set('slug', Str::slug($state)); - }), - TextInput::make('slug')->label('Slug')->unique(ignoreRecord: true)->readOnly()->required(), + Section::make('Основные данные') + ->description('Основная информация о научном журнале') + ->schema([ + Grid::make(2) + ->schema([ + TextInput::make('title') + ->label('Название журнала') + ->required() + ->maxLength(255) + ->placeholder('Введите полное название журнала') + ->helperText('Официальное название журнала как в регистрационных документах') + ->live(onBlur: true) + ->afterStateUpdated(function (string $operation, string|null $state, Forms\Set $set) { + $set('slug', Str::slug($state)); + }), + TextInput::make('slug') + ->label('URL-адрес') + ->unique(ignoreRecord: true) + ->required() + ->readOnly() + ->helperText('Формируется автоматически из названия') + ->prefix(fn () => route('client.academicJournals.index') . '/') + ->suffixAction( + Action::make('copy') + ->icon('heroicon-s-clipboard-document-check') + ->action(function ($livewire, $state) { + $livewire->js( + 'window.navigator.clipboard.writeText("'. route('client.academicJournals.index') . '/' . $state.'"); + $tooltip("'.__('Copied to clipboard').'", { timeout: 1500 });' + ); + })), + ]), ]), - ]), - Tabs::make('Tabs') + + Tabs::make('Настройки журнала') + ->persistTabInQueryString() + ->columnSpanFull() ->tabs([ - Tabs\Tab::make('Основная информация журнала') + Tabs\Tab::make('Основная информация') + ->icon('heroicon-o-information-circle') ->schema([ - Builder::make('main_info')->label('')->blocks([ - Builder\Block::make('heading')->label('Заголовок') - ->schema([ - TextInput::make('id')->hidden()->integer()->default(rand(2335235,324634264263426)), - TextInput::make('content') - ->label('') - ->live(onBlur: true) - ->afterStateUpdated(function (string $operation, string $state, Forms\Set $set, $get) { - }), - ]), - Builder\Block::make('paragraph') - ->schema([ - RichEditor::make('content') - ->toolbarButtons([ - 'blockquote', - 'bold', - 'bulletList', - 'italic', - 'link', - 'orderedList', - 'redo', - 'strike', - 'underline', - 'undo', - ]) - ->label(''), - ])->label('Текст'), - Builder\Block::make('files') - ->schema([ - Forms\Components\Repeater::make('file')->schema([ - Hidden::make('expansion')->required(), - Hidden::make('size')->required(), - TextInput::make('title') + ContentBuilderItem::getItem('main_info') + ->label('Описание журнала') + ->helperText('Добавьте полное описание журнала, его историю и основные направления'), + ]), + + Tabs\Tab::make('Редакционная коллегия') + ->icon('heroicon-o-user-group') + ->schema([ + Section::make('Главный редактор') + ->description('Информация о главном редакторе журнала') + ->collapsible() + ->schema([ + Forms\Components\Repeater::make('chief_editor')->label('') + ->schema([ + TextInput::make('name') + ->label('ФИО') + ->required() + ->maxLength(100) + ->placeholder('Иванов Иван Иванович'), + TextInput::make('academicTitle') + ->label('Учёная степень') + ->required() + ->maxLength(50) + ->placeholder('д.т.н., профессор'), + TextInput::make('position') + ->label('Должность') + ->required() + ->maxLength(100) + ->placeholder('Главный научный сотрудник'), + TextInput::make('institution') + ->label('Учреждение') ->required() ->maxLength(255) - ->autofocus(), - FileUpload::make('path') - ->required() - ->getUploadedFileNameForStorageUsing( - fn (TemporaryUploadedFile $file): string => - str(Str::slug(pathinfo($file->getClientOriginalName(), PATHINFO_FILENAME) . '-' . Carbon::now()->timestamp ) . '.' . $file->getClientOriginalExtension()) - ) - ->acceptedFileTypes([ - 'application/pdf', - 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', - 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', - 'application/vnd.openxmlformats-officedocument.presentationml.presentation', - 'application/zip' - ]) - ->maxSize(512000) - ->disk('public') - ->directory('files') - ->downloadable() - ->afterStateUpdated(function ($set, $state) { - $set('expansion', $state?->getClientOriginalExtension()); - $set('size', ByteConverter::bytesToHuman($state?->getSize())); - }) - ->visibility('public') - ]), - ]), - Builder\Block::make('person') - ->schema([ - TextInput::make('name') - ->label('Имя') - ->required() - ->maxLength(255), - FileUpload::make('photo') - ->label('Фотография') - ->image() - ->disk('public') - ->directory('images') - ->imageEditor(), - Forms\Components\Repeater::make('info')->schema([ - Forms\Components\Grid::make(2)->schema([ - TextInput::make('column') - ->required() - ->maxLength(255), - TextInput::make('content') - ->required() - ->maxLength(255), - ]), - ])->minItems(1), - ]), - Builder\Block::make('stepper') - ->schema([ - TextInput::make('step_name') - ->label('Название шага') - ->required() - ->maxLength(255), - Forms\Components\Repeater::make('steps')->schema([ - TextInput::make('title') - ->required() - ->maxLength(255)->columnSpanFull(), - RichEditor::make('content')->required(), - ])->minItems(1), - ]), - Builder\Block::make('tabs') - ->schema([ - Forms\Components\Repeater::make('tab')->schema([ - TextInput::make('title') - ->required() - ->maxLength(255)->columnSpanFull(), - \Filament\Forms\Components\Builder::make('content')->label('')->blocks([ - Builder\Block::make('heading')->label('Заголовок') - ->schema([ - TextInput::make('id')->hidden()->integer()->default(rand(2335235,324634264263426)), - TextInput::make('content') - ->label('') - ->live(onBlur: true) - ->afterStateUpdated(function (string $operation, string $state, Forms\Set $set, $get) { - }), - ]), - Builder\Block::make('paragraph') - ->schema([ - RichEditor::make('content') - ->toolbarButtons([ - 'blockquote', - 'bold', - 'bulletList', - 'italic', - 'link', - 'orderedList', - 'redo', - 'strike', - 'underline', - 'undo', - ]) - ->label(''), - ])->label('Текст'), - Builder\Block::make('files') - ->schema([ - Forms\Components\Repeater::make('file')->schema([ - TextInput::make('title') - ->required() - ->maxLength(255) - ->autofocus(), - FileUpload::make('path') - ->required() - ->acceptedFileTypes([ - 'application/pdf', - 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', - 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', - 'application/vnd.openxmlformats-officedocument.presentationml.presentation', - 'application/zip' - ]) - ->maxSize(512000) - ->disk('public') - ->directory('files') - ->downloadable() - ->visibility('public') - ]), - ]), - Builder\Block::make('person') - ->schema([ - TextInput::make('name') - ->label('Имя') - ->required() - ->maxLength(255), - FileUpload::make('photo') - ->label('Фотография') - ->image() - ->disk('public') - ->directory('images') - ->imageEditor(), - Forms\Components\Repeater::make('info')->schema([ - Forms\Components\Grid::make(2)->schema([ - TextInput::make('column') - ->required() - ->maxLength(255), - TextInput::make('content') - ->required() - ->maxLength(255), - ]), - ])->minItems(1), - ]), - Builder\Block::make('stepper') - ->schema([ - TextInput::make('step_name') - ->label('Название шага') - ->required() - ->maxLength(255), - Forms\Components\Repeater::make('steps')->schema([ - TextInput::make('title') - ->required() - ->maxLength(255)->columnSpanFull(), - RichEditor::make('content')->required(), - ])->minItems(1), - ]), - Builder\Block::make('images') - ->schema([ - FileUpload::make('url') - ->label('Изображение(-я)') - ->image() - ->multiple() - ->reorderable() - ->maxFiles(5) - ->disk('public') - ->directory('images') - ->imageEditor() - ->required(), - TextInput::make('alt') - ->label('Описание') - ->placeholder('Необязяательно') - ])->label('Слайдер изображений'), - Builder\Block::make('image') - ->schema([ - FileUpload::make('url') - ->label('Изображение(-я)') - ->image() - ->multiple() - ->reorderable() - ->maxFiles(5) - ->disk('public') - ->directory('images') - ->imageEditor() - ->required(), - TextInput::make('alt') - ->label('Описание') - ->placeholder('Необязяательно') - ])->label('Изображение'), - Builder\Block::make('video') - ->schema([ - TextInput::make('mime')->readOnly(), - TextInput::make('title') - ->required() - ->maxLength(255) - ->autofocus(), - FileUpload::make('path') - ->required() - ->acceptedFileTypes([ - 'video/mp4', - 'video/quicktime', - 'video/x-msvideo', - 'video/x-ms-wmv', - 'video/avi', - 'video/webm', - 'video/ogg', - 'video/3gpp', - 'video/3gpp2', - 'video/x-m4v', - ]) - ->disk('public') - ->directory('videos') - ->afterStateUpdated(fn (callable $set, $state) => $set('mime', $state?->getMimeType())), - ]), - Builder\Block::make('postsList') - ->schema([ - Forms\Components\Grid::make(2)->schema([ - TextInput::make('count') - ->label('Количество запией') - ->integer(), - Select::make('category') - ->options(Category::all()->pluck('title', 'id')) - ]), - ])->label('Список новостей'), - ]) - ->collapsed() - ->blockNumbers(false) - ->collapsible() - ->addActionLabel('Добавить новый блок'), - ])->minItems(1), - ]), - Builder\Block::make('images') - ->schema([ - FileUpload::make('url') - ->label('Изображение(-я)') - ->image() - ->multiple() - ->reorderable() - ->maxFiles(5) - ->disk('public') - ->directory('images') - ->imageEditor() - ->required(), - TextInput::make('alt') - ->label('Описание') - ->placeholder('Необязяательно') - ])->label('Слайдер изображений'), - Builder\Block::make('image') - ->schema([ - FileUpload::make('url') - ->label('Изображение(-я)') - ->image() - ->multiple() - ->reorderable() - ->maxFiles(5) - ->disk('public') - ->directory('images') - ->imageEditor() - ->required(), - TextInput::make('alt') - ->label('Описание') - ->placeholder('Необязяательно') - ])->label('Изображение'), - Builder\Block::make('video') - ->schema([ - TextInput::make('mime')->readOnly(), - TextInput::make('title') - ->required() - ->maxLength(255) - ->autofocus(), - FileUpload::make('path') - ->required() - ->acceptedFileTypes([ - 'video/mp4', - 'video/quicktime', - 'video/x-msvideo', - 'video/x-ms-wmv', - 'video/avi', - 'video/webm', - 'video/ogg', - 'video/3gpp', - 'video/3gpp2', - 'video/x-m4v', - ]) - ->disk('public') - ->directory('videos') - ->afterStateUpdated(fn (callable $set, $state) => $set('mime', $state?->getMimeType())), - ]), - Builder\Block::make('postsList') - ->schema([ - Forms\Components\Grid::make(2)->schema([ - TextInput::make('count') - ->label('Количество запией') - ->integer(), - Select::make('category') - ->options(Category::all()->pluck('title', 'id')) - ]), - ])->label('Список новостей'), - Builder\Block::make('postItem') - ->schema([ - Select::make('post') - ->options(Post::query()->where('status', PostStatus::PUBLISHED)->pluck('title', 'id')) - ->searchable() - ->required(), - ])->label('Новость'), - Builder\Block::make('pageItem') - ->schema([ - Select::make('page') - ->options(Page::query()->where('title', '!=' , null)->where('is_visible', true)->pluck('title', 'id')) - ->searchable() - ->required(), - ])->label('Страница'), - Builder\Block::make('customForm') - ->schema([ - Select::make('form') - ->options(CustomForm::query()->where('status', CustomFormStatus::PUBLISHED)->pluck('title', 'form_id')) - ->searchable() - ->required(), - ])->label('Форма'), - ]) - ->collapsed() - ->blockNumbers(false) - ->collapsible() - ->blockPickerColumns(3) - ->blockPickerWidth('2xl') - ->addActionLabel('Добавить новый блок'), - ]), - Tabs\Tab::make('Редакция') - ->schema([ - Forms\Components\Section::make('Главный редактор')->schema([ - Forms\Components\Repeater::make('chief_editor')->label('')->schema([ - TextInput::make('name')->label('Имя'), - TextInput::make('academicTitle')->label('Ученная степень'), - TextInput::make('position')->label('Должность'), - TextInput::make('institution')->label('Учереждение'), - ])->maxItems(1)->reorderable(false), - ]), - Forms\Components\Section::make('Редакторы')->schema([ - Forms\Components\Repeater::make('editors')->label('')->schema([ - TextInput::make('name')->label('Имя'), - TextInput::make('academicTitle')->label('Ученная степень'), - TextInput::make('position')->label('Должность'), - TextInput::make('institution')->label('Учереждение'), - ]) - ->collapsed() - ->collapsible() - ->label('') - ->addActionLabel('Добавить редактора'), + ->placeholder('МГУ имени М.В. Ломоносова'), + ]) + ->maxItems(1) + ->reorderable(false) + ->helperText('Укажите данные главного редактора журнала'), + ]), - ]), - ]), - Tabs\Tab::make('Информация для авторов') - ->schema([ - Builder::make('for_authors')->label('')->blocks([ - Builder\Block::make('heading')->label('Заголовок') - ->schema([ - TextInput::make('id')->hidden()->integer()->default(rand(2335235,324634264263426)), - TextInput::make('content') - ->label('') - ->live(onBlur: true) - ->afterStateUpdated(function (string $operation, string $state, Forms\Set $set, $get) { - }), - ]), - Builder\Block::make('paragraph') - ->schema([ - RichEditor::make('content') - ->toolbarButtons([ - 'blockquote', - 'bold', - 'bulletList', - 'italic', - 'link', - 'orderedList', - 'redo', - 'strike', - 'underline', - 'undo', - ]) - ->label(''), - ])->label('Текст'), - Builder\Block::make('files') - ->schema([ - Forms\Components\Repeater::make('file')->schema([ - Hidden::make('expansion')->required(), - Hidden::make('size')->required(), - TextInput::make('title') + Section::make('Редакционная коллегия') + ->description('Состав редакционной коллегии журнала') + ->collapsible() + ->schema([ + Forms\Components\Repeater::make('editors')->label('') + ->schema([ + TextInput::make('name') + ->label('ФИО') + ->required() + ->maxLength(100) + ->placeholder('Петров Петр Петрович'), + TextInput::make('academicTitle') + ->label('Учёная степень') + ->required() + ->maxLength(50) + ->placeholder('к.ф.-м.н., доцент'), + TextInput::make('position') + ->label('Должность') + ->required() + ->maxLength(100) + ->placeholder('Доцент кафедры'), + TextInput::make('institution') + ->label('Учреждение') ->required() ->maxLength(255) - ->autofocus(), - FileUpload::make('path') - ->required() - ->getUploadedFileNameForStorageUsing( - fn (TemporaryUploadedFile $file): string => - str(Str::slug(pathinfo($file->getClientOriginalName(), PATHINFO_FILENAME) . '-' . Carbon::now()->timestamp ) . '.' . $file->getClientOriginalExtension()) - ) - ->acceptedFileTypes([ - 'application/pdf', - 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', - 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', - 'application/vnd.openxmlformats-officedocument.presentationml.presentation', - 'application/zip' - ]) - ->maxSize(512000) - ->disk('public') - ->directory('files') - ->downloadable() - ->afterStateUpdated(function ($set, $state) { - $set('expansion', $state?->getClientOriginalExtension()); - $set('size', ByteConverter::bytesToHuman($state?->getSize())); - }) - ->visibility('public') - ]), - ]), - Builder\Block::make('person') - ->schema([ - TextInput::make('name') - ->label('Имя') - ->required() - ->maxLength(255), - FileUpload::make('photo') - ->label('Фотография') - ->image() - ->disk('public') - ->directory('images') - ->imageEditor(), - Forms\Components\Repeater::make('info')->schema([ - Forms\Components\Grid::make(2)->schema([ - TextInput::make('column') - ->required() - ->maxLength(255), - TextInput::make('content') - ->required() - ->maxLength(255), - ]), - ])->minItems(1), - ]), - Builder\Block::make('stepper') - ->schema([ - TextInput::make('step_name') - ->label('Название шага') - ->required() - ->maxLength(255), - Forms\Components\Repeater::make('steps')->schema([ - TextInput::make('title') - ->required() - ->maxLength(255)->columnSpanFull(), - RichEditor::make('content')->required(), - ])->minItems(1), - ]), - Builder\Block::make('tabs') - ->schema([ - Forms\Components\Repeater::make('tab')->schema([ - TextInput::make('title') - ->required() - ->maxLength(255)->columnSpanFull(), - \Filament\Forms\Components\Builder::make('content')->label('')->blocks([ - Builder\Block::make('heading')->label('Заголовок') - ->schema([ - TextInput::make('id')->hidden()->integer()->default(rand(2335235,324634264263426)), - TextInput::make('content') - ->label('') - ->live(onBlur: true) - ->afterStateUpdated(function (string $operation, string $state, Forms\Set $set, $get) { - }), - ]), - Builder\Block::make('paragraph') - ->schema([ - RichEditor::make('content') - ->toolbarButtons([ - 'blockquote', - 'bold', - 'bulletList', - 'italic', - 'link', - 'orderedList', - 'redo', - 'strike', - 'underline', - 'undo', - ]) - ->label(''), - ])->label('Текст'), - Builder\Block::make('files') - ->schema([ - Forms\Components\Repeater::make('file')->schema([ - TextInput::make('title') - ->required() - ->maxLength(255) - ->autofocus(), - FileUpload::make('path') - ->required() - ->acceptedFileTypes([ - 'application/pdf', - 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', - 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', - 'application/vnd.openxmlformats-officedocument.presentationml.presentation', - 'application/zip' - ]) - ->maxSize(512000) - ->disk('public') - ->directory('files') - ->downloadable() - ->visibility('public') - ]), - ]), - Builder\Block::make('person') - ->schema([ - TextInput::make('name') - ->label('Имя') - ->required() - ->maxLength(255), - FileUpload::make('photo') - ->label('Фотография') - ->image() - ->disk('public') - ->directory('images') - ->imageEditor(), - Forms\Components\Repeater::make('info')->schema([ - Forms\Components\Grid::make(2)->schema([ - TextInput::make('column') - ->required() - ->maxLength(255), - TextInput::make('content') - ->required() - ->maxLength(255), - ]), - ])->minItems(1), - ]), - Builder\Block::make('stepper') - ->schema([ - TextInput::make('step_name') - ->label('Название шага') - ->required() - ->maxLength(255), - Forms\Components\Repeater::make('steps')->schema([ - TextInput::make('title') - ->required() - ->maxLength(255)->columnSpanFull(), - RichEditor::make('content')->required(), - ])->minItems(1), - ]), - Builder\Block::make('images') - ->schema([ - FileUpload::make('url') - ->label('Изображение(-я)') - ->image() - ->multiple() - ->reorderable() - ->maxFiles(5) - ->disk('public') - ->directory('images') - ->imageEditor() - ->required(), - TextInput::make('alt') - ->label('Описание') - ->placeholder('Необязяательно') - ])->label('Слайдер изображений'), - Builder\Block::make('image') - ->schema([ - FileUpload::make('url') - ->label('Изображение(-я)') - ->image() - ->multiple() - ->reorderable() - ->maxFiles(5) - ->disk('public') - ->directory('images') - ->imageEditor() - ->required(), - TextInput::make('alt') - ->label('Описание') - ->placeholder('Необязяательно') - ])->label('Изображение'), - Builder\Block::make('video') - ->schema([ - TextInput::make('mime')->readOnly(), - TextInput::make('title') - ->required() - ->maxLength(255) - ->autofocus(), - FileUpload::make('path') - ->required() - ->acceptedFileTypes([ - 'video/mp4', - 'video/quicktime', - 'video/x-msvideo', - 'video/x-ms-wmv', - 'video/avi', - 'video/webm', - 'video/ogg', - 'video/3gpp', - 'video/3gpp2', - 'video/x-m4v', - ]) - ->disk('public') - ->directory('videos') - ->afterStateUpdated(fn (callable $set, $state) => $set('mime', $state?->getMimeType())), - ]), - Builder\Block::make('postsList') - ->schema([ - Forms\Components\Grid::make(2)->schema([ - TextInput::make('count') - ->label('Количество запией') - ->integer(), - Select::make('category') - ->options(Category::all()->pluck('title', 'id')) - ]), - ])->label('Список новостей'), - ]) - ->collapsed() - ->blockNumbers(false) - ->collapsible() - ->addActionLabel('Добавить новый блок'), - ])->minItems(1), - ]), - Builder\Block::make('images') - ->schema([ - FileUpload::make('url') - ->label('Изображение(-я)') - ->image() - ->multiple() - ->reorderable() - ->maxFiles(5) - ->disk('public') - ->directory('images') - ->imageEditor() - ->required(), - TextInput::make('alt') - ->label('Описание') - ->placeholder('Необязяательно') - ])->label('Слайдер изображений'), - Builder\Block::make('image') - ->schema([ - FileUpload::make('url') - ->label('Изображение(-я)') - ->image() - ->multiple() - ->reorderable() - ->maxFiles(5) - ->disk('public') - ->directory('images') - ->imageEditor() - ->required(), - TextInput::make('alt') - ->label('Описание') - ->placeholder('Необязяательно') - ])->label('Изображение'), - Builder\Block::make('video') - ->schema([ - TextInput::make('mime')->readOnly(), - TextInput::make('title') - ->required() - ->maxLength(255) - ->autofocus(), - FileUpload::make('path') - ->required() - ->acceptedFileTypes([ - 'video/mp4', - 'video/quicktime', - 'video/x-msvideo', - 'video/x-ms-wmv', - 'video/avi', - 'video/webm', - 'video/ogg', - 'video/3gpp', - 'video/3gpp2', - 'video/x-m4v', - ]) - ->disk('public') - ->directory('videos') - ->afterStateUpdated(fn (callable $set, $state) => $set('mime', $state?->getMimeType())), - ]), - Builder\Block::make('postsList') - ->schema([ - Forms\Components\Grid::make(2)->schema([ - TextInput::make('count') - ->label('Количество запией') - ->integer(), - Select::make('category') - ->options(Category::all()->pluck('title', 'id')) - ]), - ])->label('Список новостей'), - Builder\Block::make('postItem') - ->schema([ - Select::make('post') - ->options(Post::query()->where('status', PostStatus::PUBLISHED)->pluck('title', 'id')) - ->searchable() - ->required(), - ])->label('Новость'), - Builder\Block::make('pageItem') - ->schema([ - Select::make('page') - ->options(Page::query()->where('title', '!=' , null)->where('is_visible', true)->pluck('title', 'id')) - ->searchable() - ->required(), - ])->label('Страница'), - Builder\Block::make('customForm') - ->schema([ - Select::make('form') - ->options(CustomForm::query()->where('status', CustomFormStatus::PUBLISHED)->pluck('title', 'form_id')) - ->searchable() - ->required(), - ])->label('Форма'), - ]) - ->collapsed() - ->blockNumbers(false) - ->collapsible() - ->blockPickerColumns(3) - ->blockPickerWidth('2xl') - ->addActionLabel('Добавить новый блок'), + ->placeholder('СПбГУ'), + ]) + ->collapsed() + ->collapsible() + ->addActionLabel('Добавить редактора') + ->reorderable(true) + ->itemLabel(fn (array $state): ?string => $state['name'] ?? null) + ->helperText('Добавьте членов редакционной коллегии журнала'), + ]), ]), - ])->columnSpanFull() - + Tabs\Tab::make('Для авторов') + ->icon('heroicon-o-pencil') + ->schema([ + ContentBuilderItem::getItem('for_authors') + ->label('Информация для авторов') + ->helperText('Разместите требования к статьям, правила оформления и сроки подачи'), + ]), + ]) ]); } @@ -814,18 +179,39 @@ class AcademicJournalResource extends Resource { return $table ->columns([ - // + Tables\Columns\TextColumn::make('title') + ->label('Название журнала') + ->searchable() + ->sortable(), + Tables\Columns\TextColumn::make('created_at') + ->label('Дата создания') + ->dateTime('d.m.Y') + ->sortable(), ]) ->filters([ - // ]) ->actions([ - Tables\Actions\EditAction::make(), + Tables\Actions\EditAction::make() + ->iconButton() + ->tooltip('Редактировать'), + Tables\Actions\DeleteAction::make() + ->iconButton() + ->tooltip('Удалить'), + Tables\Actions\RestoreAction::make() + ->iconButton() + ->tooltip('Восстановить'), ]) ->bulkActions([ Tables\Actions\BulkActionGroup::make([ - Tables\Actions\DeleteBulkAction::make(), + Tables\Actions\DeleteBulkAction::make() + ->label('Удалить выбранное'), + Tables\Actions\RestoreBulkAction::make() + ->label('Восстановить выбранное'), ]), + ]) + ->emptyStateActions([ + Tables\Actions\CreateAction::make() + ->label('Добавить журнал'), ]); } @@ -844,4 +230,5 @@ class AcademicJournalResource extends Resource 'edit' => Pages\EditAcademicJournal::route('/{record}/edit'), ]; } -} + +} \ No newline at end of file diff --git a/app/Filament/Resources/AcademicJournalResource/Pages/CreateAcademicJournal.php b/app/Filament/Resources/AcademicJournalResource/Pages/CreateAcademicJournal.php index 6b4a999..f7e1a3d 100644 --- a/app/Filament/Resources/AcademicJournalResource/Pages/CreateAcademicJournal.php +++ b/app/Filament/Resources/AcademicJournalResource/Pages/CreateAcademicJournal.php @@ -3,42 +3,27 @@ namespace App\Filament\Resources\AcademicJournalResource\Pages; use App\Filament\Resources\AcademicJournalResource; +use App\Services\Filament\Traits\SeoGenerate; use Filament\Actions; use Filament\Resources\Pages\CreateRecord; use Illuminate\Support\Str; class CreateAcademicJournal extends CreateRecord { - protected static string $resource = AcademicJournalResource::class; + use SeoGenerate; - protected array $seoData; + protected static string $resource = AcademicJournalResource::class; protected function mutateFormDataBeforeCreate(array $data): array { - $this->seoData = $this->generateSeo($data); $data['search_data'] = $this->generateSearchData($data['main_info']); return $data; } protected function afterCreate(): void { - $this->record->seo()->create($this->seoData); - } - - private function generateSeo(array $data) : array - { - $title = $data['title']; - $rowData = $this->getFirstBlockByName('paragraph', $data['main_info']); - if ($rowData !== null) { - $description = strip_tags($rowData['data']['content']); - } else { - $description = null; - } - return [ - 'title' => $title, - 'description' => Str::limit(htmlspecialchars($description, ENT_QUOTES, 'UTF-8'), 160), - ]; + $this->createSeo($this->record); } private function generateSearchData(array $data) : string diff --git a/app/Filament/Resources/AcademicJournalResource/Pages/EditAcademicJournal.php b/app/Filament/Resources/AcademicJournalResource/Pages/EditAcademicJournal.php index 3787d15..a849a71 100644 --- a/app/Filament/Resources/AcademicJournalResource/Pages/EditAcademicJournal.php +++ b/app/Filament/Resources/AcademicJournalResource/Pages/EditAcademicJournal.php @@ -3,20 +3,21 @@ namespace App\Filament\Resources\AcademicJournalResource\Pages; use App\Filament\Resources\AcademicJournalResource; +use App\Services\Filament\Traits\SeoGenerate; use Filament\Actions; use Filament\Resources\Pages\EditRecord; use Illuminate\Support\Str; class EditAcademicJournal extends EditRecord { + use SeoGenerate; + protected static string $resource = AcademicJournalResource::class; - protected array $seoData; protected function mutateFormDataBeforeSave(array $data): array { - $this->seoData = $this->generateSeo($data); $data['search_data'] = $this->generateSearchData($data['main_info']); return $data; @@ -24,24 +25,7 @@ class EditAcademicJournal extends EditRecord protected function afterSave(): void { - $this->record->seo()->update($this->seoData); - } - - - - private function generateSeo(array $data) : array - { - $title = $data['title']; - $rowData = $this->getFirstBlockByName('paragraph', $data['main_info']); - if ($rowData !== null) { - $description = strip_tags($rowData['data']['content']); - } else { - $description = null; - } - return [ - 'title' => $title, - 'description' => Str::limit(htmlspecialchars($description, ENT_QUOTES, 'UTF-8'), 160), - ]; + $this->updateSeo($this->record); } private function getDataFromBlocks($block) : string @@ -94,15 +78,6 @@ class EditAcademicJournal extends EditRecord return strtolower($result); } - private function getFirstBlockByName(string $name, array $content) : array|null - { - $data = null; - foreach ($content as $block) { - $data = ($block['type'] === $name) ? $block : null; - break; - } - return $data; - } protected function getHeaderActions(): array { diff --git a/app/Filament/Resources/AcademicJournalResource/RelationManagers/JournalsRelationManager.php b/app/Filament/Resources/AcademicJournalResource/RelationManagers/JournalsRelationManager.php index 8e2c81c..3efdb3c 100644 --- a/app/Filament/Resources/AcademicJournalResource/RelationManagers/JournalsRelationManager.php +++ b/app/Filament/Resources/AcademicJournalResource/RelationManagers/JournalsRelationManager.php @@ -4,7 +4,9 @@ namespace App\Filament\Resources\AcademicJournalResource\RelationManagers; use App\Models\AcademicJournal; use Filament\Forms; +use Filament\Forms\Components\DatePicker; use Filament\Forms\Components\FileUpload; +use Filament\Forms\Components\TextInput; use Filament\Forms\Components\Toggle; use Filament\Forms\Form; use Filament\Resources\RelationManagers\RelationManager; @@ -17,27 +19,54 @@ class JournalsRelationManager extends RelationManager { protected static string $relationship = 'journals'; + protected static ?string $modelLabel = 'выпуск'; + protected static ?string $pluralModelLabel = 'выпуски'; + public function form(Form $form): Form { return $form ->schema([ - Forms\Components\TextInput::make('title')->required(), + TextInput::make('title') + ->label('Название выпуска') + ->required() + ->maxLength(255) + ->placeholder('Введите название выпуска журнала') + ->helperText('Например: "Том 15, №3 (2023)" или специальное название выпуска'), + FileUpload::make('path_file') + ->label('Файл выпуска') ->required() ->acceptedFileTypes([ - 'application/pdf', - 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', - 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', - 'application/vnd.openxmlformats-officedocument.presentationml.presentation', - 'application/zip' + 'application/pdf' => 'PDF', + 'application/vnd.openxmlformats-officedocument.wordprocessingml.document' => 'DOCX', + 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' => 'XLSX', + 'application/vnd.openxmlformats-officedocument.presentationml.presentation' => 'PPTX', + 'application/zip' => 'ZIP', ]) ->maxSize(512000) ->disk('public') - ->directory('files') + ->directory('journals/files') ->downloadable() - ->visibility('public'), - Forms\Components\TextInput::make('year_publication')->integer(), - Toggle::make('is_active')->default(true)->label('Активный выпуск')->inline(false), + ->visibility('public') + ->helperText('Максимальный размер файла: 512MB. Допустимые форматы: PDF, DOCX, XLSX, PPTX, ZIP') + ->openable() + ->previewable(false), + + + TextInput::make('year_publication') + ->label('Год публикации') + ->required() + ->numeric() + ->minValue(1900) + ->maxValue(now()->year + 1) + ->placeholder('Укажите год выпуска') + ->helperText('Год должен быть в диапазоне от 1900 до '.(now()->year + 1)), + + Toggle::make('is_active') + ->label('Активный выпуск') + ->default(true) + ->inline(false) + ->helperText('Активные выпуски отображаются на сайте'), ]); } @@ -49,22 +78,79 @@ class JournalsRelationManager extends RelationManager ->defaultSort('sort') ->recordTitleAttribute('title') ->columns([ - Tables\Columns\TextColumn::make('title'), + Tables\Columns\TextColumn::make('title') + ->label('Название выпуска') + ->searchable() + ->sortable() + ->description(fn ($record) => $record->year_publication), + + Tables\Columns\IconColumn::make('is_active') + ->label('Статус') + ->boolean() + ->trueIcon('heroicon-o-check-circle') + ->falseIcon('heroicon-o-x-circle') + ->trueColor('success') + ->falseColor('danger'), + + Tables\Columns\TextColumn::make('created_at') + ->label('Дата добавления') + ->dateTime('d.m.Y H:i') + ->sortable(), ]) ->filters([ - // + Tables\Filters\SelectFilter::make('year_publication') + ->label('Год выпуска') + ->options( + fn () => $this->getOwnerRecord() + ->journals() + ->select('year_publication') + ->distinct() + ->orderBy('year_publication', 'desc') + ->pluck('year_publication', 'year_publication') + ->toArray() + ), + + Tables\Filters\TernaryFilter::make('is_active') + ->label('Только активные') + ->trueLabel('Активные') + ->falseLabel('Неактивные') + ->queries( + true: fn (Builder $query) => $query->where('is_active', true), + false: fn (Builder $query) => $query->where('is_active', false), + ), ]) ->headerActions([ - Tables\Actions\CreateAction::make(), + Tables\Actions\CreateAction::make() + ->label('Добавить выпуск') + ->modalHeading('Добавление нового выпуска'), ]) ->actions([ - Tables\Actions\EditAction::make(), - Tables\Actions\DetachAction::make(), + Tables\Actions\EditAction::make() + ->iconButton() + ->tooltip('Редактировать'), + + Tables\Actions\DeleteAction::make() + ->iconButton() + ->tooltip('Удалить') + ->modalHeading('Удаление выпуска') + ->modalDescription('Вы уверены, что хотите удалить этот выпуск?'), ]) ->bulkActions([ Tables\Actions\BulkActionGroup::make([ - Tables\Actions\DetachBulkAction::make(), + Tables\Actions\DeleteBulkAction::make() + ->label('Удалить выбранные') + ->modalHeading('Удаление выпусков') + ->modalDescription('Вы уверены, что хотите удалить выбранные выпуски?'), ]), + ]) + ->emptyStateActions([ + Tables\Actions\CreateAction::make() + ->label('Добавить выпуск'), + ]) + ->groups([ + Tables\Grouping\Group::make('year_publication') + ->label('Год публикации') + ->collapsible(), ]); } -} +} \ No newline at end of file diff --git a/app/Filament/Resources/AdditionalEducationCategoryResource.php b/app/Filament/Resources/AdditionalEducationCategoryResource.php index a26be30..15311fa 100644 --- a/app/Filament/Resources/AdditionalEducationCategoryResource.php +++ b/app/Filament/Resources/AdditionalEducationCategoryResource.php @@ -3,18 +3,21 @@ namespace App\Filament\Resources; use App\Filament\Resources\AdditionalEducationCategoryResource\Pages; -use App\Filament\Resources\AdditionalEducationCategoryResource\RelationManagers; use App\Models\AdditionalEducationCategory; use App\Models\DirectionAdditionalEducation; use Filament\Forms; +use Filament\Forms\Components\Grid; +use Filament\Forms\Components\Section; +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\Tables; +use Filament\Tables\Columns\BadgeColumn; +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\Str; class AdditionalEducationCategoryResource extends Resource @@ -27,27 +30,57 @@ class AdditionalEducationCategoryResource extends Resource protected static ?string $pluralLabel = 'Категории дополнительного образования'; protected static ?string $navigationParentItem = 'Дополнительное Образование'; - protected static ?string $navigationIcon = 'heroicon-o-rectangle-stack'; + protected static ?string $navigationIcon = 'heroicon-o-tag'; public static function form(Form $form): Form { return $form ->schema([ - Forms\Components\Section::make()->schema([ - Forms\Components\Grid::make('2')->schema([ - TextInput::make('title')->label('Заголовок')->required() - ->live(onBlur: true) - ->afterStateUpdated(function (string $operation, string|null $state, Forms\Set $set) { - $set('slug', Str::slug($state)); - $set('seo.title', $state); - }), - TextInput::make('slug')->label('Slug')->unique(ignoreRecord: true)->readOnly()->required(), - Forms\Components\Select::make('dir_addit_educat_id')->required()->label('Направление доп. образования') - ->preload() - ->options(DirectionAdditionalEducation::where('is_active', true)->pluck('title', 'id')) + Section::make('Основная информация') + ->description('Заполните данные о категории программ ДПО') + ->collapsible() + ->schema([ + Grid::make(2) + ->schema([ + TextInput::make('title') + ->label('Название категории') + ->required() + ->maxLength(255) + ->placeholder('Например: "Профессиональная переподготовка"') + ->live(onBlur: true) + ->afterStateUpdated(function (string $operation, ?string $state, Forms\Set $set) { + $set('slug', Str::slug($state)); + }) + ->helperText('Укажите понятное название категории'), + + TextInput::make('slug') + ->label('URL-идентификатор') + ->required() + ->maxLength(255) + ->unique(ignoreRecord: true) + ->helperText('Человеко-понятный URL для категории'), + + Select::make('dir_addit_educat_id') + ->label('Направление ДПО') + ->options( + DirectionAdditionalEducation::where('is_active', true) + ->orderBy('title') + ->pluck('title', 'id') + ) + ->required() + ->preload() + ->searchable() + ->placeholder('Выберите направление') + ->helperText('К какому направлению относится категория'), + ]), + + Toggle::make('is_active') + ->label('Активная категория') + ->inline(false) + ->default(true) + ->helperText('Отображать ли категорию на сайте') + ->columnSpanFull(), ]), - Forms\Components\Toggle::make('is_active')->label('Активно')->columnSpanFull()->inline(false)->default(true), - ]), ]); } @@ -55,30 +88,77 @@ class AdditionalEducationCategoryResource extends Resource { return $table ->columns([ - TextColumn::make('id')->label('ID')->sortable(), - TextColumn::make('title')->label('Название')->sortable()->searchable(), - TextColumn::make('created_at')->label('Дата создания')->sortable(), - Tables\Columns\BadgeColumn::make('direction.title')->label('Направление')->sortable(), - Tables\Columns\ToggleColumn::make('is_active')->label('Активно')->sortable(), + TextColumn::make('title') + ->label('Название') + ->searchable() + ->sortable() + ->description(fn ($record) => $record->direction->title ?? '') + ->limit(50), + + BadgeColumn::make('direction.title') + ->label('Направление') + ->sortable() + ->searchable() + ->color('primary'), + + IconColumn::make('is_active') + ->label('Активна') + ->boolean() + ->trueIcon('heroicon-o-check-circle') + ->falseIcon('heroicon-o-x-circle') + ->trueColor('success') + ->falseColor('danger') + ->sortable(), + + TextColumn::make('updated_at') + ->label('Обновлено') + ->dateTime('d.m.Y H:i') + ->sortable() + ->toggleable(isToggledHiddenByDefault: true), ]) ->filters([ - // + Tables\Filters\SelectFilter::make('dir_addit_educat_id') + ->label('Направление ДПО') + ->options( + DirectionAdditionalEducation::where('is_active', true) + ->orderBy('title') + ->pluck('title', 'id') + ) + ->searchable(), + + Tables\Filters\TernaryFilter::make('is_active') + ->label('Только активные') + ->placeholder('Все') + ->trueLabel('Активные') + ->falseLabel('Неактивные'), ]) ->actions([ - Tables\Actions\EditAction::make(), + Tables\Actions\EditAction::make() + ->iconButton() + ->tooltip('Редактировать'), + + Tables\Actions\ViewAction::make() + ->iconButton() + ->tooltip('Просмотреть'), ]) ->bulkActions([ Tables\Actions\BulkActionGroup::make([ - Tables\Actions\DeleteBulkAction::make(), + Tables\Actions\DeleteBulkAction::make() + ->label('Удалить выбранные') + ->modalHeading('Удаление категорий') + ->modalDescription('Вы уверены, что хотите удалить выбранные категории ДПО?'), ]), - ]); + ]) + ->emptyStateActions([ + Tables\Actions\CreateAction::make() + ->label('Добавить категорию'), + ]) + ->defaultSort('title'); } public static function getRelations(): array { - return [ - // - ]; + return []; } public static function getPages(): array @@ -89,4 +169,4 @@ class AdditionalEducationCategoryResource extends Resource 'edit' => Pages\EditAdditionalEducationCategory::route('/{record}/edit'), ]; } -} +} \ No newline at end of file diff --git a/app/Filament/Resources/AdditionalEducationResource.php b/app/Filament/Resources/AdditionalEducationResource.php index d13e242..3c09b91 100644 --- a/app/Filament/Resources/AdditionalEducationResource.php +++ b/app/Filament/Resources/AdditionalEducationResource.php @@ -2,41 +2,25 @@ namespace App\Filament\Resources; -use App\Enums\CustomFormStatus; use App\Enums\FormEducation; -use App\Enums\LevelEducational; -use App\Enums\PostStatus; +use App\Filament\Components\Forms\ItemForm\Pages\ContentBuilderItem; use App\Filament\Resources\AdditionalEducationResource\Pages; -use App\Filament\Resources\AdditionalEducationResource\RelationManagers; -use App\Helpers\ByteConverter; use App\Models\AdditionalEducation; use App\Models\AdditionalEducationCategory; -use App\Models\Category; -use App\Models\CustomForm; -use App\Models\DirectionAdditionalEducation; -use App\Models\Page; -use App\Models\Post; use Filament\Forms; -use Filament\Forms\Components\Builder; -use Filament\Forms\Components\FileUpload; -use Filament\Forms\Components\Hidden; -use Filament\Forms\Components\RichEditor; +use Filament\Forms\Components\Grid; use Filament\Forms\Components\Section; use Filament\Forms\Components\Select; -use Filament\Forms\Components\SpatieTagsInput; -use Filament\Forms\Components\Tabs; use Filament\Forms\Components\TextInput; use Filament\Forms\Components\Toggle; use Filament\Forms\Form; use Filament\Resources\Resource; use Filament\Tables; +use Filament\Tables\Columns\BadgeColumn; +use Filament\Tables\Columns\IconColumn; use Filament\Tables\Columns\TextColumn; use Filament\Tables\Table; -use Illuminate\Database\Eloquent\SoftDeletingScope; -use Illuminate\Support\Carbon; use Illuminate\Support\Str; -use Livewire\Features\SupportFileUploads\TemporaryUploadedFile; -use Mohamedsabil83\FilamentFormsTinyeditor\Components\TinyEditor; class AdditionalEducationResource extends Resource { @@ -45,401 +29,117 @@ class AdditionalEducationResource extends Resource protected static ?string $navigationGroup = 'Образование'; public static ?string $label = 'Дополнительное образование'; - protected static ?string $pluralLabel = 'Дополнительное образование'; - protected static ?string $navigationIcon = 'heroicon-o-academic-cap'; + protected static ?string $pluralLabel = 'Дополнительное образование'; + protected static ?string $navigationIcon = 'heroicon-o-book-open'; public static function form(Form $form): Form { return $form ->schema([ - Section::make()->schema([ - Tabs::make('Tabs') - ->tabs([ - Tabs\Tab::make('Основная информация') - ->schema([ - Forms\Components\Grid::make('2')->schema([ - TextInput::make('title')->label('Заголовок')->required() - ->live(onBlur: true) - ->afterStateUpdated(function (string $operation, string|null $state, Forms\Set $set) { - $set('slug', Str::slug($state)); - }), - TextInput::make('slug')->label('Slug')->unique(ignoreRecord: true)->readOnly()->required(), - - Forms\Components\Select::make('category_id')->required()->label('Категория') - ->options(AdditionalEducationCategory::where('is_active', true)->pluck('title', 'id'))->preload()->searchable() - ]), - Forms\Components\TextInput::make('target_group')->required()->columnSpanFull()->label('Целевая аудитория'), - Forms\Components\TextInput::make('qualification')->required()->columnSpanFull()->label('Присваиваемая квалификация'), - Forms\Components\Grid::make('2')->schema([ - Forms\Components\TextInput::make('price')->required()->integer()->label('Стоимость'), - Forms\Components\TextInput::make('learning_time')->required()->integer()->label('Объем обучения'), - ]), - Forms\Components\Select::make('form_education')->label('Форма обучения')->required()->options(FormEducation::class), - Forms\Components\Toggle::make('is_active')->label('Активно')->columnSpanFull()->inline(false)->default(true), - ]), - Tabs\Tab::make('Контент') - ->schema([ - Builder::make('content')->label('')->blocks([ - Builder\Block::make('heading')->label('Заголовок') + Forms\Components\Tabs::make('Программа ДПО') + ->persistTabInQueryString() + ->columnSpanFull() + ->tabs([ + Forms\Components\Tabs\Tab::make('Основные данные') + ->icon('heroicon-o-information-circle') + ->schema([ + Section::make('Общая информация') + ->description('Основные сведения о программе') + ->schema([ + Grid::make(2) ->schema([ - TextInput::make('id')->hidden()->integer()->default(rand(2335235,324634264263426)), - TextInput::make('content') - ->label('') - ->live(onBlur: true) - ->afterStateUpdated(function (string $operation, string $state, Forms\Set $set, $get) { - }), - ]), - Builder\Block::make('paragraph') - ->schema([ - RichEditor::make('content') - ->toolbarButtons([ - 'blockquote', - 'bold', - 'bulletList', - 'italic', - 'link', - 'orderedList', - 'redo', - 'strike', - 'underline', - 'undo', - ]) - ->label(''), - ])->label('Текст'), - Builder\Block::make('files') - ->schema([ - Forms\Components\Repeater::make('file')->schema([ - Hidden::make('expansion')->required(), - Hidden::make('size')->required(), - TextInput::make('title') - ->required() - ->maxLength(255) - ->autofocus(), - FileUpload::make('path') - ->required() - ->getUploadedFileNameForStorageUsing( - fn (TemporaryUploadedFile $file): string => - str(Str::slug(pathinfo($file->getClientOriginalName(), PATHINFO_FILENAME) . '-' . Carbon::now()->timestamp ) . '.' . $file->getClientOriginalExtension()) - ) - ->acceptedFileTypes([ - 'application/pdf', - 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', - 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', - 'application/vnd.openxmlformats-officedocument.presentationml.presentation', - 'application/zip' - ]) - ->maxSize(512000) - ->disk('public') - ->directory('files') - ->downloadable() - ->afterStateUpdated(function ($set, $state) { - $set('expansion', $state?->getClientOriginalExtension()); - $set('size', ByteConverter::bytesToHuman($state?->getSize())); - }) - ->visibility('public') - ]), - ]), - Builder\Block::make('person') - ->schema([ - TextInput::make('name') - ->label('Имя') - ->required() - ->maxLength(255), - FileUpload::make('photo') - ->label('Фотография') - ->image() - ->disk('public') - ->directory('images') - ->imageEditor(), - Forms\Components\Repeater::make('info')->schema([ - Forms\Components\Grid::make(2)->schema([ - TextInput::make('column') - ->required() - ->maxLength(255), - TextInput::make('content') - ->required() - ->maxLength(255), - ]), - ])->minItems(1), - ]), - Builder\Block::make('stepper') - ->schema([ - TextInput::make('step_name') - ->label('Название шага') - ->required() - ->maxLength(255), - Forms\Components\Repeater::make('steps')->schema([ - TextInput::make('title') - ->required() - ->maxLength(255)->columnSpanFull(), - RichEditor::make('content')->required(), - ])->minItems(1), - ]), - Builder\Block::make('tabs') - ->schema([ - Forms\Components\Repeater::make('tab')->schema([ - TextInput::make('title') - ->required() - ->maxLength(255)->columnSpanFull(), - \Filament\Forms\Components\Builder::make('content')->label('')->blocks([ - Builder\Block::make('heading')->label('Заголовок') - ->schema([ - TextInput::make('id')->hidden()->integer()->default(rand(2335235,324634264263426)), - TextInput::make('content') - ->label('') - ->live(onBlur: true) - ->afterStateUpdated(function (string $operation, string $state, Forms\Set $set, $get) { - }), - ]), - Builder\Block::make('paragraph') - ->schema([ - RichEditor::make('content') - ->toolbarButtons([ - 'blockquote', - 'bold', - 'bulletList', - 'italic', - 'link', - 'orderedList', - 'redo', - 'strike', - 'underline', - 'undo', - ]) - ->label(''), - ])->label('Текст'), - Builder\Block::make('files') - ->schema([ - Forms\Components\Repeater::make('file')->schema([ - TextInput::make('title') - ->required() - ->maxLength(255) - ->autofocus(), - FileUpload::make('path') - ->required() - ->acceptedFileTypes([ - 'application/pdf', - 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', - 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', - 'application/vnd.openxmlformats-officedocument.presentationml.presentation', - 'application/zip' - ]) - ->maxSize(512000) - ->disk('public') - ->directory('files') - ->downloadable() - ->visibility('public') - ]), - ]), - Builder\Block::make('person') - ->schema([ - TextInput::make('name') - ->label('Имя') - ->required() - ->maxLength(255), - FileUpload::make('photo') - ->label('Фотография') - ->image() - ->disk('public') - ->directory('images') - ->imageEditor(), - Forms\Components\Repeater::make('info')->schema([ - Forms\Components\Grid::make(2)->schema([ - TextInput::make('column') - ->required() - ->maxLength(255), - TextInput::make('content') - ->required() - ->maxLength(255), - ]), - ])->minItems(1), - ]), - Builder\Block::make('stepper') - ->schema([ - TextInput::make('step_name') - ->label('Название шага') - ->required() - ->maxLength(255), - Forms\Components\Repeater::make('steps')->schema([ - TextInput::make('title') - ->required() - ->maxLength(255)->columnSpanFull(), - RichEditor::make('content')->required(), - ])->minItems(1), - ]), - Builder\Block::make('images') - ->schema([ - FileUpload::make('url') - ->label('Изображение(-я)') - ->image() - ->multiple() - ->reorderable() - ->maxFiles(5) - ->disk('public') - ->directory('images') - ->imageEditor() - ->required(), - TextInput::make('alt') - ->label('Описание') - ->placeholder('Необязяательно') - ])->label('Слайдер изображений'), - Builder\Block::make('image') - ->schema([ - FileUpload::make('url') - ->label('Изображение(-я)') - ->image() - ->multiple() - ->reorderable() - ->maxFiles(5) - ->disk('public') - ->directory('images') - ->imageEditor() - ->required(), - TextInput::make('alt') - ->label('Описание') - ->placeholder('Необязяательно') - ])->label('Изображение'), - Builder\Block::make('video') - ->schema([ - TextInput::make('mime')->readOnly(), - TextInput::make('title') - ->required() - ->maxLength(255) - ->autofocus(), - FileUpload::make('path') - ->required() - ->acceptedFileTypes([ - 'video/mp4', - 'video/quicktime', - 'video/x-msvideo', - 'video/x-ms-wmv', - 'video/avi', - 'video/webm', - 'video/ogg', - 'video/3gpp', - 'video/3gpp2', - 'video/x-m4v', - ]) - ->disk('public') - ->directory('videos') - ->afterStateUpdated(fn (callable $set, $state) => $set('mime', $state?->getMimeType())), - ]), - Builder\Block::make('postsList') - ->schema([ - Forms\Components\Grid::make(2)->schema([ - TextInput::make('count') - ->label('Количество запией') - ->integer(), - Select::make('category') - ->options(Category::all()->pluck('title', 'id')) - ]), - ])->label('Список новостей'), - ]) - ->collapsed() - ->blockNumbers(false) - ->collapsible() - ->addActionLabel('Добавить новый блок'), - ])->minItems(1), - ]), - Builder\Block::make('images') - ->schema([ - FileUpload::make('url') - ->label('Изображение(-я)') - ->image() - ->multiple() - ->reorderable() - ->maxFiles(5) - ->disk('public') - ->directory('images') - ->imageEditor() - ->required(), - TextInput::make('alt') - ->label('Описание') - ->placeholder('Необязяательно') - ])->label('Слайдер изображений'), - Builder\Block::make('image') - ->schema([ - FileUpload::make('url') - ->label('Изображение(-я)') - ->image() - ->multiple() - ->reorderable() - ->maxFiles(5) - ->disk('public') - ->directory('images') - ->imageEditor() - ->required(), - TextInput::make('alt') - ->label('Описание') - ->placeholder('Необязяательно') - ])->label('Изображение'), - Builder\Block::make('video') - ->schema([ - TextInput::make('mime')->readOnly(), TextInput::make('title') + ->label('Название программы') ->required() ->maxLength(255) - ->autofocus(), - FileUpload::make('path') + ->placeholder('Например: "Цифровые технологии в управлении"') + ->live(onBlur: true) + ->afterStateUpdated(function (string $operation, ?string $state, Forms\Set $set) { + $set('slug', Str::slug($state)); + }) + ->helperText('Полное официальное название программы'), + + TextInput::make('slug') + ->label('URL-адрес') ->required() - ->acceptedFileTypes([ - 'video/mp4', - 'video/quicktime', - 'video/x-msvideo', - 'video/x-ms-wmv', - 'video/avi', - 'video/webm', - 'video/ogg', - 'video/3gpp', - 'video/3gpp2', - 'video/x-m4v', - ]) - ->disk('public') - ->directory('videos') - ->afterStateUpdated(fn (callable $set, $state) => $set('mime', $state?->getMimeType())), + ->readonly() + ->maxLength(255) + ->unique(ignoreRecord: true) + ->helperText('Человеко-понятный URL для страницы программы'), ]), - Builder\Block::make('postsList') + + Select::make('category_id') + ->label('Категория') + ->options(AdditionalEducationCategory::where('is_active', true)->pluck('title', 'id')) + ->required() + ->preload() + ->searchable() + ->placeholder('Выберите категорию') + ->helperText('К какой категории относится программа'), + + TextInput::make('target_group') + ->label('Целевая аудитория') + ->required() + ->maxLength(255) + ->placeholder('Например: "Руководители среднего звена"') + ->columnSpanFull() + ->helperText('Для кого предназначена эта программа'), + + TextInput::make('qualification') + ->label('Выдаваемый документ') + ->required() + ->maxLength(255) + ->placeholder('Например: "Удостоверение о повышении квалификации"') + ->columnSpanFull() + ->helperText('Какой документ получат слушатели'), + ]), + + Section::make('Параметры обучения') + ->schema([ + Grid::make(2) ->schema([ - Forms\Components\Grid::make(2)->schema([ - TextInput::make('count') - ->label('Количество запией') - ->integer(), - Select::make('category') - ->options(Category::all()->pluck('title', 'id')) - ]), - ])->label('Список новостей'), - Builder\Block::make('postItem') - ->schema([ - Select::make('post') - ->options(Post::query()->where('status', PostStatus::PUBLISHED)->pluck('title', 'id')) - ->searchable() - ->required(), - ])->label('Новость'), - Builder\Block::make('pageItem') - ->schema([ - Select::make('page') - ->options(Page::query()->where('title', '!=' , null)->where('is_visible', true)->pluck('title', 'id')) - ->searchable() - ->required(), - ])->label('Страница'), - Builder\Block::make('customForm') - ->schema([ - Select::make('form') - ->options(CustomForm::query()->where('status', CustomFormStatus::PUBLISHED)->pluck('title', 'form_id')) - ->searchable() - ->required(), - ])->label('Форма'), - ]) - ->collapsed() - ->blockNumbers(false) - ->collapsible() - ->blockPickerColumns(3) - ->blockPickerWidth('2xl') - ->addActionLabel('Добавить новый блок'), - ]), - ]), - ]), + TextInput::make('price') + ->label('Стоимость (руб)') + ->required() + ->numeric() + ->minValue(0) + ->placeholder('Укажите стоимость') + ->helperText('Полная стоимость программы'), + + TextInput::make('learning_time') + ->label('Объем (часов)') + ->required() + ->numeric() + ->minValue(1) + ->placeholder('Укажите количество часов') + ->helperText('Общий объем программы в академических часах'), + + Select::make('form_education') + ->label('Форма обучения') + ->options(FormEducation::class) + ->required() + ->native(false) + ->placeholder('Выберите форму') + ->helperText('Основная форма проведения занятий'), + + Toggle::make('is_active') + ->label('Активна для записи') + ->inline(false) + ->default(true) + ->helperText('Отображать ли программу на сайте'), + ]), + ]), + ]), + + Forms\Components\Tabs\Tab::make('Содержание программы') + ->icon('heroicon-o-document-text') + ->schema([ + ContentBuilderItem::getItem('content') + ->label('Описание программы') + ->helperText('Создайте подробное описание программы с помощью конструктора') + ]), + ]), ]); } @@ -447,30 +147,94 @@ class AdditionalEducationResource extends Resource { return $table ->columns([ - TextColumn::make('id')->label('ID')->sortable(), - TextColumn::make('title')->label('Название')->sortable()->searchable(), - TextColumn::make('created_at')->label('Дата создания')->sortable(), - Tables\Columns\BadgeColumn::make('category.title')->label('Категория')->sortable(), - Tables\Columns\ToggleColumn::make('is_active')->label('Активно')->sortable(), + TextColumn::make('title') + ->label('Название') + ->searchable() + ->sortable() + ->description(fn ($record) => $record->target_group) + ->limit(50), + + BadgeColumn::make('category.title') + ->label('Категория') + ->sortable() + ->searchable() + ->color('primary'), + + TextColumn::make('price') + ->label('Стоимость') + ->sortable() + ->money('RUB') + ->alignEnd(), + + TextColumn::make('learning_time') + ->label('Часов') + ->sortable() + ->alignCenter(), + + BadgeColumn::make('form_education') + ->label('Форма') + ->formatStateUsing(fn ($state) => FormEducation::tryFrom($state->value)?->getLabel()) + ->color(fn ($state) => FormEducation::tryFrom($state->value)?->getColor()) + ->sortable(), + + IconColumn::make('is_active') + ->label('Активна') + ->boolean() + ->trueIcon('heroicon-o-check-circle') + ->falseIcon('heroicon-o-x-circle') + ->trueColor('success') + ->falseColor('danger') + ->sortable(), + + TextColumn::make('updated_at') + ->label('Обновлено') + ->dateTime('d.m.Y H:i') + ->sortable() + ->toggleable(isToggledHiddenByDefault: true), ]) ->filters([ - // + Tables\Filters\SelectFilter::make('category_id') + ->label('Категория') + ->options(AdditionalEducationCategory::where('is_active', true)->pluck('title', 'id')) + ->searchable(), + + Tables\Filters\SelectFilter::make('form_education') + ->label('Форма обучения') + ->options(FormEducation::class), + + Tables\Filters\TernaryFilter::make('is_active') + ->label('Только активные') + ->placeholder('Все') + ->trueLabel('Активные') + ->falseLabel('Неактивные'), ]) ->actions([ - Tables\Actions\EditAction::make(), + Tables\Actions\EditAction::make() + ->iconButton() + ->tooltip('Редактировать'), + + Tables\Actions\ViewAction::make() + ->iconButton() + ->tooltip('Просмотреть'), ]) ->bulkActions([ Tables\Actions\BulkActionGroup::make([ - Tables\Actions\DeleteBulkAction::make(), + Tables\Actions\DeleteBulkAction::make() + ->label('Удалить выбранные') + ->modalHeading('Удаление программ') + ->modalDescription('Вы уверены, что хотите удалить выбранные программы ДПО?'), ]), - ]); + ]) + ->emptyStateActions([ + Tables\Actions\CreateAction::make() + ->label('Добавить программу'), + ]) + ->defaultSort('title'); } public static function getRelations(): array { - return [ - // - ]; + return []; } public static function getPages(): array @@ -481,4 +245,4 @@ class AdditionalEducationResource extends Resource 'edit' => Pages\EditAdditionalEducation::route('/{record}/edit'), ]; } -} +} \ No newline at end of file diff --git a/app/Filament/Resources/AdditionalEducationResource/Pages/CreateAdditionalEducation.php b/app/Filament/Resources/AdditionalEducationResource/Pages/CreateAdditionalEducation.php index e8615ed..aed4530 100644 --- a/app/Filament/Resources/AdditionalEducationResource/Pages/CreateAdditionalEducation.php +++ b/app/Filament/Resources/AdditionalEducationResource/Pages/CreateAdditionalEducation.php @@ -3,20 +3,20 @@ namespace App\Filament\Resources\AdditionalEducationResource\Pages; use App\Filament\Resources\AdditionalEducationResource; +use App\Services\Filament\Traits\SeoGenerate; use Filament\Actions; use Filament\Resources\Pages\CreateRecord; use Illuminate\Support\Str; class CreateAdditionalEducation extends CreateRecord { + use SeoGenerate; + protected static string $resource = AdditionalEducationResource::class; - protected array $seoData; protected function mutateFormDataBeforeCreate(array $data): array { - $this->seoData = $this->generateSeo($data); - $data['search_data'] = $this->generateSearchData($data['content']); return $data; @@ -24,32 +24,9 @@ class CreateAdditionalEducation extends CreateRecord protected function afterCreate(): void { - $this->record->seo()->create($this->seoData); + $this->createSeo($this->record); } - private function generateSeo(array $data) : array - { - $title = $data['title']; - $rowData = $this->getFirstBlockByName('paragraph', $data['content']); - if ($rowData !== null) { - $description = strip_tags($rowData['data']['content']); - } else { - $description = null; - } - return [ - 'title' => $title, - 'description' => Str::limit(htmlspecialchars($description, ENT_QUOTES, 'UTF-8'), 160), - ]; - } - private function getFirstBlockByName(string $name, array $content) : array|null - { - $data = null; - foreach ($content as $block) { - $data = ($block['type'] === $name) ? $block : null; - break; - } - return $data; - } private function generateSearchData(array $data) : string { diff --git a/app/Filament/Resources/AdditionalEducationResource/Pages/EditAdditionalEducation.php b/app/Filament/Resources/AdditionalEducationResource/Pages/EditAdditionalEducation.php index fae9c11..ef8b8ef 100644 --- a/app/Filament/Resources/AdditionalEducationResource/Pages/EditAdditionalEducation.php +++ b/app/Filament/Resources/AdditionalEducationResource/Pages/EditAdditionalEducation.php @@ -3,22 +3,22 @@ namespace App\Filament\Resources\AdditionalEducationResource\Pages; use App\Filament\Resources\AdditionalEducationResource; +use App\Services\Filament\Traits\SeoGenerate; use Filament\Actions; use Filament\Resources\Pages\EditRecord; use Illuminate\Support\Str; class EditAdditionalEducation extends EditRecord { + use SeoGenerate; + protected static string $resource = AdditionalEducationResource::class; - protected array $seoData; protected function mutateFormDataBeforeSave(array $data): array { - $this->seoData = $this->generateSeo($data); - $data['search_data'] = $this->generateSearchData($data['content']); return $data; @@ -26,25 +26,9 @@ class EditAdditionalEducation extends EditRecord protected function afterSave(): void { - $this->record->seo()->update($this->seoData); - + $this->updateSeo($this->record); } - private function generateSeo(array $data) : array - { - $title = $data['title']; - $rowData = $this->getFirstBlockByName('paragraph', $data['content']); - if ($rowData !== null) { - $description = strip_tags($rowData['data']['content']); - } else { - $description = null; - } - - return [ - 'title' => $title, - 'description' => Str::limit(htmlspecialchars($description, ENT_QUOTES, 'UTF-8'), 160), - ]; - } private function getFirstBlockByName(string $name, array $content) : array|null { $data = null; diff --git a/app/Filament/Resources/AdmissionCampaignResource.php b/app/Filament/Resources/AdmissionCampaignResource.php index 79fc2e5..70e4d21 100644 --- a/app/Filament/Resources/AdmissionCampaignResource.php +++ b/app/Filament/Resources/AdmissionCampaignResource.php @@ -2,60 +2,138 @@ namespace App\Filament\Resources; -use App\Enums\FormEducation; +use App\Enums\AdmissionCampaignStatus; use App\Enums\LevelEducational; use App\Filament\Resources\AdmissionCampaignResource\Pages; -use App\Filament\Resources\AdmissionCampaignResource\RelationManagers; use App\Models\AdmissionCampaign; use Filament\Forms; +use Filament\Forms\Components\Grid; +use Filament\Forms\Components\Repeater; +use Filament\Forms\Components\Section; +use Filament\Forms\Components\Select; use Filament\Forms\Components\TextInput; use Filament\Forms\Form; use Filament\Resources\Resource; use Filament\Tables; +use Filament\Tables\Columns\BadgeColumn; +use Filament\Tables\Columns\TextColumn; use Filament\Tables\Table; -use Illuminate\Database\Eloquent\Builder; -use Illuminate\Database\Eloquent\SoftDeletingScope; class AdmissionCampaignResource extends Resource { protected static ?string $model = AdmissionCampaign::class; - protected static ?string $navigationGroup = 'Образование'; - protected static ?string $navigationIcon = 'heroicon-o-clipboard-document-check'; - protected static ?string $pluralLabel = 'Приемная-компания'; - + protected static ?string $modelLabel = 'Приемная кампания'; public static function form(Form $form): Form { return $form ->schema([ - Forms\Components\Section::make()->schema([ - TextInput::make('name')->label('Название')->required()->columnSpanFull(), - Forms\Components\Grid::make()->schema([ - Forms\Components\Select::make('academic_year')->label('Академический год')->required() - ->options(self::generateAcademicYears()), - Forms\Components\Select::make('status')->label('Статус')->required() - ->options(['1' => 'Активный', '2' => 'Архивный', '3' => 'Скрыт']), + Section::make('Основные настройки') + ->description('Общая информация о приемной кампании') + ->collapsible() + ->schema([ + TextInput::make('name') + ->label('Название кампании') + ->required() + ->maxLength(255) + ->placeholder('Например: "Приемная кампания 2024"') + ->columnSpanFull() + ->helperText('Укажите понятное название для идентификации кампании'), + + Grid::make(2) + ->schema([ + Select::make('academic_year') + ->label('Академический год') + ->required() + ->options(self::generateAcademicYears()) + ->searchable() + ->placeholder('Выберите учебный год') + ->helperText('Выберите учебный год, к которому относится кампания'), + + Select::make('status') + ->label('Статус кампании') + ->required() + ->options(AdmissionCampaignStatus::class) + ->native(false) + ->placeholder('Выберите статус') + ->helperText('Определяет видимость и доступность кампании'), + ]), + ]), + + Section::make('Информация о наборе') + ->description('Данные о программах и местах для разных уровней образования') + ->collapsible() + ->schema([ + Repeater::make('info') + ->label('') + ->addActionLabel('Добавить уровень образования') + ->schema([ + Select::make('edu_name') + ->label('Уровень образования') + ->options(LevelEducational::class) + ->required() + ->native(false) + ->placeholder('Выберите уровень образования') + ->helperText('Выберите уровень образовательной программы'), + + Grid::make(2) + ->schema([ + Section::make('Программы') + ->schema([ + TextInput::make('total_programs') + ->label('Количество программ') + ->required() + ->numeric() + ->minValue(0) + ->placeholder('Укажите количество') + ->helperText('Общее количество программ по набору'), + ]), + + Section::make('Распределение мест') + ->schema([ + TextInput::make('och_count') + ->label('Очная форма') + ->required() + ->numeric() + ->minValue(0) + ->placeholder('Укажите количество') + ->helperText('Количество мест на очной форме'), + + TextInput::make('zaoch_count') + ->label('Заочная форма') + ->required() + ->numeric() + ->minValue(0) + ->placeholder('Укажите количество') + ->helperText('Количество мест на заочной форме'), + + TextInput::make('budget_places') + ->label('Бюджетные места') + ->required() + ->numeric() + ->minValue(0) + ->placeholder('Укажите количество') + ->helperText('Количество бюджетных мест'), + + TextInput::make('non_budget_places') + ->label('Платные места') + ->required() + ->numeric() + ->minValue(0) + ->placeholder('Укажите количество') + ->helperText('Количество платных мест'), + ]), + ]), + ]) + ->itemLabel(fn (array $state): ?string => + LevelEducational::tryFrom($state['edu_name'] ?? '')?->getLabel() ?? 'Новый уровень') + ->collapsible() + ->cloneable() + ->columnSpanFull(), ]), - ]), - Forms\Components\Section::make()->schema([ - Forms\Components\Repeater::make('info')->schema([ - Forms\Components\Select::make('edu_name')->options(LevelEducational::class), - Forms\Components\Grid::make(2)->schema([ - Forms\Components\Section::make()->schema([ - TextInput::make('total_programs')->label('Количество программ по набору')->integer()->required(), - ]), - Forms\Components\Section::make('Места')->schema([ - TextInput::make('och_count')->label('Количество мест (Очная форма)')->integer()->required(), - TextInput::make('zaoch_count')->label('Количество мест (Заочная форма)')->integer()->required(), - TextInput::make('budget_places')->label('Количество бюджетных мест')->integer()->required(), - TextInput::make('non_budget_places')->label('Количество платных мест')->integer()->required(), - ]), - ]), - ])->columnSpanFull(), - ]), ]); } @@ -63,28 +141,65 @@ class AdmissionCampaignResource extends Resource { return $table ->columns([ - Tables\Columns\TextColumn::make('name'), - Tables\Columns\TextColumn::make('academic_year'), - Tables\Columns\TextColumn::make('status'), + TextColumn::make('name') + ->label('Название') + ->searchable() + ->sortable() + ->description(fn ($record) => $record->academic_year), + + BadgeColumn::make('status') + ->label('Статус') + ->formatStateUsing(fn ($state) => AdmissionCampaignStatus::tryFrom($state)?->getLabel()) + ->color(fn ($state) => AdmissionCampaignStatus::tryFrom($state)?->getColor()) + ->sortable(), + + TextColumn::make('info_count') + ->label('Программ') + ->getStateUsing(fn ($record) => count($record->info ?? [])) + ->badge(), + + TextColumn::make('updated_at') + ->label('Обновлено') + ->dateTime('d.m.Y H:i') + ->sortable() + ->toggleable(isToggledHiddenByDefault: true), ]) ->filters([ - // + Tables\Filters\SelectFilter::make('status') + ->label('Статус') + ->options(AdmissionCampaignStatus::class), + + Tables\Filters\SelectFilter::make('academic_year') + ->label('Учебный год') + ->options(self::generateAcademicYears()), ]) ->actions([ - Tables\Actions\EditAction::make(), + Tables\Actions\EditAction::make() + ->iconButton() + ->tooltip('Редактировать'), + + Tables\Actions\ViewAction::make() + ->iconButton() + ->tooltip('Просмотреть'), ]) ->bulkActions([ Tables\Actions\BulkActionGroup::make([ - Tables\Actions\DeleteBulkAction::make(), + Tables\Actions\DeleteBulkAction::make() + ->label('Удалить выбранные') + ->modalHeading('Удаление приемных кампаний') + ->modalDescription('Вы уверены, что хотите удалить выбранные кампании? Это действие нельзя отменить.'), ]), - ]); + ]) + ->emptyStateActions([ + Tables\Actions\CreateAction::make() + ->label('Создать кампанию'), + ]) + ->defaultSort('academic_year', 'desc'); } public static function getRelations(): array { - return [ - // - ]; + return []; } public static function getPages(): array @@ -99,15 +214,15 @@ class AdmissionCampaignResource extends Resource protected static function generateAcademicYears(): array { $currentYear = (int) date('Y') - 5; - $yearsAhead = 10; // Количество лет вперед + $yearsAhead = 10; $academicYears = []; for ($i = 0; $i < $yearsAhead; $i++) { $startYear = $currentYear + $i; $endYear = $startYear + 1; - $academicYears[$startYear] = "{$startYear}/{$endYear}"; + $academicYears["{$startYear}/{$endYear}"] = "{$startYear}/{$endYear}"; } return $academicYears; } -} +} \ No newline at end of file diff --git a/app/Filament/Resources/AdmissionCampaignResource/Pages/CreateAdmissionCampaign.php b/app/Filament/Resources/AdmissionCampaignResource/Pages/CreateAdmissionCampaign.php index ba38266..602bc11 100644 --- a/app/Filament/Resources/AdmissionCampaignResource/Pages/CreateAdmissionCampaign.php +++ b/app/Filament/Resources/AdmissionCampaignResource/Pages/CreateAdmissionCampaign.php @@ -8,5 +8,6 @@ use Filament\Resources\Pages\CreateRecord; class CreateAdmissionCampaign extends CreateRecord { + protected static string $resource = AdmissionCampaignResource::class; } diff --git a/app/Filament/Resources/AdmissionPlanResource.php b/app/Filament/Resources/AdmissionPlanResource.php index 91f1e2a..9007767 100644 --- a/app/Filament/Resources/AdmissionPlanResource.php +++ b/app/Filament/Resources/AdmissionPlanResource.php @@ -15,7 +15,9 @@ use Filament\Forms; use Filament\Forms\Components\Builder; use Filament\Forms\Components\FileUpload; use Filament\Forms\Components\Hidden; +use Filament\Forms\Components\Repeater; use Filament\Forms\Components\Section; +use Filament\Forms\Components\Select; use Filament\Forms\Components\TextInput; use Filament\Forms\Form; use Filament\Forms\Get; @@ -42,45 +44,127 @@ class AdmissionPlanResource extends Resource return $form ->schema([ Section::make()->schema([ - Forms\Components\Select::make('educational_programs_id') + Select::make('educational_programs_id') + ->label('Приемная кампания') + ->required() + ->columnSpanFull() + ->options(EducationalProgram::whereIn('status', [EducationalProgramStatus::PUBLISHED, EducationalProgramStatus::IN_PROGRESS])->pluck('name', 'id')) ->searchable() - ->label('Образовательная программа') - ->options(EducationalProgram::whereIn('status', [EducationalProgramStatus::PUBLISHED, EducationalProgramStatus::IN_PROGRESS])->pluck('name', 'id')), - Forms\Components\Select::make('admission_campaigns_id') - ->label('Приемная компания') - ->options(AdmissionCampaign::all()->pluck('name', 'id')), - ]), - Section::make('План приема')->schema([ - Forms\Components\Repeater::make('exams')->label('Вступительные испытания')->schema([ - TextInput::make('title')->label('Название-предмета'), - Forms\Components\Select::make('type_exam')->label('Тип-ВИ') - ->options(['ege' => 'ЕГЭ', 'internal_test' => 'ВИ, проводимое организацией самостоятельно']), - TextInput::make('min_score')->label('Минимальный-балл')->integer() - ])->live()->maxItems(2)->collapsed()->addActionLabel('Добавить вступительное испытание')->columns(3) ->itemLabel(function (Get $get) { - static $count = 0; - $maxCount = count($get('exams')); - $count = ($count++ <= $maxCount) ? $count : 1; - return "Вступительное испытание #" . $count; - }), - Forms\Components\Repeater::make('contests')->label('Условия поступления')->schema([ - Forms\Components\Select::make('form_education')->label('Форма обучения') - ->options(FormEducation::class), - Forms\Components\Select::make('financing_source')->label('Источник финансирования') - ->options(BudgetEducation::class), - TextInput::make('position_count')->label('Количество мест на прием')->integer(), - ])->live()->maxItems(2)->collapsed()->addActionLabel('Добавить группу')->columns(3) - ->itemLabel(function (Get $get) { - static $count = 0; - $maxCount = count($get('contests')); - $count = ($count++ <= $maxCount) ? $count : 1; - return "Группа #" . $count; - }), + ->preload() + ->placeholder('Выберите образовательную программу'), + Select::make('admission_campaigns_id') + ->label('Приемная кампания') + ->required() + ->columnSpanFull() + ->options( + AdmissionCampaign::query() + ->orderBy('name') + ->pluck('name', 'id') + ) + ->searchable() + ->preload() + ->placeholder('Выберите приемную кампанию') + ->helperText('Выберите связанную приемную кампанию'), ]), + Section::make('План приема') + ->description('Настройка вступительных испытаний и условий поступления') + ->collapsible() + ->schema([ + self::getExamsRepeater(), + self::getContestsRepeater(), + ]), + ]); } + protected static function getExamsRepeater(): Repeater + { + return Repeater::make('exams') + ->label('Вступительные испытания') + ->schema([ + TextInput::make('title') + ->label('Название предмета') + ->required() + ->maxLength(100) + ->placeholder('Например: Математика') + ->helperText('Название вступительного испытания'), + + Select::make('type_exam') + ->label('Тип испытания') + ->required() + ->options([ + 'ege' => 'ЕГЭ', + 'internal_test' => 'Внутреннее испытание', + ]) + ->native(false) + ->placeholder('Выберите тип') + ->helperText('Тип вступительного испытания'), + + TextInput::make('min_score') + ->label('Минимальный балл') + ->required() + ->numeric() + ->minValue(0) + ->maxValue(100) + ->placeholder('Укажите минимальный балл') + ->helperText('Минимальный проходной балл'), + ]) + ->columns(3) + ->maxItems(10) + ->collapsible() + ->collapsed() + ->addActionLabel('Добавить испытание') + ->itemLabel(fn (array $state): ?string => $state['title'] ?? 'Новое испытание') + ->helperText('Добавьте все необходимые вступительные испытания'); + } + + protected static function getContestsRepeater(): Repeater + { + return Repeater::make('contests') + ->label('Условия поступления') + ->schema([ + Select::make('form_education') + ->label('Форма обучения') + ->options(FormEducation::class) + ->required() + ->native(false) + ->placeholder('Выберите форму') + ->columnSpanFull() + ->helperText('Форма обучения для данной группы'), + + Repeater::make('places') + ->label('Места') + ->schema([ + Select::make('form_budget') + ->label('Форма финансирования') + ->options(BudgetEducation::class) + ->required() + ->native(false) + ->placeholder('Выберите тип') + ->helperText('Бюджетные или платные места'), + + TextInput::make('count') + ->label('Количество мест') + ->required() + ->numeric() + ->minValue(0) + ->placeholder('Укажите количество') + ->helperText('Количество доступных мест'), + ]) + ->columnSpanFull() + ->maxItems(2) + ->addActionLabel('Добавить тип мест') + ]) + ->columns(2) + ->maxItems(3) + ->collapsible() + ->collapsed() + ->addActionLabel('Добавить группу') + ->helperText('Добавьте группы с условиями поступления'); + } + public static function table(Table $table): Table { return $table diff --git a/app/Filament/Resources/ContactWidgetResource.php b/app/Filament/Resources/ContactWidgetResource.php index 4b06a7c..8ff6c40 100644 --- a/app/Filament/Resources/ContactWidgetResource.php +++ b/app/Filament/Resources/ContactWidgetResource.php @@ -24,46 +24,128 @@ class ContactWidgetResource extends Resource { protected static ?string $model = ContactWidget::class; + protected static ?string $pluralLabel = 'Контактная информация'; + + protected static ?string $navigationIcon = 'heroicon-o-rectangle-stack'; + protected static ?string $navigationGroup = 'Виджеты'; + + public static function form(Form $form): Form { return $form ->schema([ - Forms\Components\Section::make('')->schema([ - Tabs::make('Tabs') - ->tabs([ - Tabs\Tab::make('Основная информация') - ->schema([ - Forms\Components\Grid::make(2)->schema([ - TextInput::make('title')->label('Название ресурса')->required() - ->live(onBlur: true) - ->afterStateUpdated(function (string $operation, string|null $state, Forms\Set $set) { - $set('slug', Str::slug($state)); - $set('seo.title', $state); - }), - TextInput::make('slug')->label('Slug')->unique(ignoreRecord: true)->readOnly()->required(), - Toggle::make('is_active')->default(true)->label('Активный ресурс')->inline(false), - ]) - ]), - Tabs\Tab::make('Содержание ресурса') - ->schema([ - Repeater::make('content')->label('Ресурсы')->schema([ - TextInput::make('title')->label('Главный заголовок столбца')->required(), - Repeater::make('items')->label('Контакты')->schema([ - TextInput::make('header')->label('Заголовок')->required(), - Repeater::make('details')->label('Компонент контакта')->schema([ - Forms\Components\Grid::make(2)->schema([ - TextInput::make('content')->label('содержание')->required(), - TextInput::make('url')->label('Ссылка(Необязательно)'), - ]), - ]), - ]), - ])->collapsed()->required(), - ]), + Forms\Components\Section::make('Ресурс') + ->description('Настройка контактных ресурсов') + ->collapsible() + ->schema([ + Tabs::make('Настройки ресурса') + ->persistTabInQueryString() + ->columnSpanFull() + ->tabs([ + Tabs\Tab::make('Основная информация') + ->icon('heroicon-o-information-circle') + ->schema([ + Forms\Components\Grid::make(2) + ->schema([ + TextInput::make('title') + ->label('Название ресурса') + ->placeholder('Введите название ресурса') + ->helperText('Это название будет отображаться в интерфейсе') + ->required() + ->maxLength(255) + ->live(onBlur: true) + ->afterStateUpdated(function (string $operation, string|null $state, Forms\Set $set) { + $set('slug', Str::slug($state)); + $set('seo.title', $state); + }) + ->columnSpan(1), - ]), - ]), + TextInput::make('slug') + ->label('URL-адрес (Slug)') + ->helperText('Автоматически генерируется из названия') + ->hintIcon('heroicon-o-information-circle', tooltip: 'Изменить можно только вручную') + ->unique(ignoreRecord: true) + ->readOnly() + ->required() + ->columnSpan(1), + + Toggle::make('is_active') + ->label('Активность ресурса') + ->helperText('Отключите, чтобы скрыть ресурс') + ->default(true) + ->inline(false) + ->onColor('success') + ->offColor('danger') + ->columnSpanFull(), + ]) + ]), + + Tabs\Tab::make('Содержание ресурса') + ->icon('heroicon-o-document-text') + ->schema([ + Repeater::make('content') + ->label('Структура ресурса') + ->helperText('Добавьте столбцы с контактной информацией') + ->addActionLabel('Добавить столбец') + ->itemLabel(fn (array $state): ?string => $state['title'] ?? 'Новый столбец') + ->collapsible() + ->cloneable() + ->grid(2) + ->schema([ + TextInput::make('title') + ->label('Заголовок столбца') + ->placeholder('Например: Контакты') + ->helperText('Основной заголовок для группы контактов') + ->required() + ->maxLength(255), + + Repeater::make('items') + ->label('Контактные блоки') + ->helperText('Добавьте контактные блоки в этот столбец') + ->addActionLabel('Добавить контактный блок') + ->itemLabel(fn (array $state): ?string => $state['header'] ?? 'Новый контакт') + ->collapsible() + ->cloneable() + ->schema([ + TextInput::make('header') + ->label('Заголовок контакта') + ->placeholder('Например: Телефон') + ->helperText('Название контактной информации') + ->required() + ->maxLength(255), + + Repeater::make('details') + ->label('Детали контакта') + ->helperText('Добавьте контактные данные') + ->addActionLabel('Добавить деталь') + ->collapsible() + ->cloneable() + ->schema([ + Forms\Components\Grid::make(2) + ->schema([ + TextInput::make('content') + ->label('Значение') + ->placeholder('Например: +7 (123) 456-78-90') + ->helperText('Основная контактная информация') + ->columnSpanFull() + ->required(), + + TextInput::make('url') + ->label('Ссылка') + ->placeholder('https://example.com') + ->helperText('Необязательная ссылка, связанная с контактом') + ->url() + ->columnSpanFull(), + ]) + ]) + ]) + ]) + ->required(), + ]), + ]), + ]), ]); } diff --git a/app/Filament/Resources/CustomFormResource.php b/app/Filament/Resources/CustomFormResource.php index 0e2d687..ad93ab1 100644 --- a/app/Filament/Resources/CustomFormResource.php +++ b/app/Filament/Resources/CustomFormResource.php @@ -40,6 +40,9 @@ class CustomFormResource extends Resource public static ?string $label = 'Форма'; protected static ?string $pluralLabel = 'Пользовательские формы'; + protected static ?string $navigationGroup = 'Виджеты'; + + protected static ?string $model = CustomForm::class; diff --git a/app/Filament/Resources/DepartmentResource.php b/app/Filament/Resources/DepartmentResource.php index 1605319..0c0421f 100644 --- a/app/Filament/Resources/DepartmentResource.php +++ b/app/Filament/Resources/DepartmentResource.php @@ -2,476 +2,155 @@ namespace App\Filament\Resources; -use App\Enums\CustomFormStatus; -use App\Enums\PostStatus; +use App\Filament\Components\Forms\ItemForm\Pages\ContentBuilderItem; use App\Filament\Resources\DepartmentResource\Pages; use App\Filament\Resources\DepartmentResource\RelationManagers; -use App\Helpers\ByteConverter; -use App\Models\Category; -use App\Models\CustomForm; use App\Models\Department; use App\Models\Faculty; -use App\Models\Page; -use App\Models\PageReferenceList; -use App\Models\Post; use Filament\Forms; -use Filament\Forms\Components\Builder; -use Filament\Forms\Components\FileUpload; -use Filament\Forms\Components\Hidden; -use Filament\Forms\Components\RichEditor; use Filament\Forms\Components\Section; use Filament\Forms\Components\Select; -use Filament\Forms\Components\Tabs; use Filament\Forms\Components\TextInput; use Filament\Forms\Components\Toggle; use Filament\Forms\Form; use Filament\Resources\Resource; use Filament\Tables; +use Filament\Tables\Columns\IconColumn; use Filament\Tables\Columns\TextColumn; use Filament\Tables\Table; -use Illuminate\Database\Eloquent\SoftDeletingScope; -use Illuminate\Support\Carbon; use Illuminate\Support\Str; -use Livewire\Features\SupportFileUploads\TemporaryUploadedFile; class DepartmentResource extends Resource { protected static ?string $model = Department::class; - protected static ?string $navigationGroup = 'Структура института'; - - protected static ?string $navigationIcon = 'heroicon-o-rectangle-stack'; - + protected static ?string $navigationIcon = 'heroicon-o-building-office'; protected static ?string $pluralLabel = 'Кафедры'; - - public static ?string $label = 'Кафедра'; - - + protected static ?string $modelLabel = 'кафедра'; protected static ?string $navigationParentItem = 'Факультеты'; public static function form(Form $form): Form { return $form ->schema([ - Section::make() - ->schema([ - Tabs::make('Tabs') - ->tabs([ - Tabs\Tab::make('Основная информация') + Forms\Components\Tabs::make('Настройки кафедры') + ->persistTabInQueryString() + ->columnSpanFull() + ->tabs([ + Forms\Components\Tabs\Tab::make('Основные данные') + ->icon('heroicon-o-information-circle') + ->schema([ + Section::make('Идентификация') + ->description('Основная информация о кафедре') ->schema([ - Forms\Components\Grid::make()->schema([ - TextInput::make('title')->label('Название факультета')->required() - ->live(onBlur: true) - ->afterStateUpdated(function (string $operation, string|null $state, Forms\Set $set) { - $set('slug', Str::slug($state)); - $set('seo.title', $state); - }), - TextInput::make('slug')->label('Slug')->unique(ignoreRecord: true)->readOnly()->required(), - ]), - Toggle::make('is_active')->default(true)->label('Активный факультет')->inline(false), - Forms\Components\Select::make('faculty_id') - ->options(Faculty::all()->pluck('title', 'id')) - ->label('Факультет') - ->required(), - ]), - Tabs\Tab::make('Описание факультета') - ->schema([ - Builder::make('content')->label('')->blocks([ - Builder\Block::make('heading')->label('Заголовок') - ->schema([ - TextInput::make('id')->hidden()->integer()->default(rand(2335235,324634264263426)), - TextInput::make('content') - ->label('') - ->live(onBlur: true) - ->afterStateUpdated(function (string $operation, string $state, Forms\Set $set, $get) { - }), - ]), - Builder\Block::make('paragraph') - ->schema([ - RichEditor::make('content') - ->toolbarButtons([ - 'blockquote', - 'bold', - 'bulletList', - 'italic', - 'link', - 'orderedList', - 'redo', - 'strike', - 'underline', - 'undo', - ]) - ->label(''), - ])->label('Текст'), - Builder\Block::make('files') - ->schema([ - Forms\Components\Repeater::make('file')->schema([ - Hidden::make('expansion')->required(), - Hidden::make('size')->required(), - TextInput::make('title') - ->required() - ->maxLength(255) - ->autofocus(), - FileUpload::make('path') - ->required() - ->getUploadedFileNameForStorageUsing( - fn (TemporaryUploadedFile $file): string => - str(Str::slug(pathinfo($file->getClientOriginalName(), PATHINFO_FILENAME) . '-' . Carbon::now()->timestamp ) . '.' . $file->getClientOriginalExtension()) - ) - ->acceptedFileTypes([ - 'application/pdf', - 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', - 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', - 'application/vnd.openxmlformats-officedocument.presentationml.presentation', - 'application/zip' - ]) - ->maxSize(512000) - ->disk('public') - ->directory('files') - ->downloadable() - ->afterStateUpdated(function ($set, $state) { - $set('expansion', $state?->getClientOriginalExtension()); - $set('size', ByteConverter::bytesToHuman($state?->getSize())); - }) - ->visibility('public') - ]), - ]), - Builder\Block::make('person') - ->schema([ - TextInput::make('name') - ->label('Имя') - ->required() - ->maxLength(255), - FileUpload::make('photo') - ->label('Фотография') - ->image() - ->disk('public') - ->directory('images') - ->imageEditor(), - Forms\Components\Repeater::make('info')->schema([ - Forms\Components\Grid::make(2)->schema([ - TextInput::make('column') - ->required() - ->maxLength(255), - TextInput::make('content') - ->required() - ->maxLength(255), - ]), - ])->minItems(1), - ]), - Builder\Block::make('stepper') - ->schema([ - TextInput::make('step_name') - ->label('Название шага') - ->required() - ->maxLength(255), - Forms\Components\Repeater::make('steps')->schema([ - TextInput::make('title') - ->required() - ->maxLength(255)->columnSpanFull(), - RichEditor::make('content')->required(), - ])->minItems(1), - ]), - Builder\Block::make('tabs') - ->schema([ - Forms\Components\Repeater::make('tab')->schema([ - TextInput::make('title') - ->required() - ->maxLength(255)->columnSpanFull(), - \Filament\Forms\Components\Builder::make('content')->label('')->blocks([ - Builder\Block::make('heading')->label('Заголовок') - ->schema([ - TextInput::make('id')->hidden()->integer()->default(rand(2335235,324634264263426)), - TextInput::make('content') - ->label('') - ->live(onBlur: true) - ->afterStateUpdated(function (string $operation, string $state, Forms\Set $set, $get) { - }), - ]), - Builder\Block::make('paragraph') - ->schema([ - RichEditor::make('content') - ->toolbarButtons([ - 'blockquote', - 'bold', - 'bulletList', - 'italic', - 'link', - 'orderedList', - 'redo', - 'strike', - 'underline', - 'undo', - ]) - ->label(''), - ])->label('Текст'), - Builder\Block::make('files') - ->schema([ - Forms\Components\Repeater::make('file')->schema([ - TextInput::make('title') - ->required() - ->maxLength(255) - ->autofocus(), - FileUpload::make('path') - ->required() - ->acceptedFileTypes([ - 'application/pdf', - 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', - 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', - 'application/vnd.openxmlformats-officedocument.presentationml.presentation', - 'application/zip' - ]) - ->maxSize(512000) - ->disk('public') - ->directory('files') - ->downloadable() - ->visibility('public') - ]), - ]), - Builder\Block::make('person') - ->schema([ - TextInput::make('name') - ->label('Имя') - ->required() - ->maxLength(255), - FileUpload::make('photo') - ->label('Фотография') - ->image() - ->disk('public') - ->directory('images') - ->imageEditor(), - Forms\Components\Repeater::make('info')->schema([ - Forms\Components\Grid::make(2)->schema([ - TextInput::make('column') - ->required() - ->maxLength(255), - TextInput::make('content') - ->required() - ->maxLength(255), - ]), - ])->minItems(1), - ]), - Builder\Block::make('stepper') - ->schema([ - TextInput::make('step_name') - ->label('Название шага') - ->required() - ->maxLength(255), - Forms\Components\Repeater::make('steps')->schema([ - TextInput::make('title') - ->required() - ->maxLength(255)->columnSpanFull(), - RichEditor::make('content')->required(), - ])->minItems(1), - ]), - Builder\Block::make('images') - ->schema([ - FileUpload::make('url') - ->label('Изображение(-я)') - ->image() - ->multiple() - ->reorderable() - ->maxFiles(5) - ->disk('public') - ->directory('images') - ->imageEditor() - ->required(), - TextInput::make('alt') - ->label('Описание') - ->placeholder('Необязяательно') - ])->label('Слайдер изображений'), - Builder\Block::make('image') - ->schema([ - FileUpload::make('url') - ->label('Изображение(-я)') - ->image() - ->multiple() - ->reorderable() - ->maxFiles(5) - ->disk('public') - ->directory('images') - ->imageEditor() - ->required(), - TextInput::make('alt') - ->label('Описание') - ->placeholder('Необязяательно') - ])->label('Изображение'), - Builder\Block::make('video') - ->schema([ - TextInput::make('mime')->readOnly(), - TextInput::make('title') - ->required() - ->maxLength(255) - ->autofocus(), - FileUpload::make('path') - ->required() - ->acceptedFileTypes([ - 'video/mp4', - 'video/quicktime', - 'video/x-msvideo', - 'video/x-ms-wmv', - 'video/avi', - 'video/webm', - 'video/ogg', - 'video/3gpp', - 'video/3gpp2', - 'video/x-m4v', - ]) - ->disk('public') - ->directory('videos') - ->afterStateUpdated(fn (callable $set, $state) => $set('mime', $state?->getMimeType())), - ]), - Builder\Block::make('postsList') - ->schema([ - Forms\Components\Grid::make(2)->schema([ - TextInput::make('count') - ->label('Количество запией') - ->integer(), - Select::make('category') - ->options(Category::all()->pluck('title', 'id')) - ]), - ])->label('Список новостей'), - ]) - ->collapsed() - ->blockNumbers(false) - ->collapsible() - ->addActionLabel('Добавить новый блок'), - ])->minItems(1), - ]), - Builder\Block::make('images') - ->schema([ - FileUpload::make('url') - ->label('Изображение(-я)') - ->image() - ->multiple() - ->reorderable() - ->maxFiles(5) - ->disk('public') - ->directory('images') - ->imageEditor() - ->required(), - TextInput::make('alt') - ->label('Описание') - ->placeholder('Необязяательно') - ])->label('Слайдер изображений'), - Builder\Block::make('image') - ->schema([ - FileUpload::make('url') - ->label('Изображение(-я)') - ->image() - ->multiple() - ->reorderable() - ->maxFiles(5) - ->disk('public') - ->directory('images') - ->imageEditor() - ->required(), - TextInput::make('alt') - ->label('Описание') - ->placeholder('Необязяательно') - ])->label('Изображение'), - Builder\Block::make('video') - ->schema([ - TextInput::make('mime')->readOnly(), - TextInput::make('title') - ->required() - ->maxLength(255) - ->autofocus(), - FileUpload::make('path') - ->required() - ->acceptedFileTypes([ - 'video/mp4', - 'video/quicktime', - 'video/x-msvideo', - 'video/x-ms-wmv', - 'video/avi', - 'video/webm', - 'video/ogg', - 'video/3gpp', - 'video/3gpp2', - 'video/x-m4v', - ]) - ->disk('public') - ->directory('videos') - ->afterStateUpdated(fn (callable $set, $state) => $set('mime', $state?->getMimeType())), - ]), - Builder\Block::make('postsList') - ->schema([ - Forms\Components\Grid::make(2)->schema([ - TextInput::make('count') - ->label('Количество запией') - ->integer(), - Select::make('category') - ->options(Category::all()->pluck('title', 'id')) - ]), - ])->label('Список новостей'), - Builder\Block::make('postItem') - ->schema([ - Select::make('post') - ->options(Post::query()->where('status', PostStatus::PUBLISHED)->pluck('title', 'id')) - ->searchable() - ->required(), - ])->label('Новость'), - Builder\Block::make('pageItem') - ->schema([ - Select::make('page') - ->options(Page::query()->where('title', '!=' , null)->where('is_visible', true)->pluck('title', 'id')) - ->searchable() - ->required(), - ])->label('Страница'), - Builder\Block::make('customForm') - ->schema([ - Select::make('form') - ->options(CustomForm::query()->where('status', CustomFormStatus::PUBLISHED)->pluck('title', 'form_id')) - ->searchable() - ->required(), - ])->label('Форма'), - Builder\Block::make('pageResourceList') - ->schema([ - Select::make('resource') - ->options(PageReferenceList::query()->where('is_active', true)->pluck('title', 'slug')) - ->searchable() - ->required(), - ])->label('Ресурсы'), - ]) - ->collapsed() - ->blockNumbers(false) - ->collapsible() + TextInput::make('title') + ->label('Полное название') ->required() - ->blockPickerColumns(3) - ->blockPickerWidth('2xl') - ->addActionLabel('Добавить новый блок'), + ->maxLength(255) + ->placeholder('Например: Кафедра программной инженерии') + ->live(onBlur: true) + ->afterStateUpdated(function (string $operation, ?string $state, Forms\Set $set) { + $set('slug', Str::slug($state)); + }) + ->helperText('Официальное название кафедры'), + + TextInput::make('slug') + ->label('URL-идентификатор') + ->required() + ->maxLength(255) + ->unique(ignoreRecord: true) + ->helperText('Человеко-понятный URL для страницы кафедры'), + + Select::make('faculty_id') + ->label('Факультет') + ->options(Faculty::query()->orderBy('title')->pluck('title', 'id')) + ->searchable() + ->preload() + ->required() + ->native(false) + ->helperText('К какому факультету относится кафедра'), + + Toggle::make('is_active') + ->label('Активная кафедра') + ->inline(false) + ->default(true) + ->helperText('Отображать ли кафедру на сайте'), ]), ]), - ]) + + Forms\Components\Tabs\Tab::make('Контент') + ->icon('heroicon-o-document-text') + ->schema([ + ContentBuilderItem::getItem('content') + ]), + ]), ]); - - } - - public static function table(Table $table): Table { return $table ->columns([ - TextColumn::make('id')->label('ID')->sortable(), - TextColumn::make('title')->label('Название')->sortable()->searchable(), - Tables\Columns\TextColumn::make('faculty.title')->label('Факультет')->words(2), - TextColumn::make('created_at')->label('Дата создания')->sortable(), - Tables\Columns\ToggleColumn::make('is_active')->label('Активно')->sortable(), + TextColumn::make('title') + ->label('Название') + ->searchable() + ->sortable() + ->description(fn ($record) => $record->faculty->abbreviation ?? ''), + + TextColumn::make('faculty.title') + ->label('Факультет') + ->sortable() + ->toggleable(isToggledHiddenByDefault: false), + + IconColumn::make('is_active') + ->label('Статус') + ->boolean() + ->trueIcon('heroicon-o-check-circle') + ->falseIcon('heroicon-o-x-circle') + ->trueColor('success') + ->falseColor('danger') + ->sortable(), + + TextColumn::make('updated_at') + ->label('Обновлено') + ->dateTime('d.m.Y H:i') + ->sortable() + ->toggleable(isToggledHiddenByDefault: true), ]) ->filters([ - // + Tables\Filters\SelectFilter::make('faculty_id') + ->label('Факультет') + ->options(Faculty::query()->orderBy('title')->pluck('title', 'id')) + ->searchable(), + + Tables\Filters\TernaryFilter::make('is_active') + ->label('Только активные') + ->placeholder('Все') + ->trueLabel('Активные') + ->falseLabel('Неактивные'), ]) ->actions([ - Tables\Actions\EditAction::make(), + Tables\Actions\EditAction::make() + ->iconButton() + ->tooltip('Редактировать'), + + Tables\Actions\ViewAction::make() + ->iconButton() + ->tooltip('Просмотреть'), ]) ->bulkActions([ Tables\Actions\BulkActionGroup::make([ - Tables\Actions\DeleteBulkAction::make(), + Tables\Actions\DeleteBulkAction::make() + ->label('Удалить выбранные') + ->modalHeading('Удаление кафедр') + ->modalDescription('Вы уверены, что хотите удалить выбранные кафедры?'), ]), - ]); + ]) + ->emptyStateActions([ + Tables\Actions\CreateAction::make() + ->label('Добавить кафедру'), + ]) + ->defaultSort('title'); } public static function getRelations(): array @@ -491,4 +170,4 @@ class DepartmentResource extends Resource 'edit' => Pages\EditDepartment::route('/{record}/edit'), ]; } -} +} \ No newline at end of file diff --git a/app/Filament/Resources/DepartmentResource/Pages/CreateDepartment.php b/app/Filament/Resources/DepartmentResource/Pages/CreateDepartment.php index 299b5e3..43ad9f7 100644 --- a/app/Filament/Resources/DepartmentResource/Pages/CreateDepartment.php +++ b/app/Filament/Resources/DepartmentResource/Pages/CreateDepartment.php @@ -3,41 +3,27 @@ namespace App\Filament\Resources\DepartmentResource\Pages; use App\Filament\Resources\DepartmentResource; +use App\Services\Filament\Traits\SeoGenerate; use Filament\Actions; use Filament\Resources\Pages\CreateRecord; use Illuminate\Support\Str; class CreateDepartment extends CreateRecord { + use SeoGenerate; + protected static string $resource = DepartmentResource::class; - protected array $seoData; protected function mutateFormDataBeforeCreate(array $data): array { - $this->seoData = $this->generateSeo($data); $data['search_data'] = $this->generateSearchData($data['content']); return $data; } protected function afterCreate(): void { - $this->record->seo()->create($this->seoData); - } - - private function generateSeo(array $data) : array - { - $title = $data['title']; - $rowData = $this->getFirstBlockByName('paragraph', $data['content']); - if ($rowData !== null) { - $description = strip_tags($rowData['data']['content']); - } else { - $description = null; - } - return [ - 'title' => $title, - 'description' => Str::limit(htmlspecialchars($description, ENT_QUOTES, 'UTF-8'), 160), - ]; + $this->createSeo($this->record); } private function generateSearchData(array $data) : string @@ -52,15 +38,6 @@ class CreateDepartment extends CreateRecord return strtolower($result); } - private function getFirstBlockByName(string $name, array $content) : array|null - { - $data = null; - foreach ($content as $block) { - $data = ($block['type'] === $name) ? $block : null; - break; - } - return $data; - } private function getDataFromBlocks($block) : string diff --git a/app/Filament/Resources/DepartmentResource/Pages/EditDepartment.php b/app/Filament/Resources/DepartmentResource/Pages/EditDepartment.php index 5fb2f96..4e3e001 100644 --- a/app/Filament/Resources/DepartmentResource/Pages/EditDepartment.php +++ b/app/Filament/Resources/DepartmentResource/Pages/EditDepartment.php @@ -3,20 +3,20 @@ namespace App\Filament\Resources\DepartmentResource\Pages; use App\Filament\Resources\DepartmentResource; +use App\Services\Filament\Traits\SeoGenerate; use Filament\Actions; use Filament\Resources\Pages\EditRecord; use Illuminate\Support\Str; class EditDepartment extends EditRecord { - protected static string $resource = DepartmentResource::class; + use SeoGenerate; - protected array $seoData; + protected static string $resource = DepartmentResource::class; protected function mutateFormDataBeforeSave(array $data): array { - $this->seoData = $this->generateSeo($data); $data['search_data'] = $this->generateSearchData($data['content']); return $data; @@ -24,24 +24,7 @@ class EditDepartment extends EditRecord protected function afterSave(): void { - $this->record->seo()->update($this->seoData); - } - - - - private function generateSeo(array $data) : array - { - $title = $data['title']; - $rowData = $this->getFirstBlockByName('paragraph', $data['content']); - if ($rowData !== null) { - $description = strip_tags($rowData['data']['content']); - } else { - $description = null; - } - return [ - 'title' => $title, - 'description' => Str::limit(htmlspecialchars($description, ENT_QUOTES, 'UTF-8'), 160), - ]; + $this->updateSeo($this->record); } private function getDataFromBlocks($block) : string diff --git a/app/Filament/Resources/DepartmentResource/RelationManagers/ProgramsRelationManager.php b/app/Filament/Resources/DepartmentResource/RelationManagers/ProgramsRelationManager.php index ca343a2..b09e89e 100644 --- a/app/Filament/Resources/DepartmentResource/RelationManagers/ProgramsRelationManager.php +++ b/app/Filament/Resources/DepartmentResource/RelationManagers/ProgramsRelationManager.php @@ -15,14 +15,22 @@ use Illuminate\Database\Eloquent\SoftDeletingScope; class ProgramsRelationManager extends RelationManager { protected static string $relationship = 'programs'; + protected static ?string $title = 'Образовательные программы кафедры'; public function form(Form $form): Form { return $form ->schema([ - Forms\Components\TextInput::make('name') - ->required() - ->maxLength(255), + Forms\Components\Section::make('Основная информация') + ->description('Связь образовательной программы с кафедрой') + ->schema([ + Forms\Components\TextInput::make('name') + ->label('Название программы') + ->required() + ->maxLength(255) + ->placeholder('Например: Информатика и вычислительная техника') + ->helperText('Полное название образовательной программы'), + ]) ]); } @@ -31,24 +39,65 @@ class ProgramsRelationManager extends RelationManager return $table ->recordTitleAttribute('name') ->columns([ - Tables\Columns\TextColumn::make('name'), + Tables\Columns\TextColumn::make('name') + ->label('Название программы') + ->searchable() + ->sortable() + ->wrap(), + + Tables\Columns\TextColumn::make('status') + ->label('Статус') + ->badge() + ->formatStateUsing(fn($state): string => EducationalProgramStatus::tryFrom($state)->getLabel()) + ->color(fn($state): string => EducationalProgramStatus::tryFrom($state)->getColor()) + ->sortable(), ]) ->filters([ - // + Tables\Filters\SelectFilter::make('status') + ->label('Статус программы') + ->options(EducationalProgramStatus::class) + ->default(EducationalProgramStatus::PUBLISHED->value), ]) ->headerActions([ AttachAction::make() - ->recordSelectOptionsQuery(fn (Builder $query) => $query->where('status', EducationalProgramStatus::PUBLISHED)), - + ->label('Добавить программу') + ->modalHeading('Добавление программы к кафедре') + ->modalSubmitActionLabel('Добавить') + ->preloadRecordSelect() + ->recordSelectOptionsQuery(fn(Builder $query) => $query->where('status', EducationalProgramStatus::PUBLISHED)) + ->recordSelect( + fn(Forms\Components\Select $select) => $select + ->placeholder('Выберите программу') + ->label('Образовательная программа') + ->helperText('Только опубликованные программы') + ->searchable() + ->columnSpanFull() + ) ]) ->actions([ - Tables\Actions\EditAction::make(), - Tables\Actions\DetachAction::make(), + + Tables\Actions\DetachAction::make() + ->iconButton() + ->tooltip('Открепить программу') + ->modalHeading('Открепление программы') + ->modalSubmitActionLabel('Открепить') + ->modalDescription('Вы уверены, что хотите открепить эту программу от кафедры?'), ]) ->bulkActions([ Tables\Actions\BulkActionGroup::make([ - Tables\Actions\DetachBulkAction::make(), + Tables\Actions\DetachBulkAction::make() + ->label('Открепить выбранные') + ->modalHeading('Открепление программ') + ->modalSubmitActionLabel('Открепить') + ->modalDescription('Вы уверены, что хотите открепить выбранные программы от кафедры?'), ]), - ]); + ]) + ->emptyStateActions([ + AttachAction::make() + ->label('Добавить программу'), + ]) + ->defaultSort('name') + ->deferLoading() + ->persistFiltersInSession(); } -} +} \ No newline at end of file diff --git a/app/Filament/Resources/DepartmentResource/RelationManagers/TeachersRelationManager.php b/app/Filament/Resources/DepartmentResource/RelationManagers/TeachersRelationManager.php index 16f8815..a11d8cd 100644 --- a/app/Filament/Resources/DepartmentResource/RelationManagers/TeachersRelationManager.php +++ b/app/Filament/Resources/DepartmentResource/RelationManagers/TeachersRelationManager.php @@ -14,21 +14,48 @@ use Illuminate\Database\Eloquent\SoftDeletingScope; class TeachersRelationManager extends RelationManager { protected static string $relationship = 'teachers'; - protected static ?string $inverseRelationship = 'departments_teach'; - - protected static ?string $title = 'Преподаватели кафедры'; - public function form(Form $form): Form { return $form ->schema([ - Forms\Components\TextInput::make('teaching_position')->label('Преподавательская должность')->required(), - Forms\Components\TextInput::make('service_email')->label('Служебная почта'), - Forms\Components\TextInput::make('service_phone')->label('Служебный телефон'), - Forms\Components\TextInput::make('cabinet')->label('Кабинет'), + Forms\Components\Section::make('Информация о преподавателе') + ->description('Основные данные о работе преподавателя на кафедре') + ->schema([ + Forms\Components\TextInput::make('teaching_position') + ->label('Преподавательская должность') + ->required() + ->maxLength(255) + ->placeholder('Например: Профессор') + ->helperText('Официальная преподавательская должность'), + + Forms\Components\TextInput::make('service_email') + ->label('Служебная почта') + ->email() + ->maxLength(255) + ->placeholder('example@university.edu') + ->helperText('Корпоративная электронная почта'), + + Forms\Components\TextInput::make('service_phone') + ->label('Служебный телефон') + ->tel() + ->maxLength(20) + ->placeholder('+7 (XXX) XXX-XX-XX') + ->helperText('Формат: +7 (XXX) XXX-XX-XX') + ->regex('/^\+?[0-9\s\-\(\)]{7,}$/') + ->validationMessages([ + 'regex' => 'Пожалуйста, введите корректный номер телефона. Допустимые форматы: +7 (XXX) XXX-XX-XX или XXX-XX-XX', + ]), + + Forms\Components\TextInput::make('cabinet') + ->label('Кабинет') + ->maxLength(10) + ->placeholder('Например: 305а') + ->helperText('Номер кабинета преподавателя'), + ]) + ->columns(2), ]); } @@ -37,29 +64,105 @@ class TeachersRelationManager extends RelationManager return $table ->recordTitleAttribute('name') ->columns([ - Tables\Columns\TextColumn::make('name'), + Tables\Columns\TextColumn::make('name') + ->label('ФИО') + ->searchable() + ->sortable(), + + Tables\Columns\TextColumn::make('teaching_position') + ->label('Должность') + ->searchable() + ->sortable() + ->wrap(), + + Tables\Columns\TextColumn::make('service_email') + ->label('Почта') + ->searchable() + ->toggleable(isToggledHiddenByDefault: true), + + Tables\Columns\TextColumn::make('cabinet') + ->label('Кабинет') + ->sortable() + ->toggleable(), ]) ->filters([ - // ]) ->headerActions([ AttachAction::make() + ->preloadRecordSelect() + ->recordSelectOptionsQuery(fn (Builder $query) => $query->has('userDetail')) ->form(fn (AttachAction $action): array => [ - $action->getRecordSelect(), - Forms\Components\TextInput::make('teaching_position')->label('Преподавательская должность')->required(), - Forms\Components\TextInput::make('service_email')->label('Служебная почта'), - Forms\Components\TextInput::make('service_phone')->label('Служебный телефон'), - Forms\Components\TextInput::make('cabinet')->label('Кабинет'), + Forms\Components\Section::make('') + ->schema([ + $action->getRecordSelect() + ->placeholder('Выбрать преподавателя') + ->columnSpanFull() + ->searchable() + ->preload() + ->helperText('Выберите преподавателя') + ->required(), + + Forms\Components\TextInput::make('teaching_position') + ->label('Преподавательская должность') + ->required() + ->maxLength(255) + ->placeholder('Например: Профессор') + ->helperText('Официальная преподавательская должность'), + + Forms\Components\TextInput::make('service_email') + ->label('Служебная почта') + ->email() + ->maxLength(255) + ->placeholder('example@university.edu') + ->helperText('Корпоративная электронная почта'), + + Forms\Components\TextInput::make('service_phone') + ->label('Служебный телефон') + ->tel() + ->maxLength(20) + ->placeholder('+7 (XXX) XXX-XX-XX') + ->helperText('Формат: +7 (XXX) XXX-XX-XX') + ->regex('/^\+?[0-9\s\-\(\)]{7,}$/') + ->validationMessages([ + 'regex' => 'Пожалуйста, введите корректный номер телефона. Допустимые форматы: +7 (XXX) XXX-XX-XX или XXX-XX-XX', + ]), + + Forms\Components\TextInput::make('cabinet') + ->label('Кабинет') + ->maxLength(10) + ->placeholder('Например: 305а') + ->helperText('Номер кабинета преподавателя'), + ]) + ->columns(1), ]) + ->modalSubmitActionLabel('Добавить') ]) ->actions([ - Tables\Actions\EditAction::make(), - Tables\Actions\DetachAction::make(), + Tables\Actions\EditAction::make() + ->iconButton() + ->tooltip('Редактировать'), + + Tables\Actions\DetachAction::make() + ->iconButton() + ->tooltip('Убрать с кафедры') + ->modalHeading('Удаление связи') + ->modalSubmitActionLabel('Убрать') + ->modalDescription('Вы уверены, что хотите убрать этого преподавателя с кафедры?'), ]) ->bulkActions([ Tables\Actions\BulkActionGroup::make([ - Tables\Actions\DetachBulkAction::make(), + Tables\Actions\DetachBulkAction::make() + ->label('Убрать выбранных') + ->modalHeading('Удаление связей') + ->modalSubmitActionLabel('Убрать') + ->modalDescription('Вы уверены, что хотите убрать выбранных преподавателей с кафедры?'), ]), - ]); + ]) + ->emptyStateActions([ + AttachAction::make() + ->label('Добавить преподавателя'), + ]) + ->defaultSort('name') + ->deferLoading(); } -} +} \ No newline at end of file diff --git a/app/Filament/Resources/DepartmentResource/RelationManagers/WorkersRelationManager.php b/app/Filament/Resources/DepartmentResource/RelationManagers/WorkersRelationManager.php index 6c08b63..b13ca2e 100644 --- a/app/Filament/Resources/DepartmentResource/RelationManagers/WorkersRelationManager.php +++ b/app/Filament/Resources/DepartmentResource/RelationManagers/WorkersRelationManager.php @@ -14,20 +14,48 @@ use Illuminate\Database\Eloquent\SoftDeletingScope; class WorkersRelationManager extends RelationManager { protected static string $relationship = 'workers'; - protected static ?string $inverseRelationship = 'departments_work'; - protected static ?string $title = 'Сотрудники кафедры'; - public function form(Form $form): Form { return $form ->schema([ - Forms\Components\TextInput::make('position')->label('Должность')->required(), - Forms\Components\TextInput::make('service_email')->label('Служебная почта'), - Forms\Components\TextInput::make('service_phone')->label('Служебный телефон'), - Forms\Components\TextInput::make('cabinet')->label('Кабинет'), + Forms\Components\Section::make('Информация о должности') + ->description('Основные данные о работе сотрудника на кафедре') + ->schema([ + Forms\Components\TextInput::make('position') + ->label('Должность') + ->required() + ->maxLength(255) + ->placeholder('Например: Заведующий кафедрой') + ->helperText('Официальная должность сотрудника на кафедре'), + + Forms\Components\TextInput::make('service_email') + ->label('Служебная почта') + ->email() + ->maxLength(255) + ->placeholder('example@university.edu') + ->helperText('Корпоративная электронная почта'), + + Forms\Components\TextInput::make('service_phone') + ->label('Служебный телефон') + ->tel() + ->maxLength(20) + ->placeholder('+7 (XXX) XXX-XX-XX') + ->helperText('Формат: +7 (XXX) XXX-XX-XX') + ->regex('/^\+?[0-9\s\-\(\)]{7,}$/') // Разрешаем +, цифры, пробелы, дефисы, скобки + ->validationMessages([ + 'regex' => 'Пожалуйста, введите корректный номер телефона. Допустимые форматы: +7 (XXX) XXX-XX-XX или XXX-XX-XX', + ]), + + Forms\Components\TextInput::make('cabinet') + ->label('Кабинет') + ->maxLength(10) + ->placeholder('Например: 305а') + ->helperText('Номер кабинета сотрудника'), + ]) + ->columns(2), ]); } @@ -36,30 +64,105 @@ class WorkersRelationManager extends RelationManager return $table ->recordTitleAttribute('name') ->columns([ - Tables\Columns\TextColumn::make('name'), - Tables\Columns\TextColumn::make('position'), + Tables\Columns\TextColumn::make('name') + ->label('ФИО') + ->searchable() + ->sortable(), + + Tables\Columns\TextColumn::make('position') + ->label('Должность') + ->searchable() + ->sortable() + ->wrap(), + + Tables\Columns\TextColumn::make('service_email') + ->label('Почта') + ->searchable() + ->toggleable(isToggledHiddenByDefault: true), + + Tables\Columns\TextColumn::make('cabinet') + ->label('Кабинет') + ->sortable() + ->toggleable(), ]) ->filters([ - // ]) ->headerActions([ AttachAction::make() + ->preloadRecordSelect() + ->recordSelectOptionsQuery(fn (Builder $query) => $query->has('userDetail')) ->form(fn (AttachAction $action): array => [ - $action->getRecordSelect(), - Forms\Components\TextInput::make('position')->label('Должность')->required(), - Forms\Components\TextInput::make('service_email')->label('Служебная почта'), - Forms\Components\TextInput::make('service_phone')->label('Служебный телефон'), - Forms\Components\TextInput::make('cabinet')->label('Кабинет'), + Forms\Components\Section::make('') + ->schema([ + $action->getRecordSelect() + ->placeholder('Выбрать сотрудника') + ->columnSpanFull() + ->searchable() + ->preload() + ->helperText('Выберите сотрудника') + ->required(), + + Forms\Components\TextInput::make('position') + ->label('Должность') + ->required() + ->maxLength(255) + ->placeholder('Например: Заведующий кафедрой') + ->helperText('Официальная должность сотрудника на кафедре'), + + Forms\Components\TextInput::make('service_email') + ->label('Служебная почта') + ->email() + ->maxLength(255) + ->placeholder('example@university.edu') + ->helperText('Корпоративная электронная почта'), + + Forms\Components\TextInput::make('service_phone') + ->label('Служебный телефон') + ->tel() + ->maxLength(20) + ->placeholder('+7 (XXX) XXX-XX-XX') + ->helperText('Формат: +7 (XXX) XXX-XX-XX') + ->regex('/^\+?[0-9\s\-\(\)]{7,}$/') // Разрешаем +, цифры, пробелы, дефисы, скобки + ->validationMessages([ + 'regex' => 'Пожалуйста, введите корректный номер телефона. Допустимые форматы: +7 (XXX) XXX-XX-XX или XXX-XX-XX', + ]), + + Forms\Components\TextInput::make('cabinet') + ->label('Кабинет') + ->maxLength(10) + ->placeholder('Например: 305а') + ->helperText('Номер кабинета сотрудника'), + ]) + ->columns(2), ]) + ->modalSubmitActionLabel('Добавить') ]) ->actions([ - Tables\Actions\EditAction::make(), - Tables\Actions\DetachAction::make(), + Tables\Actions\EditAction::make() + ->iconButton() + ->tooltip('Редактировать'), + + Tables\Actions\DetachAction::make() + ->iconButton() + ->tooltip('Убрать с кафедры') + ->modalHeading('Удаление связи') + ->modalSubmitActionLabel('Убрать') + ->modalDescription('Вы уверены, что хотите убрать этого сотрудника с кафедры?'), ]) ->bulkActions([ Tables\Actions\BulkActionGroup::make([ - Tables\Actions\DetachBulkAction::make(), + Tables\Actions\DetachBulkAction::make() + ->label('Убрать выбранных') + ->modalHeading('Удаление связей') + ->modalSubmitActionLabel('Убрать') + ->modalDescription('Вы уверены, что хотите убрать выбранных сотрудников с кафедры?'), ]), - ]); + ]) + ->emptyStateActions([ + AttachAction::make() + ->label('Добавить сотрудника'), + ]) + ->defaultSort('name') + ->deferLoading(); } -} +} \ No newline at end of file diff --git a/app/Filament/Resources/DirectionAdditionalEducationResource.php b/app/Filament/Resources/DirectionAdditionalEducationResource.php index 54f3015..152d6e8 100644 --- a/app/Filament/Resources/DirectionAdditionalEducationResource.php +++ b/app/Filament/Resources/DirectionAdditionalEducationResource.php @@ -3,17 +3,18 @@ namespace App\Filament\Resources; use App\Filament\Resources\DirectionAdditionalEducationResource\Pages; -use App\Filament\Resources\DirectionAdditionalEducationResource\RelationManagers; use App\Models\DirectionAdditionalEducation; use Filament\Forms; +use Filament\Forms\Components\Grid; +use Filament\Forms\Components\Section; use Filament\Forms\Components\TextInput; +use Filament\Forms\Components\Toggle; use Filament\Forms\Form; use Filament\Resources\Resource; 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\Str; class DirectionAdditionalEducationResource extends Resource @@ -25,25 +26,44 @@ class DirectionAdditionalEducationResource extends Resource public static ?string $label = 'Направление'; protected static ?string $pluralLabel = 'Направления дополнительного образования'; protected static ?string $navigationParentItem = 'Дополнительное Образование'; - - protected static ?string $navigationIcon = 'heroicon-o-rectangle-stack'; + protected static ?string $navigationIcon = 'heroicon-o-arrow-trending-up'; public static function form(Form $form): Form { return $form ->schema([ - Forms\Components\Section::make()->schema([ - Forms\Components\Grid::make()->schema([ - TextInput::make('title')->label('Заголовок')->required() - ->live(onBlur: true) - ->afterStateUpdated(function (string $operation, string|null $state, Forms\Set $set) { - $set('slug', Str::slug($state)); - $set('seo.title', $state); - }), - TextInput::make('slug')->label('Slug')->unique(ignoreRecord: true)->readOnly()->required(), + Section::make('Основная информация') + ->description('Заполните данные о направлении дополнительного образования') + ->collapsible() + ->schema([ + Grid::make(2) + ->schema([ + TextInput::make('title') + ->label('Название направления') + ->required() + ->maxLength(255) + ->placeholder('Например: "Информационные технологии"') + ->live(onBlur: true) + ->afterStateUpdated(function (string $operation, ?string $state, Forms\Set $set) { + $set('slug', Str::slug($state)); + }) + ->helperText('Укажите понятное название направления'), + + TextInput::make('slug') + ->label('URL-идентификатор') + ->required() + ->maxLength(255) + ->unique(ignoreRecord: true) + ->helperText('Человеко-понятный URL для направления'), + ]), + + Toggle::make('is_active') + ->label('Активное направление') + ->inline(false) + ->default(true) + ->helperText('Отображать ли направление на сайте') + ->columnSpanFull(), ]), - Forms\Components\Toggle::make('is_active')->label('Активно')->columnSpanFull()->inline(false)->default(true), - ]), ]); } @@ -51,29 +71,67 @@ class DirectionAdditionalEducationResource extends Resource { return $table ->columns([ - TextColumn::make('id')->label('ID')->sortable(), - TextColumn::make('title')->label('Название')->sortable()->searchable(), - TextColumn::make('created_at')->label('Дата создания')->sortable(), - Tables\Columns\ToggleColumn::make('is_active')->label('Активно')->sortable(), + TextColumn::make('title') + ->label('Название') + ->searchable() + ->sortable() + ->limit(50), + + IconColumn::make('is_active') + ->label('Активно') + ->boolean() + ->trueIcon('heroicon-o-check-circle') + ->falseIcon('heroicon-o-x-circle') + ->trueColor('success') + ->falseColor('danger') + ->sortable(), + + TextColumn::make('created_at') + ->label('Дата создания') + ->dateTime('d.m.Y H:i') + ->sortable() + ->toggleable(isToggledHiddenByDefault: true), + + TextColumn::make('updated_at') + ->label('Обновлено') + ->dateTime('d.m.Y H:i') + ->sortable() + ->toggleable(isToggledHiddenByDefault: true), ]) ->filters([ - // + Tables\Filters\TernaryFilter::make('is_active') + ->label('Только активные') + ->placeholder('Все') + ->trueLabel('Активные') + ->falseLabel('Неактивные'), ]) ->actions([ - Tables\Actions\EditAction::make(), + Tables\Actions\EditAction::make() + ->iconButton() + ->tooltip('Редактировать'), + + Tables\Actions\ViewAction::make() + ->iconButton() + ->tooltip('Просмотреть'), ]) ->bulkActions([ Tables\Actions\BulkActionGroup::make([ - Tables\Actions\DeleteBulkAction::make(), + Tables\Actions\DeleteBulkAction::make() + ->label('Удалить выбранные') + ->modalHeading('Удаление направлений') + ->modalDescription('Вы уверены, что хотите удалить выбранные направления ДПО?'), ]), - ]); + ]) + ->emptyStateActions([ + Tables\Actions\CreateAction::make() + ->label('Добавить направление'), + ]) + ->defaultSort('title'); } public static function getRelations(): array { - return [ - // - ]; + return []; } public static function getPages(): array @@ -84,4 +142,4 @@ class DirectionAdditionalEducationResource extends Resource 'edit' => Pages\EditDirectionAdditionalEducation::route('/{record}/edit'), ]; } -} +} \ No newline at end of file diff --git a/app/Filament/Resources/DirectionStudyResource.php b/app/Filament/Resources/DirectionStudyResource.php index c171796..8041efc 100644 --- a/app/Filament/Resources/DirectionStudyResource.php +++ b/app/Filament/Resources/DirectionStudyResource.php @@ -4,63 +4,132 @@ namespace App\Filament\Resources; use App\Enums\LevelEducational; use App\Filament\Resources\DirectionStudyResource\Pages; -use App\Filament\Resources\DirectionStudyResource\RelationManagers; use App\Models\DirectionStudy; use Filament\Forms; +use Filament\Forms\Components\Grid; +use Filament\Forms\Components\Section; +use Filament\Forms\Components\Select; +use Filament\Forms\Components\TextInput; use Filament\Forms\Form; use Filament\Resources\Resource; use Filament\Tables; +use Filament\Tables\Columns\BadgeColumn; +use Filament\Tables\Columns\TextColumn; use Filament\Tables\Table; -use Illuminate\Database\Eloquent\Builder; -use Illuminate\Database\Eloquent\SoftDeletingScope; use Illuminate\Support\Str; class DirectionStudyResource extends Resource { protected static ?string $model = DirectionStudy::class; - - protected static ?string $navigationIcon = 'heroicon-o-rectangle-stack'; - - protected static ?string $pluralLabel = 'Направление подготовки'; + protected static ?string $navigationIcon = 'heroicon-o-academic-cap'; + protected static ?string $pluralLabel = 'Направления подготовки'; + protected static ?string $modelLabel = 'Направление подготовки'; protected static ?string $navigationGroup = 'Образование'; - - protected static ?string $navigationParentItem = 'Приемная-компания'; public static function form(Form $form): Form { return $form - ->schema([]); + ->schema([ + Section::make('Основная информация') + ->description('Заполните основные данные о направлении подготовки') + ->collapsible() + ->schema([ + Grid::make(2) + ->schema([ + TextInput::make('name') + ->label('Название направления') + ->required() + ->maxLength(255) + ->placeholder('Например: Информатика и вычислительная техника') + ->live(onBlur: true) + ->afterStateUpdated(function (string $operation, ?string $state, Forms\Set $set) { + $set('slug', Str::slug($state)); + }) + ->helperText('Полное название направления подготовки'), + + TextInput::make('slug') + ->label('URL-идентификатор') + ->required() + ->readOnly() + ->maxLength(255) + ->unique(ignoreRecord: true) + ->helperText('Человеко-понятный URL для направления'), + + TextInput::make('code') + ->label('Код направления') + ->required() + ->maxLength(50) + ->placeholder('Например: 09.03.01') + ->helperText('Код направления по ФГОС'), + + Select::make('lvl_edu') + ->label('Уровень образования') + ->options(LevelEducational::class) + ->required() + ->native(false) + ->placeholder('Выберите уровень') + ->helperText('Выберите уровень образовательной программы'), + ]), + ]), + ]); } public static function table(Table $table): Table { return $table ->columns([ - Tables\Columns\TextColumn::make('name')->label('Название'), - Tables\Columns\TextColumn::make('code')->label('Код направления'), - Tables\Columns\TextColumn::make('lvl_edu')->label('Уровень образования') - ->formatStateUsing(fn ($state) => $state->getLabel()) + TextColumn::make('code') + ->label('Код') + ->searchable() + ->sortable() + ->description(fn ($record) => $record->name), + + BadgeColumn::make('lvl_edu') + ->label('Уровень') + ->formatStateUsing(fn ($state) => LevelEducational::tryFrom($state->value)?->getLabel()) + ->color(fn ($state) => LevelEducational::tryFrom($state->value)?->getColor()) + ->sortable(), + + TextColumn::make('created_at') + ->label('Добавлено') + ->dateTime('d.m.Y H:i') + ->sortable() + ->toggleable(isToggledHiddenByDefault: true), ]) ->filters([ - // + Tables\Filters\SelectFilter::make('lvl_edu') + ->label('Уровень образования') + ->options(LevelEducational::class), ]) ->actions([ - Tables\Actions\EditAction::make(), + Tables\Actions\EditAction::make() + ->iconButton() + ->tooltip('Редактировать'), + + Tables\Actions\ViewAction::make() + ->iconButton() + ->tooltip('Просмотреть'), ]) ->bulkActions([ Tables\Actions\BulkActionGroup::make([ - Tables\Actions\DeleteBulkAction::make(), + Tables\Actions\DeleteBulkAction::make() + ->label('Удалить выбранные') + ->modalHeading('Удаление направлений') + ->modalDescription('Вы уверены, что хотите удалить выбранные направления подготовки?'), ]), - ]); + ]) + ->emptyStateActions([ + Tables\Actions\CreateAction::make() + ->label('Добавить направление'), + ]) + ->defaultSort('code'); } public static function getRelations(): array { - return [ - // - ]; + return []; } public static function getPages(): array @@ -71,4 +140,4 @@ class DirectionStudyResource extends Resource 'edit' => Pages\EditDirectionStudy::route('/{record}/edit'), ]; } -} +} \ No newline at end of file diff --git a/app/Filament/Resources/DivisionResource.php b/app/Filament/Resources/DivisionResource.php index 56e6573..8faf67d 100644 --- a/app/Filament/Resources/DivisionResource.php +++ b/app/Filament/Resources/DivisionResource.php @@ -2,20 +2,15 @@ namespace App\Filament\Resources; -use App\Enums\PostStatus; +use App\Filament\Components\Forms\ItemForm\Pages\ContentBuilderItem; use App\Filament\Resources\DivisionResource\Pages; use App\Filament\Resources\DivisionResource\RelationManagers; -use App\Models\Category; use App\Models\Division; -use App\Models\Page; -use App\Models\Post; use Filament\Forms; +use Filament\Forms\Components\Actions\Action; use Filament\Forms\Components\Builder; -use Filament\Forms\Components\FileUpload; -use Filament\Forms\Components\Hidden; -use Filament\Forms\Components\RichEditor; +use Illuminate\Database\Eloquent\Builder as EloquentBuilder; use Filament\Forms\Components\Section; -use Filament\Forms\Components\Select; use Filament\Forms\Components\Tabs; use Filament\Forms\Components\TextInput; use Filament\Forms\Components\Toggle; @@ -24,410 +19,156 @@ use Filament\Resources\Resource; use Filament\Tables; use Filament\Tables\Columns\TextColumn; use Filament\Tables\Table; -use Illuminate\Database\Eloquent\SoftDeletingScope; use Illuminate\Support\Str; -use Mohamedsabil83\FilamentFormsTinyeditor\Components\TinyEditor; class DivisionResource extends Resource { protected static ?string $model = Division::class; - protected static ?string $navigationGroup = 'Структура института'; - protected static ?string $navigationIcon = 'heroicon-o-squares-2x2'; - - public static ?string $label = 'Подразделение'; - - protected static ?string $pluralLabel = 'Подразделения института'; + protected static ?string $modelLabel = 'Подразделение'; + protected static ?string $pluralModelLabel = 'Подразделения института'; + protected static ?int $navigationSort = 100; public static function form(Form $form): Form { return $form ->schema([ - Section::make() + Section::make('Основные настройки') + ->collapsible() ->schema([ - Tabs::make('Tabs') + Tabs::make('Конструктор подразделения') + ->persistTabInQueryString() + ->columnSpanFull() ->tabs([ Tabs\Tab::make('Основная информация') + ->icon('heroicon-o-information-circle') ->schema([ - Forms\Components\Grid::make(2)->schema([ - TextInput::make('title')->label('Заголовок')->required() - ->live(onBlur: true) - ->afterStateUpdated(function (string $operation, string|null $state, Forms\Set $set) { - $set('slug', Str::slug($state)); - }), - TextInput::make('slug')->label('Slug')->unique(ignoreRecord: true)->readOnly()->required(), - ]), - Toggle::make('is_active')->default(true)->label('Активное подразделение')->inline(false), + Forms\Components\Grid::make(2) + ->schema([ + TextInput::make('title') + ->label('Название подразделения') + ->required() + ->maxLength(255) + ->live(onBlur: true) + ->afterStateUpdated(function (string $operation, string|null $state, Forms\Set $set) { + if ($operation === 'create') { + $set('slug', Str::slug($state)); + } + }) + ->placeholder('Введите полное название подразделения') + ->helperText('Официальное название, которое будет отображаться на сайте'), + + TextInput::make('slug') + ->label('URL-адрес') + ->unique(ignoreRecord: true) + ->required() + ->readOnly() + ->helperText('Формируется автоматически из названия') + ->prefix(fn () => route('client.division.index') . '/') + ->suffixAction( + Action::make('copy') + ->icon('heroicon-s-clipboard-document-check') + ->action(function ($livewire, $state) { + $livewire->js( + 'window.navigator.clipboard.writeText("'. route('client.division.index') . '/' . $state.'"); + $tooltip("'.__('Copied to clipboard').'", { timeout: 1500 });' + ); + })), + + + + ]), + + Toggle::make('is_active') + ->label('Активно на сайте') + ->default(true) + ->inline(false) + ->helperText('Отключите, чтобы временно скрыть подразделение'), ]), - Tabs\Tab::make('Содержание') + + Tabs\Tab::make('Контент') + ->icon('heroicon-o-document-text') ->schema([ - \Filament\Forms\Components\Builder::make('description')->label('')->blocks([ - Builder\Block::make('heading')->label('Заголовок') - ->schema([ - TextInput::make('id')->hidden()->integer()->default(rand(2335235,324634264263426)), - TextInput::make('content') - ->label('') - ->live(onBlur: true) - ->afterStateUpdated(function (string $operation, string $state, Forms\Set $set, $get) { - }), - ]), - Builder\Block::make('paragraph')->label('Текст') - ->schema([ - TinyEditor::make('content') - ->label('') - ->profile('test') - ]), - Builder\Block::make('files')->label('Файлы') - ->schema([ - Forms\Components\Repeater::make('file')->schema([ - TextInput::make('title') - ->required() - ->maxLength(255) - ->autofocus(), - FileUpload::make('path') - ->required() - ->acceptedFileTypes([ - 'application/pdf', - 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', - 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', - 'application/vnd.openxmlformats-officedocument.presentationml.presentation', - 'application/zip' - ]) - ->maxSize(512000) - ->disk('public') - ->directory('files') - ->downloadable() - ->visibility('public') - ]), - ]), - Builder\Block::make('person')->label('Персона') - ->schema([ - TextInput::make('name') - ->label('Имя') - ->required() - ->maxLength(255), - FileUpload::make('photo') - ->label('Фотография') - ->image() - ->disk('public') - ->directory('images') - ->imageEditor(), - Forms\Components\Repeater::make('info')->schema([ - Forms\Components\Grid::make(2)->schema([ - TextInput::make('column') - ->required() - ->maxLength(255), - TextInput::make('content') - ->required() - ->maxLength(255), - ]), - ])->minItems(1), - ]), - Builder\Block::make('stepper')->label('Этапы') - ->schema([ - TextInput::make('step_name') - ->label('Название шага') - ->required() - ->maxLength(255), - Forms\Components\Repeater::make('steps')->schema([ - TextInput::make('title') - ->required() - ->live() - ->maxLength(255)->columnSpanFull(), - TinyEditor::make('content') - ->label('') - ->profile('test') - ->required(), ]) - ->itemLabel(fn (array $state): ?string => $state['title'] ?? null) - ->minItems(1) - ->collapsible() - ->collapsed() - - - ]), - Builder\Block::make('tabs')->label('Вкладки') - ->schema([ - Forms\Components\Repeater::make('tab')->schema([ - TextInput::make('title') - ->required() - ->maxLength(255)->columnSpanFull(), - \Filament\Forms\Components\Builder::make('content')->label('')->blocks([ - Builder\Block::make('heading')->label('Заголовок') - ->schema([ - TextInput::make('id')->hidden()->integer()->default(rand(2335235,324634264263426)), - TextInput::make('content') - ->label('') - ->live(onBlur: true) - ->afterStateUpdated(function (string $operation, string $state, Forms\Set $set, $get) { - }), - ]), - Builder\Block::make('paragraph') - ->schema([ - RichEditor::make('content') - ->toolbarButtons([ - 'blockquote', - 'bold', - 'bulletList', - 'italic', - 'link', - 'orderedList', - 'redo', - 'strike', - 'underline', - 'undo', - ]) - ->label(''), - ])->label('Текст'), - Builder\Block::make('files') - ->schema([ - Forms\Components\Repeater::make('file')->schema([ - TextInput::make('title') - ->required() - ->maxLength(255) - ->autofocus(), - FileUpload::make('path') - ->required() - ->acceptedFileTypes([ - 'application/pdf', - 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', - 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', - 'application/vnd.openxmlformats-officedocument.presentationml.presentation', - 'application/zip' - ]) - ->maxSize(512000) - ->disk('public') - ->directory('files') - ->downloadable() - ->visibility('public') - ]), - ]), - Builder\Block::make('person') - ->schema([ - TextInput::make('name') - ->label('Имя') - ->required() - ->maxLength(255), - FileUpload::make('photo') - ->label('Фотография') - ->image() - ->disk('public') - ->directory('images') - ->imageEditor(), - Forms\Components\Repeater::make('info')->schema([ - Forms\Components\Grid::make(2)->schema([ - TextInput::make('column') - ->required() - ->maxLength(255), - TextInput::make('content') - ->required() - ->maxLength(255), - ]), - ])->minItems(1), - ]), - Builder\Block::make('stepper') - ->schema([ - TextInput::make('step_name') - ->label('Название шага') - ->required() - ->maxLength(255), - Forms\Components\Repeater::make('steps')->schema([ - TextInput::make('title') - ->required() - ->maxLength(255)->columnSpanFull(), - RichEditor::make('content')->required(), - ])->minItems(1), - ]), - Builder\Block::make('images') - ->schema([ - FileUpload::make('url') - ->label('Изображение(-я)') - ->image() - ->multiple() - ->reorderable() - ->maxFiles(5) - ->disk('public') - ->directory('images') - ->imageEditor() - ->required(), - TextInput::make('alt') - ->label('Описание') - ->placeholder('Необязяательно') - ])->label('Слайдер изображений'), - Builder\Block::make('image') - ->schema([ - FileUpload::make('url') - ->label('Изображение(-я)') - ->image() - ->multiple() - ->reorderable() - ->maxFiles(5) - ->disk('public') - ->directory('images') - ->imageEditor() - ->required(), - TextInput::make('alt') - ->label('Описание') - ->placeholder('Необязяательно') - ])->label('Изображение'), - Builder\Block::make('video') - ->schema([ - TextInput::make('mime')->readOnly(), - TextInput::make('title') - ->required() - ->maxLength(255) - ->autofocus(), - FileUpload::make('path') - ->required() - ->acceptedFileTypes([ - 'video/mp4', - 'video/quicktime', - 'video/x-msvideo', - 'video/x-ms-wmv', - 'video/avi', - 'video/webm', - 'video/ogg', - 'video/3gpp', - 'video/3gpp2', - 'video/x-m4v', - ]) - ->disk('public') - ->directory('videos') - ->afterStateUpdated(fn (callable $set, $state) => $set('mime', $state?->getMimeType())), - ]), - Builder\Block::make('postsList') - ->schema([ - Forms\Components\Grid::make(2)->schema([ - TextInput::make('count') - ->label('Количество запией') - ->integer(), - Select::make('category') - ->options(Category::all()->pluck('title', 'id')) - ]), - ])->label('Список новостей'), - ]) - ->collapsed() - ->blockNumbers(false) - ->collapsible() - ->addActionLabel('Добавить новый блок'), - ])->minItems(1), - ]), - Builder\Block::make('images')->label('Слайдер изображений') - ->schema([ - FileUpload::make('url') - ->label('Изображение(-я)') - ->image() - ->multiple() - ->reorderable() - ->maxFiles(5) - ->disk('public') - - ->directory('images') - ->imageEditor() - ->required(), - TextInput::make('alt') - ->label('Описание') - ->placeholder('Необязяательно') - ]), - Builder\Block::make('image')->label('Изображение') - ->schema([ - FileUpload::make('url') - ->label('Изображение(-я)') - ->image() - ->multiple() - ->reorderable() - ->maxFiles(5) - ->disk('public') - ->directory('images') - ->imageEditor() - ->required(), - TextInput::make('alt') - ->label('Описание') - ->placeholder('Необязяательно') - ]), - Builder\Block::make('video')->label('Видео') - ->schema([ - TextInput::make('mime')->readOnly(), - TextInput::make('title') - ->required() - ->maxLength(255) - ->autofocus(), - FileUpload::make('path') - ->required() - ->acceptedFileTypes([ - 'video/mp4', - 'video/quicktime', - 'video/x-msvideo', - 'video/x-ms-wmv', - 'video/avi', - 'video/webm', - 'video/ogg', - 'video/3gpp', - 'video/3gpp2', - 'video/x-m4v', - ]) - ->disk('public') - ->directory('videos') - ->afterStateUpdated(fn (callable $set, $state) => $set('mime', $state?->getMimeType())), - ]), - Builder\Block::make('postsList')->label('Список новостей') - ->schema([ - Forms\Components\Grid::make(2)->schema([ - TextInput::make('count') - ->label('Количество запией') - ->integer(), - Select::make('category') - ->options(Category::all()->pluck('title', 'id')) - ]), - ]), - Builder\Block::make('postItem')->label('Новость') - ->schema([ - Select::make('post') - ->options(Post::query()->where('status', PostStatus::PUBLISHED)->pluck('title', 'id')) - ->searchable() - ->required(), - ]), - Builder\Block::make('pageItem')->label('Страница') - ->schema([ - Select::make('page') - ->options(Page::query()->where('title', '!=' , null)->where('is_visible', true)->pluck('title', 'id')) - ->searchable() - ->required(), - ]), - ]) - ->collapsed() - ->blockNumbers(false) - ->collapsible() - ->blockPickerColumns(3) - ->blockPickerWidth('2xl') - ->addActionLabel('Добавить новый блок'), + ContentBuilderItem::getItem('content') ]), ]), ]), - ]); } public static function table(Table $table): Table { return $table + ->defaultSort('created_at', 'desc') + ->reorderable('order_column') + ->paginated([10, 25, 50, 100]) ->columns([ - TextColumn::make('id')->label('ID')->sortable(), - TextColumn::make('title')->label('Название')->sortable()->searchable(), - TextColumn::make('created_at')->label('Дата создания')->sortable(), - Tables\Columns\ToggleColumn::make('is_active')->label('Активно')->sortable(), - ]) + TextColumn::make('id') + ->label('ID') + ->sortable() + ->toggleable(isToggledHiddenByDefault: true), + + TextColumn::make('title') + ->label('Название') + ->sortable() + ->searchable() + ->description(fn (Division $record) => Str::limit($record->slug, 30)) + ->wrap(), + + TextColumn::make('created_at') + ->label('Дата создания') + ->dateTime('d.m.Y H:i') + ->sortable() + ->toggleable(), + + TextColumn::make('updated_at') + ->label('Последнее обновление') + ->dateTime('d.m.Y H:i') + ->sortable() + ->toggleable(isToggledHiddenByDefault: true), + + Tables\Columns\ToggleColumn::make('is_active') + ->label('Статус') + ->sortable() + ->alignCenter(), + ]) ->filters([ - // + Tables\Filters\Filter::make('is_active') + ->label('Только активные') + ->query(fn (EloquentBuilder $query) => $query->where('is_active', true)) + ->default(), ]) ->actions([ - Tables\Actions\EditAction::make(), + Tables\Actions\EditAction::make() + ->iconButton() + ->tooltip('Редактировать'), ]) ->bulkActions([ Tables\Actions\BulkActionGroup::make([ - Tables\Actions\DeleteBulkAction::make(), + Tables\Actions\DeleteBulkAction::make() + ->label('Удалить выбранные') + ->modalHeading('Подтверждение удаления') + ->modalSubmitActionLabel('Да, удалить') + ->modalDescription('Вы уверены, что хотите удалить выбранные подразделения? Это действие нельзя отменить.'), + + Tables\Actions\ForceDeleteBulkAction::make() + ->label('Принудительно удалить') + ->modalHeading('Подтверждение удаления') + ->modalSubmitActionLabel('Да, удалить безвозвратно') + ->modalDescription('Внимание! Это действие окончательно удалит записи из базы данных.'), + + Tables\Actions\RestoreBulkAction::make() + ->label('Восстановить выбранные'), ]), - ]); + ]) + ->emptyStateActions([ + Tables\Actions\CreateAction::make() + ->label('Добавить подразделение'), + ]) + ->persistFiltersInSession() + ->persistSearchInSession() + ->striped(); } public static function getRelations(): array @@ -445,4 +186,4 @@ class DivisionResource extends Resource 'edit' => Pages\EditDivision::route('/{record}/edit'), ]; } -} +} \ No newline at end of file diff --git a/app/Filament/Resources/DivisionResource/Pages/CreateDivision.php b/app/Filament/Resources/DivisionResource/Pages/CreateDivision.php index ba801e3..6f9ad6b 100644 --- a/app/Filament/Resources/DivisionResource/Pages/CreateDivision.php +++ b/app/Filament/Resources/DivisionResource/Pages/CreateDivision.php @@ -3,42 +3,27 @@ namespace App\Filament\Resources\DivisionResource\Pages; use App\Filament\Resources\DivisionResource; +use App\Services\Filament\Traits\SeoGenerate; use Filament\Actions; use Filament\Resources\Pages\CreateRecord; use Illuminate\Support\Str; class CreateDivision extends CreateRecord { - protected static string $resource = DivisionResource::class; + use SeoGenerate; - protected array $seoData; + protected static string $resource = DivisionResource::class; protected function mutateFormDataBeforeCreate(array $data): array { - $this->seoData = $this->generateSeo($data); $data['search_data'] = $this->generateSearchData($data['description']); return $data; } protected function afterCreate(): void { - $this->record->seo()->create($this->seoData); - } - - private function generateSeo(array $data) : array - { - $title = $data['title']; - $rowData = $this->getFirstBlockByName('paragraph', $data['description']); - if ($rowData !== null) { - $description = strip_tags($rowData['data']['content']); - } else { - $description = null; - } - return [ - 'title' => $title, - 'description' => Str::limit(htmlspecialchars($description, ENT_QUOTES, 'UTF-8'), 160), - ]; + $this->createSeo($this->record); } private function generateSearchData(array $data) : string diff --git a/app/Filament/Resources/DivisionResource/Pages/EditDivision.php b/app/Filament/Resources/DivisionResource/Pages/EditDivision.php index 76ca95d..130b512 100644 --- a/app/Filament/Resources/DivisionResource/Pages/EditDivision.php +++ b/app/Filament/Resources/DivisionResource/Pages/EditDivision.php @@ -3,20 +3,21 @@ namespace App\Filament\Resources\DivisionResource\Pages; use App\Filament\Resources\DivisionResource; +use App\Services\Filament\Traits\SeoGenerate; use Filament\Actions; use Filament\Resources\Pages\EditRecord; use Illuminate\Support\Str; class EditDivision extends EditRecord { + use SeoGenerate; + protected static string $resource = DivisionResource::class; - protected array $seoData; protected function mutateFormDataBeforeSave(array $data): array { - $this->seoData = $this->generateSeo($data); $data['search_data'] = $this->generateSearchData($data['description']); return $data; @@ -24,25 +25,11 @@ class EditDivision extends EditRecord protected function afterSave(): void { - $this->record->seo()->update($this->seoData); + $this->updateSeo($this->record); } - private function generateSeo(array $data) : array - { - $title = $data['title']; - $rowData = $this->getFirstBlockByName('paragraph', $data['description']); - if ($rowData !== null) { - $description = strip_tags($rowData['data']['content']); - } else { - $description = null; - } - return [ - 'title' => $title, - 'description' => Str::limit(htmlspecialchars($description, ENT_QUOTES, 'UTF-8'), 160), - ]; - } private function getDataFromBlocks($block) : string { diff --git a/app/Filament/Resources/DivisionResource/RelationManagers/WorkersRelationManager.php b/app/Filament/Resources/DivisionResource/RelationManagers/WorkersRelationManager.php index 585e8a1..f1297fa 100644 --- a/app/Filament/Resources/DivisionResource/RelationManagers/WorkersRelationManager.php +++ b/app/Filament/Resources/DivisionResource/RelationManagers/WorkersRelationManager.php @@ -14,17 +14,48 @@ use Illuminate\Database\Eloquent\SoftDeletingScope; class WorkersRelationManager extends RelationManager { protected static string $relationship = 'workers'; - - protected static ?string $title = 'Сотрудники'; + protected static ?string $title = 'Сотрудники подразделения'; + protected static ?string $inverseRelationship = 'divisions'; public function form(Form $form): Form { return $form ->schema([ - Forms\Components\TextInput::make('administrativePosition')->label('Должность')->required(), - Forms\Components\TextInput::make('service_email')->label('Служебная почта'), - Forms\Components\TextInput::make('service_phone')->label('Служебный телефон'), - Forms\Components\TextInput::make('cabinet')->label('Кабинет'), + Forms\Components\Section::make('Служебная информация') + ->description('Данные о сотруднике в рамках подразделения') + ->schema([ + Forms\Components\TextInput::make('administrativePosition') + ->label('Административная должность') + ->required() + ->maxLength(255) + ->placeholder('Например: Руководитель отдела') + ->helperText('Официальная должность в подразделении'), + + Forms\Components\TextInput::make('service_email') + ->label('Служебная почта') + ->email() + ->maxLength(255) + ->placeholder('example@university.edu') + ->helperText('Корпоративная электронная почта в подразделении'), + + Forms\Components\TextInput::make('service_phone') + ->label('Служебный телефон') + ->tel() + ->maxLength(20) + ->placeholder('+7 (XXX) XXX-XX-XX') + ->helperText('Формат: +7 (XXX) XXX-XX-XX') + ->regex('/^\+?[0-9\s\-\(\)]{7,}$/') + ->validationMessages([ + 'regex' => 'Пожалуйста, введите корректный номер телефона', + ]), + + Forms\Components\TextInput::make('cabinet') + ->label('Кабинет') + ->maxLength(10) + ->placeholder('Например: 305а') + ->helperText('Номер кабинета в подразделении'), + ]) + ->columns(2), ]); } @@ -35,32 +66,114 @@ class WorkersRelationManager extends RelationManager ->reorderable('sort') ->defaultSort('sort') ->columns([ - Tables\Columns\TextColumn::make('name'), - Tables\Columns\TextColumn::make('administrativePosition'), + Tables\Columns\TextColumn::make('sort') + ->label('№') + ->sortable() + ->toggleable(isToggledHiddenByDefault: true), + Tables\Columns\TextColumn::make('name') + ->label('ФИО') + ->searchable() + ->sortable() + ->weight('medium') + ->description(fn ($record) => $record->service_email), + + Tables\Columns\TextColumn::make('administrativePosition') + ->label('Должность') + ->searchable() + ->wrap() + ->description(fn ($record) => $record->cabinet), + + Tables\Columns\TextColumn::make('service_phone') + ->label('Телефон') + ->searchable() + ->toggleable(), ]) ->filters([ - // ]) ->headerActions([ AttachAction::make() + ->label('Добавить сотрудника') + ->modalHeading('Добавление сотрудника') + ->modalSubmitActionLabel('Добавить') ->preloadRecordSelect() + ->recordSelectOptionsQuery(fn (Builder $query) => $query->has('userDetail')) ->form(fn (AttachAction $action): array => [ - $action->getRecordSelect(), - Forms\Components\TextInput::make('administrativePosition')->label('Должность')->required(), - Forms\Components\TextInput::make('service_email')->label('Служебная почта'), - Forms\Components\TextInput::make('service_phone')->label('Служебный телефон'), - Forms\Components\TextInput::make('cabinet')->label('Кабинет'), - ]) + Forms\Components\Section::make() + ->schema([ + $action->getRecordSelect() + ->label('Сотрудник') + ->placeholder('Выберите сотрудника') + ->searchable() + ->preload() + ->required() + ->columnSpanFull(), + + Forms\Components\TextInput::make('administrativePosition') + ->label('Административная должность') + ->required() + ->columnSpanFull() + ->maxLength(255) + ->placeholder('Например: Руководитель отдела') + ->helperText('Официальная должность в подразделении'), + + Forms\Components\TextInput::make('service_email') + ->label('Служебная почта') + ->email() + ->columnSpanFull() + ->maxLength(255) + ->placeholder('example@university.edu') + ->helperText('Корпоративная электронная почта в подразделении'), + + Forms\Components\TextInput::make('service_phone') + ->label('Служебный телефон') + ->tel() + ->columnSpanFull() + ->maxLength(20) + ->placeholder('+7 (XXX) XXX-XX-XX') + ->helperText('Формат: +7 (XXX) XXX-XX-XX') + ->regex('/^\+?[0-9\s\-\(\)]{7,}$/') + ->validationMessages([ + 'regex' => 'Пожалуйста, введите корректный номер телефона', + ]), + + Forms\Components\TextInput::make('cabinet') + ->label('Кабинет') + ->columnSpanFull() + ->maxLength(10) + ->placeholder('Например: 305а') + ->helperText('Номер кабинета в подразделении'), + ]) + ->columns(2), + ]), ]) ->actions([ - Tables\Actions\EditAction::make(), - Tables\Actions\DetachAction::make(), + Tables\Actions\EditAction::make() + ->iconButton() + ->tooltip('Редактировать данные сотрудника'), + + Tables\Actions\DetachAction::make() + ->iconButton() + ->tooltip('Убрать из подразделения') + ->modalHeading('Подтверждение удаления') + ->modalSubmitActionLabel('Убрать') + ->modalDescription('Вы уверены, что хотите убрать этого сотрудника из подразделения?'), ]) ->bulkActions([ Tables\Actions\BulkActionGroup::make([ - Tables\Actions\DetachBulkAction::make(), + Tables\Actions\DetachBulkAction::make() + ->label('Убрать выбранных') + ->modalHeading('Подтверждение удаления') + ->modalSubmitActionLabel('Убрать') + ->modalDescription('Вы уверены, что хотите убрать выбранных сотрудников из подразделения?'), ]), - ]); + ]) + ->emptyStateActions([ + AttachAction::make() + ->label('Добавить сотрудника'), + ]) + ->persistFiltersInSession() + ->paginated([10, 25, 50, 100]) + ->striped(); } -} +} \ No newline at end of file diff --git a/app/Filament/Resources/EducationalGroupResource.php b/app/Filament/Resources/EducationalGroupResource.php index 069f70c..dbf0871 100644 --- a/app/Filament/Resources/EducationalGroupResource.php +++ b/app/Filament/Resources/EducationalGroupResource.php @@ -4,41 +4,64 @@ namespace App\Filament\Resources; use App\Enums\FormEducation; use App\Filament\Resources\EducationalGroupResource\Pages; -use App\Filament\Resources\EducationalGroupResource\RelationManagers; use App\Models\EducationalGroup; use App\Models\Faculty; use Filament\Forms; +use Filament\Forms\Components\Grid; +use Filament\Forms\Components\Section; +use Filament\Forms\Components\Select; +use Filament\Forms\Components\TextInput; use Filament\Forms\Form; use Filament\Resources\Resource; use Filament\Tables; +use Filament\Tables\Columns\BadgeColumn; use Filament\Tables\Columns\TextColumn; use Filament\Tables\Table; use Illuminate\Database\Eloquent\Builder; -use Illuminate\Database\Eloquent\SoftDeletingScope; class EducationalGroupResource extends Resource { protected static ?string $navigationGroup = 'Расписание и группы'; - protected static ?string $model = EducationalGroup::class; - - protected static ?string $pluralLabel = 'Группы'; - + protected static ?string $pluralLabel = 'Учебные группы'; + protected static ?string $modelLabel = 'учебная группа'; protected static ?string $navigationIcon = 'heroicon-o-user-group'; public static function form(Form $form): Form { return $form ->schema([ - Forms\Components\Section::make()->schema([ - Forms\Components\Grid::make(2)->schema([ - Forms\Components\TextInput::make('title')->label('Название группы')->required(), - Forms\Components\Select::make('faculty_id')->label('Факультет')->required() - ->options(Faculty::all()->pluck('title', 'id')), - Forms\Components\Select::make('education_form_id')->label('Форма обучения') - ->options(FormEducation::class) + Section::make('Основная информация') + ->description('Заполните основные данные о группе') + ->collapsible() + ->schema([ + Grid::make(2) + ->schema([ + TextInput::make('title') + ->label('Название группы') + ->required() + ->maxLength(50) + ->placeholder('Например: ИВТ-21-1') + ->helperText('Введите краткое название группы в принятом формате'), + + Select::make('faculty_id') + ->label('Факультет') + ->required() + ->options(Faculty::query()->orderBy('title')->pluck('title', 'id')) + ->searchable() + ->preload() + ->placeholder('Выберите факультет') + ->helperText('Выберите факультет, к которому относится группа'), + + Select::make('education_form_id') + ->label('Форма обучения') + ->required() + ->options(FormEducation::class) + ->native(false) + ->placeholder('Выберите форму обучения') + ->helperText('Выберите форму обучения для группы'), + ]), ]), - ]), ]); } @@ -46,29 +69,71 @@ class EducationalGroupResource extends Resource { return $table ->columns([ - TextColumn::make('id')->label('ID')->sortable(), - TextColumn::make('title')->label('Название')->sortable()->searchable(), - TextColumn::make('created_at')->label('Дата создания')->sortable(), - Tables\Columns\BadgeColumn::make('faculty.title')->label('Категория')->sortable(), + TextColumn::make('id') + ->label('ID') + ->sortable() + ->searchable() + ->toggleable(isToggledHiddenByDefault: true), + + TextColumn::make('title') + ->label('Название группы') + ->sortable() + ->searchable() + ->description(fn ($record) => $record->faculty->title ?? ''), + + BadgeColumn::make('education_form_id') + ->label('Форма обучения') + ->formatStateUsing(fn ($state) => FormEducation::tryFrom($state)?->label()) + ->color(fn ($state) => match($state) { + FormEducation::FULL_TIME->value => 'success', + FormEducation::PART_TIME->value => 'warning', + default => 'gray', + }) + ->sortable(), + + TextColumn::make('created_at') + ->label('Дата создания') + ->dateTime('d.m.Y') + ->sortable() + ->toggleable(), ]) ->filters([ - // + Tables\Filters\SelectFilter::make('faculty_id') + ->label('Факультет') + ->options(Faculty::query()->orderBy('title')->pluck('title', 'id')) + ->searchable(), + + Tables\Filters\SelectFilter::make('education_form_id') + ->label('Форма обучения') + ->options(FormEducation::class), ]) ->actions([ - Tables\Actions\EditAction::make(), + Tables\Actions\EditAction::make() + ->iconButton() + ->tooltip('Редактировать'), + + Tables\Actions\ViewAction::make() + ->iconButton() + ->tooltip('Просмотреть'), ]) ->bulkActions([ Tables\Actions\BulkActionGroup::make([ - Tables\Actions\DeleteBulkAction::make(), + Tables\Actions\DeleteBulkAction::make() + ->label('Удалить выбранные') + ->modalHeading('Удаление групп') + ->modalDescription('Вы уверены, что хотите удалить выбранные учебные группы?'), ]), - ]); + ]) + ->emptyStateActions([ + Tables\Actions\CreateAction::make() + ->label('Добавить группу'), + ]) + ->defaultSort('title'); } public static function getRelations(): array { - return [ - // - ]; + return []; } public static function getPages(): array @@ -79,4 +144,4 @@ class EducationalGroupResource extends Resource 'edit' => Pages\EditEducationalGroup::route('/{record}/edit'), ]; } -} +} \ No newline at end of file diff --git a/app/Filament/Resources/EducationalProgramResource.php b/app/Filament/Resources/EducationalProgramResource.php index f83a373..f5f17b6 100644 --- a/app/Filament/Resources/EducationalProgramResource.php +++ b/app/Filament/Resources/EducationalProgramResource.php @@ -4,263 +4,167 @@ namespace App\Filament\Resources; use App\Enums\EducationalProgramStatus; use App\Enums\LevelEducational; +use App\Filament\Components\Forms\ItemForm\Pages\ContentBuilderItem; use App\Filament\Resources\EducationalProgramResource\Pages; -use App\Filament\Resources\EducationalProgramResource\RelationManagers; use App\Filament\Resources\EducationalProgramResource\RelationManagers\AdmissionPlansRelationManager; -use App\Models\Category; use App\Models\EducationalProgram; use Filament\Forms; use Filament\Forms\Components\Builder; use Filament\Forms\Components\FileUpload; +use Filament\Forms\Components\Grid; use Filament\Forms\Components\Hidden; use Filament\Forms\Components\RichEditor; use Filament\Forms\Components\Section; use Filament\Forms\Components\Select; -use Filament\Forms\Components\SpatieTagsInput; +use Filament\Forms\Components\Tabs; use Filament\Forms\Components\TextInput; use Filament\Forms\Form; use Filament\Resources\Resource; use Filament\Tables; +use Filament\Tables\Columns\BadgeColumn; +use Filament\Tables\Columns\TextColumn; use Filament\Tables\Table; -use Illuminate\Database\Eloquent\SoftDeletingScope; use Illuminate\Support\Str; -use Mohamedsabil83\FilamentFormsTinyeditor\Components\TinyEditor; class EducationalProgramResource extends Resource { protected static ?string $model = EducationalProgram::class; - - protected static ?string $navigationIcon = 'heroicon-o-rectangle-stack'; - + protected static ?string $navigationIcon = 'heroicon-o-academic-cap'; protected static ?string $navigationGroup = 'Образование'; - protected static ?string $pluralLabel = 'Образовательные программы'; - + protected static ?string $modelLabel = 'Образовательная программа'; protected static ?string $navigationParentItem = 'Приемная-компания'; public static function form(Form $form): Form { return $form ->schema([ - Section::make() - ->schema([ - TextInput::make('name')->label('Название')->required(), - Section::make('О программе')->schema([ - Builder::make('about_program')->label('')->blocks([ - Builder\Block::make('heading')->label('Заголовок') + Tabs::make('') + ->tabs([ + Tabs\Tab::make('Основная информация') + ->icon('heroicon-o-information-circle') + ->schema([ + TextInput::make('name') + ->label('Название программы') + ->required() + ->maxLength(255) + ->placeholder('Введите полное название программы') + ->columnSpanFull() + ->helperText('Официальное название программы как в лицензии'), + + Grid::make(2) ->schema([ - TextInput::make('id')->hidden()->integer()->default(rand(2335235,324634264263426)), - TextInput::make('content') - ->label('') - ->live(onBlur: true) - ->afterStateUpdated(function (string $operation, string $state, Forms\Set $set, $get) { - }), + Select::make('lvl_edu') + ->label('Уровень образования') + ->options(LevelEducational::class) + ->required() + ->native(false) + ->helperText('Выберите уровень образовательной программы'), + + Select::make('status') + ->label('Статус программы') + ->options(EducationalProgramStatus::class) + ->required() + ->native(false) + ->helperText('Определяет видимость программы на сайте'), + + TextInput::make('lang_stud') + ->label('Язык обучения') + ->required() + ->placeholder('Например: русский, английский') + ->helperText('Укажите основной язык преподавания') + ->columnSpan(2), ]), - Builder\Block::make('paragraph') - ->schema([ - RichEditor::make('content') - ->toolbarButtons([ - 'blockquote', - 'bold', - 'bulletList', - 'italic', - 'link', - 'orderedList', - 'redo', - 'strike', - 'underline', - 'undo', - ]) - ->label('') - ->required() - ]), - Builder\Block::make('image') - ->schema([ - FileUpload::make('url') - ->label('Изображение(-я)') - ->image() - ->multiple() - ->reorderable() - ->maxFiles(5) - ->disk('public') - ->directory('images') - ->imageEditor() - ->required(), - TextInput::make('alt') - ->label('Описание') - ->placeholder('Необязяательно') - ])->label('Изображение(-я)'), - Builder\Block::make('video') - ->schema([ - Hidden::make('mime'), + ]), - TextInput::make('title') - ->required() - ->maxLength(255) - ->autofocus(), - - FileUpload::make('path') - ->required() - ->acceptedFileTypes(['video/mp4','video/ogg','video/webm']) - ->maxSize(512000) - ->disk('videos') - ->visibility('public') - ->afterStateUpdated(fn (callable $set, $state) => $set('mime', $state?->getMimeType())), - ]), - Builder\Block::make('files') - ->schema([ - TextInput::make('title') - ->required() - ->maxLength(255) - ->autofocus(), - FileUpload::make('path') - ->required() - ->acceptedFileTypes([ - 'application/pdf', - 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', - 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', - 'application/vnd.openxmlformats-officedocument.presentationml.presentation', - 'application/zip' - ]) - ->maxSize(512000) - ->disk('public') - ->directory('files') - ->downloadable() - ->visibility('public') - ]) - ]) - ->collapsed() - ->blockNumbers(false) - ->collapsible() - ->addActionLabel('Добавить новый блок'), - - ]), - Section::make('Особенности программы')->schema([ - Builder::make('program_features')->label('')->blocks([ - Builder\Block::make('heading')->label('Заголовок') - ->schema([ - TextInput::make('id')->hidden()->integer()->default(rand(2335235,324634264263426)), - TextInput::make('content') - ->label('') - ->live(onBlur: true) - ->afterStateUpdated(function (string $operation, string $state, Forms\Set $set, $get) { - }), - ]), - Builder\Block::make('paragraph') - ->schema([ - RichEditor::make('content') - ->toolbarButtons([ - 'blockquote', - 'bold', - 'bulletList', - 'italic', - 'link', - 'orderedList', - 'redo', - 'strike', - 'underline', - 'undo', - ]) - ->label('') - ->required() - ]), - Builder\Block::make('image') - ->schema([ - FileUpload::make('url') - ->label('Изображение(-я)') - ->image() - ->multiple() - ->reorderable() - ->maxFiles(5) - ->disk('public') - ->directory('images') - ->imageEditor() - ->required(), - TextInput::make('alt') - ->label('Описание') - ->placeholder('Необязяательно') - ])->label('Изображение(-я)'), - Builder\Block::make('video') - ->schema([ - Hidden::make('mime'), - - TextInput::make('title') - ->required() - ->maxLength(255) - ->autofocus(), - - FileUpload::make('path') - ->required() - ->acceptedFileTypes(['video/mp4','video/ogg','video/webm']) - ->maxSize(512000) - ->disk('videos') - ->visibility('public') - ->afterStateUpdated(fn (callable $set, $state) => $set('mime', $state?->getMimeType())), - ]), - Builder\Block::make('files') - ->schema([ - TextInput::make('title') - ->required() - ->maxLength(255) - ->autofocus(), - FileUpload::make('path') - ->required() - ->acceptedFileTypes([ - 'application/pdf', - 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', - 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', - 'application/vnd.openxmlformats-officedocument.presentationml.presentation', - 'application/zip' - ]) - ->maxSize(512000) - ->disk('public') - ->directory('files') - ->downloadable() - ->visibility('public') - ]) - ]) - ->collapsed() - ->blockNumbers(false) - ->collapsible() - ->addActionLabel('Добавить новый блок'), - - ]), - - Select::make('lvl_edu')->options(LevelEducational::class)->label('Уровень образования')->required(), - Select::make('status') - ->options(EducationalProgramStatus::class) - ->label('Статус программы')->required(), - TextInput::make('lang_stud')->label('На каком языке ведется образование')->required(), + Tabs\Tab::make('Описание программы') + ->icon('heroicon-o-document-text') + ->schema([ + self::getContentBuilder('about_program', 'О программе') + ->columnSpanFull(), + ]), + Tabs\Tab::make('Особенности программы') + ->icon('heroicon-o-sparkles') + ->schema([ + self::getContentBuilder('program_features', 'Особенности программы') + ->columnSpanFull(), + ]), ]) + ->persistTabInQueryString() + ->columnSpanFull(), ]); - + } + protected static function getContentBuilder(string $field, string $label): Builder + { + return ContentBuilderItem::getItem($field); } public static function table(Table $table): Table { return $table ->columns([ - Tables\Columns\TextColumn::make('name')->label('Название программы')->sortable()->searchable(), - Tables\Columns\TextColumn::make('directionStudy.lvl_edu')->label('Уровень образования')->limit(30), + TextColumn::make('name') + ->label('Название') + ->sortable() + ->searchable() + ->description(fn ($record) => $record->lang_stud), + + BadgeColumn::make('lvl_edu') + ->label('Уровень') + ->formatStateUsing(fn ($state) => LevelEducational::tryFrom($state->value)?->getLabel()) + ->color(fn ($state) => LevelEducational::tryFrom($state->value)?->getColor()) + ->sortable(), + + BadgeColumn::make('status') + ->label('Статус') + ->formatStateUsing(fn ($state) => EducationalProgramStatus::tryFrom($state)?->getLabel()) + ->color(fn ($state) => EducationalProgramStatus::tryFrom($state)?->getColor()) + ->sortable(), + + TextColumn::make('updated_at') + ->label('Обновлено') + ->dateTime('d.m.Y H:i') + ->sortable() + ->toggleable(isToggledHiddenByDefault: true), ]) ->filters([ - // + Tables\Filters\SelectFilter::make('lvl_edu') + ->label('Уровень образования') + ->options(LevelEducational::class), + + Tables\Filters\SelectFilter::make('status') + ->label('Статус программы') + ->options(EducationalProgramStatus::class), ]) ->actions([ - Tables\Actions\EditAction::make(), + Tables\Actions\EditAction::make() + ->iconButton() + ->tooltip('Редактировать'), + + Tables\Actions\ViewAction::make() + ->iconButton() + ->tooltip('Просмотреть'), ]) ->bulkActions([ Tables\Actions\BulkActionGroup::make([ - Tables\Actions\DeleteBulkAction::make(), + Tables\Actions\DeleteBulkAction::make() + ->label('Удалить выбранные') + ->modalHeading('Удаление программ') + ->modalDescription('Вы уверены, что хотите удалить выбранные программы?'), ]), - ]); + ]) + ->emptyStateActions([ + Tables\Actions\CreateAction::make() + ->label('Добавить программу'), + ]) + ->defaultSort('name'); } public static function getRelations(): array { return [ - AdmissionPlansRelationManager::class + AdmissionPlansRelationManager::class, ]; } @@ -272,4 +176,4 @@ class EducationalProgramResource extends Resource 'edit' => Pages\EditEducationalProgram::route('/{record}/edit'), ]; } -} +} \ No newline at end of file diff --git a/app/Filament/Resources/EducationalProgramResource/RelationManagers/AdmissionPlansRelationManager.php b/app/Filament/Resources/EducationalProgramResource/RelationManagers/AdmissionPlansRelationManager.php index e04e5f1..0f7170b 100644 --- a/app/Filament/Resources/EducationalProgramResource/RelationManagers/AdmissionPlansRelationManager.php +++ b/app/Filament/Resources/EducationalProgramResource/RelationManagers/AdmissionPlansRelationManager.php @@ -3,89 +3,191 @@ namespace App\Filament\Resources\EducationalProgramResource\RelationManagers; use App\Enums\BudgetEducation; -use App\Enums\EducationalProgramStatus; use App\Enums\FormEducation; use App\Models\AdmissionCampaign; -use App\Models\EducationalProgram; use Filament\Forms; +use Filament\Forms\Components\Grid; +use Filament\Forms\Components\Repeater; use Filament\Forms\Components\Section; +use Filament\Forms\Components\Select; use Filament\Forms\Components\TextInput; use Filament\Forms\Form; -use Filament\Forms\Get; use Filament\Resources\RelationManagers\RelationManager; use Filament\Tables; +use Filament\Tables\Columns\TextColumn; use Filament\Tables\Table; -use Illuminate\Database\Eloquent\Builder; -use Illuminate\Database\Eloquent\SoftDeletingScope; class AdmissionPlansRelationManager extends RelationManager { protected static string $relationship = 'admission_plans'; + protected static ?string $title = 'Планы приема'; + protected static ?string $modelLabel = 'план приема'; + protected static ?string $pluralModelLabel = 'планы приема'; public function form(Form $form): Form { return $form ->schema([ - Forms\Components\Select::make('admission_campaigns_id') - ->label('Приемная компания') + Select::make('admission_campaigns_id') + ->label('Приемная кампания') ->required() - ->options(AdmissionCampaign::all()->pluck('name', 'id')), - Section::make('План приема')->schema([ - Forms\Components\Repeater::make('exams')->label('Вступительные испытания')->schema([ - TextInput::make('title')->label('Название-предмета')->required(), - Forms\Components\Select::make('type_exam')->label('Тип-ВИ') - ->options(['ege' => 'ЕГЭ', 'internal_test' => 'ВИ, проводимое организацией самостоятельно'])->required(), - TextInput::make('min_score')->label('Минимальный-балл')->integer()->required() - ])->live()->maxItems(10)->collapsed()->addActionLabel('Добавить вступительное испытание')->columns(3)->required() ->itemLabel(function (Get $get) { - static $count = 0; - $maxCount = count($get('exams')); - $count = ($count++ <= $maxCount) ? $count : 1; - return "Вступительное испытание #" . $count; - }), - Forms\Components\Repeater::make('contests')->label('Условия поступления')->schema([ - Forms\Components\Grid::make(1)->schema([ - Forms\Components\Select::make('form_education')->label('Форма образования') - ->options(FormEducation::class)->required(), - ]), - Forms\Components\Repeater::make('places')->schema([ - Forms\Components\Select::make('form_budget')->label('Форма финансирования') - ->options(BudgetEducation::class)->required(), - TextInput::make('count')->label('Количество мест')->integer()->required(), - ])->columnSpanFull()->maxItems(2), - ])->live()->maxItems(3)->collapsed()->addActionLabel('Добавить группу')->columns(3)->required() - ->itemLabel(function (Get $get) { - static $count = 0; - $maxCount = count($get('contests')); - $count = ($count++ <= $maxCount) ? $count : 1; - return "Группа #" . $count; - }), + ->columnSpanFull() - ]), + ->options( + AdmissionCampaign::query() + ->orderBy('name') + ->pluck('name', 'id') + ) + ->searchable() + ->preload() + ->placeholder('Выберите приемную кампанию') + ->helperText('Выберите связанную приемную кампанию'), + Section::make('План приема') + ->description('Настройка вступительных испытаний и условий поступления') + ->collapsible() + ->schema([ + self::getExamsRepeater(), + self::getContestsRepeater(), + ]), ]); } + protected static function getExamsRepeater(): Repeater + { + return Repeater::make('exams') + ->label('Вступительные испытания') + ->schema([ + TextInput::make('title') + ->label('Название предмета') + ->required() + ->maxLength(100) + ->placeholder('Например: Математика') + ->helperText('Название вступительного испытания'), + + Select::make('type_exam') + ->label('Тип испытания') + ->required() + ->options([ + 'ege' => 'ЕГЭ', + 'internal_test' => 'Внутреннее испытание', + ]) + ->native(false) + ->placeholder('Выберите тип') + ->helperText('Тип вступительного испытания'), + + TextInput::make('min_score') + ->label('Минимальный балл') + ->required() + ->numeric() + ->minValue(0) + ->maxValue(100) + ->placeholder('Укажите минимальный балл') + ->helperText('Минимальный проходной балл'), + ]) + ->columns(3) + ->maxItems(10) + ->collapsible() + ->collapsed() + ->addActionLabel('Добавить испытание') + ->itemLabel(fn (array $state): ?string => $state['title'] ?? 'Новое испытание') + ->helperText('Добавьте все необходимые вступительные испытания'); + } + + protected static function getContestsRepeater(): Repeater + { + return Repeater::make('contests') + ->label('Условия поступления') + ->schema([ + Select::make('form_education') + ->label('Форма обучения') + ->options(FormEducation::class) + ->required() + ->native(false) + ->placeholder('Выберите форму') + ->columnSpanFull() + ->helperText('Форма обучения для данной группы'), + + Repeater::make('places') + ->label('Места') + ->schema([ + Select::make('form_budget') + ->label('Форма финансирования') + ->options(BudgetEducation::class) + ->required() + ->native(false) + ->placeholder('Выберите тип') + ->helperText('Бюджетные или платные места'), + + TextInput::make('count') + ->label('Количество мест') + ->required() + ->numeric() + ->minValue(0) + ->placeholder('Укажите количество') + ->helperText('Количество доступных мест'), + ]) + ->columnSpanFull() + ->maxItems(2) + ->addActionLabel('Добавить тип мест') + ]) + ->columns(2) + ->maxItems(3) + ->collapsible() + ->collapsed() + ->addActionLabel('Добавить группу') + ->helperText('Добавьте группы с условиями поступления'); + } + public function table(Table $table): Table { return $table ->recordTitleAttribute('name') ->columns([ - Tables\Columns\TextColumn::make('admissionCampaign.name'), + TextColumn::make('admissionCampaign.name') + ->label('Приемная кампания') + ->sortable() + ->searchable(), + + TextColumn::make('exams_count') + ->label('Испытаний') + ->getStateUsing(fn ($record) => count($record->exams ?? [])) + ->badge(), + + TextColumn::make('contests_count') + ->label('Групп') + ->getStateUsing(fn ($record) => count($record->contests ?? [])) + ->badge(), ]) ->filters([ // ]) ->headerActions([ - Tables\Actions\CreateAction::make(), + Tables\Actions\CreateAction::make() + ->label('Добавить план') + ->modalHeading('Создание плана приема'), ]) ->actions([ - Tables\Actions\EditAction::make(), - Tables\Actions\DeleteAction::make(), + Tables\Actions\EditAction::make() + ->iconButton() + ->tooltip('Редактировать'), + + Tables\Actions\DeleteAction::make() + ->iconButton() + ->tooltip('Удалить'), ]) ->bulkActions([ Tables\Actions\BulkActionGroup::make([ - Tables\Actions\DetachBulkAction::make(), + Tables\Actions\DeleteBulkAction::make() + ->label('Удалить выбранные') + ->modalHeading('Удаление планов приема') + ->modalDescription('Вы уверены, что хотите удалить выбранные планы?'), ]), - ]); + ]) + ->emptyStateActions([ + Tables\Actions\CreateAction::make() + ->label('Добавить план приема'), + ]) + ->defaultSort('admissionCampaign.name'); } -} +} \ No newline at end of file diff --git a/app/Filament/Resources/EventResource.php b/app/Filament/Resources/EventResource.php index 75bd74a..961b1ad 100644 --- a/app/Filament/Resources/EventResource.php +++ b/app/Filament/Resources/EventResource.php @@ -2,191 +2,211 @@ namespace App\Filament\Resources; +use App\Filament\Components\Forms\ItemForm\Pages\ContentBuilderItem; use App\Filament\Resources\EventResource\Pages; -use App\Filament\Resources\EventResource\RelationManagers; -use App\Models\Category; -use App\Models\EventCategory; -use Filament\Forms\Components\Builder; - use App\Models\Event; +use App\Models\EventCategory; use Filament\Forms; +use Filament\Forms\Components\Actions\Action; use Filament\Forms\Components\DatePicker; -use Filament\Forms\Components\DateTimePicker; -use Filament\Forms\Components\FileUpload; -use Filament\Forms\Components\Hidden; -use Filament\Forms\Components\RichEditor; +use Filament\Forms\Components\Grid; use Filament\Forms\Components\Section; use Filament\Forms\Components\Select; use Filament\Forms\Components\SpatieTagsInput; +use Filament\Forms\Components\Tabs; use Filament\Forms\Components\TextInput; +use Filament\Forms\Components\TimePicker; use Filament\Forms\Components\Toggle; use Filament\Forms\Form; use Filament\Resources\Resource; use Filament\Tables; +use Filament\Tables\Columns\IconColumn; use Filament\Tables\Columns\TextColumn; use Filament\Tables\Table; -use Illuminate\Database\Eloquent\SoftDeletingScope; use Illuminate\Support\Str; -use Mohamedsabil83\FilamentFormsTinyeditor\Components\TinyEditor; class EventResource extends Resource { protected static ?string $model = Event::class; protected static ?string $navigationGroup = 'Новости и мероприятия'; - protected static ?string $navigationIcon = 'heroicon-o-calendar-days'; - - protected static ?string $pluralLabel = 'Мероприятия'; + protected static ?string $modelLabel = 'Мероприятие'; + protected static ?string $pluralModelLabel = 'Мероприятия'; + protected static ?string $navigationLabel = 'Мероприятия'; public static function form(Form $form): Form { return $form ->schema([ - Section::make() - ->schema([ - Forms\Components\Grid::make(2)->schema([ - TextInput::make('title')->label('Заголовок')->required() - ->live(onBlur: true) - ->afterStateUpdated(function (string $operation, $state, Forms\Set $set) { - if ($state !== null) { - $set('slug', Str::slug($state)); - } else { - $set('slug', null); - } - }), - TextInput::make('slug')->label('Slug (Заполнится автоматически)')->unique(ignoreRecord: true)->readOnly()->required(), - ]), - Section::make('Контент')->schema([ - \Filament\Forms\Components\Builder::make('content')->label('')->blocks([ - Builder\Block::make('heading')->label('Заголовок') + Tabs::make('Мероприятие') + ->tabs([ + Tabs\Tab::make('Основное') + ->icon('heroicon-o-information-circle') + ->schema([ + Grid::make(2) ->schema([ - TextInput::make('id')->hidden()->integer()->default(rand(2335235,324634264263426)), - TextInput::make('content') - ->label('') + TextInput::make('title') + ->label('Название мероприятия') + ->placeholder('Введите название мероприятия') + ->helperText('Отображается на сайте') + ->required() + ->maxLength(255) ->live(onBlur: true) - ->afterStateUpdated(function (string $operation, string $state, Forms\Set $set, $get) { + ->afterStateUpdated(function (string $operation, $state, Forms\Set $set) { + $set('slug', $state ? Str::slug($state) : null); }), + + TextInput::make('slug') + ->label('URL-адрес') + ->unique(ignoreRecord: true) + ->required() + ->readOnly() + ->helperText('Формируется автоматически из названия') + ->prefix(fn () => route('client.event.index') . '/') + ->suffixAction( + Action::make('copy') + ->icon('heroicon-s-clipboard-document-check') + ->action(function ($livewire, $state) { + $livewire->js( + 'window.navigator.clipboard.writeText("'. route('client.event.index') . '/' . $state.'"); + $tooltip("'.__('Copied to clipboard').'", { timeout: 1500 });' + ); + })), ]), - Builder\Block::make('paragraph') - ->schema([ - TinyEditor::make('content') - ->label('') - ->profile('test') - ->required(), - ])->label('Текст'), - Builder\Block::make('image') - ->schema([ - FileUpload::make('url') - ->label('Изображение(-я)') - ->image() - ->optimize('jpg') - ->resize(30) - ->multiple() - ->reorderable() - ->maxFiles(5) - ->disk('public') - ->directory('images') - ->imageEditor() - ->required(), - TextInput::make('alt') - ->label('Описание') - ->placeholder('Необязяательно') - ])->label('Изображение(-я)'), - Builder\Block::make('video') - ->schema([ - Hidden::make('mime'), - TextInput::make('title') - ->required() - ->maxLength(255) - ->autofocus(), + Select::make('category_id') + ->label('Категория') + ->placeholder('Выберите категорию') + ->options(EventCategory::all()->pluck('title', 'id')) + ->preload() + ->helperText('Для систематизации мероприятий'), - FileUpload::make('path') - ->required() - ->acceptedFileTypes(['video/mp4','video/ogg','video/webm']) - ->maxSize(512000) - ->disk('videos') - ->visibility('public') - ->afterStateUpdated(fn (callable $set, $state) => $set('mime', $state?->getMimeType())), - ]), - Builder\Block::make('files') - ->schema([ - TextInput::make('title') - ->required() - ->maxLength(255) - ->autofocus(), - FileUpload::make('path') - ->required() - ->acceptedFileTypes([ - 'application/pdf', - 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', - 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', - 'application/vnd.openxmlformats-officedocument.presentationml.presentation', - 'application/zip' - ]) - ->maxSize(512000) - ->disk('public') - ->directory('files') - ->downloadable() - ->visibility('public') - ]) - ]) - ->collapsed() - ->blockNumbers(false) - ->collapsible() - ->addActionLabel('Добавить новый блок'), + SpatieTagsInput::make('tags') + ->label('Теги') + ->placeholder('Добавьте теги') + ->helperText('Для фильтрации и поиска'), - ]), - TextInput::make('address')->label('Адрес')->required(), - - Forms\Components\Grid::make(2)->schema([ - Forms\Components\Grid::make(2)->schema([ - DatePicker::make('event_date_start')->label('Дата начала мероприятия')->required()->native(false) - ->minDate(now()) - ->maxDate(now()->addYear()), - Forms\Components\TimePicker::make('event_time_start')->label('Время начала мероприятия')->seconds(false)->required()->native(false), - DatePicker::make('event_date_end')->label('Дата окончания мероприятия (Опиционально)')->native(false) - ->minDate(now()) - ->maxDate(now()->addYear()), + Toggle::make('is_online') + ->label('Онлайн-формат') + ->helperText('Отметьте для онлайн-мероприятий') + ->default(false) + ->inline(false) + ->onColor('success') + ->offColor('gray'), ]), - Toggle::make('is_online')->default(false)->label('Онлайн мероприятие')->inline(false) + Tabs\Tab::make('Контент') + ->icon('heroicon-o-document-text') + ->schema([ + ContentBuilderItem::getItem('content') + ->columnSpanFull(), + ]), - ]), + Tabs\Tab::make('Дата и место') + ->icon('heroicon-o-map-pin') + ->schema([ + TextInput::make('address') + ->label('Место проведения') + ->placeholder('Адрес или платформа') + ->helperText('Для онлайн укажите платформу (Zoom, YouTube и т.д.)') + ->required() + ->maxLength(255), - Forms\Components\Grid::make(2)->schema([ - Select::make('category_id') - ->options(EventCategory::all()->pluck('title', 'id')) - ->preload() - ->label('Категория'), - SpatieTagsInput::make('tags')->label('Тэги'), - ]), + Grid::make(2) + ->schema([ + DatePicker::make('event_date_start') + ->label('Дата начала') + ->native(false) + ->displayFormat('d/m/Y') + ->helperText('Когда начинается мероприятие') + ->required() + ->minDate(now()) + ->maxDate(now()->addYear()), + TimePicker::make('event_time_start') + ->label('Время начала') + ->seconds(false) + ->native(false) + ->helperText('По местному времени') + ->required(), + + DatePicker::make('event_date_end') + ->label('Дата окончания') + ->native(false) + ->displayFormat('d/m/Y') + ->helperText('Оставьте пустым для однодневного мероприятия') + ->minDate(now()) + ->maxDate(now()->addYear()), + ]), + ]), ]) + ->persistTabInQueryString() + ->columnSpanFull(), ]); - } - public static function table(Table $table): Table { return $table ->columns([ - TextColumn::make('id')->label('ID')->sortable(), - TextColumn::make('title')->label('Название')->sortable()->searchable(), - TextColumn::make('event_date_start')->label('Начало мероприятия')->sortable(), - TextColumn::make('created_at')->label('Дата создания')->sortable(), + TextColumn::make('id') + ->label('ID') + ->sortable() + ->searchable(), + + TextColumn::make('title') + ->label('Название') + ->sortable() + ->searchable() + ->limit(30), + + TextColumn::make('event_date_start') + ->label('Дата начала') + ->date('d.m.Y H:i') + ->sortable(), + + IconColumn::make('is_online') + ->label('Онлайн') + ->boolean() + ->trueIcon('heroicon-o-globe-alt') + ->falseIcon('heroicon-o-map-pin'), + + TextColumn::make('created_at') + ->label('Создано') + ->dateTime('d.m.Y H:i') + ->sortable(), ]) ->filters([ - // + Tables\Filters\SelectFilter::make('category_id') + ->label('Категория') + ->relationship('category', 'title'), + + Tables\Filters\Filter::make('is_online') + ->label('Только онлайн') + ->query(fn ($query) => $query->where('is_online', true)), ]) ->actions([ - Tables\Actions\EditAction::make(), + Tables\Actions\EditAction::make() + ->icon('heroicon-o-pencil') + ->tooltip('Редактировать'), + + Tables\Actions\Action::make('view') + ->icon('heroicon-o-eye') + ->tooltip('Просмотреть на сайте') + ->url(fn (Event $record) => route('client.event.show', $record->slug)) + ->openUrlInNewTab(), ]) ->bulkActions([ Tables\Actions\BulkActionGroup::make([ - Tables\Actions\DeleteBulkAction::make(), + Tables\Actions\DeleteBulkAction::make() + ->label('Удалить выбранное') + ->icon('heroicon-o-trash'), ]), + ]) + ->defaultSort('event_date_start', 'desc') + ->emptyStateActions([ + Tables\Actions\CreateAction::make() + ->label('Добавить мероприятие'), ]); } @@ -205,4 +225,4 @@ class EventResource extends Resource 'edit' => Pages\EditEvent::route('/{record}/edit'), ]; } -} +} \ No newline at end of file diff --git a/app/Filament/Resources/EventResource/Pages/CreateEvent.php b/app/Filament/Resources/EventResource/Pages/CreateEvent.php index dcf0ade..7bd40de 100644 --- a/app/Filament/Resources/EventResource/Pages/CreateEvent.php +++ b/app/Filament/Resources/EventResource/Pages/CreateEvent.php @@ -3,10 +3,26 @@ namespace App\Filament\Resources\EventResource\Pages; use App\Filament\Resources\EventResource; +use App\Services\Filament\Domain\Seo\SeoGeneratorService; +use App\Services\Filament\Traits\SeoGenerate; use Filament\Actions; use Filament\Resources\Pages\CreateRecord; class CreateEvent extends CreateRecord { + use SeoGenerate; + protected static string $resource = EventResource::class; + +// protected function mutateFormDataBeforeCreate(array $data): array +// { +// } + + + protected function afterCreate(): void + { + $this->createSeo($this->record); + } + + } diff --git a/app/Filament/Resources/EventResource/Pages/EditEvent.php b/app/Filament/Resources/EventResource/Pages/EditEvent.php index 6f2497e..0f975c0 100644 --- a/app/Filament/Resources/EventResource/Pages/EditEvent.php +++ b/app/Filament/Resources/EventResource/Pages/EditEvent.php @@ -3,13 +3,21 @@ namespace App\Filament\Resources\EventResource\Pages; use App\Filament\Resources\EventResource; +use App\Services\Filament\Traits\SeoGenerate; use Filament\Actions; use Filament\Resources\Pages\EditRecord; class EditEvent extends EditRecord { + use SeoGenerate; + protected static string $resource = EventResource::class; + protected function afterSave(): void + { + $this->updateSeo($this->record); + } + protected function getHeaderActions(): array { return [ diff --git a/app/Filament/Resources/FacultyRecourceResource/RelationManagers/DepartmentsRelationManager.php b/app/Filament/Resources/FacultyRecourceResource/RelationManagers/DepartmentsRelationManager.php deleted file mode 100644 index be608a6..0000000 --- a/app/Filament/Resources/FacultyRecourceResource/RelationManagers/DepartmentsRelationManager.php +++ /dev/null @@ -1,457 +0,0 @@ -schema([ - Section::make() - ->schema([ - Tabs::make('Tabs') - ->tabs([ - Tabs\Tab::make('Основная информация') - ->schema([ - TextInput::make('title')->label('Название факультета')->required() - ->live(onBlur: true) - ->afterStateUpdated(function (string $operation, string|null $state, Forms\Set $set) { - $set('slug', Str::slug($state)); - $set('seo.title', $state); - }), - TextInput::make('slug')->label('Slug')->unique(ignoreRecord: true)->readOnly()->required(), - Toggle::make('is_active')->default(true)->label('Активный факультет')->inline(false), - Forms\Components\Select::make('faculty_id') - ->options(Faculty::all()->pluck('title', 'id')) - ->label('Факультет') - ->required(), - ]), - Tabs\Tab::make('Описание факультета') - ->schema([ - Builder::make('content')->label('')->blocks([ - Builder\Block::make('heading')->label('Заголовок') - ->schema([ - TextInput::make('id')->hidden()->integer()->default(rand(2335235,324634264263426)), - TextInput::make('content') - ->label('') - ->live(onBlur: true) - ->afterStateUpdated(function (string $operation, string $state, Forms\Set $set, $get) { - }), - ]), - Builder\Block::make('paragraph') - ->schema([ - RichEditor::make('content') - ->toolbarButtons([ - 'blockquote', - 'bold', - 'bulletList', - 'italic', - 'link', - 'orderedList', - 'redo', - 'strike', - 'underline', - 'undo', - ]) - ->label(''), - ])->label('Текст'), - Builder\Block::make('files') - ->schema([ - Forms\Components\Repeater::make('file')->schema([ - Hidden::make('expansion')->required(), - Hidden::make('size')->required(), - TextInput::make('title') - ->required() - ->maxLength(255) - ->autofocus(), - FileUpload::make('path') - ->required() - ->getUploadedFileNameForStorageUsing( - fn (TemporaryUploadedFile $file): string => - str(Str::slug(pathinfo($file->getClientOriginalName(), PATHINFO_FILENAME) . '-' . Carbon::now()->timestamp ) . '.' . $file->getClientOriginalExtension()) - ) - ->acceptedFileTypes([ - 'application/pdf', - 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', - 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', - 'application/vnd.openxmlformats-officedocument.presentationml.presentation', - 'application/zip' - ]) - ->maxSize(512000) - ->disk('public') - ->directory('files') - ->downloadable() - ->afterStateUpdated(function ($set, $state) { - $set('expansion', $state?->getClientOriginalExtension()); - $set('size', ByteConverter::bytesToHuman($state?->getSize())); - }) - ->visibility('public') - ]), - ]), - Builder\Block::make('person') - ->schema([ - TextInput::make('name') - ->label('Имя') - ->required() - ->maxLength(255), - FileUpload::make('photo') - ->label('Фотография') - ->image() - ->disk('public') - ->directory('images') - ->imageEditor(), - Forms\Components\Repeater::make('info')->schema([ - Forms\Components\Grid::make(2)->schema([ - TextInput::make('column') - ->required() - ->maxLength(255), - TextInput::make('content') - ->required() - ->maxLength(255), - ]), - ])->minItems(1), - ]), - Builder\Block::make('stepper') - ->schema([ - TextInput::make('step_name') - ->label('Название шага') - ->required() - ->maxLength(255), - Forms\Components\Repeater::make('steps')->schema([ - TextInput::make('title') - ->required() - ->maxLength(255)->columnSpanFull(), - RichEditor::make('content')->required(), - ])->minItems(1), - ]), - Builder\Block::make('tabs') - ->schema([ - Forms\Components\Repeater::make('tab')->schema([ - TextInput::make('title') - ->required() - ->maxLength(255)->columnSpanFull(), - \Filament\Forms\Components\Builder::make('content')->label('')->blocks([ - Builder\Block::make('heading')->label('Заголовок') - ->schema([ - TextInput::make('id')->hidden()->integer()->default(rand(2335235,324634264263426)), - TextInput::make('content') - ->label('') - ->live(onBlur: true) - ->afterStateUpdated(function (string $operation, string $state, Forms\Set $set, $get) { - }), - ]), - Builder\Block::make('paragraph') - ->schema([ - RichEditor::make('content') - ->toolbarButtons([ - 'blockquote', - 'bold', - 'bulletList', - 'italic', - 'link', - 'orderedList', - 'redo', - 'strike', - 'underline', - 'undo', - ]) - ->label(''), - ])->label('Текст'), - Builder\Block::make('files') - ->schema([ - Forms\Components\Repeater::make('file')->schema([ - TextInput::make('title') - ->required() - ->maxLength(255) - ->autofocus(), - FileUpload::make('path') - ->required() - ->acceptedFileTypes([ - 'application/pdf', - 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', - 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', - 'application/vnd.openxmlformats-officedocument.presentationml.presentation', - 'application/zip' - ]) - ->maxSize(512000) - ->disk('public') - ->directory('files') - ->downloadable() - ->visibility('public') - ]), - ]), - Builder\Block::make('person') - ->schema([ - TextInput::make('name') - ->label('Имя') - ->required() - ->maxLength(255), - FileUpload::make('photo') - ->label('Фотография') - ->image() - ->disk('public') - ->directory('images') - ->imageEditor(), - Forms\Components\Repeater::make('info')->schema([ - Forms\Components\Grid::make(2)->schema([ - TextInput::make('column') - ->required() - ->maxLength(255), - TextInput::make('content') - ->required() - ->maxLength(255), - ]), - ])->minItems(1), - ]), - Builder\Block::make('stepper') - ->schema([ - TextInput::make('step_name') - ->label('Название шага') - ->required() - ->maxLength(255), - Forms\Components\Repeater::make('steps')->schema([ - TextInput::make('title') - ->required() - ->maxLength(255)->columnSpanFull(), - RichEditor::make('content')->required(), - ])->minItems(1), - ]), - Builder\Block::make('images') - ->schema([ - FileUpload::make('url') - ->label('Изображение(-я)') - ->image() - ->multiple() - ->reorderable() - ->maxFiles(5) - ->disk('public') - ->directory('images') - ->imageEditor() - ->required(), - TextInput::make('alt') - ->label('Описание') - ->placeholder('Необязяательно') - ])->label('Слайдер изображений'), - Builder\Block::make('image') - ->schema([ - FileUpload::make('url') - ->label('Изображение(-я)') - ->image() - ->multiple() - ->reorderable() - ->maxFiles(5) - ->disk('public') - ->directory('images') - ->imageEditor() - ->required(), - TextInput::make('alt') - ->label('Описание') - ->placeholder('Необязяательно') - ])->label('Изображение'), - Builder\Block::make('video') - ->schema([ - TextInput::make('mime')->readOnly(), - TextInput::make('title') - ->required() - ->maxLength(255) - ->autofocus(), - FileUpload::make('path') - ->required() - ->acceptedFileTypes([ - 'video/mp4', - 'video/quicktime', - 'video/x-msvideo', - 'video/x-ms-wmv', - 'video/avi', - 'video/webm', - 'video/ogg', - 'video/3gpp', - 'video/3gpp2', - 'video/x-m4v', - ]) - ->disk('public') - ->directory('videos') - ->afterStateUpdated(fn (callable $set, $state) => $set('mime', $state?->getMimeType())), - ]), - Builder\Block::make('postsList') - ->schema([ - Forms\Components\Grid::make(2)->schema([ - TextInput::make('count') - ->label('Количество запией') - ->integer(), - Select::make('category') - ->options(Category::all()->pluck('title', 'id')) - ]), - ])->label('Список новостей'), - ]) - ->collapsed() - ->blockNumbers(false) - ->collapsible() - ->addActionLabel('Добавить новый блок'), - ])->minItems(1), - ]), - Builder\Block::make('images') - ->schema([ - FileUpload::make('url') - ->label('Изображение(-я)') - ->image() - ->multiple() - ->reorderable() - ->maxFiles(5) - ->disk('public') - ->directory('images') - ->imageEditor() - ->required(), - TextInput::make('alt') - ->label('Описание') - ->placeholder('Необязяательно') - ])->label('Слайдер изображений'), - Builder\Block::make('image') - ->schema([ - FileUpload::make('url') - ->label('Изображение(-я)') - ->image() - ->multiple() - ->reorderable() - ->maxFiles(5) - ->disk('public') - ->directory('images') - ->imageEditor() - ->required(), - TextInput::make('alt') - ->label('Описание') - ->placeholder('Необязяательно') - ])->label('Изображение'), - Builder\Block::make('video') - ->schema([ - TextInput::make('mime')->readOnly(), - TextInput::make('title') - ->required() - ->maxLength(255) - ->autofocus(), - FileUpload::make('path') - ->required() - ->acceptedFileTypes([ - 'video/mp4', - 'video/quicktime', - 'video/x-msvideo', - 'video/x-ms-wmv', - 'video/avi', - 'video/webm', - 'video/ogg', - 'video/3gpp', - 'video/3gpp2', - 'video/x-m4v', - ]) - ->disk('public') - ->directory('videos') - ->afterStateUpdated(fn (callable $set, $state) => $set('mime', $state?->getMimeType())), - ]), - Builder\Block::make('postsList') - ->schema([ - Forms\Components\Grid::make(2)->schema([ - TextInput::make('count') - ->label('Количество запией') - ->integer(), - Select::make('category') - ->options(Category::all()->pluck('title', 'id')) - ]), - ])->label('Список новостей'), - Builder\Block::make('postItem') - ->schema([ - Select::make('post') - ->options(Post::query()->where('status', PostStatus::PUBLISHED)->pluck('title', 'id')) - ->searchable() - ->required(), - ])->label('Новость'), - Builder\Block::make('pageItem') - ->schema([ - Select::make('page') - ->options(Page::query()->where('title', '!=' , null)->where('is_visible', true)->pluck('title', 'id')) - ->searchable() - ->required(), - ])->label('Страница'), - Builder\Block::make('customForm') - ->schema([ - Select::make('form') - ->options(CustomForm::query()->where('status', CustomFormStatus::PUBLISHED)->pluck('title', 'form_id')) - ->searchable() - ->required(), - ])->label('Форма'), - Builder\Block::make('pageResourceList') - ->schema([ - Select::make('resource') - ->options(PageReferenceList::query()->where('is_active', true)->pluck('title', 'slug')) - ->searchable() - ->required(), - ])->label('Ресурсы'), - ]) - ->collapsed() - ->blockNumbers(false) - ->collapsible() - ->required() - ->blockPickerColumns(3) - ->blockPickerWidth('2xl') - ->addActionLabel('Добавить новый блок'), - ]), - ]), - ]) - ]); - } - - public function table(Table $table): Table - { - return $table - ->recordTitleAttribute('title') - ->columns([ - TextColumn::make('id')->label('ID')->sortable(), - TextColumn::make('title')->label('Название')->sortable()->searchable(), - TextColumn::make('created_at')->label('Дата создания')->sortable(), - Tables\Columns\ToggleColumn::make('is_active')->label('Активно')->sortable(), ]) - ->filters([ - // - ]) - ->headerActions([ - Tables\Actions\CreateAction::make(), - Tables\Actions\AssociateAction::make(), - ]) - ->actions([ - Tables\Actions\EditAction::make(), - ]); - } -} diff --git a/app/Filament/Resources/FacultyResource.php b/app/Filament/Resources/FacultyResource.php index f38b5e8..1d7eadd 100644 --- a/app/Filament/Resources/FacultyResource.php +++ b/app/Filament/Resources/FacultyResource.php @@ -4,11 +4,10 @@ namespace App\Filament\Resources; use App\Enums\CustomFormStatus; use App\Enums\PostStatus; -use App\Filament\Resources\FacultyRecourceResource\RelationManagers\DepartmentsRelationManager; +use App\Filament\Components\Forms\ItemForm\Pages\ContentBuilderItem; use App\Filament\Resources\FacultyResource\Pages; -use App\Filament\Resources\FacultyResource\RelationManagers; +use App\Filament\Resources\FacultyResource\RelationManagers\DepartmentsRelationManager; use App\Filament\Resources\FacultyResource\RelationManagers\WorkersRelationManager; -use App\Helpers\ByteConverter; use App\Models\Category; use App\Models\CustomForm; use App\Models\Faculty; @@ -16,446 +15,153 @@ use App\Models\Page; use App\Models\PageReferenceList; use App\Models\Post; use Filament\Forms; +use Filament\Forms\Components\Actions\Action; use Filament\Forms\Components\Builder; use Filament\Forms\Components\FileUpload; -use Filament\Forms\Components\Hidden; -use Filament\Forms\Components\RichEditor; use Filament\Forms\Components\Section; use Filament\Forms\Components\Select; -use Filament\Forms\Components\Tabs; use Filament\Forms\Components\TextInput; use Filament\Forms\Components\Toggle; use Filament\Forms\Form; use Filament\Resources\Resource; use Filament\Tables; +use Filament\Tables\Columns\IconColumn; use Filament\Tables\Columns\TextColumn; use Filament\Tables\Table; -use Illuminate\Database\Eloquent\SoftDeletingScope; -use Illuminate\Support\Carbon; use Illuminate\Support\Str; -use Livewire\Features\SupportFileUploads\TemporaryUploadedFile; -use Mohamedsabil83\FilamentFormsTinyeditor\Components\TinyEditor; class FacultyResource extends Resource { protected static ?string $model = Faculty::class; - protected static ?string $navigationGroup = 'Структура института'; - protected static ?string $navigationIcon = 'heroicon-o-building-office-2'; - protected static ?string $pluralLabel = 'Факультеты'; - - public static ?string $label = 'Факультет'; - - + protected static ?string $modelLabel = 'факультет'; public static function form(Form $form): Form { return $form ->schema([ - Section::make() - ->schema([ - Tabs::make('Tabs') + Forms\Components\Tabs::make('Настройки факультета') + ->persistTabInQueryString() + ->columnSpanFull() ->tabs([ - Tabs\Tab::make('Основная информация') + Forms\Components\Tabs\Tab::make('Основные данные') + ->icon('heroicon-o-information-circle') ->schema([ - TextInput::make('title')->label('Название факультета')->required() - ->live(onBlur: true) - ->afterStateUpdated(function (string $operation, string|null $state, Forms\Set $set) { - $set('slug', Str::slug($state)); - $set('seo.title', $state); - }), - TextInput::make('slug')->label('Slug')->unique(ignoreRecord: true)->readOnly()->required(), - TextInput::make('abbreviation')->label('Аббревиатура')->required(), - Toggle::make('is_active')->default(true)->label('Активный факультет')->inline(false), + Section::make('Идентификация') + ->description('Основная информация о факультете') + ->schema([ + TextInput::make('title') + ->label('Полное название') + ->required() + ->maxLength(255) + ->placeholder('Например: Факультет информационных технологий') + ->live(onBlur: true) + ->afterStateUpdated(function (string $operation, ?string $state, Forms\Set $set) { + $set('slug', Str::slug($state)); + if ($operation !== 'edit') { + $set('seo.title', $state); + } + }) + ->helperText('Официальное название факультета'), + + TextInput::make('slug') + ->label('URL-адрес') + ->unique(ignoreRecord: true) + ->required() + ->readOnly() + ->helperText('Формируется автоматически из названия') + ->prefix(fn () => route('client.faculty.index') . '/') + ->suffixAction( + Action::make('copy') + ->icon('heroicon-s-clipboard-document-check') + ->action(function ($livewire, $state) { + $livewire->js( + 'window.navigator.clipboard.writeText("'. route('client.faculty.index') . '/' . $state.'"); + $tooltip("'.__('Copied to clipboard').'", { timeout: 1500 });' + ); + })), + + TextInput::make('abbreviation') + ->label('Аббревиатура') + ->required() + ->maxLength(10) + ->placeholder('Например: ФИТ') + ->helperText('Короткое обозначение факультета'), + + Toggle::make('is_active') + ->label('Активный факультет') + ->inline(false) + ->default(true) + ->helperText('Отображать ли факультет на сайте'), + ]), ]), - Tabs\Tab::make('Описание факультета') + + Forms\Components\Tabs\Tab::make('Контент') + ->icon('heroicon-o-document-text') ->schema([ - Builder::make('content')->label('')->blocks([ - Builder\Block::make('heading')->label('Заголовок') - ->schema([ - TextInput::make('id')->hidden()->integer()->default(rand(2335235,324634264263426)), - TextInput::make('content') - ->label('') - ->live(onBlur: true) - ->afterStateUpdated(function (string $operation, string $state, Forms\Set $set, $get) { - }), - ]), - Builder\Block::make('paragraph') - ->schema([ - TinyEditor::make('content') - ->label('') - ->profile('test') - ->required(), - ])->label('Текст'), - Builder\Block::make('files') - ->schema([ - Forms\Components\Repeater::make('file')->schema([ - Hidden::make('expansion')->required(), - Hidden::make('size')->required(), - TextInput::make('title') - ->required() - ->maxLength(255) - ->autofocus(), - FileUpload::make('path') - ->required() - ->getUploadedFileNameForStorageUsing( - fn (TemporaryUploadedFile $file): string => - str(Str::slug(pathinfo($file->getClientOriginalName(), PATHINFO_FILENAME) . '-' . Carbon::now()->timestamp ) . '.' . $file->getClientOriginalExtension()) - ) - ->acceptedFileTypes([ - 'application/pdf', - 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', - 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', - 'application/vnd.openxmlformats-officedocument.presentationml.presentation', - 'application/zip' - ]) - ->maxSize(512000) - ->disk('public') - ->directory('files') - ->downloadable() - ->afterStateUpdated(function ($set, $state) { - $set('expansion', $state?->getClientOriginalExtension()); - $set('size', ByteConverter::bytesToHuman($state?->getSize())); - }) - ->visibility('public') - ]), - ]), - Builder\Block::make('person') - ->schema([ - TextInput::make('name') - ->label('Имя') - ->required() - ->maxLength(255), - FileUpload::make('photo') - ->label('Фотография') - ->image() - ->disk('public') - ->directory('images') - ->imageEditor(), - Forms\Components\Repeater::make('info')->schema([ - Forms\Components\Grid::make(2)->schema([ - TextInput::make('column') - ->required() - ->maxLength(255), - TextInput::make('content') - ->required() - ->maxLength(255), - ]), - ])->minItems(1), - ]), - Builder\Block::make('stepper') - ->schema([ - TextInput::make('step_name') - ->label('Название шага') - ->required() - ->maxLength(255), - Forms\Components\Repeater::make('steps')->schema([ - TextInput::make('title') - ->required() - ->maxLength(255)->columnSpanFull(), - TinyEditor::make('content') - ->label('') - ->profile('test') - ->required(), - ])->minItems(1), - ]), - Builder\Block::make('tabs') - ->schema([ - Forms\Components\Repeater::make('tab')->schema([ - TextInput::make('title') - ->required() - ->maxLength(255)->columnSpanFull(), - \Filament\Forms\Components\Builder::make('content')->label('')->blocks([ - Builder\Block::make('heading')->label('Заголовок') - ->schema([ - TextInput::make('id')->hidden()->integer()->default(rand(2335235,324634264263426)), - TextInput::make('content') - ->label('') - ->live(onBlur: true) - ->afterStateUpdated(function (string $operation, string $state, Forms\Set $set, $get) { - }), - ]), - Builder\Block::make('paragraph') - ->schema([ - RichEditor::make('content') - ->toolbarButtons([ - 'blockquote', - 'bold', - 'bulletList', - 'italic', - 'link', - 'orderedList', - 'redo', - 'strike', - 'underline', - 'undo', - ]) - ->label(''), - ])->label('Текст'), - Builder\Block::make('files') - ->schema([ - Forms\Components\Repeater::make('file')->schema([ - TextInput::make('title') - ->required() - ->maxLength(255) - ->autofocus(), - FileUpload::make('path') - ->required() - ->acceptedFileTypes([ - 'application/pdf', - 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', - 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', - 'application/vnd.openxmlformats-officedocument.presentationml.presentation', - 'application/zip' - ]) - ->maxSize(512000) - ->disk('public') - ->directory('files') - ->downloadable() - ->visibility('public') - ]), - ]), - Builder\Block::make('person') - ->schema([ - TextInput::make('name') - ->label('Имя') - ->required() - ->maxLength(255), - FileUpload::make('photo') - ->label('Фотография') - ->image() - ->disk('public') - ->directory('images') - ->imageEditor(), - Forms\Components\Repeater::make('info')->schema([ - Forms\Components\Grid::make(2)->schema([ - TextInput::make('column') - ->required() - ->maxLength(255), - TextInput::make('content') - ->required() - ->maxLength(255), - ]), - ])->minItems(1), - ]), - Builder\Block::make('stepper') - ->schema([ - TextInput::make('step_name') - ->label('Название шага') - ->required() - ->maxLength(255), - Forms\Components\Repeater::make('steps')->schema([ - TextInput::make('title') - ->required() - ->maxLength(255)->columnSpanFull(), - RichEditor::make('content')->required(), - ])->minItems(1), - ]), - Builder\Block::make('images') - ->schema([ - FileUpload::make('url') - ->label('Изображение(-я)') - ->image() - ->multiple() - ->reorderable() - ->maxFiles(5) - ->disk('public') - ->directory('images') - ->imageEditor() - ->required(), - TextInput::make('alt') - ->label('Описание') - ->placeholder('Необязяательно') - ])->label('Слайдер изображений'), - Builder\Block::make('image') - ->schema([ - FileUpload::make('url') - ->label('Изображение(-я)') - ->image() - ->multiple() - ->reorderable() - ->maxFiles(5) - ->disk('public') - ->directory('images') - ->imageEditor() - ->required(), - TextInput::make('alt') - ->label('Описание') - ->placeholder('Необязяательно') - ])->label('Изображение'), - Builder\Block::make('video') - ->schema([ - TextInput::make('mime')->readOnly(), - TextInput::make('title') - ->required() - ->maxLength(255) - ->autofocus(), - FileUpload::make('path') - ->required() - ->acceptedFileTypes([ - 'video/mp4', - 'video/quicktime', - 'video/x-msvideo', - 'video/x-ms-wmv', - 'video/avi', - 'video/webm', - 'video/ogg', - 'video/3gpp', - 'video/3gpp2', - 'video/x-m4v', - ]) - ->disk('public') - ->directory('videos') - ->afterStateUpdated(fn (callable $set, $state) => $set('mime', $state?->getMimeType())), - ]), - Builder\Block::make('postsList') - ->schema([ - Forms\Components\Grid::make(2)->schema([ - TextInput::make('count') - ->label('Количество запией') - ->integer(), - Select::make('category') - ->options(Category::all()->pluck('title', 'id')) - ]), - ])->label('Список новостей'), - ]) - ->collapsed() - ->blockNumbers(false) - ->collapsible() - ->addActionLabel('Добавить новый блок'), - ])->minItems(1), - ]), - Builder\Block::make('images') - ->schema([ - FileUpload::make('url') - ->label('Изображение(-я)') - ->image() - ->multiple() - ->reorderable() - ->maxFiles(5) - ->disk('public') - ->directory('images') - ->imageEditor() - ->required(), - TextInput::make('alt') - ->label('Описание') - ->placeholder('Необязяательно') - ])->label('Слайдер изображений'), - Builder\Block::make('image') - ->schema([ - FileUpload::make('url') - ->label('Изображение(-я)') - ->image() - ->multiple() - ->reorderable() - ->maxFiles(5) - ->disk('public') - ->directory('images') - ->imageEditor() - ->required(), - TextInput::make('alt') - ->label('Описание') - ->placeholder('Необязяательно') - ])->label('Изображение'), - Builder\Block::make('video') - ->schema([ - TextInput::make('mime')->readOnly(), - TextInput::make('title') - ->required() - ->maxLength(255) - ->autofocus(), - FileUpload::make('path') - ->required() - ->acceptedFileTypes([ - 'video/mp4', - 'video/quicktime', - 'video/x-msvideo', - 'video/x-ms-wmv', - 'video/avi', - 'video/webm', - 'video/ogg', - 'video/3gpp', - 'video/3gpp2', - 'video/x-m4v', - ]) - ->disk('public') - ->directory('videos') - ->afterStateUpdated(fn (callable $set, $state) => $set('mime', $state?->getMimeType())), - ]), - Builder\Block::make('postsList') - ->schema([ - Forms\Components\Grid::make(2)->schema([ - TextInput::make('count') - ->label('Количество запией') - ->integer(), - Select::make('category') - ->options(Category::all()->pluck('title', 'id')) - ]), - ])->label('Список новостей'), - Builder\Block::make('postItem') - ->schema([ - Select::make('post') - ->options(Post::query()->where('status', PostStatus::PUBLISHED)->pluck('title', 'id')) - ->searchable() - ->required(), - ])->label('Новость'), - Builder\Block::make('pageItem') - ->schema([ - Select::make('page') - ->options(Page::query()->where('title', '!=' , null)->where('is_visible', true)->pluck('title', 'id')) - ->searchable() - ->required(), - ])->label('Страница'), - Builder\Block::make('customForm') - ->schema([ - Select::make('form') - ->options(CustomForm::query()->where('status', CustomFormStatus::PUBLISHED)->pluck('title', 'form_id')) - ->searchable() - ->required(), - ])->label('Форма'), - Builder\Block::make('pageResourceList') - ->schema([ - Select::make('resource') - ->options(PageReferenceList::query()->where('is_active', true)->pluck('title', 'slug')) - ->searchable() - ->required(), - ])->label('Ресурсы'), - ]) - ->collapsed() - ->blockNumbers(false) - ->collapsible() - ->required() - ->blockPickerColumns(3) - ->blockPickerWidth('2xl') - ->addActionLabel('Добавить новый блок'), + ContentBuilderItem::getItem('content') ]), ]), - ]) - ]); + ]); } + public static function table(Table $table): Table { return $table ->columns([ - TextColumn::make('id')->label('ID')->sortable(), - TextColumn::make('title')->label('Название')->sortable()->searchable(), - TextColumn::make('created_at')->label('Дата создания')->sortable(), - Tables\Columns\ToggleColumn::make('is_active')->label('Активно')->sortable(), - ]) + TextColumn::make('title') + ->label('Название') + ->searchable() + ->sortable() + ->description(fn ($record) => $record->abbreviation), + + IconColumn::make('is_active') + ->label('Статус') + ->boolean() + ->trueIcon('heroicon-o-check-circle') + ->falseIcon('heroicon-o-x-circle') + ->trueColor('success') + ->falseColor('danger') + ->sortable(), + + TextColumn::make('updated_at') + ->label('Обновлено') + ->dateTime('d.m.Y H:i') + ->sortable() + ->toggleable(isToggledHiddenByDefault: true), + ]) ->filters([ - // + Tables\Filters\TernaryFilter::make('is_active') + ->label('Только активные') + ->placeholder('Все') + ->trueLabel('Активные') + ->falseLabel('Неактивные'), ]) ->actions([ - Tables\Actions\EditAction::make(), + Tables\Actions\EditAction::make() + ->iconButton() + ->tooltip('Редактировать'), + + Tables\Actions\ViewAction::make() + ->iconButton() + ->tooltip('Просмотреть'), ]) ->bulkActions([ Tables\Actions\BulkActionGroup::make([ - Tables\Actions\DeleteBulkAction::make(), + Tables\Actions\DeleteBulkAction::make() + ->label('Удалить выбранные') + ->modalHeading('Удаление факультетов') + ->modalDescription('Вы уверены, что хотите удалить выбранные факультеты?'), ]), - ]); + ]) + ->emptyStateActions([ + Tables\Actions\CreateAction::make() + ->label('Добавить факультет'), + ]) + ->defaultSort('title'); } public static function getRelations(): array @@ -474,4 +180,4 @@ class FacultyResource extends Resource 'edit' => Pages\EditFaculty::route('/{record}/edit'), ]; } -} +} \ No newline at end of file diff --git a/app/Filament/Resources/FacultyResource/Pages/CreateFaculty.php b/app/Filament/Resources/FacultyResource/Pages/CreateFaculty.php index 466ba62..61400c2 100644 --- a/app/Filament/Resources/FacultyResource/Pages/CreateFaculty.php +++ b/app/Filament/Resources/FacultyResource/Pages/CreateFaculty.php @@ -3,42 +3,28 @@ namespace App\Filament\Resources\FacultyResource\Pages; use App\Filament\Resources\FacultyResource; +use App\Services\Filament\Traits\SeoGenerate; use Filament\Actions; use Filament\Resources\Pages\CreateRecord; use Illuminate\Support\Str; class CreateFaculty extends CreateRecord { + use SeoGenerate; + protected static string $resource = FacultyResource::class; - protected array $seoData; protected function mutateFormDataBeforeCreate(array $data): array { - $this->seoData = $this->generateSeo($data); $data['search_data'] = $this->generateSearchData($data['content']); return $data; } protected function afterCreate(): void { - $this->record->seo()->create($this->seoData); - } - - private function generateSeo(array $data) : array - { - $title = $data['title']; - $rowData = $this->getFirstBlockByName('paragraph', $data['content']); - if ($rowData !== null) { - $description = strip_tags($rowData['data']['content']); - } else { - $description = null; - } - return [ - 'title' => $title, - 'description' => Str::limit(htmlspecialchars($description, ENT_QUOTES, 'UTF-8'), 160), - ]; + $this->createSeo($this->record); } private function generateSearchData(array $data) : string diff --git a/app/Filament/Resources/FacultyResource/Pages/EditFaculty.php b/app/Filament/Resources/FacultyResource/Pages/EditFaculty.php index 85d7b3f..84070ec 100644 --- a/app/Filament/Resources/FacultyResource/Pages/EditFaculty.php +++ b/app/Filament/Resources/FacultyResource/Pages/EditFaculty.php @@ -3,20 +3,20 @@ namespace App\Filament\Resources\FacultyResource\Pages; use App\Filament\Resources\FacultyResource; +use App\Services\Filament\Traits\SeoGenerate; use Filament\Actions; use Filament\Resources\Pages\EditRecord; use Illuminate\Support\Str; class EditFaculty extends EditRecord { - protected static string $resource = FacultyResource::class; + use SeoGenerate; - protected array $seoData; + protected static string $resource = FacultyResource::class; protected function mutateFormDataBeforeSave(array $data): array { - $this->seoData = $this->generateSeo($data); $data['search_data'] = $this->generateSearchData($data['content']); return $data; @@ -24,7 +24,7 @@ class EditFaculty extends EditRecord protected function afterSave(): void { - $this->record->seo()->update($this->seoData); + $this->updateSeo($this->record); } diff --git a/app/Filament/Resources/FacultyResource/RelationManagers/DepartmentsRelationManager.php b/app/Filament/Resources/FacultyResource/RelationManagers/DepartmentsRelationManager.php index 7484878..4edd5f9 100644 --- a/app/Filament/Resources/FacultyResource/RelationManagers/DepartmentsRelationManager.php +++ b/app/Filament/Resources/FacultyResource/RelationManagers/DepartmentsRelationManager.php @@ -39,11 +39,11 @@ class DepartmentsRelationManager extends RelationManager ]) ->actions([ Tables\Actions\EditAction::make(), - Tables\Actions\DetachAction::make(), + Tables\Actions\DeleteAction::make(), ]) ->bulkActions([ Tables\Actions\BulkActionGroup::make([ - Tables\Actions\DetachBulkAction::make(), + Tables\Actions\DeleteBulkAction::make(), ]), ]); } diff --git a/app/Filament/Resources/FacultyResource/RelationManagers/WorkersRelationManager.php b/app/Filament/Resources/FacultyResource/RelationManagers/WorkersRelationManager.php index c0651bd..5f442ce 100644 --- a/app/Filament/Resources/FacultyResource/RelationManagers/WorkersRelationManager.php +++ b/app/Filament/Resources/FacultyResource/RelationManagers/WorkersRelationManager.php @@ -9,23 +9,47 @@ use Filament\Tables; use Filament\Tables\Actions\AttachAction; use Filament\Tables\Table; use Illuminate\Database\Eloquent\Builder; -use Illuminate\Database\Eloquent\SoftDeletingScope; class WorkersRelationManager extends RelationManager { protected static string $relationship = 'workers'; - - protected static ?string $title = 'Сотрудники'; - + protected static ?string $title = 'Сотрудники факультета'; + protected static ?string $modelLabel = 'сотрудник'; + protected static ?string $pluralModelLabel = 'сотрудники'; public function form(Form $form): Form { return $form ->schema([ - Forms\Components\TextInput::make('position')->label('Должность')->required(), - Forms\Components\TextInput::make('service_email')->label('Служебная почта'), - Forms\Components\TextInput::make('service_phone')->label('Служебный телефон'), - Forms\Components\TextInput::make('cabinet')->label('Кабинет'), + Forms\Components\Grid::make(2) + ->schema([ + Forms\Components\TextInput::make('position') + ->label('Должность') + ->required() + ->maxLength(255) + ->placeholder('Например: Декан факультета') + ->helperText('Укажите официальную должность'), + + Forms\Components\TextInput::make('service_email') + ->label('Рабочая почта') + ->email() + ->maxLength(255) + ->placeholder('example@university.ru') + ->helperText('Корпоративная электронная почта'), + + Forms\Components\TextInput::make('service_phone') + ->label('Рабочий телефон') + ->tel() + ->maxLength(20) + ->placeholder('+7 (XXX) XXX-XX-XX') + ->helperText('Номер рабочего телефона с кодом'), + + Forms\Components\TextInput::make('cabinet') + ->label('Кабинет') + ->maxLength(10) + ->placeholder('Например: 305а') + ->helperText('Номер кабинета для приема'), + ]) ]); } @@ -36,9 +60,34 @@ class WorkersRelationManager extends RelationManager ->reorderable('sort') ->defaultSort('sort') ->columns([ - Tables\Columns\TextColumn::make('name')->label('Имя'), - Tables\Columns\TextColumn::make('position')->label('Должность'), + Tables\Columns\TextColumn::make('name') + ->label('ФИО') + ->searchable() + ->sortable(), + Tables\Columns\TextColumn::make('position') + ->label('Должность') + ->searchable() + ->sortable() + ->limit(30), + + Tables\Columns\TextColumn::make('service_email') + ->label('Почта') + ->searchable() + ->icon('heroicon-o-envelope') + ->toggleable(isToggledHiddenByDefault: true), + + Tables\Columns\TextColumn::make('service_phone') + ->label('Телефон') + ->searchable() + ->icon('heroicon-o-phone') + ->toggleable(isToggledHiddenByDefault: true), + + Tables\Columns\TextColumn::make('cabinet') + ->label('Кабинет') + ->searchable() + ->icon('heroicon-o-home-modern') + ->toggleable(isToggledHiddenByDefault: false), ]) ->filters([ // @@ -46,21 +95,70 @@ class WorkersRelationManager extends RelationManager ->headerActions([ AttachAction::make() ->form(fn (AttachAction $action): array => [ - $action->getRecordSelect()->preload(), - Forms\Components\TextInput::make('position')->label('Должность')->required(), - Forms\Components\TextInput::make('service_email')->label('Служебная почта'), - Forms\Components\TextInput::make('service_phone')->label('Служебный телефон'), - Forms\Components\TextInput::make('cabinet')->label('Кабинет'), + $action->getRecordSelect() + ->label('Сотрудник') + ->searchable() + ->preload() + ->required() + ->helperText('Выберите сотрудника из списка'), + + Forms\Components\TextInput::make('position') + ->label('Должность на факультете') + ->required() + ->maxLength(255) + ->placeholder('Например: Старший преподаватель') + ->helperText('Укажите должность на этом факультете'), + + Forms\Components\Grid::make(1) + ->schema([ + Forms\Components\TextInput::make('service_email') + ->label('Рабочая почта') + ->email() + ->maxLength(255) + ->placeholder('example@university.ru') + ->helperText('Корпоративная электронная почта'), + + Forms\Components\TextInput::make('service_phone') + ->label('Рабочий телефон') + ->tel() + ->maxLength(20) + ->placeholder('+7 (XXX) XXX-XX-XX') + ->helperText('Номер рабочего телефона с кодом'), + + Forms\Components\TextInput::make('cabinet') + ->label('Кабинет') + ->maxLength(10) + ->placeholder('Например: 305а') + ->helperText('Номер кабинета для приема'), + ]) ]) + ->modalHeading('Добавить сотрудника') + ->modalSubmitActionLabel('Добавить') + ->modalButton('Добавить') ]) ->actions([ - Tables\Actions\EditAction::make(), - Tables\Actions\DetachAction::make(), + Tables\Actions\EditAction::make() + ->iconButton() + ->tooltip('Редактировать'), + + Tables\Actions\DetachAction::make() + ->iconButton() + ->tooltip('Открепить') + ->modalHeading('Открепить сотрудника') + ->modalDescription('Вы уверены, что хотите открепить этого сотрудника от факультета?') + ->modalSubmitActionLabel('Открепить'), ]) ->bulkActions([ Tables\Actions\BulkActionGroup::make([ - Tables\Actions\DetachBulkAction::make(), + Tables\Actions\DetachBulkAction::make() + ->label('Открепить выбранных') + ->modalHeading('Открепить сотрудников') + ->modalDescription('Вы уверены, что хотите открепить выбранных сотрудников от факультета?') + ->modalSubmitActionLabel('Открепить'), ]), - ]); + ]) + ->emptyStateHeading('Нет сотрудников') + ->emptyStateDescription('Добавьте сотрудников, используя кнопку выше') + ->emptyStateIcon('heroicon-o-user-group'); } -} +} \ No newline at end of file diff --git a/app/Filament/Resources/MainSliderResource.php b/app/Filament/Resources/MainSliderResource.php deleted file mode 100644 index b2c62ad..0000000 --- a/app/Filament/Resources/MainSliderResource.php +++ /dev/null @@ -1,221 +0,0 @@ -schema([ - Forms\Components\Section::make('Быстрая настройка слайда')->schema([ - Forms\Components\Grid::make()->schema([ - Forms\Components\Select::make('model_select') - ->name('') - ->label('Выбор типа данных') - ->options([ - 'Post' => 'Новость', - 'Page' => 'Страница', - 'Event' => 'Мероприятие', - 'Custom' => 'Кастомная ссылка', - ]) - ->afterStateUpdated(function (string $operation, string|null $state, Forms\Set $set, Forms\Get $get) { - if ($get('model_select') === 'Custom') { - $set('model', null); - $set('title', null); - $set('content', null); - $set('link', null); - }; - })->live(onBlur: true), - - Forms\Components\Select::make('model') - ->label('Поиск данных') - ->name('') - ->live(onBlur: true) - ->searchable() - ->live(onBlur: true) - ->afterStateUpdated(function (string $operation, string|null $state, Forms\Set $set, Forms\Get $get) { - if ($get('model_select') === 'Post') { - $post = Post::find($state); - $set('title', $post->title); - $relativeUrl = parse_url(route('client.post.show', $post->slug), PHP_URL_PATH); - $set('link', $relativeUrl); - }; - if ($get('model_select') === 'Page') { - $page = Page::find($state); - $set('title', $page->title); - $set('link', $page->path); - }; - if ($get('model_select') === 'Event') { - $event = Event::find($state); - $set('title', $event->title); - $relativeUrl = parse_url(route('client.event.show', $event->slug), PHP_URL_PATH); - $set('link', $relativeUrl); - }; - - }) - ->options(function (Forms\Get $get) { - if ($get('model_select') === 'Post') { - return Post::where('status', '=', 'published')->pluck('title', 'id'); - }; - if ($get('model_select') === 'Page') { - return Page::where('title', '!=', null)->pluck('title', 'id'); - }; - if ($get('model_select') === 'Event') { - return Event::all()->pluck('title', 'id'); - }; - if ($get('model_select') === 'Custom') { - return []; - }; - }), - ]), - ]), - Forms\Components\Section::make('Слайдер')->schema([ - - Forms\Components\Section::make('Информация слайда')->schema([ - Forms\Components\TextInput::make('title') - ->label('Заголовок слайда'), - Forms\Components\Textarea::make('content') - ->label('Текст слайда'), - Forms\Components\Grid::make()->schema([ - ColorPicker::make('color_theme') - ->label('Цвет текста') - ->default('#ffffff') - ->required(), - Forms\Components\ToggleButtons::make('settings.text_position') - ->options([ - 'left' => 'Текст слева', - 'center' => 'Текст по середине', - 'right' => 'Текст справа' - ]) - ->inline()->default('left')->grouped() - ->label('Позиция текста на слайде'), - ]), - Forms\Components\Grid::make()->schema([ - Toggle::make('active_button') - ->label('Использовать кнопку для ссылки (Ссылка будет открываться при нажатии на слайд)') - ->inline(false) - ->default(true) // Проверяем, есть ли текст в link_text - ->live() - ->afterStateHydrated(function (Toggle $component, $state, $get) { - if ($state === null && !empty($get('settings.link_text'))) { - $component->state(true); // Устанавливаем значение по умолчанию - } - }) - ->dehydrated(false), - Forms\Components\TextInput::make('settings.link_text') - ->default('Читать') - ->label('Текст кнопки') - ->disabled(fn (Forms\Get $get) => !$get('active_button')) - ]), - ]), - Forms\Components\Section::make('Изображение')->schema([ - FileUpload::make('image.url') - ->label('Изображение') - ->image() - ->optimize('webp') - ->resize(50) - ->disk('public') - ->directory('images') - ->imageEditor() - ->required(), - ToggleButtons::make('image.shading')->inline()->grouped()->label('Уровень затемнения изображения')->options([ - '1' => 'Без затемнения', - '0.7' => 'Слабое затемнение', - '0.5' => 'Среднее затемнение', - '0.3' => 'Сильное затемнение', - ]), - ]), - Forms\Components\Section::make('Общая часть')->schema([ - Forms\Components\Grid::make()->schema([ - DateTimePicker::make('start_time') - ->label('Слайд начинается с') - ->native() - ->displayFormat('d/m/Y') - ->default(Carbon::now()) - ->maxDate(Carbon::now()->addWeeks(2)), - DateTimePicker::make('end_time') - ->label('Слайд действует до') - ->native() - ->displayFormat('d/m/Y') - ->default(Carbon::now()->addWeeks(2)) - ->minDate(Carbon::now()) - ->maxDate(Carbon::now()->addMonth()), - ]), - Forms\Components\TextInput::make('link') - ->label('Ссылка кнопки') - ->required(), - ]), - - Toggle::make('is_active')->default(true)->label('Активный слайдер')->inline(false), - ]), - ]); - } - - public static function table(Table $table): Table - { - return $table - ->reorderable('sort') - ->defaultSort('sort') - ->columns([ - Tables\Columns\TextColumn::make('title'), - Tables\Columns\ToggleColumn::make('is_active') - ]) - ->filters([ - // - ]) - ->actions([ - Tables\Actions\EditAction::make(), - ]) - ->bulkActions([ - Tables\Actions\BulkActionGroup::make([ - Tables\Actions\DeleteBulkAction::make(), - ]), - ]); - } - - public static function getRelations(): array - { - return [ - // - ]; - } - - public static function getPages(): array - { - return [ - 'index' => Pages\ListMainSliders::route('/'), - 'create' => Pages\CreateMainSlider::route('/create'), - 'edit' => Pages\EditMainSlider::route('/{record}/edit'), - ]; - } -} diff --git a/app/Filament/Resources/MainSliderResource/Pages/CreateMainSlider.php b/app/Filament/Resources/MainSliderResource/Pages/CreateMainSlider.php deleted file mode 100644 index 150ddbc..0000000 --- a/app/Filament/Resources/MainSliderResource/Pages/CreateMainSlider.php +++ /dev/null @@ -1,20 +0,0 @@ -schema([ - Forms\Components\Section::make('')->schema([ - Tabs::make('Tabs') - ->tabs([ - Tabs\Tab::make('Основная информация') - ->schema([ - Forms\Components\Grid::make(2)->schema([ - TextInput::make('title')->label('Название ресурса')->required() - ->live(onBlur: true) - ->afterStateUpdated(function (string $operation, string|null $state, Forms\Set $set) { - $set('slug', Str::slug($state)); - $set('seo.title', $state); - }), - TextInput::make('slug')->label('Slug')->unique(ignoreRecord: true)->readOnly()->required(), - Toggle::make('is_active')->default(true)->label('Активный ресурс')->inline(false), - ]) - ]), - Tabs\Tab::make('Содержание ресурса') - ->schema([ - Repeater::make('content')->label('Ресурсы')->schema([ - Forms\Components\Section::make('Быстрая настройка ресурса')->schema([ - Forms\Components\Grid::make()->schema([ - Forms\Components\Select::make('model_select') - ->name('') - ->label('Выбор типа данных') - ->options([ - 'Post' => 'Новость', - 'Page' => 'Страница', - 'Event' => 'Мероприятие', - 'Custom' => 'Кастомная ссылка', - ]) - ->afterStateUpdated(function (string $operation, string|null $state, Forms\Set $set, Forms\Get $get) { - if ($get('model_select') === 'Custom') { - $set('model', null); - $set('title', null); - $set('content', null); - $set('link', null); - }; - })->live(onBlur: true), - - Forms\Components\Select::make('model') - ->label('Поиск данных') - ->name('') + Forms\Components\Section::make('Ресурс') + ->description('Управление контентом ресурса') + ->collapsible() + ->schema([ + Tabs::make('Настройки ресурса') + ->persistTabInQueryString() + ->columnSpanFull() + ->tabs([ + Tabs\Tab::make('Основная информация') + ->icon('heroicon-o-information-circle') + ->schema([ + Forms\Components\Grid::make(2) + ->schema([ + TextInput::make('title') + ->label('Название ресурса') + ->placeholder('Введите название ресурса') + ->helperText('Это название будет отображаться в административной панели') + ->required() + ->maxLength(255) ->live(onBlur: true) - ->searchable() - ->live(onBlur: true) - ->afterStateUpdated(function (string $operation, string|null $state, Forms\Set $set, Forms\Get $get) { - if ($get('model_select') === 'Post') { - $post = Post::find($state); - $set('title', $post->title); - $relativeUrl = parse_url(route('client.post.show', $post->slug), PHP_URL_PATH); - $set('link', $relativeUrl); - }; - if ($get('model_select') === 'Page') { - $page = Page::find($state); - $set('title', $page->title); - $set('link', $page->path); - }; - if ($get('model_select') === 'Event') { - $event = Event::find($state); - $set('title', $event->title); - $relativeUrl = parse_url(route('client.event.show', $event->slug), PHP_URL_PATH); - $set('link', $relativeUrl); - }; - - }) - ->options(function (Forms\Get $get) { - if ($get('model_select') === 'Post') { - return Post::where('status', '=', 'published')->pluck('title', 'id'); - }; - if ($get('model_select') === 'Page') { - return Page::where('title', '!=', null)->pluck('title', 'id'); - }; - if ($get('model_select') === 'Event') { - return Event::all()->pluck('title', 'id'); - }; - if ($get('model_select') === 'Custom') { - return []; - }; + ->afterStateUpdated(function (string $operation, string|null $state, Forms\Set $set) { + $set('slug', Str::slug($state)); + $set('seo.title', $state); }), - ]), - ]), - TextInput::make('title')->label('Заголовок ресурса')->required(), - FileUpload::make('image') - ->label('Изображение предпросмотра') - ->image() - ->optimize('webp') - ->resize(50) - ->disk('public') - ->directory('images') - ->imageEditor(), - Forms\Components\Grid::make(2)->schema([ - Forms\Components\TextInput::make('link') - ->label('Ссылка ресурса') - ->required(), - Forms\Components\TextInput::make('link_text') - ->default('Читать') - ->label('Текст кнопки') - ->required(), - ]), - ])->collapsed()->required(), - ]), - ]), + TextInput::make('slug') + ->label('URL-адрес (Slug)') + ->helperText('Автоматически генерируется из названия') + ->required() + ->unique(ignoreRecord: true) + ->readOnly() + ->maxLength(255), + + Toggle::make('is_active') + ->label('Активность ресурса') + ->helperText('Отключите, чтобы скрыть ресурс') + ->default(true) + ->inline(false) + ->onColor('success') + ->offColor('danger') + ->columnSpanFull(), + ]) + ]), + + Tabs\Tab::make('Содержание ресурса') + ->icon('heroicon-o-document-text') + ->schema([ + Repeater::make('content') + ->label('Элементы ресурса') + ->helperText('Добавьте и настройте элементы ресурса') + ->addActionLabel('Добавить элемент') + ->collapsed() + ->itemLabel(fn (array $state): ?string => $state['title'] ?? 'Новый элемент') + ->required() + ->schema([ + Forms\Components\Section::make('Быстрая настройка') + ->description('Выберите тип и источник данных') + ->collapsible() + ->collapsed() + ->schema([ + Forms\Components\Grid::make(2) + ->schema([ + Forms\Components\Select::make('model_select') + ->label('Тип данных') + ->placeholder('Выберите тип данных') + ->helperText('Выберите тип контента для этого элемента') + ->options([ + 'Post' => 'Новость', + 'Page' => 'Страница', + 'Event' => 'Мероприятие', + 'Custom' => 'Кастомная ссылка', + ]) + ->live(onBlur: true) + ->afterStateUpdated(function (string $operation, string|null $state, Forms\Set $set, Forms\Get $get) { + if ($get('model_select') === 'Custom') { + $set('model', null); + $set('title', null); + $set('content', null); + $set('link', null); + } + }), + + Forms\Components\Select::make('model') + ->label('Выбор элемента') + ->placeholder('Выберите элемент') + ->helperText(function (Forms\Get $get) { + if ($get('model_select') === 'Post') return 'Выберите новость'; + if ($get('model_select') === 'Page') return 'Выберите страницу'; + if ($get('model_select') === 'Event') return 'Выберите мероприятие'; + return 'Доступно после выбора типа данных'; + }) + ->searchable() + ->live(onBlur: true) + ->afterStateUpdated(function (string $operation, string|null $state, Forms\Set $set, Forms\Get $get) { + if ($get('model_select') === 'Post') { + $post = Post::find($state); + if ($post) { + $set('title', $post->title); + $relativeUrl = parse_url(route('client.post.show', $post->slug), PHP_URL_PATH); + $set('link', $relativeUrl); + } + } + if ($get('model_select') === 'Page') { + $page = Page::find($state); + if ($page) { + $set('title', $page->title); + $set('link', $page->path); + } + } + if ($get('model_select') === 'Event') { + $event = Event::find($state); + if ($event) { + $set('title', $event->title); + $relativeUrl = parse_url(route('client.event.show', $event->slug), PHP_URL_PATH); + $set('link', $relativeUrl); + } + } + }) + ->options(function (Forms\Get $get) { + if ($get('model_select') === 'Post') { + return Post::where('status', '=', 'published')->pluck('title', 'id'); + } + if ($get('model_select') === 'Page') { + return Page::whereNotNull('title')->pluck('title', 'id'); + } + if ($get('model_select') === 'Event') { + return Event::all()->pluck('title', 'id'); + } + return []; + }) + ->disabled(fn (Forms\Get $get) => empty($get('model_select')) || $get('model_select') === 'Custom'), + ]), + ]), + + TextInput::make('title') + ->label('Заголовок') + ->placeholder('Введите заголовок элемента') + ->helperText('Заголовок будет отображаться пользователям') + ->required() + ->maxLength(255), + + FileUpload::make('image') + ->label('Изображение предпросмотра') + ->helperText('Рекомендуемый формат: PNG, JPEG, JPG') + ->image() + ->optimize('webp') + ->resize(50) + ->disk('public') + ->directory('images') + ->imageEditor() + ->downloadable() + ->openable(), + + Forms\Components\Grid::make(2) + ->schema([ + Forms\Components\TextInput::make('link') + ->label('Ссылка') + ->placeholder('https://example.com или /path') + ->helperText('URL-адрес или относительный путь') + ->required() + ->maxLength(255), + + Forms\Components\TextInput::make('link_text') + ->label('Текст кнопки') + ->placeholder('Например: Читать далее') + ->helperText('Текст для кнопки перехода') + ->default('Читать') + ->required() + ->maxLength(50), + ]), + ]) + ->columnSpanFull(), + ]), + ]), ]), ]); } diff --git a/app/Filament/Resources/PageResource/Pages/CreatePage.php b/app/Filament/Resources/PageResource/Pages/CreatePage.php index 52d422e..68b7e1a 100644 --- a/app/Filament/Resources/PageResource/Pages/CreatePage.php +++ b/app/Filament/Resources/PageResource/Pages/CreatePage.php @@ -4,15 +4,17 @@ namespace App\Filament\Resources\PageResource\Pages; use App\Filament\Resources\PageResource; use App\Models\SubSection; +use App\Services\Filament\Domain\Seo\SeoGeneratorService; +use App\Services\Filament\Traits\SeoGenerate; use Filament\Actions; use Filament\Resources\Pages\CreateRecord; use Illuminate\Support\Str; class CreatePage extends CreateRecord { - protected static string $resource = PageResource::class; + use SeoGenerate; - protected array $seoData; + protected static string $resource = PageResource::class; protected function mutateFormDataBeforeCreate(array $data): array { @@ -27,8 +29,6 @@ class CreatePage extends CreateRecord } unset($data['sub_section_id']); - $this->seoData = $this->generateSeo($data); - $data['search_data'] = $this->generateSearchData($data['content']); return $data; @@ -36,23 +36,9 @@ class CreatePage extends CreateRecord protected function afterCreate(): void { - $this->record->seo()->create($this->seoData); + $this->createSeo($this->record); } - private function generateSeo(array $data) : array - { - $title = $data['title']; - $rowData = $this->getFirstBlockByName('paragraph', $data['content']); - if ($rowData !== null) { - $description = strip_tags($rowData['data']['content']); - } else { - $description = null; - } - return [ - 'title' => $title, - 'description' => Str::limit(htmlspecialchars($description, ENT_QUOTES, 'UTF-8'), 160), - ]; - } private function getFirstBlockByName(string $name, array $content) : array|null { $data = null; diff --git a/app/Filament/Resources/PageResource/Pages/EditPage.php b/app/Filament/Resources/PageResource/Pages/EditPage.php index 65a749e..b93d16c 100644 --- a/app/Filament/Resources/PageResource/Pages/EditPage.php +++ b/app/Filament/Resources/PageResource/Pages/EditPage.php @@ -3,20 +3,20 @@ namespace App\Filament\Resources\PageResource\Pages; use App\Filament\Resources\PageResource; +use App\Services\Filament\Domain\Seo\SeoGeneratorService; +use App\Services\Filament\Traits\SeoGenerate; use Filament\Actions; use Filament\Resources\Pages\EditRecord; use Illuminate\Support\Str; class EditPage extends EditRecord { - protected static string $resource = PageResource::class; + use SeoGenerate; - protected array $seoData; + protected static string $resource = PageResource::class; protected function mutateFormDataBeforeSave(array $data): array { - $this->seoData = $this->generateSeo($data); - $data['search_data'] = $this->generateSearchData($data['content']); return $data; @@ -34,8 +34,7 @@ class EditPage extends EditRecord } } - $this->record->seo()->update($this->seoData); - + $this->updateSeo($this->record); } @@ -47,22 +46,6 @@ class EditPage extends EditRecord ]; } - - private function generateSeo(array $data) : array - { - $title = $data['title']; - $rowData = $this->getFirstBlockByName('paragraph', $data['content']); - if ($rowData !== null) { - $description = strip_tags($rowData['data']['content']); - } else { - $description = null; - } - - return [ - 'title' => $title, - 'description' => Str::limit(htmlspecialchars($description, ENT_QUOTES, 'UTF-8'), 160), - ]; - } private function getFirstBlockByName(string $name, array $content) : array|null { $data = null; diff --git a/app/Filament/Resources/PageResource/RelationManagers/SectionRelationManager.php b/app/Filament/Resources/PageResource/RelationManagers/SectionRelationManager.php index c45f80b..9181176 100644 --- a/app/Filament/Resources/PageResource/RelationManagers/SectionRelationManager.php +++ b/app/Filament/Resources/PageResource/RelationManagers/SectionRelationManager.php @@ -38,12 +38,11 @@ class SectionRelationManager extends RelationManager // ]) ->headerActions([ - Tables\Actions\CreateAction::make()->visible(!$this->ownerRecord->section->exists()), - Tables\Actions\AssociateAction::make() +// Tables\Actions\CreateAction::make()->visible(!$this->ownerRecord->section->exists()), ]) ->actions([ Tables\Actions\EditAction::make(), - Tables\Actions\DetachAction::make(), + Tables\Actions\DissociateAction::make(), ]) ->bulkActions([ Tables\Actions\BulkActionGroup::make([ diff --git a/app/Filament/Resources/PostResource.php b/app/Filament/Resources/PostResource.php index 8935126..c04b28a 100644 --- a/app/Filament/Resources/PostResource.php +++ b/app/Filament/Resources/PostResource.php @@ -2,41 +2,14 @@ namespace App\Filament\Resources; -use App\Enums\PostStatus; use App\Filament\Components\Forms\PostForm; use App\Filament\Resources\PostResource\Pages; -use App\Models\Category; -use App\Models\Page; use App\Models\Post; use BezhanSalleh\FilamentShield\Contracts\HasShieldPermissions; -use Filament\Actions\DeleteAction; -use Filament\Facades\Filament; -use Filament\Forms; -use Filament\Forms\Components\Builder; -use Filament\Forms\Components\Checkbox; -use Filament\Forms\Components\FileUpload; -use Filament\Forms\Components\Hidden; -use Filament\Forms\Components\RichEditor; -use Filament\Forms\Components\Section; -use Filament\Forms\Components\Select; -use Filament\Forms\Components\SpatieTagsInput; -use Filament\Forms\Components\Tabs; -use Filament\Forms\Components\Textarea; -use Filament\Forms\Components\TextInput; use Filament\Forms\Form; -use Filament\Forms\Get; -use Filament\Forms\Set; use Filament\Resources\Resource; use Filament\Tables; use Filament\Tables\Table; -use Filament\Infolists\Components\Card; -use Illuminate\Database\Eloquent\SoftDeletingScope; -use Illuminate\Http\File; -use Illuminate\Http\UploadedFile; -use Illuminate\Support\Carbon; -use Illuminate\Support\Str; -use Livewire\Features\SupportFileUploads\TemporaryUploadedFile; -use Mohamedsabil83\FilamentFormsTinyeditor\Components\TinyEditor; class PostResource extends Resource implements HasShieldPermissions { @@ -59,7 +32,6 @@ class PostResource extends Resource implements HasShieldPermissions { return $table ->columns([ -// Tables\Columns\TextColumn::make('id')->sortable(), Tables\Columns\TextColumn::make('created_at')->label('Дата создания')->sortable(), Tables\Columns\TextColumn::make('title')->label('Заголовок')->sortable()->searchable(), Tables\Columns\TextColumn::make('status')->label('Статус')->sortable()->badge(), diff --git a/app/Filament/Resources/PostResource/Pages/CreatePost.php b/app/Filament/Resources/PostResource/Pages/CreatePost.php index 19dff94..5b2d412 100644 --- a/app/Filament/Resources/PostResource/Pages/CreatePost.php +++ b/app/Filament/Resources/PostResource/Pages/CreatePost.php @@ -10,13 +10,15 @@ use App\Services\Filament\Domain\Posts\PostNotificationService; use App\Services\Filament\Domain\Posts\PostSeoGenerator; use App\Services\Filament\Domain\Posts\PostSliderService; use App\Services\Filament\Domain\Posts\VkPostPublisher; +use App\Services\Filament\Domain\Seo\SeoGeneratorService; +use App\Services\Filament\Traits\SeoGenerate; use Filament\Resources\Pages\CreateRecord; class CreatePost extends CreateRecord { - protected static string $resource = PostResource::class; + use SeoGenerate; - protected array $seoData; + protected static string $resource = PostResource::class; protected array $publicationAgreements; protected array $slideData; @@ -45,7 +47,7 @@ class CreatePost extends CreateRecord protected function afterCreate(): void { $this->handleSlides(); - $this->generateSeo(); + $this->createSeo($this->record); $this->sendNotifications(); $this->publishToVk(); } @@ -63,15 +65,6 @@ class CreatePost extends CreateRecord (new PostSliderService($sliderDTO, $this->record))->create(); } - protected function generateSeo(): void - { - $seoData = (new PostSeoGenerator())->generate([ - 'title' => $this->record->title, - 'content' => $this->record->content, - 'preview' => $this->record->preview, - ]); - $this->record->seo()->create($seoData); - } protected function sendNotifications(): void { diff --git a/app/Filament/Resources/PostResource/Pages/EditPost.php b/app/Filament/Resources/PostResource/Pages/EditPost.php index 5a35db6..2d16bc3 100644 --- a/app/Filament/Resources/PostResource/Pages/EditPost.php +++ b/app/Filament/Resources/PostResource/Pages/EditPost.php @@ -11,23 +11,26 @@ use App\Services\Filament\Domain\Posts\PostNotificationService; use App\Services\Filament\Domain\Posts\PostSeoGenerator; use App\Services\Filament\Domain\Posts\PostSliderService; use App\Services\Filament\Domain\Posts\VkPostPublisher; +use App\Services\Filament\Domain\Seo\SeoGeneratorService; +use App\Services\Filament\Traits\SeoGenerate; use Carbon\Carbon; use Filament\Actions; use Filament\Resources\Pages\EditRecord; class EditPost extends EditRecord { + use SeoGenerate; + protected static string $resource = PostResource::class; - protected array $seoData; protected array $publicationAgreements; protected array $slideData; protected function mutateFormDataBeforeFill(array $data): array { - $post = Post::query()->with(['seo', 'mainSlider'])->find($data['id']); - $data['slide'] = $post->mainSlider->toArray() ?? null; + $post = Post::query()->with(['seo', 'slide'])->find($data['id']); + $data['slide'] = $post->slide->toArray() ?? null; return $data; } @@ -52,7 +55,7 @@ class EditPost extends EditRecord protected function afterSave(): void { $this->handleSlides(); - $this->generateSeo(); + $this->updateSeo($this->record); $this->sendNotifications(); $this->publishToVk(); } @@ -73,7 +76,7 @@ class EditPost extends EditRecord protected function generateSeo(): void { - $seoData = (new PostSeoGenerator())->generate([ + $seoData = app(SeoGeneratorService::class)->generate([ 'title' => $this->record->title, 'content' => $this->record->content, 'preview' => $this->record->preview, diff --git a/app/Filament/Resources/ScheduleResource.php b/app/Filament/Resources/ScheduleResource.php index ce00bbd..57485b5 100644 --- a/app/Filament/Resources/ScheduleResource.php +++ b/app/Filament/Resources/ScheduleResource.php @@ -3,151 +3,104 @@ namespace App\Filament\Resources; use App\Filament\Resources\ScheduleResource\Pages; -use App\Filament\Resources\ScheduleResource\RelationManagers; -use App\Helpers\ByteConverter; -use App\Models\Category; use App\Models\EducationalGroup; use App\Models\Schedule; -use Closure; use Filament\Forms; -use Filament\Forms\Components\Builder; - -use Filament\Forms\Components\Checkbox; use Filament\Forms\Components\FileUpload; -use Filament\Forms\Components\Hidden; -use Filament\Forms\Components\RichEditor; +use Filament\Forms\Components\Grid; use Filament\Forms\Components\Section; use Filament\Forms\Components\Select; -use Filament\Forms\Components\SpatieTagsInput; use Filament\Forms\Components\TextInput; +use Filament\Forms\Components\Toggle; use Filament\Forms\Form; -use Filament\Forms\Get; use Filament\Resources\Resource; use Filament\Tables; use Filament\Tables\Columns\IconColumn; +use Filament\Tables\Columns\TextColumn; use Filament\Tables\Table; -use Illuminate\Database\Eloquent\SoftDeletingScope; use Illuminate\Support\Carbon; use Illuminate\Support\Str; -use Livewire\Component; use Livewire\Features\SupportFileUploads\TemporaryUploadedFile; -use Livewire\Livewire; class ScheduleResource extends Resource { protected static ?string $navigationGroup = 'Расписание и группы'; - protected static ?string $model = Schedule::class; - - protected static ?string $navigationIcon = 'heroicon-o-rectangle-stack'; - - protected static ?string $pluralLabel = 'Расписание'; - - - protected static array $weekDays = ['Понедельник', 'Вторник', 'Среда', 'Четверг', 'Пятница', 'Суббота', 'Воскресенье']; - protected static array $typeWeek = ['Четная', 'Нечетная']; - - - - + protected static ?string $navigationIcon = 'heroicon-o-calendar'; + protected static ?string $pluralLabel = 'Расписания'; + protected static ?string $modelLabel = 'расписание'; public static function form(Form $form): Form { return $form ->schema([ - Section::make() + Section::make('Основные настройки') + ->description('Основная информация о расписании') + ->collapsible() ->schema([ - Forms\Components\Grid::make(2)->schema([ - Select::make('educational_group_id')->options(EducationalGroup::all()->pluck('title', 'id')) - ->live() - ->label('Выбрать группу') - ->required(), -// TextInput::make('title') -// ->live() -// ->label('Заголовок')->required(), + Grid::make(2) + ->schema([ + Select::make('educational_group_id') + ->label('Учебная группа') + ->options(EducationalGroup::query()->orderBy('title')->pluck('title', 'id')) + ->searchable() + ->preload() + ->required() + ->live() + ->helperText('Выберите группу для которой создается расписание'), -// Select::make('type')->options([ -// 'schedule' => 'Обычное расписание', -// 'interval' => 'Временное расписание', -// 'exam' => 'Промежуточная аттестация', -// ])->label('Тип расписания')->required()->live(), - Forms\Components\Toggle::make('is_zaoch')->label('Очная|Заочная')->inline(false), + Toggle::make('is_zaoch') + ->label('Форма обучения') + ->inline(false) + ->onColor('success') + ->offColor('primary') + ->helperText('Очная | Заочная') + ->afterStateHydrated(function (Toggle $component, $state) { + $component->state((bool) $state); + }), + ]), + ]), - ]), -// Forms\Components\Repeater::make('days')->label('')->schema([ -// Forms\Components\Repeater::make('form')->label('')->schema([ -// Forms\Components\Repeater::make('weeks')->label('')->schema([ -// Forms\Components\Repeater::make('lesson_info')->label('')->schema([ -// TextInput::make('title')->label('Название-пары'), -// TextInput::make('teacher')->label('Преподаватель'), -// TextInput::make('studyRoom')->label('Кабинет') -// ])->live()->maxItems(2)->collapsed()->addActionLabel('Добавить подгруппу')->columns(3) -// ->itemLabel(function (Get $get, $state) { -// static $count = 1; -// if (count($get('lesson_info')) === 1) { -// return "Общая группа"; -// } else { -// $nmb = $count++ % 2 == 0 ? 2 : 1; -// return "Подгруппа " . $nmb; } -// }), -// ])->maxItems(2)->addActionLabel('Добавить четную/нечетную неделю') -// ->itemLabel(function (Get $get) { -// static $position = 0; -// if (count($get('weeks')) === 1) { -// return "Общая неделя"; -// } else { -// $nmb = $position++ % 2 == 0 ? 0 : 1; -// return self::$typeWeek[$nmb] . " неделя"; -// } -// }) -// ->collapsed() -// ]) -// ->maxItems(5) -// ->itemLabel(function (Get $get) { -// static $count = 0; -// $maxCount = count($get('form')); -// $count = ($count++ <= $maxCount) ? $count : 1; -// return "Пара #" . $count; -// }) -// ->addActionLabel('Добавить пару'), -// ])->maxItems(6)->minItems(1)->itemLabel(function ($state) { -// static $position = 0; -// return self::$weekDays[$position++]; -// })->addActionLabel('Добавить день недели')->collapsed()->defaultItems(6)->hidden(function (callable $get) { -// if ($get('type') === 'schedule' || $get('type') === 'interval') { -// return false; -// } else { -// return true; -// } -// }), - ]), - Section::make() + Section::make('Файлы расписания') + ->description('Загрузите файлы с расписанием') + ->collapsible() ->schema([ - Forms\Components\Repeater::make('file')->schema([ + Forms\Components\Repeater::make('file') + ->label('') + ->addActionLabel('Добавить файл расписания') + ->schema([ + TextInput::make('title') + ->label('Название файла') + ->required() + ->maxLength(255) + ->placeholder('Например: "Расписание на весенний семестр 2024"') + ->helperText('Укажите понятное название файла для идентификации'), - TextInput::make('title') - ->required() - ->maxLength(255) - ->autofocus(), - FileUpload::make('path') - ->required() - ->getUploadedFileNameForStorageUsing( - fn (TemporaryUploadedFile $file): string => - str(Str::slug(pathinfo($file->getClientOriginalName(), PATHINFO_FILENAME) . '-' . Carbon::now()->timestamp ) . '.' . $file->getClientOriginalExtension()) - ) - ->acceptedFileTypes([ - 'application/pdf', - ]) - ->maxSize(512000) - ->disk('public') - ->directory('files') - ->downloadable() - ->afterStateUpdated(function ($set, $state) { - $set('title', pathinfo($state?->getClientOriginalName(), PATHINFO_FILENAME)); - }) - ->visibility('public') - ]), - ]) + FileUpload::make('path') + ->label('Файл PDF') + ->required() + ->acceptedFileTypes(['application/pdf']) + ->maxSize(5120) // 5MB + ->disk('public') + ->directory('schedules') + ->downloadable() + ->openable() + ->previewable(false) + ->helperText('Только PDF файлы, макс. размер 5MB') + ->getUploadedFileNameForStorageUsing( + fn (TemporaryUploadedFile $file): string => + str(Str::slug(pathinfo($file->getClientOriginalName(), PATHINFO_FILENAME) . '-' . Carbon::now()->timestamp) . '.' . $file->getClientOriginalExtension() + ) + ->afterStateUpdated(function ($set, $state) { + $set('title', pathinfo($state?->getClientOriginalName(), PATHINFO_FILENAME)); + }) + ->visibility('public')), + ]) + ->itemLabel(fn (array $state): ?string => $state['title'] ?? 'Новый файл') + ->collapsible() + ->cloneable() + ->defaultItems(1), + ]), ]); } @@ -155,29 +108,69 @@ class ScheduleResource extends Resource { return $table ->columns([ - Tables\Columns\TextColumn::make('title'), - Tables\Columns\TextColumn::make('type'), + TextColumn::make('educational_group.title') + ->label('Учебная группа') + ->sortable() + ->searchable(), + + TextColumn::make('file_count') + ->label('Файлов') + ->getStateUsing(fn ($record) => count($record->file ?? [])) + ->badge(), + IconColumn::make('is_zaoch') - ->boolean(), + ->label('Форма обучения') + ->boolean() + ->trueIcon('heroicon-o-academic-cap') + ->falseIcon('heroicon-o-building-office') + ->trueColor('success') + ->falseColor('primary') + ->formatStateUsing(fn ($state) => $state ? 'Заочная' : 'Очная'), + + TextColumn::make('updated_at') + ->label('Обновлено') + ->dateTime('d.m.Y H:i') + ->sortable(), ]) ->filters([ - // + Tables\Filters\SelectFilter::make('educational_group_id') + ->label('Учебная группа') + ->options(EducationalGroup::query()->orderBy('title')->pluck('title', 'id')) + ->searchable(), + + Tables\Filters\TernaryFilter::make('is_zaoch') + ->label('Форма обучения') + ->placeholder('Все') + ->trueLabel('Заочная') + ->falseLabel('Очная'), ]) ->actions([ - Tables\Actions\EditAction::make(), + Tables\Actions\EditAction::make() + ->iconButton() + ->tooltip('Редактировать'), + + Tables\Actions\ViewAction::make() + ->iconButton() + ->tooltip('Просмотреть'), ]) ->bulkActions([ Tables\Actions\BulkActionGroup::make([ - Tables\Actions\DeleteBulkAction::make(), + Tables\Actions\DeleteBulkAction::make() + ->label('Удалить выбранные') + ->modalHeading('Удаление расписаний') + ->modalDescription('Вы уверены, что хотите удалить выбранные расписания? Это действие нельзя отменить.'), ]), - ]); + ]) + ->emptyStateActions([ + Tables\Actions\CreateAction::make() + ->label('Добавить расписание'), + ]) + ->defaultSort('educational_group.title'); } public static function getRelations(): array { - return [ - // - ]; + return []; } public static function getPages(): array @@ -188,4 +181,4 @@ class ScheduleResource extends Resource 'edit' => Pages\EditSchedule::route('/{record}/edit'), ]; } -} +} \ No newline at end of file diff --git a/app/Filament/Resources/Shield/RoleResource.php b/app/Filament/Resources/Shield/RoleResource.php index e0e5a1c..27bc2aa 100644 --- a/app/Filament/Resources/Shield/RoleResource.php +++ b/app/Filament/Resources/Shield/RoleResource.php @@ -157,9 +157,7 @@ class RoleResource extends Resource implements HasShieldPermissions public static function getNavigationGroup(): ?string { - return Utils::isResourceNavigationGroupEnabled() - ? __('filament-shield::filament-shield.nav.group') - : ''; + return 'Настройки приложения'; } public static function getNavigationLabel(): string diff --git a/app/Filament/Resources/SlideResource.php b/app/Filament/Resources/SlideResource.php index 0f1bdae..fcae4fc 100644 --- a/app/Filament/Resources/SlideResource.php +++ b/app/Filament/Resources/SlideResource.php @@ -27,6 +27,11 @@ class SlideResource extends Resource protected static ?string $navigationIcon = 'heroicon-o-rectangle-stack'; + protected static ?string $navigationGroup = 'Виджеты'; + + protected static ?string $pluralLabel = 'Слайды'; + protected static ?string $navigationParentItem = 'Слайдеры'; + public static function form(Form $form): Form { return $form diff --git a/app/Filament/Resources/SliderResource.php b/app/Filament/Resources/SliderResource.php index f458429..28c13c5 100644 --- a/app/Filament/Resources/SliderResource.php +++ b/app/Filament/Resources/SliderResource.php @@ -6,37 +6,61 @@ use App\Filament\Resources\SliderResource\Pages; use App\Filament\Resources\SliderResource\RelationManagers; use App\Models\Slider; use Filament\Forms; +use Filament\Forms\Components\Section; use Filament\Forms\Components\TextInput; use Filament\Forms\Components\Toggle; use Filament\Forms\Form; use Filament\Resources\Resource; use Filament\Tables; +use Filament\Tables\Columns\TextColumn; use Filament\Tables\Table; -use Illuminate\Database\Eloquent\Builder; -use Illuminate\Database\Eloquent\SoftDeletingScope; use Illuminate\Support\Str; class SliderResource extends Resource { protected static ?string $model = Slider::class; - protected static ?string $navigationIcon = 'heroicon-o-rectangle-stack'; + protected static ?string $navigationGroup = 'Виджеты'; + protected static ?string $navigationLabel = 'Слайдеры'; + protected static ?string $modelLabel = 'Слайдер'; + protected static ?string $pluralModelLabel = 'Слайдеры'; + protected static ?string $navigationIcon = 'heroicon-o-photo'; public static function form(Form $form): Form { return $form ->schema([ - Forms\Components\Section::make()->schema([ - Forms\Components\TextInput::make('title') - ->live(onBlur: true) - ->afterStateUpdated(function (string $operation, string|null $state, Forms\Set $set) { - $set('slug', Str::slug($state)); - }) - ->label('Заголовок слайдера') - ->required(), - TextInput::make('slug')->label('Slug')->unique(ignoreRecord: true)->readOnly()->required(), - Toggle::make('is_active')->default(true)->label('Активный слайдер')->inline(false), - ]), + Section::make('Настройки слайдера') + ->description('Основные параметры отображения слайдера') + ->collapsible() + ->schema([ + TextInput::make('title') + ->label('Название слайдера') + ->placeholder('Например: Главный слайдер') + ->helperText('Это название будет использоваться в административной панели') + ->required() + ->maxLength(255) + ->live(onBlur: true) + ->afterStateUpdated(function (string $operation, string|null $state, Forms\Set $set) { + $set('slug', Str::slug($state)); + }), + + TextInput::make('slug') + ->label('URL-идентификатор') + ->helperText('Автоматически генерируется из названия') + ->required() + ->unique(ignoreRecord: true) + ->readOnly() + ->maxLength(255), + + Toggle::make('is_active') + ->label('Активность слайдера') + ->helperText('Отключите, чтобы временно скрыть слайдер') + ->default(true) + ->inline(false) + ->onColor('success') + ->offColor('danger'), + ]), ]); } @@ -44,25 +68,60 @@ class SliderResource extends Resource { return $table ->columns([ - // + TextColumn::make('title') + ->label('Название') + ->sortable() + ->searchable() + ->description(fn (Slider $record) => $record->slug), + + TextColumn::make('slides_count') + ->counts('slides') + ->label('Кол-во слайдов') + ->badge() + ->color(fn (int $state): string => $state > 0 ? 'success' : 'danger'), + + TextColumn::make('is_active') + ->label('Статус') + ->badge() + ->color(fn (bool $state): string => $state ? 'success' : 'danger') + ->formatStateUsing(fn (bool $state): string => $state ? 'Активен' : 'Неактивен'), ]) ->filters([ - // + Tables\Filters\SelectFilter::make('is_active') + ->label('Статус активности') + ->options([ + true => 'Активные', + false => 'Неактивные', + ]), ]) ->actions([ - Tables\Actions\EditAction::make(), + Tables\Actions\EditAction::make() + ->icon('heroicon-o-pencil') + ->tooltip('Редактировать'), + + Tables\Actions\Action::make('manage_slides') + ->icon('heroicon-o-photo') + ->tooltip('Управление слайдами') + ->url(fn (Slider $record) => SliderResource::getUrl('edit', ['record' => $record]) . '?activeRelationManager=0'), ]) ->bulkActions([ Tables\Actions\BulkActionGroup::make([ - Tables\Actions\DeleteBulkAction::make(), + Tables\Actions\DeleteBulkAction::make() + ->icon('heroicon-o-trash') + ->label('Удалить выбранное'), ]), - ]); + ]) + ->emptyStateActions([ + Tables\Actions\CreateAction::make() + ->label('Создать слайдер'), + ]) + ->defaultSort('title', 'asc'); } public static function getRelations(): array { return [ - // + RelationManagers\SlidesRelationManager::class, ]; } @@ -74,4 +133,4 @@ class SliderResource extends Resource 'edit' => Pages\EditSlider::route('/{record}/edit'), ]; } -} +} \ No newline at end of file diff --git a/app/Filament/Resources/SliderResource/RelationManagers/SlidesRelationManager.php b/app/Filament/Resources/SliderResource/RelationManagers/SlidesRelationManager.php new file mode 100644 index 0000000..edac4f3 --- /dev/null +++ b/app/Filament/Resources/SliderResource/RelationManagers/SlidesRelationManager.php @@ -0,0 +1,310 @@ +schema([ + Section::make('Быстрая настройка слайда') + ->description('Выберите источник данных для слайда') + ->collapsible() + ->collapsed() + ->schema([ + Forms\Components\Grid::make(2) + ->schema([ + Forms\Components\Select::make('model_select') + ->label('Тип контента') + ->placeholder('Выберите тип контента') + ->helperText('Выберите откуда брать данные для слайда') + ->options([ + 'Post' => 'Новость', + 'Page' => 'Страница', + 'Event' => 'Мероприятие', + 'Custom' => 'Кастомная ссылка', + ]) + ->live(onBlur: true) + ->afterStateUpdated(function (string $operation, string|null $state, Forms\Set $set, Forms\Get $get) { + if ($get('model_select') === 'Custom') { + $set('model', null); + $set('title', null); + $set('content', null); + $set('link', null); + } + }) + ->dehydrated(false), + + Forms\Components\Select::make('model') + ->label('Выбор элемента') + ->placeholder('Выберите элемент') + ->helperText(function (Forms\Get $get) { + if ($get('model_select') === 'Post') return 'Выберите новость'; + if ($get('model_select') === 'Page') return 'Выберите страницу'; + if ($get('model_select') === 'Event') return 'Выберите мероприятие'; + return 'Доступно после выбора типа контента'; + }) + ->searchable() + ->live(onBlur: true) + ->afterStateUpdated(function (string $operation, string|null $state, Forms\Set $set, Forms\Get $get) { + if ($get('model_select') === 'Post' && $state) { + $post = Post::find($state); + if ($post) { + $set('title', $post->title); + $relativeUrl = parse_url(route('client.post.show', $post->slug), PHP_URL_PATH); + $set('link', $relativeUrl); + } + } + if ($get('model_select') === 'Page' && $state) { + $page = Page::find($state); + if ($page) { + $set('title', $page->title); + $set('link', $page->path); + } + } + if ($get('model_select') === 'Event' && $state) { + $event = Event::find($state); + if ($event) { + $set('title', $event->title); + $relativeUrl = parse_url(route('client.event.show', $event->slug), PHP_URL_PATH); + $set('link', $relativeUrl); + } + } + }) + ->options(function (Forms\Get $get) { + if ($get('model_select') === 'Post') { + return Post::where('status', 'published')->pluck('title', 'id'); + } + if ($get('model_select') === 'Page') { + return Page::whereNotNull('title')->pluck('title', 'id'); + } + if ($get('model_select') === 'Event') { + return Event::all()->pluck('title', 'id'); + } + return []; + }) + ->disabled(fn (Forms\Get $get) => empty($get('model_select'))) + ->dehydrated(false), + ]), + ]), + + Section::make('Контент слайда') + ->schema([ + Section::make('Текстовая часть') + ->collapsible() + ->schema([ + TextInput::make('title') + ->label('Заголовок слайда') + ->placeholder('Введите заголовок слайда') + ->maxLength(255), + + Textarea::make('content') + ->label('Описание слайда') + ->placeholder('Введите текст слайда') + ->maxLength(1000), + + Forms\Components\Grid::make(2) + ->schema([ + ColorPicker::make('color_theme') + ->label('Цвет текста') + ->default('#ffffff') + ->required(), + + ToggleButtons::make('settings.text_position') + ->label('Позиция текста') + ->helperText('Расположение текста на слайде') + ->options([ + 'left' => 'Слева', + 'center' => 'По центру', + 'right' => 'Справа' + ]) + ->inline() + ->grouped() + ->default('left'), + ]), + + Forms\Components\Grid::make(2) + ->schema([ + Toggle::make('active_button') + ->label('Показывать кнопку') + ->helperText('Если выключено - ссылка будет работать при клике на весь слайд') + ->inline(false) + ->dehydrated(false) + ->default(true) + ->live(), + + TextInput::make('settings.link_text') + ->label('Текст кнопки') + ->placeholder('Например: Подробнее') + ->default('Читать') + ->disabled(fn (Forms\Get $get) => !$get('active_button')) + ->maxLength(50), + ]), + ]), + + Section::make('Изображение') + ->collapsible() + ->schema([ + FileUpload::make('image.url') + ->label('Изображение слайда') + ->helperText('Рекомендуемое соотношение сторон: 16:9') + ->image() + ->optimize('webp') + ->resize(50) + ->disk('public') + ->directory('slider-images') + ->imageEditor() + ->required() + ->downloadable() + ->openable(), + + ToggleButtons::make('image.shading') + ->label('Затемнение фона') + ->helperText('Для лучшей читаемости текста') + ->options([ + '1' => 'Нет', + '0.7' => 'Слабое', + '0.5' => 'Среднее', + '0.3' => 'Сильное', + ]) + ->inline() + ->grouped() + ->default('0.5'), + ]), + + Section::make('Настройки отображения') + ->collapsible() + ->schema([ + Forms\Components\Grid::make(2) + ->schema([ + DateTimePicker::make('start_time') + ->label('Дата начала показа') + ->helperText('Когда слайд станет активным') + ->native(false) + ->displayFormat('d/m/Y H:i') + ->seconds(false) + ->default(Carbon::now()) + ->minDate(fn($record, $context) => $context === 'edit' ? $record?->start_time : Carbon::now()), + + DateTimePicker::make('end_time') + ->label('Дата окончания показа') + ->helperText('Когда слайд перестанет показываться') + ->native(false) + ->displayFormat('d/m/Y H:i') + ->seconds(false) + ->default(Carbon::now()->addWeeks(2)) + ->minDate(fn (Forms\Get $get) => $get('start_time') ?: Carbon::now()), + ]), + + TextInput::make('link') + ->label('Целевая ссылка') + ->placeholder('URL или относительный путь') + ->required() + ->maxLength(255), + + Toggle::make('is_active') + ->label('Активный слайд') + ->helperText('Отключите чтобы временно скрыть слайд') + ->default(true) + ->inline(false) + ->onColor('success') + ->offColor('danger'), + ]), + ]), + ]); + } + + public function table(Table $table): Table + { + return $table + ->recordTitleAttribute('title') + ->defaultSort('sort') + ->reorderable('sort') + ->columns([ + Tables\Columns\TextColumn::make('sort') + ->label('Порядок') + ->sortable(), + + ImageColumn::make('image.url') + ->label('Изображение') + ->size(80), + + Tables\Columns\TextColumn::make('title') + ->label('Заголовок') + ->searchable() + ->limit(30), + + Tables\Columns\ToggleColumn::make('is_active') + ->label('Активен') + ->onColor('success') + ->offColor('danger') + ->updateStateUsing(function ($record, $state) { + $record->is_active = $state; + $record->save(); + }), + + Tables\Columns\TextColumn::make('start_time') + ->label('Начало') + ->date('d.m.Y') + ->sortable(), + + Tables\Columns\TextColumn::make('end_time') + ->label('Окончание') + ->date('d.m.Y') + ->sortable(), + ]) + ->filters([ + Tables\Filters\SelectFilter::make('is_active') + ->label('Статус') + ->options([ + true => 'Активные', + false => 'Неактивные', + ]), + ]) + ->headerActions([ + Tables\Actions\CreateAction::make() + ->label('Добавить слайд'), + ]) + ->actions([ + Tables\Actions\EditAction::make() + ->icon('heroicon-o-pencil') + ->tooltip('Редактировать'), + + Tables\Actions\DeleteAction::make() + ->icon('heroicon-o-trash') + ->tooltip('Удалить'), + ]) + ->bulkActions([ + Tables\Actions\BulkActionGroup::make([ + Tables\Actions\DeleteBulkAction::make() + ->label('Удалить выбранное'), + ]), + ]) + ->emptyStateActions([ + Tables\Actions\CreateAction::make() + ->label('Добавить слайд'), + ]); + }} \ No newline at end of file diff --git a/app/Filament/Resources/UserDetailResource.php b/app/Filament/Resources/UserDetailResource.php index 63d3a44..9234b7d 100644 --- a/app/Filament/Resources/UserDetailResource.php +++ b/app/Filament/Resources/UserDetailResource.php @@ -20,7 +20,7 @@ class UserDetailResource extends Resource { protected static ?string $model = UserDetail::class; - protected static ?string $navigationGroup = 'Settings'; + protected static ?string $navigationGroup = 'Настройки приложения'; protected static ?string $pluralLabel = 'Доп. Информация'; diff --git a/app/Filament/Resources/UserResource.php b/app/Filament/Resources/UserResource.php index 3a169e0..8bb490d 100644 --- a/app/Filament/Resources/UserResource.php +++ b/app/Filament/Resources/UserResource.php @@ -21,7 +21,7 @@ class UserResource extends Resource implements HasShieldPermissions { protected static ?string $model = User::class; - protected static ?string $navigationGroup = 'Settings'; + protected static ?string $navigationGroup = 'Настройки приложения'; protected static ?string $pluralLabel = 'Пользователи'; diff --git a/app/Http/Controllers/ClientAcademicJournalController.php b/app/Http/Controllers/ClientAcademicJournalController.php index 12658eb..eec9efb 100644 --- a/app/Http/Controllers/ClientAcademicJournalController.php +++ b/app/Http/Controllers/ClientAcademicJournalController.php @@ -2,37 +2,76 @@ namespace App\Http\Controllers; +use App\Enums\CacheKeys; use App\Http\Resources\ClientAcademicJournalListResource; -use App\Http\Resources\ClientVirtualExhibitionListResource; use App\Models\AcademicJournal; use App\Models\JournalIssue; -use App\Models\VirtualExhibition; -use Illuminate\Http\Request; +use App\Services\App\Breadcrumb\BreadcrumbService; +use App\Services\App\Seo\SeoPageProvider; +use Illuminate\Support\Facades\Cache; use Inertia\Inertia; class ClientAcademicJournalController extends Controller { + public function __construct(readonly SeoPageProvider $seoPageProvider){} + public function index() { - $journals = ClientAcademicJournalListResource::collection(AcademicJournal::query()->get()); + $journals = Cache::remember( + CacheKeys::ACADEMIC_JOURNALS_PREFIX->value . 'list', + now()->addWeek(), // Кешируем на неделю, так как журналы меняются редко + function () { + return ClientAcademicJournalListResource::collection( + AcademicJournal::query()->get() + ); + } + ); - return Inertia::render('Client/AcademicJournals/Index', compact('journals')); + $seo = $this->seoPageProvider->getSeoForCurrentPage(); + + return Inertia::render('Client/AcademicJournals/Index', compact('journals', 'seo')); } public function show(string $slug) { - $journal = new ClientAcademicJournalListResource(AcademicJournal::query()->where('slug', '=', $slug)->firstOrFail()); - $journalIssues = JournalIssue::where('academic_journal_id', $journal->id) - ->groupBy('year_publication')->get(); + // Кешируем основной журнал + [$journal, $seo] = Cache::remember( + CacheKeys::ACADEMIC_JOURNAL_PREFIX->value . $slug, + now()->addWeek(), + function () use ($slug) { + $journal = AcademicJournal::query() + ->where('slug', $slug) + ->firstOrFail(); + $seo = $this->seoPageProvider->getSeoForModel($journal); + return [ + new ClientAcademicJournalListResource($journal), + $seo + ]; + } + ); - $journals = []; + // Кешируем выпуски журнала, сгруппированные по годам + $journals = Cache::remember( + CacheKeys::ACADEMIC_JOURNAL_PREFIX->value . 'issues_' . $slug, + now()->addWeek(), + function () use ($journal) { + $journalIssues = JournalIssue::where('academic_journal_id', $journal->id) + ->get() + ->groupBy('year_publication'); - foreach ($journalIssues as $year => $journalGroup) { - $journals[] = [ - 'year_publication' => $year, - 'journalIssues' => $journalGroup - ]; - } - return Inertia::render('Client/AcademicJournals/Show', compact('journal', 'journals')); + $groupedIssues = []; + foreach ($journalIssues as $year => $journalGroup) { + $groupedIssues[] = [ + 'year_publication' => $year, + 'journalIssues' => $journalGroup + ]; + } + + return $groupedIssues; + } + ); + + + return Inertia::render('Client/AcademicJournals/Show', compact('journal', 'journals', 'seo')); } } diff --git a/app/Http/Controllers/ClientAdditionalEducationController.php b/app/Http/Controllers/ClientAdditionalEducationController.php index 6ca9288..557561a 100644 --- a/app/Http/Controllers/ClientAdditionalEducationController.php +++ b/app/Http/Controllers/ClientAdditionalEducationController.php @@ -2,6 +2,7 @@ namespace App\Http\Controllers; +use App\Enums\CacheKeys; use App\Enums\FormEducation; use App\Http\Resources\AdditionalEducationCategoryPreviewResource; use App\Http\Resources\AdditionalEducationCategoryResource; @@ -14,128 +15,148 @@ use App\Models\AdditionalEducation; use App\Models\AdditionalEducationCategory; use App\Models\DirectionAdditionalEducation; use App\Models\Page; +use App\Services\App\Breadcrumb\BreadcrumbService; +use App\Services\App\Seo\SeoPageProvider; use Illuminate\Http\Request; +use Illuminate\Support\Facades\Cache; use Inertia\Inertia; class ClientAdditionalEducationController extends Controller { + public function __construct(readonly SeoPageProvider $seoPageProvider){} + + public function index(Request $request) { + $cacheKey = md5(serialize([ + 'direction' => $request->input('direction'), + 'form' => $request->input('form'), + 'category' => $request->input('category'), + ])); - $directionAdditionalEducations = DirectionAdditionalEducationResource::collection( - DirectionAdditionalEducation::query() - ->where('is_active', true) - ->whereHas('additionalEducationCategories', function ($q) { - $q->whereHas('additionalEducations'); - })->get()); - - $additionalEducations = AdditionalEducationCategoryResource::collection(AdditionalEducationCategory::query() - ->WithActivePrograms() - ->where('is_active', '=', true) - ->when($request->input('direction'), function ($q, $direction) { - $q->whereHas('direction', function ($query) use ($direction) { - $query->where('slug', $direction); - }); - }) - ->when(request()->input('form'), function ($query, $form) { - $query->whereHas('additionalEducations', function ($q) use ($form) { - $q->where('form_education', FormEducation::fromName($form)); - }); - $query->with(['additionalEducations' => function ($q) use ($form) { - $q->where('form_education', FormEducation::fromName($form)); - }]); - }) - ->when(request()->input('category'), function ($query) { - $slugs = request()->input('category'); - if (is_array($slugs)) { - $query->whereIn('slug', $slugs); - } - }) - ->has('additionalEducations') - ->get()); - - $categories = AdditionalEducationCategoryPreviewResource::collection( - AdditionalEducationCategory::query() - ->where('is_active', true) - ->has('additionalEducations') - ->get() + // Основные данные (кешируются) + $directionAdditionalEducations = Cache::remember( + CacheKeys::ADDITIONAL_EDUCATIONAL_PROGRAMS_PREFIX->value . 'directions_' . $cacheKey, + now()->addDay(), + function () { + return DirectionAdditionalEducationResource::collection( + DirectionAdditionalEducation::query() + ->where('is_active', true) + ->whereHas('additionalEducationCategories', fn ($q) => $q->whereHas('additionalEducations')) + ->get() + ); + } ); + + $additionalEducations = Cache::remember( + CacheKeys::ADDITIONAL_EDUCATIONAL_PROGRAMS_PREFIX->value . $cacheKey, + now()->addDay(), + function () use ($request) { + return AdditionalEducationCategoryResource::collection( + AdditionalEducationCategory::query() + ->WithActivePrograms() + ->where('is_active', true) + ->when($request->direction, fn ($q, $direction) => + $q->whereHas('direction', fn ($query) => $query->where('slug', $direction)) + ) + ->when($request->form, fn ($query, $form) => + $query->whereHas('additionalEducations', fn ($q) => + $q->where('form_education', FormEducation::fromName($form)) + ) + ->when($request->category, fn ($query) => + is_array($request->category) + ? $query->whereIn('slug', $request->category) + : $query + ) + ->has('additionalEducations') + ->get() + )); + } + ); + + $categories = Cache::remember( + CacheKeys::ADDITIONAL_EDUCATIONAL_PROGRAMS_PREFIX->value . 'categories', + now()->addWeek(), + function () { + return AdditionalEducationCategoryPreviewResource::collection( + AdditionalEducationCategory::query() + ->where('is_active', true) + ->has('additionalEducations') + ->get() + ); + } + ); + + // Динамические данные (не кешируются) $categoriesContent = []; - if (request()->input('category')) { - foreach (request()->input('category') as $item) { - $categoriesContent[$item] = new AdditionalEducationCategoryResource(AdditionalEducationCategory::where('slug', $item)->first()); + if ($request->category) { + foreach ((array)$request->category as $item) { + $categoriesContent[$item] = new AdditionalEducationCategoryResource( + AdditionalEducationCategory::where('slug', $item)->first() + ); } } - $forms_education = []; - foreach (FormEducation::cases() as $case) { - $forms_education[$case->name] = $case->getLabel(); - } + $forms_education = array_reduce( + FormEducation::cases(), + fn ($acc, $case) => $acc + [$case->name => $case->getLabel()], + [] + ); + $filters = [ 'direction_filter' => [ 'type' => 'direction', - 'value' => request()->input('direction'), + 'value' => $request->input('direction'), 'param' => 'direction' ], 'form_education_filter' => [ 'type' => 'form', - 'value' => request()->input('form'), + 'value' => $request->input('form'), 'param' => 'form' ], 'category_filter' => [ 'type' => 'category', - 'value' => request()->input('category'), + 'value' => $request->input('category'), 'param' => 'category', 'content' => $categoriesContent, ], ]; - $routeUrl = route('client.additionalEducation.index'); - $path = ltrim(parse_url($routeUrl, PHP_URL_PATH), '/'); + $seo = $this->seoPageProvider->getSeoForCurrentPage(); - $page = Page::where('path', '=', $path)->with('section.pages.section', 'section.mainSection')->first(); - if (isset($page->section)) { - $breadcrumbs = [ - 'mainSection' => new ClientBreadcrumbSection($page->section->mainSection), - 'subSection' => new ClientBreadcrumbSubSection($page->section), - 'page' => new ClientBreadcrumbPage($page), - ]; - } else { - $breadcrumbs = null; - } - - return Inertia::render('Client/Additional-educations/Index', - compact( - 'directionAdditionalEducations', - 'additionalEducations', - 'filters', - 'forms_education', - 'categories', - 'breadcrumbs' - )); + return Inertia::render('Client/Additional-educations/Index', compact( + 'directionAdditionalEducations', + 'additionalEducations', + 'filters', + 'forms_education', + 'categories', + 'seo' + )); } - public function show(string $slug) { - $additionalEducation = new AdditionalEducationResource(AdditionalEducation::query()->with('category.direction')->where('slug', $slug)->first()); - $routeUrl = route('client.additionalEducation.index'); - $path = ltrim(parse_url($routeUrl, PHP_URL_PATH), '/'); + // Кешируем основную программу дополнительного образования + [$additionalEducation, $seo] = Cache::remember( + CacheKeys::ADDITIONAL_EDUCATIONAL_PROGRAM_PREFIX->value . $slug, + now()->addDay(), + function () use ($slug) { + $additionalEducation = AdditionalEducation::query() + ->with('category.direction') + ->where('slug', $slug) + ->first(); + $seo = $this->seoPageProvider->getSeoForModel($additionalEducation); + return [ + new AdditionalEducationResource($additionalEducation), + $seo + ]; + } + ); - $page = Page::where('path', '=', $path)->with('section.pages.section', 'section.mainSection')->first(); + // SEO-данные берём из кешированного ресурса - if (isset($page->section)) { - $breadcrumbs = [ - 'mainSection' => new ClientBreadcrumbSection($page->section->mainSection), - 'subSection' => new ClientBreadcrumbSubSection($page->section), - 'page' => new ClientBreadcrumbPage($page), - ]; - } else { - $breadcrumbs = null; - } - - $seo = $additionalEducation->seo ?? null; - - return Inertia::render('Client/Additional-educations/Show', compact('additionalEducation', 'breadcrumbs', 'seo')); - } -} + return Inertia::render('Client/Additional-educations/Show', compact( + 'additionalEducation', + 'seo' + )); + }} diff --git a/app/Http/Controllers/ClientDepartmentController.php b/app/Http/Controllers/ClientDepartmentController.php index d515ebd..7f3b49e 100644 --- a/app/Http/Controllers/ClientDepartmentController.php +++ b/app/Http/Controllers/ClientDepartmentController.php @@ -2,34 +2,93 @@ namespace App\Http\Controllers; +use App\Enums\CacheKeys; use App\Http\Resources\ClientDepartmentPreviewResource; use App\Http\Resources\DepartmentResource; use App\Models\Department; use App\Models\Faculty; +use App\Services\App\Breadcrumb\BreadcrumbService; +use App\Services\App\Seo\SeoPageProvider; use Illuminate\Database\Eloquent\Collection; use Illuminate\Http\Request; +use Illuminate\Support\Facades\Cache; use Inertia\Inertia; class ClientDepartmentController extends Controller { + public function __construct(readonly SeoPageProvider $seoPageProvider){} + public function show(string $facultySlug, string $departmentSlug) { - $faculty = Faculty::query()->where('slug', $facultySlug)->first(); - $departments = ClientDepartmentPreviewResource::collection( - Department::query() - ->where('is_active', true) - ->where('faculty_id', $faculty->id) - ->get() - ); - $department = new DepartmentResource(Department::query() - ->where('slug', $departmentSlug) - ->where('is_active', true) - ->with(['faculty', 'workers.userDetail', 'teachers.userDetail', 'programs.directionStudy']) - ->first()); - $directions = $this->groupProgramsByDirection($department->programs); + // Ключ для кеширования + $cacheKey = "{$facultySlug}_{$departmentSlug}"; - $seo = $department->seo ?? null; - return Inertia::render('Client/Departments/Show', compact('department', 'departments', 'directions', 'seo')); + // Кешируем факультет + $faculty = Cache::remember( + CacheKeys::FACULTY_PREFIX->value . $facultySlug, + now()->addDay(), + function () use ($facultySlug) { + return Faculty::query() + ->where('slug', $facultySlug) + ->first(); + } + ); + + // Кешируем список активных кафедр факультета + $departments = Cache::remember( + CacheKeys::DEPARTMENTS_PREFIX->value . 'active_' . $faculty->id, + now()->addDay(), + function () use ($faculty) { + return ClientDepartmentPreviewResource::collection( + Department::query() + ->where('is_active', true) + ->where('faculty_id', $faculty->id) + ->get() + ); + } + ); + + // Кешируем полные данные кафедры с отношениями + [$department, $seo] = Cache::remember( + CacheKeys::DEPARTMENT_PREFIX->value . $cacheKey, + now()->addDay(), + function () use ($departmentSlug) { + $department = Department::query() + ->where('slug', $departmentSlug) + ->where('is_active', true) + ->with([ + 'faculty', + 'workers.userDetail', + 'teachers.userDetail', + 'programs.directionStudy', + 'seo' + ]) + ->first(); + + $seo = $this->seoPageProvider->getSeoForModel($department); + return [ + new DepartmentResource($department), + $seo + ]; + } + ); + + // Кешируем сгруппированные направления + $directions = Cache::remember( + CacheKeys::DEPARTMENT_PREFIX->value . 'directions_' . $cacheKey, + now()->addDay(), + function () use ($department) { + return $this->groupProgramsByDirection($department->programs); + } + ); + + + return Inertia::render('Client/Departments/Show', compact( + 'department', + 'departments', + 'directions', + 'seo', + )); } diff --git a/app/Http/Controllers/ClientDivisionController.php b/app/Http/Controllers/ClientDivisionController.php index 5ef8d33..b2d6e58 100644 --- a/app/Http/Controllers/ClientDivisionController.php +++ b/app/Http/Controllers/ClientDivisionController.php @@ -4,22 +4,31 @@ namespace App\Http\Controllers; use App\Http\Resources\DivisionResource; use App\Models\Division; +use App\Services\App\Breadcrumb\BreadcrumbService; +use App\Services\App\Seo\SeoPageProvider; use Illuminate\Http\Request; use Inertia\Inertia; class ClientDivisionController extends Controller { + public function __construct(readonly SeoPageProvider $seoPageProvider){} + public function index() { $divisions = DivisionResource::collection(Division::query()->where('is_active', true)->get()); - return Inertia::render('Client/Divisions/Index', compact('divisions')); + + $seo = $this->seoPageProvider->getSeoForCurrentPage(); + + return Inertia::render('Client/Divisions/Index', compact('divisions', 'seo')); } public function show(string $slug) { $divisions = DivisionResource::collection(Division::query()->where('is_active', true)->get()); - $division = new DivisionResource(Division::with('workers.userDetail')->where('is_active', true)->where('slug', $slug)->firstOrFail()); - $seo = $division->seo ?? null; + $division = new DivisionResource($divisionModel = Division::with(['workers.userDetail', 'seo'])->where('is_active', true)->where('slug', $slug)->firstOrFail()); + + $seo = $this->seoPageProvider->getSeoForModel($divisionModel); + return Inertia::render('Client/Divisions/Show', compact('divisions', 'division', 'seo')); } } diff --git a/app/Http/Controllers/ClientEventController.php b/app/Http/Controllers/ClientEventController.php index 31fe635..9570431 100644 --- a/app/Http/Controllers/ClientEventController.php +++ b/app/Http/Controllers/ClientEventController.php @@ -2,6 +2,7 @@ namespace App\Http\Controllers; +use App\Enums\CacheKeys; use App\Http\Resources\ClientBreadcrumbPage; use App\Http\Resources\ClientBreadcrumbSection; use App\Http\Resources\ClientBreadcrumbSubSection; @@ -13,53 +14,102 @@ use App\Models\Event; use App\Models\EventCategory; use App\Models\Page; use App\Services\App\Breadcrumb\BreadcrumbService; +use App\Services\App\Seo\SeoPageProvider; use Carbon\Carbon; use DateTime; use Illuminate\Http\Request; +use Illuminate\Support\Facades\Cache; use Inertia\Inertia; class ClientEventController extends Controller { - public function __construct(private readonly BreadcrumbService $breadcrumbService){} - + public function __construct(readonly SeoPageProvider $seoPageProvider){} public function index(Request $request): \Inertia\Response { $currentDate = $this->getCurrentDate($request); + $cacheKey = md5(serialize([$currentDate, $request->all()])); + + $events = Cache::remember( + CacheKeys::EVENTS_PREFIX->value . $cacheKey, + now()->addHours(12), + fn() => $this->getEvents($currentDate) + ); + + $eventDates = Cache::remember( + CacheKeys::EVENTS_PREFIX->value . 'dates_' . $cacheKey, + now()->addHours(12), + fn() => $this->getEventDates($this->getFilters()) + ); + + $categories = Cache::remember( + CacheKeys::EVENTS_PREFIX->value . 'categories', + now()->addDay(), + fn() => ClientEventCategoryResource::collection(EventCategory::has('events')->get()) + ); + $filters = $this->getFilters(); - $eventDates = $this->getEventDates($filters); - $events = $this->getEvents($currentDate); - $categories = ClientEventCategoryResource::collection(EventCategory::has('events')->get()); + $seo = $this->seoPageProvider->getSeoForCurrentPage(); - $breadcrumbs = $this->breadcrumbService->generateBreadcrumbs('client.event.index'); - - - return Inertia::render('Client/Events/Index', compact('eventDates', 'events', 'currentDate', 'filters', 'categories', 'breadcrumbs')); + return Inertia::render('Client/Events/Index', compact( + 'eventDates', + 'events', + 'currentDate', + 'filters', + 'categories', + 'seo' + )); } public function show(string $slug): \Inertia\Response { - $event = new ClientEventFullResource(Event::where('slug', '=', $slug)->with('category')->first()); + [$event, $seo] = Cache::remember( + CacheKeys::EVENT_PREFIX->value . $slug, + now()->addDay(), + function ($slug) { + $event = Event::where('slug', $slug)->with(['category', 'seo'])->first(); + $seo = $this->seoPageProvider->getSeoForModel($event); + return [ + new ClientEventFullResource($event), + $seo + ]; + } + ); - $breadcrumbs = $this->breadcrumbService->generateBreadcrumbs('client.event.index'); - $seo = $event->seo ?? null; - - return Inertia::render('Client/Events/Show', compact('event', 'breadcrumbs', 'seo')); + return Inertia::render('Client/Events/Show', compact( + 'event', + 'seo' + )); } public function archive(Request $request): \Inertia\Response { + $cacheKey = md5(serialize($request->all())); + + $events = Cache::remember( + CacheKeys::EVENTS_PREFIX->value . 'archive_' . $cacheKey, + now()->addDay(), + fn() => $this->getEventsArchive() + ); + + $categories = Cache::remember( + CacheKeys::EVENTS_PREFIX->value . 'categories', + now()->addDay(), + fn() => ClientEventCategoryResource::collection(EventCategory::has('events')->get()) + ); + $filters = $this->getFilters(); - $events = $this->getEventsArchive(); - $categories = ClientEventCategoryResource::collection(EventCategory::has('events')->get()); + $seo = $this->seoPageProvider->getSeoForCurrentPage(); - $breadcrumbs = $this->breadcrumbService->generateBreadcrumbs('client.event.archive'); - - - return Inertia::render('Client/Events/Archive', compact('events', 'filters', 'categories', 'breadcrumbs')); + return Inertia::render('Client/Events/Archive', compact( + 'events', + 'filters', + 'categories', + 'seo' + )); } private function getCurrentDate(Request $request): array @@ -164,7 +214,12 @@ class ClientEventController extends Controller ->orderBy('event_date_start') ->get(); - $mappingDates = $events->map(function ($event) { + // Получаем массив без ключей + + + + // Извлекаем уникальные даты из событий + return $events->map(function ($event) { $date = new DateTime($event->event_date_start); return [ 'day' => $date->format('j'), @@ -182,12 +237,7 @@ class ClientEventController extends Controller ]; }) ->sortKeys() // Сортируем ключи по возрастанию - ->values(); // Получаем массив без ключей - - - - // Извлекаем уникальные даты из событий - return $mappingDates; + ->values(); } private function getFilters(): array diff --git a/app/Http/Controllers/ClientFacultyController.php b/app/Http/Controllers/ClientFacultyController.php index 0265c5f..c81f0df 100644 --- a/app/Http/Controllers/ClientFacultyController.php +++ b/app/Http/Controllers/ClientFacultyController.php @@ -2,26 +2,75 @@ namespace App\Http\Controllers; +use App\Enums\CacheKeys; use App\Http\Resources\FacultyResource; use App\Http\Resources\FullFacultyResource; use App\Models\Faculty; +use App\Services\App\Breadcrumb\BreadcrumbService; +use App\Services\App\Seo\SeoPageProvider; use Illuminate\Http\Request; +use Illuminate\Support\Facades\Cache; use Inertia\Inertia; class ClientFacultyController extends Controller { - public function index() + public function __construct(readonly SeoPageProvider $seoPageProvider){} + + + public function index(Request $request) { - $faculties = FacultyResource::collection(Faculty::query()->where('is_active', true)->get()); - return Inertia::render('Client/Faculties/Index', compact('faculties')); + $faculties = Cache::remember( + CacheKeys::FACULTIES_PREFIX->value . 'active_list', + now()->addDay(), // Кешируем на 1 день + function () { + return FacultyResource::collection( + Faculty::query() + ->where('is_active', true) + ->get() + ); + } + ); + + $seo = $this->seoPageProvider->getSeoForCurrentPage(); + + return Inertia::render('Client/Faculties/Index', compact('faculties', 'seo')); } public function show(string $slug) { - $faculties = FacultyResource::collection(Faculty::query()->where('is_active', true)->get()); - $faculty = new FullFacultyResource(Faculty::where('slug', $slug)->where('is_active', true)->with(['departments.faculty', 'workers.userDetail'])->firstOrFail()); - $seo = $faculty->seo ?? null; + // Кешируем список факультетов + $faculties = Cache::remember( + CacheKeys::FACULTIES_PREFIX->value . 'active_list', + now()->addDay(), + function () { + return FacultyResource::collection( + Faculty::query() + ->where('is_active', true) + ->get() + ); + } + ); + + // Кешируем данные конкретного факультета + [$faculty, $seo] = Cache::remember( + CacheKeys::FACULTY_PREFIX->value . $slug, + now()->addDay(), + function () use ($slug) { + $faculty = Faculty::where('slug', $slug) + ->where('is_active', true) + ->with(['departments.faculty', 'workers.userDetail', 'seo']) + ->firstOrFail(); + $seo = $this->seoPageProvider->getSeoForModel($faculty); + return [ + new FullFacultyResource($faculty), + $seo + ]; + } + ); + + + return Inertia::render('Client/Faculties/Show', compact('faculty', 'faculties', 'seo')); } } diff --git a/app/Http/Controllers/ClientPostController.php b/app/Http/Controllers/ClientPostController.php index c4988bf..03e0e24 100644 --- a/app/Http/Controllers/ClientPostController.php +++ b/app/Http/Controllers/ClientPostController.php @@ -3,14 +3,9 @@ namespace App\Http\Controllers; use App\Http\Resources\CategoryResource; -use App\Http\Resources\ClientBreadcrumbPage; -use App\Http\Resources\ClientBreadcrumbSection; -use App\Http\Resources\ClientBreadcrumbSubSection; -use App\Http\Resources\ClientNavigationResource; use App\Http\Resources\ClientPostListResource; use App\Http\Resources\ClientTagResource; -use App\Http\Resources\MainSectionResource; -use App\Http\Resources\PageResource; + use App\Http\Resources\PostResource; use App\Models\Category; use App\Models\MainSection; @@ -18,8 +13,8 @@ use App\Models\Page; use App\Models\Post; use App\Models\Tag; use App\Services\App\Breadcrumb\BreadcrumbService; +use App\Services\App\Seo\SeoPageProvider; use Carbon\Carbon; -use Doctrine\DBAL\Schema\Column; use Illuminate\Http\Request; use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\DB; @@ -27,12 +22,12 @@ use Inertia\Inertia; class ClientPostController extends Controller { - public function __construct(private readonly BreadcrumbService $breadcrumbService){} + public function __construct(readonly SeoPageProvider $seoPageProvider){} public function index(Request $request) { // Кешируем список тегов - $tagIds = Cache::remember('tag_ids', now()->addHours(1), function () { + $tagIds = Cache::remember('tag_ids', now()->addHours(), function () { return DB::table('taggables') ->distinct() ->select('tag_id') @@ -132,11 +127,9 @@ class ClientPostController extends Controller ], ]; - $breadcrumbs = $this->breadcrumbService->generateBreadcrumbs('client.post.index'); + $seo = $this->seoPageProvider->getSeoForCurrentPage(); - - - return Inertia::render('Client/Posts/Index', compact('filters', 'posts', 'categories', 'tags', 'breadcrumbs')); + return Inertia::render('Client/Posts/Index', compact('filters', 'posts', 'categories', 'tags', 'seo')); } public function show(Request $request, $slug) @@ -154,19 +147,17 @@ class ClientPostController extends Controller // Преобразуем пост в ресурс $postResource = new PostResource($post); - $breadcrumbs = $this->breadcrumbService->generateBreadcrumbs('client.post.index'); - // SEO-данные - $seo = $post->seo ?? null; + $seo = $this->seoPageProvider->getSeoForModel($post); // Возвращаем данные для кеширования return [ 'post' => $postResource, - 'breadcrumbs' => $breadcrumbs, 'seo' => $seo, ]; }); + // Возвращаем ответ с использованием кешированных данных return Inertia::render('Client/Posts/Show', $data); } diff --git a/app/Http/Controllers/ClientProgramController.php b/app/Http/Controllers/ClientProgramController.php index 3866c1e..9f18d32 100644 --- a/app/Http/Controllers/ClientProgramController.php +++ b/app/Http/Controllers/ClientProgramController.php @@ -3,6 +3,7 @@ namespace App\Http\Controllers; use App\Enums\BudgetEducation; +use App\Enums\CacheKeys; use App\Enums\FormEducation; use App\Enums\LevelEducational; use App\Http\Resources\CampaignDegreeResource; @@ -16,121 +17,150 @@ use App\Models\CampaignDegree; use App\Models\DirectionStudy; use App\Models\EducationalProgram; use App\Models\MainSection; +use App\Services\App\Seo\SeoPageProvider; use Illuminate\Http\Request; +use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\DB; use Illuminate\Support\Str; use Inertia\Inertia; class ClientProgramController extends Controller { + public function __construct(readonly SeoPageProvider $seoPageProvider){} + public function index(Request $request) { - $activeCampaign = AdmissionCampaign::query()->where('status', 1)->first(); + $cacheKey = CacheKeys::EDUCATION_PROGRAMS_PREFIX->value . md5(serialize($request->all())); - $uniqueValues = EducationalProgram::distinct()->pluck('lvl_edu'); - $levelsEducational = $uniqueValues->mapWithKeys(function ($level) { - return [$level->name => $level->getLabel()]; - }); + $data = Cache::remember($cacheKey, now()->addHours(1), function () use ($request) { + $activeCampaign = AdmissionCampaign::query()->where('status', 1)->first(); - $direction_studies = DirectionStudy::query() - ->withAdmissionCampaignByYear($activeCampaign->academic_year) - ->withActivePrograms() - ->get(); + $uniqueValues = EducationalProgram::distinct()->pluck('lvl_edu'); + $levelsEducational = $uniqueValues->mapWithKeys(function ($level) { + return [$level->name => $level->getLabel()]; + }); - $level = request()->input('level'); - $form = request()->input('form'); - $budget = request()->input('budget'); - - $naprs = DirectionStudyResource::collection( - DirectionStudy::query() + $direction_studies = DirectionStudy::query() ->withAdmissionCampaignByYear($activeCampaign->academic_year) ->withActivePrograms() - ->with('programs.admission_plans') - ->when($level, function ($query) use ($level) { - $query->where('lvl_edu', LevelEducational::fromName($level)->value); - }) - ->when($form, function ($query) use ($form) { - $this->applyFormFilter($query, $form); - }) - ->when($budget, function ($query) use ($budget) { - $this->applyBudgetFilter($query, $budget); - }) - ->when(request()->input('direction'), function ($query) { - $slugs = request()->input('direction'); - if (is_array($slugs)) { - $query->whereIn('slug', $slugs); - } - }) - ->get() - ); + ->get(); + + $level = request()->input('level'); + $form = request()->input('form'); + $budget = request()->input('budget'); + + $naprs = DirectionStudyResource::collection( + DirectionStudy::query() + ->withAdmissionCampaignByYear($activeCampaign->academic_year) + ->withActivePrograms() + ->with('programs.admission_plans') + ->when($level, function ($query) use ($level) { + $query->where('lvl_edu', LevelEducational::fromName($level)->value); + }) + ->when($form, function ($query) use ($form) { + $this->applyFormFilter($query, $form); + }) + ->when($budget, function ($query) use ($budget) { + $this->applyBudgetFilter($query, $budget); + }) + ->when(request()->input('direction'), function ($query) { + $slugs = request()->input('direction'); + if (is_array($slugs)) { + $query->whereIn('slug', $slugs); + } + }) + ->get() + ); + + $campaignName = $this->getAdmissionCampaignName(); + $formsEducational = FormEducation::cases(); + $formsEducational = collect($formsEducational); + $formsEdu = $formsEducational->mapWithKeys(function ($formEducational) { + return [$formEducational->name => $formEducational->getLabel()]; + }); + $typesBudget = BudgetEducation::cases(); + $typesBudget = collect($typesBudget); + $budgetEdu = $typesBudget->mapWithKeys(function ($typeBudget) { + return [$typeBudget->name => $typeBudget->getLabel()]; + }); + + $filters = [ + 'level_filter' => [ + 'type' => 'level', + 'value' => request()->input('level'), + 'param' => 'level' + ], + 'budget_filter' => [ + 'type' => 'budget', + 'value' => request()->input('budget'), + 'param' => 'budget' + ], + 'formEdu_filter' => [ + 'type' => 'form', + 'value' => request()->input('form'), + 'param' => 'form' + ], + 'direction_filter' => [ + 'type' => 'direction', + 'value' => request()->input('direction'), + 'param' => 'direction' + ], + ]; + + $seo = $this->seoPageProvider->getSeoForCurrentPage(); - $campaignName = $this->getAdmissionCampaignName(); - $formsEducational = FormEducation::cases(); - $formsEducational = collect($formsEducational); - $formsEdu = $formsEducational->mapWithKeys(function ($formEducational) { - return [$formEducational->name => $formEducational->getLabel()]; - }); - $typesBudget = BudgetEducation::cases(); - $typesBudget = collect($typesBudget); - $budgetEdu = $typesBudget->mapWithKeys(function ($typeBudget) { - return [$typeBudget->name => $typeBudget->getLabel()]; - }); - - $filters = [ - 'level_filter' => [ - 'type' => 'level', - 'value' => request()->input('level'), - 'param' => 'level' - ], - 'budget_filter' => [ - 'type' => 'budget', - 'value' => request()->input('budget'), - 'param' => 'budget' - ], - 'formEdu_filter' => [ - 'type' => 'form', - 'value' => request()->input('form'), - 'param' => 'form' - ], - 'direction_filter' => [ - 'type' => 'direction', - 'value' => request()->input('direction'), - 'param' => 'direction' - ], - ]; - - return Inertia::render('Client/Programs/Index', - compact( + return compact( 'naprs', 'campaignName', 'levelsEducational', 'filters', 'formsEdu', 'budgetEdu', - 'direction_studies' - )); + 'direction_studies', + 'seo' + ); + }); + + + return Inertia::render('Client/Programs/Index', $data); } public function show(string $slug) { - $program = new EducationalProgramFullResource(EducationalProgram::query()->where('slug', $slug)->with(['admission_plans', 'directionStudy'])->firstOrFail()); - $formsEducational = BudgetEducation::cases(); - $formsEducational = collect($formsEducational); - $formsEdu = $formsEducational->mapWithKeys(function ($formEducational) { - return [$formEducational->value => $formEducational->getLabel()]; + $cacheKey = CacheKeys::EDUCATION_PROGRAM_PREFIX->value . md5($slug); + + $data = Cache::remember($cacheKey, now()->addHours(1), function () use ($slug) { + $program = new EducationalProgramFullResource( + $programModel = EducationalProgram::query() + ->where('slug', $slug) + ->with(['admission_plans', 'directionStudy', 'seo']) + ->firstOrFail() + ); + + $formsEducational = BudgetEducation::cases(); + $formsEducational = collect($formsEducational); + $formsEdu = $formsEducational->mapWithKeys(function ($formEducational) { + return [$formEducational->value => $formEducational->getLabel()]; + }); + + $seo = $this->seoPageProvider->getSeoForModel($programModel); + + return compact('program', 'formsEdu', 'seo'); }); - $seo = $program->seo ?? null; - return Inertia::render('Client/Programs/Show', compact('program', 'formsEdu', 'seo')); + return Inertia::render('Client/Programs/Show', $data); } - private function getAdmissionCampaignName() : string + private function getAdmissionCampaignName(): string { - $campaign = AdmissionCampaign::query()->where('status', 1)->first(); - return $campaign->name; - } + $cacheKey = CacheKeys::EDUCATION_PROGRAM_PREFIX->value . 'active_campaign_name'; + return Cache::remember($cacheKey, now()->addHours(1), function () { + $campaign = AdmissionCampaign::query()->where('status', 1)->first(); + return $campaign->name; + }); + } private function applyFormFilter($query, $form) { @@ -150,7 +180,6 @@ class ClientProgramController extends Controller { $budgetValue = Str::of(BudgetEducation::fromName($budget)->value)->toString(); - $query->whereHas('programs.admission_plans', function ($query) use ($budgetValue) { $query->whereJsonContains('contests', ['financing_source' => $budgetValue]); }) @@ -160,5 +189,4 @@ class ClientProgramController extends Controller }); }]); } - -} +} \ No newline at end of file diff --git a/app/Http/Controllers/ClientScheduleController.php b/app/Http/Controllers/ClientScheduleController.php index 52ce791..0be3318 100644 --- a/app/Http/Controllers/ClientScheduleController.php +++ b/app/Http/Controllers/ClientScheduleController.php @@ -8,11 +8,14 @@ use App\Http\Resources\ScheduleResource; use App\Models\EducationalGroup; use App\Models\Faculty; use App\Models\Schedule; +use App\Services\App\Seo\SeoPageProvider; use Illuminate\Http\Request; use Inertia\Inertia; class ClientScheduleController extends Controller { + public function __construct(readonly SeoPageProvider $seoPageProvider){} + public function index(Request $request) { $educationalGroups = ClientEducationalGroupResource::collection(EducationalGroup::query() @@ -46,10 +49,6 @@ class ClientScheduleController extends Controller } - - - - $forms_education = []; foreach (FormEducation::cases() as $case) { $forms_education[$case->name] = $case->getLabel(); @@ -78,8 +77,10 @@ class ClientScheduleController extends Controller ] ]; + $seo = $this->seoPageProvider->getSeoForCurrentPage(); + // Возвращаем данные в представление - return Inertia::render('Client/Schedules/Index', compact('educationalGroups', 'filters', 'forms_education', 'schedulesByFaculty')); + return Inertia::render('Client/Schedules/Index', compact('educationalGroups', 'filters', 'forms_education', 'schedulesByFaculty', 'seo')); } public function show($id) diff --git a/app/Http/Controllers/ClientWidgetAdditionalEducationalProgramController.php b/app/Http/Controllers/ClientWidgetAdditionalEducationalProgramController.php index 7726afd..8fdd9c2 100644 --- a/app/Http/Controllers/ClientWidgetAdditionalEducationalProgramController.php +++ b/app/Http/Controllers/ClientWidgetAdditionalEducationalProgramController.php @@ -2,22 +2,31 @@ namespace App\Http\Controllers; +use App\Enums\CacheKeys; use App\Enums\PostStatus; use App\Http\Resources\AdditionalEducationSearchResource; use App\Http\Resources\PostThumbnailResource; use App\Models\AdditionalEducation; use App\Models\Post; use Illuminate\Http\Request; +use Illuminate\Support\Facades\Cache; class ClientWidgetAdditionalEducationalProgramController extends Controller { public function index() { - return AdditionalEducationSearchResource::collection( - AdditionalEducation::query() - ->where('is_active', true) - ->orderBy('title', 'desc') - ->get()); + return Cache::remember( + CacheKeys::ADDITIONAL_EDUCATIONAL_PROGRAMS_PREFIX->value . 'search_list', + now()->addDay(), // Кешируем на 1 день + function () { + return AdditionalEducationSearchResource::collection( + AdditionalEducation::query() + ->where('is_active', true) + ->orderBy('title', 'desc') + ->get() + ); + } + ); } } diff --git a/app/Http/Controllers/ClientWidgetContactController.php b/app/Http/Controllers/ClientWidgetContactController.php index f0716be..7655a2c 100644 --- a/app/Http/Controllers/ClientWidgetContactController.php +++ b/app/Http/Controllers/ClientWidgetContactController.php @@ -2,14 +2,26 @@ namespace App\Http\Controllers; +use App\Enums\CacheKeys; use App\Http\Resources\ClientContactWidgetResource; use App\Http\Resources\ClientPageReferenceListResource; use App\Models\ContactWidget; +use Illuminate\Support\Facades\Cache; class ClientWidgetContactController extends Controller { public function show(string $slug) { - return new ClientContactWidgetResource(ContactWidget::query()->where('slug', $slug)->first()); + return Cache::remember( + CacheKeys::CONTACT_WIDGET_PREFIX->value . $slug, + now()->addHours(12), // Кешируем на 12 часов + function () use ($slug) { + return new ClientContactWidgetResource( + ContactWidget::query() + ->where('slug', $slug) + ->first() + ); + } + ); } } diff --git a/app/Http/Controllers/ClientWidgetEducationalProgramController.php b/app/Http/Controllers/ClientWidgetEducationalProgramController.php index fe96d62..c549b9c 100644 --- a/app/Http/Controllers/ClientWidgetEducationalProgramController.php +++ b/app/Http/Controllers/ClientWidgetEducationalProgramController.php @@ -2,19 +2,28 @@ namespace App\Http\Controllers; +use App\Enums\CacheKeys; use App\Enums\EducationalProgramStatus; use App\Http\Resources\EducationalProgramSearchResource; use App\Models\EducationalProgram; use Illuminate\Http\Request; +use Illuminate\Support\Facades\Cache; class ClientWidgetEducationalProgramController extends Controller { public function index() { - return EducationalProgramSearchResource::collection( - EducationalProgram::query() - ->where('status', EducationalProgramStatus::PUBLISHED) - ->orderBy('name', 'desc') - ->get()); + return Cache::remember( + CacheKeys::EDUCATION_PROGRAMS_PREFIX->value . 'search_list', + now()->addDay(), // Кешируем на 1 день + function () { + return EducationalProgramSearchResource::collection( + EducationalProgram::query() + ->where('status', EducationalProgramStatus::PUBLISHED) + ->orderBy('name', 'desc') + ->get() + ); + } + ); } } diff --git a/app/Http/Controllers/ClientWidgetPageReferenceListController.php b/app/Http/Controllers/ClientWidgetPageReferenceListController.php index cf277c6..422f30c 100644 --- a/app/Http/Controllers/ClientWidgetPageReferenceListController.php +++ b/app/Http/Controllers/ClientWidgetPageReferenceListController.php @@ -2,14 +2,26 @@ namespace App\Http\Controllers; +use App\Enums\CacheKeys; use App\Http\Resources\ClientPageReferenceListResource; use App\Models\PageReferenceList; use Illuminate\Http\Request; +use Illuminate\Support\Facades\Cache; class ClientWidgetPageReferenceListController extends Controller { public function show(string $slug) { - return new ClientPageReferenceListResource(PageReferenceList::query()->where('slug', $slug)->first()); + return Cache::remember( + CacheKeys::PAGE_REFERENCE_LIST_PREFIX->value . $slug, + now()->addWeek(), // Кешируем на неделю, так как справочники меняются редко + function () use ($slug) { + return new ClientPageReferenceListResource( + PageReferenceList::query() + ->where('slug', $slug) + ->first() + ); + } + ); } } diff --git a/app/Http/Controllers/ClientWidgetSliderController.php b/app/Http/Controllers/ClientWidgetSliderController.php index 2dd1d27..d1d6951 100644 --- a/app/Http/Controllers/ClientWidgetSliderController.php +++ b/app/Http/Controllers/ClientWidgetSliderController.php @@ -15,7 +15,9 @@ class ClientWidgetSliderController extends Controller $slider = Slider::query() ->where('slug', $slug) ->where('is_active', true) - ->with('slides') + ->with(['slides' => function($query) { + $query->where('is_active', true); + }]) ->first(); return $slider ?: null; diff --git a/app/Http/Controllers/PageController.php b/app/Http/Controllers/PageController.php index 530efbf..8164e80 100644 --- a/app/Http/Controllers/PageController.php +++ b/app/Http/Controllers/PageController.php @@ -14,6 +14,7 @@ use App\Http\Resources\RegisteredPageResource; use App\Models\MainSection; use App\Models\Page; use App\Models\Post; +use App\Services\App\Seo\SeoPageProvider; use Illuminate\Http\Request; use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\Http; @@ -22,7 +23,9 @@ use Inertia\Inertia; class PageController extends Controller { - public function render(Request $request, $path) + public function __construct(readonly SeoPageProvider $seoPageProvider){} + + public function render(string $path): \Inertia\Response { // Генерируем уникальный ключ для кеширования $cacheKey = 'page_' . md5($path); @@ -37,45 +40,17 @@ class PageController extends Controller if ($page === null) { abort(404); } + $subSectionPages = $page->section ? PageResource::collection($page->section->pages) : null; - if (isset($page->section)) { - $subSectionPages = PageResource::collection($page->section->pages); - $breadcrumbs = [ - 'mainSection' => new ClientBreadcrumbSection($page->section->mainSection), - 'subSection' => new ClientBreadcrumbSubSection($page->section), - 'page' => new ClientBreadcrumbPage($page), - ]; - } else { - $subSectionPages = null; - $breadcrumbs = null; - } - - $seo = $page->seo ?? null; + $seo = $this->seoPageProvider->getSeoForModel($page); $page = new PageResource($page); - $error = $page->code; if ($page->code != 200) { - abort($error); + abort($page->code); } - return Inertia::render('Page', compact('page', 'subSectionPages', 'breadcrumbs', 'seo')); - } - public function getRegisteredPages() - { - $pages = PageResource::collection(Page::query() - ->when(request()->input('search'), function ($query, $search) { - $query->where('title', 'like', "%{$search}%"); - }) - ->where('is_registered', true) - ->where('is_visible', true) - ->orderBy('id', 'desc') - ->paginate(request()->input('perPage', 9)) - ->withQueryString()); - $filters = [ - 'search' => request()->input('search'), - ]; - return Inertia::render('AdminPanel/Pages/Registered', compact('pages', 'filters')); + return Inertia::render('Page', compact('page', 'subSectionPages', 'seo')); } } diff --git a/app/Http/Controllers/PersonController.php b/app/Http/Controllers/PersonController.php index 1b0146c..93888c8 100644 --- a/app/Http/Controllers/PersonController.php +++ b/app/Http/Controllers/PersonController.php @@ -2,6 +2,7 @@ namespace App\Http\Controllers; +use App\Enums\CacheKeys; use App\Http\Resources\ClientFullInfoPersonResource; use App\Http\Resources\ClientNavigationResource; use App\Http\Resources\MainSectionResource; @@ -10,23 +11,30 @@ use App\Http\Resources\UserResource; use App\Models\MainSection; use App\Models\User; use App\Models\UserDetail; +use App\Services\App\Seo\SeoPageProvider; use Illuminate\Http\Request; +use Illuminate\Support\Facades\Cache; use Inertia\Inertia; class PersonController extends Controller { - public function index() - { - $persons = UserDetailResource::collection(UserDetail::all()); - $filters = [ - 'search' => request()->input('search'), - ]; - return Inertia::render('Client/Persons/Index', compact('persons', 'filters')); - } + public function __construct(readonly SeoPageProvider $seoPageProvider){} public function show(string $slug) { - $person = new ClientFullInfoPersonResource(User::query()->with(['userDetail', 'departments_work.faculty', 'departments_teach.faculty', 'divisions', 'faculties'])->where('slug', $slug)->firstOrFail()); - return Inertia::render('Client/Persons/Show', compact('person')); + $cacheKey = CacheKeys::USER_PREFIX->value . md5($slug); + + [$person, $seo] = Cache::remember($cacheKey, now()->addHours(24), function () use ($slug) { + $person = User::query()->with(['userDetail', 'departments_work.faculty', 'departments_teach.faculty', 'divisions', 'faculties', 'seo']) + ->where('slug', $slug) + ->firstOrFail(); + $seo = $this->seoPageProvider->getSeoForModel($person); + return [ + new ClientFullInfoPersonResource($person), + $seo + ]; + }); + + return Inertia::render('Client/Persons/Show', compact('person', 'seo')); } } diff --git a/app/Http/Controllers/SearchController.php b/app/Http/Controllers/SearchController.php index e376144..861d672 100644 --- a/app/Http/Controllers/SearchController.php +++ b/app/Http/Controllers/SearchController.php @@ -9,6 +9,7 @@ use App\Http\Resources\EventSearchResource; use App\Http\Resources\FacultySearchResource; use App\Http\Resources\PageSearchResource; use App\Http\Resources\PostSearchResource; +use App\Http\Resources\StaticPageSearchResource; use App\Http\Resources\UserSearchResource; use App\Models\AdditionalEducation; use App\Models\EducationalGroup; diff --git a/app/Http/Controllers/StaticSearchController.php b/app/Http/Controllers/StaticSearchController.php new file mode 100644 index 0000000..8848b9c --- /dev/null +++ b/app/Http/Controllers/StaticSearchController.php @@ -0,0 +1,28 @@ +search( + $request->input('search'), + $request->input('page', 1) + ); + } + + public function getCategories() + { + return Cache::remember('page_static_categories', now()->addWeek(), function () { + return app(CategoryFinderService::class)->getCategories(); + }); + } +} + diff --git a/app/Http/Kernel.php b/app/Http/Kernel.php index 30caad4..6b5c487 100644 --- a/app/Http/Kernel.php +++ b/app/Http/Kernel.php @@ -3,6 +3,7 @@ namespace App\Http; use App\Http\Middleware\AccessCheck; +use App\Http\Middleware\FormTimePeriodMiddleware; use App\Http\Middleware\InternalRequestOnly; use App\Http\Middleware\LimitPost; use App\Http\Middleware\RateLimitCheckMiddleware; @@ -86,6 +87,7 @@ class Kernel extends HttpKernel 'ensure.browser' => InternalRequestOnly::class, 'superadmin' => \App\Http\Middleware\EnsureUserIsSuperadmin::class, 'limit.post' => LimitPost::class, + 'form.time.period' => FormTimePeriodMiddleware::class, ); } diff --git a/app/Http/Middleware/FormTimePeriodMiddleware.php b/app/Http/Middleware/FormTimePeriodMiddleware.php new file mode 100644 index 0000000..ffbaa4d --- /dev/null +++ b/app/Http/Middleware/FormTimePeriodMiddleware.php @@ -0,0 +1,47 @@ +find($request->route('id')); + + if (!$form) { + abort(Response::HTTP_NOT_FOUND, 'Form not found'); + } + + if (!isset($form->settings['period'])) { + abort(Response::HTTP_BAD_REQUEST, 'Invalid form settings'); + } + + $period = $form->settings['period']; + + try { + $start_time = Carbon::parse($period['start_time']); + $end_time = Carbon::parse($period['end_time']); + } catch (\Exception $e) { + abort(Response::HTTP_BAD_REQUEST, 'Invalid time format'); + } + + $now = Carbon::now(); + + if ($now >= $start_time && $now <= $end_time) { + return $next($request); + } + + abort(Response::HTTP_FORBIDDEN, 'Form is not available at this time'); + } +} diff --git a/app/Http/Middleware/HandleInertiaRequests.php b/app/Http/Middleware/HandleInertiaRequests.php index cea890e..b44d531 100644 --- a/app/Http/Middleware/HandleInertiaRequests.php +++ b/app/Http/Middleware/HandleInertiaRequests.php @@ -4,6 +4,7 @@ namespace App\Http\Middleware; use App\Http\Resources\ClientNavigationResource; use App\Models\MainSection; +use App\Services\App\Breadcrumb\BreadcrumbService; use Illuminate\Http\Request; use Illuminate\Support\Facades\Cache; use Inertia\Middleware; @@ -11,28 +12,16 @@ use Tightenco\Ziggy\Ziggy; class HandleInertiaRequests extends Middleware { - /** - * The root template that is loaded on the first page visit. - * - * @var string - */ protected $rootView = 'app'; - /** - * Determine the current asset version. - */ - public function version(Request $request): string|null + public function version(Request $request): ?string { return parent::version($request); } - /** - * Define the props that are shared by default. - * - * @return array - */ public function share(Request $request): array { + // Навигация (кешированная) $navigation = Cache::remember('navigation', now()->addHours(1), function () { return ClientNavigationResource::collection( MainSection::with('subSections.pages.section') @@ -41,6 +30,9 @@ class HandleInertiaRequests extends Middleware ); }); + // Хлебные крошки (автоматически по текущему URL) + $breadcrumbs = app(BreadcrumbService::class)->generateBreadcrumbs(); + return [ ...parent::share($request), 'auth' => [ @@ -51,13 +43,13 @@ class HandleInertiaRequests extends Middleware 'location' => $request->url(), ], 'navigation' => $navigation, - 'urlPrev' => function() { - if (url()->previous() !== '' && url()->previous() !== url()->current()) { + 'breadcrumbs' => $breadcrumbs, // Добавляем хлебные крошки + 'urlPrev' => function () { + if (url()->previous() !== url()->current()) { return url()->previous(); - } else { - return 'empty'; } + return 'empty'; }, ]; } -} +} \ No newline at end of file diff --git a/app/Http/Resources/ClientBreadcrumbSection.php b/app/Http/Resources/ClientBreadcrumbSection.php index 58cb6a6..d9ec1b4 100644 --- a/app/Http/Resources/ClientBreadcrumbSection.php +++ b/app/Http/Resources/ClientBreadcrumbSection.php @@ -15,8 +15,8 @@ class ClientBreadcrumbSection extends JsonResource public function toArray(Request $request): array { return [ - 'title' => $this->title, - 'slug' => $this->slug, + 'title' => $this->title ?? null, + 'slug' => $this->slug ?? null, ]; } } diff --git a/app/Http/Resources/ClientEventFullResource.php b/app/Http/Resources/ClientEventFullResource.php index 5344bd8..680687e 100644 --- a/app/Http/Resources/ClientEventFullResource.php +++ b/app/Http/Resources/ClientEventFullResource.php @@ -26,7 +26,7 @@ class ClientEventFullResource extends JsonResource 'event_time_start' => Carbon::parse($this->event_time_start)->format('H:i'), 'address' => $this->address, 'is_online' => $this->is_online, - 'category' => $this->category->title ?? null, + 'category' => $this->category ?? null, ]; } } diff --git a/app/Http/Resources/ClientEventResource.php b/app/Http/Resources/ClientEventResource.php index fddfd87..ec3178c 100644 --- a/app/Http/Resources/ClientEventResource.php +++ b/app/Http/Resources/ClientEventResource.php @@ -23,7 +23,7 @@ class ClientEventResource extends JsonResource 'event_time_start' => Carbon::parse($this->event_time_start)->format('H:i'), 'address' => $this->address, 'is_online' => $this->is_online, - 'category' => $this->category->title ?? null, + 'category' => $this->category ?? null, ]; } } diff --git a/app/Models/AcademicJournal.php b/app/Models/AcademicJournal.php index 9edc9e4..ec32a9b 100644 --- a/app/Models/AcademicJournal.php +++ b/app/Models/AcademicJournal.php @@ -2,10 +2,11 @@ namespace App\Models; +use App\Services\App\Seo\SeoDescriptionInterface; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; -class AcademicJournal extends Model +class AcademicJournal extends Model implements SeoDescriptionInterface { use HasFactory; @@ -27,4 +28,9 @@ class AcademicJournal extends Model { return $this->morphOne(Seo::class, 'seoable'); } + + public function getSeoDescription(): array + { + return $this->main_info; + } } diff --git a/app/Models/CustomForm.php b/app/Models/CustomForm.php index 5f7c9bc..f37edcb 100644 --- a/app/Models/CustomForm.php +++ b/app/Models/CustomForm.php @@ -16,6 +16,7 @@ class CustomForm extends Model 'columns' => 'array', 'status' => CustomFormStatus::class, 'mail_settings' => 'array', + 'settings' => 'array', ]; public function responses() diff --git a/app/Models/Page.php b/app/Models/Page.php index 8479009..274fbb4 100644 --- a/app/Models/Page.php +++ b/app/Models/Page.php @@ -23,15 +23,6 @@ class Page extends Model return $this->morphOne(Seo::class, 'seoable'); } - public function getBreadcrumbs(string $url) : array - { - $path = ltrim(parse_url(route($url), PHP_URL_PATH), '/'); - - $page = self::where('path', '=', $path)->with('section.pages.section', 'section.mainSection')->first(); - - dd($page); - } - protected $casts = [ 'content' => 'array', 'settings' => 'array', diff --git a/app/Models/Post.php b/app/Models/Post.php index b7e8877..bea16a3 100644 --- a/app/Models/Post.php +++ b/app/Models/Post.php @@ -41,14 +41,14 @@ class Post extends Model return $this->belongsTo(User::class, 'user_id'); } - public function seo() + public function seo(): MorphOne { return $this->morphOne(Seo::class, 'seoable'); } - public function mainSlider() + public function slide(): MorphOne { - return $this->morphOne(MainSlider::class, 'slidable'); + return $this->morphOne(Slide::class, 'slidable'); } protected $casts = [ diff --git a/app/Models/Slide.php b/app/Models/Slide.php index 3d9e6ba..1c23da3 100644 --- a/app/Models/Slide.php +++ b/app/Models/Slide.php @@ -21,4 +21,9 @@ class Slide extends Model { return $this->belongsTo(Slider::class); } + + public function slidable() + { + return $this->morphTo(); + } } diff --git a/app/Observers/AcademicJournalObserver.php b/app/Observers/AcademicJournalObserver.php new file mode 100644 index 0000000..0de345a --- /dev/null +++ b/app/Observers/AcademicJournalObserver.php @@ -0,0 +1,56 @@ +academicJournalCacheService = app(AcademicJournalCacheService::class); + } + + /** + * Handle the AcademicJournal "created" event. + */ + public function created(AcademicJournal $academicJournal): void + { + $this->academicJournalCacheService->clearAllCacheByModel(); + } + + /** + * Handle the AcademicJournal "updated" event. + */ + public function updated(AcademicJournal $academicJournal): void + { + $this->academicJournalCacheService->clearCache($academicJournal); + } + + /** + * Handle the AcademicJournal "deleted" event. + */ + public function deleted(AcademicJournal $academicJournal): void + { + $this->academicJournalCacheService->clearAllCacheByModel(); + } + + /** + * Handle the AcademicJournal "restored" event. + */ + public function restored(AcademicJournal $academicJournal): void + { + // + } + + /** + * Handle the AcademicJournal "force deleted" event. + */ + public function forceDeleted(AcademicJournal $academicJournal): void + { + // + } +} \ No newline at end of file diff --git a/app/Observers/AdditionalEducationObserver.php b/app/Observers/AdditionalEducationObserver.php new file mode 100644 index 0000000..d2d8bf7 --- /dev/null +++ b/app/Observers/AdditionalEducationObserver.php @@ -0,0 +1,56 @@ +additionalEducationCacheService = app(AdditionalEducationCacheService::class); + } + + /** + * Handle the AdditionalEducation "created" event. + */ + public function created(AdditionalEducation $additionalEducation): void + { + $this->additionalEducationCacheService->clearAllCacheByModel(); + } + + /** + * Handle the AdditionalEducation "updated" event. + */ + public function updated(AdditionalEducation $additionalEducation): void + { + $this->additionalEducationCacheService->clearCache($additionalEducation); + } + + /** + * Handle the AdditionalEducation "deleted" event. + */ + public function deleted(AdditionalEducation $additionalEducation): void + { + $this->additionalEducationCacheService->clearAllCacheByModel(); + } + + /** + * Handle the AdditionalEducation "restored" event. + */ + public function restored(AdditionalEducation $additionalEducation): void + { + $this->additionalEducationCacheService->clearAllCacheByModel(); + } + + /** + * Handle the AdditionalEducation "force deleted" event. + */ + public function forceDeleted(AdditionalEducation $additionalEducation): void + { + $this->additionalEducationCacheService->clearAllCacheByModel(); + } +} \ No newline at end of file diff --git a/app/Observers/ContactWidgetObserver.php b/app/Observers/ContactWidgetObserver.php new file mode 100644 index 0000000..8a11dfd --- /dev/null +++ b/app/Observers/ContactWidgetObserver.php @@ -0,0 +1,59 @@ +contactWidgetCacheService = app(ContactWidgetCacheService::class); + } + + /** + * Handle the ContactWidget "created" event. + */ + public function created(ContactWidget $contactWidget): void + { + $this->contactWidgetCacheService->clearAllCacheByModel(); + } + + /** + * Handle the ContactWidget "updated" event. + */ + public function updated(ContactWidget $contactWidget): void + { + $this->contactWidgetCacheService->clearCache($contactWidget); + $this->contactWidgetCacheService->clearAllCacheByModel(); + } + + /** + * Handle the ContactWidget "deleted" event. + */ + public function deleted(ContactWidget $contactWidget): void + { + $this->contactWidgetCacheService->clearCache($contactWidget); + $this->contactWidgetCacheService->clearAllCacheByModel(); + } + + /** + * Handle the ContactWidget "restored" event. + */ + public function restored(ContactWidget $contactWidget): void + { + $this->contactWidgetCacheService->clearAllCacheByModel(); + } + + /** + * Handle the ContactWidget "force deleted" event. + */ + public function forceDeleted(ContactWidget $contactWidget): void + { + $this->contactWidgetCacheService->clearCache($contactWidget); + $this->contactWidgetCacheService->clearAllCacheByModel(); + } +} \ No newline at end of file diff --git a/app/Observers/DepartmentObserver.php b/app/Observers/DepartmentObserver.php new file mode 100644 index 0000000..eafff36 --- /dev/null +++ b/app/Observers/DepartmentObserver.php @@ -0,0 +1,59 @@ +departmentCacheService = app(DepartmentCacheService::class); + } + + /** + * Handle the Department "created" event. + */ + public function created(Department $department): void + { + $this->departmentCacheService->clearAllCacheByModel(); + } + + /** + * Handle the Department "updated" event. + */ + public function updated(Department $department): void + { + $this->departmentCacheService->clearCache($department); + $this->departmentCacheService->clearAllCacheByModel(); // Очищаем и общий кэш, если есть списки + } + + /** + * Handle the Department "deleted" event. + */ + public function deleted(Department $department): void + { + $this->departmentCacheService->clearCache($department); + $this->departmentCacheService->clearAllCacheByModel(); + } + + /** + * Handle the Department "restored" event. + */ + public function restored(Department $department): void + { + $this->departmentCacheService->clearAllCacheByModel(); + } + + /** + * Handle the Department "force deleted" event. + */ + public function forceDeleted(Department $department): void + { + $this->departmentCacheService->clearCache($department); + $this->departmentCacheService->clearAllCacheByModel(); + } +} \ No newline at end of file diff --git a/app/Observers/DivisionObserver.php b/app/Observers/DivisionObserver.php new file mode 100644 index 0000000..7ae030f --- /dev/null +++ b/app/Observers/DivisionObserver.php @@ -0,0 +1,59 @@ +divisionCacheService = app(DivisionCacheService::class); + } + + /** + * Handle the Division "created" event. + */ + public function created(Division $division): void + { + $this->divisionCacheService->clearAllCacheByModel(); + } + + /** + * Handle the Division "updated" event. + */ + public function updated(Division $division): void + { + $this->divisionCacheService->clearCache($division); + $this->divisionCacheService->clearAllCacheByModel(); + } + + /** + * Handle the Division "deleted" event. + */ + public function deleted(Division $division): void + { + $this->divisionCacheService->clearCache($division); + $this->divisionCacheService->clearAllCacheByModel(); + } + + /** + * Handle the Division "restored" event. + */ + public function restored(Division $division): void + { + $this->divisionCacheService->clearAllCacheByModel(); + } + + /** + * Handle the Division "force deleted" event. + */ + public function forceDeleted(Division $division): void + { + $this->divisionCacheService->clearCache($division); + $this->divisionCacheService->clearAllCacheByModel(); + } +} \ No newline at end of file diff --git a/app/Observers/EducationalProgramObserver.php b/app/Observers/EducationalProgramObserver.php new file mode 100644 index 0000000..38ce337 --- /dev/null +++ b/app/Observers/EducationalProgramObserver.php @@ -0,0 +1,59 @@ +educationalProgramCacheService = app(EducationalProgramCacheService::class); + } + + /** + * Handle the EducationalProgram "created" event. + */ + public function created(EducationalProgram $educationalProgram): void + { + $this->educationalProgramCacheService->clearAllCacheByModel(); + } + + /** + * Handle the EducationalProgram "updated" event. + */ + public function updated(EducationalProgram $educationalProgram): void + { + $this->educationalProgramCacheService->clearCache($educationalProgram); + $this->educationalProgramCacheService->clearAllCacheByModel(); + } + + /** + * Handle the EducationalProgram "deleted" event. + */ + public function deleted(EducationalProgram $educationalProgram): void + { + $this->educationalProgramCacheService->clearCache($educationalProgram); + $this->educationalProgramCacheService->clearAllCacheByModel(); + } + + /** + * Handle the EducationalProgram "restored" event. + */ + public function restored(EducationalProgram $educationalProgram): void + { + $this->educationalProgramCacheService->clearAllCacheByModel(); + } + + /** + * Handle the EducationalProgram "force deleted" event. + */ + public function forceDeleted(EducationalProgram $educationalProgram): void + { + $this->educationalProgramCacheService->clearCache($educationalProgram); + $this->educationalProgramCacheService->clearAllCacheByModel(); + } +} \ No newline at end of file diff --git a/app/Observers/EventObserver.php b/app/Observers/EventObserver.php new file mode 100644 index 0000000..cefc23c --- /dev/null +++ b/app/Observers/EventObserver.php @@ -0,0 +1,59 @@ +eventCacheService = app(EventCacheService::class); + } + + /** + * Handle the Event "created" event. + */ + public function created(Event $event): void + { + $this->eventCacheService->clearAllCacheByModel(); + } + + /** + * Handle the Event "updated" event. + */ + public function updated(Event $event): void + { + $this->eventCacheService->clearCache($event); + $this->eventCacheService->clearAllCacheByModel(); + } + + /** + * Handle the Event "deleted" event. + */ + public function deleted(Event $event): void + { + $this->eventCacheService->clearCache($event); + $this->eventCacheService->clearAllCacheByModel(); + } + + /** + * Handle the Event "restored" event. + */ + public function restored(Event $event): void + { + $this->eventCacheService->clearAllCacheByModel(); + } + + /** + * Handle the Event "force deleted" event. + */ + public function forceDeleted(Event $event): void + { + $this->eventCacheService->clearCache($event); + $this->eventCacheService->clearAllCacheByModel(); + } +} \ No newline at end of file diff --git a/app/Observers/FacultyObserver.php b/app/Observers/FacultyObserver.php new file mode 100644 index 0000000..2b459ef --- /dev/null +++ b/app/Observers/FacultyObserver.php @@ -0,0 +1,59 @@ +facultyCacheService = app(FacultyCacheService::class); + } + + /** + * Handle the Faculty "created" event. + */ + public function created(Faculty $faculty): void + { + $this->facultyCacheService->clearAllCacheByModel(); + } + + /** + * Handle the Faculty "updated" event. + */ + public function updated(Faculty $faculty): void + { + $this->facultyCacheService->clearCache($faculty); + $this->facultyCacheService->clearAllCacheByModel(); + } + + /** + * Handle the Faculty "deleted" event. + */ + public function deleted(Faculty $faculty): void + { + $this->facultyCacheService->clearCache($faculty); + $this->facultyCacheService->clearAllCacheByModel(); + } + + /** + * Handle the Faculty "restored" event. + */ + public function restored(Faculty $faculty): void + { + $this->facultyCacheService->clearAllCacheByModel(); + } + + /** + * Handle the Faculty "force deleted" event. + */ + public function forceDeleted(Faculty $faculty): void + { + $this->facultyCacheService->clearCache($faculty); + $this->facultyCacheService->clearAllCacheByModel(); + } +} \ No newline at end of file diff --git a/app/Observers/PageObserver.php b/app/Observers/PageObserver.php index c074945..aff5546 100644 --- a/app/Observers/PageObserver.php +++ b/app/Observers/PageObserver.php @@ -26,7 +26,7 @@ class PageObserver /** * Handle the Page "updated" event. */ - public function updated(Page $page) + public function updated(Page $page): void { $this->pageCacheService->clearCache($page); } @@ -34,7 +34,7 @@ class PageObserver /** * Handle the Page "deleted" event. */ - public function deleted(Page $page) + public function deleted(Page $page): void { $this->pageCacheService->clearAllCacheByModel(); } diff --git a/app/Observers/PageReferenceListObserver.php b/app/Observers/PageReferenceListObserver.php new file mode 100644 index 0000000..4527f06 --- /dev/null +++ b/app/Observers/PageReferenceListObserver.php @@ -0,0 +1,59 @@ +pageReferenceListCacheService = app(PageReferenceListCacheService::class); + } + + /** + * Handle the PageReferenceList "created" event. + */ + public function created(PageReferenceList $pageReferenceList): void + { + $this->pageReferenceListCacheService->clearAllCacheByModel(); + } + + /** + * Handle the PageReferenceList "updated" event. + */ + public function updated(PageReferenceList $pageReferenceList): void + { + $this->pageReferenceListCacheService->clearCache($pageReferenceList); + $this->pageReferenceListCacheService->clearAllCacheByModel(); + } + + /** + * Handle the PageReferenceList "deleted" event. + */ + public function deleted(PageReferenceList $pageReferenceList): void + { + $this->pageReferenceListCacheService->clearCache($pageReferenceList); + $this->pageReferenceListCacheService->clearAllCacheByModel(); + } + + /** + * Handle the PageReferenceList "restored" event. + */ + public function restored(PageReferenceList $pageReferenceList): void + { + $this->pageReferenceListCacheService->clearAllCacheByModel(); + } + + /** + * Handle the PageReferenceList "force deleted" event. + */ + public function forceDeleted(PageReferenceList $pageReferenceList): void + { + $this->pageReferenceListCacheService->clearCache($pageReferenceList); + $this->pageReferenceListCacheService->clearAllCacheByModel(); + } +} \ No newline at end of file diff --git a/app/Observers/ScheduleObserver.php b/app/Observers/ScheduleObserver.php new file mode 100644 index 0000000..e279498 --- /dev/null +++ b/app/Observers/ScheduleObserver.php @@ -0,0 +1,59 @@ +scheduleCacheService = app(ScheduleCacheService::class); + } + + /** + * Handle the Schedule "created" event. + */ + public function created(Schedule $schedule): void + { + $this->scheduleCacheService->clearAllCacheByModel(); + } + + /** + * Handle the Schedule "updated" event. + */ + public function updated(Schedule $schedule): void + { + $this->scheduleCacheService->clearCache($schedule); + $this->scheduleCacheService->clearAllCacheByModel(); + } + + /** + * Handle the Schedule "deleted" event. + */ + public function deleted(Schedule $schedule): void + { + $this->scheduleCacheService->clearCache($schedule); + $this->scheduleCacheService->clearAllCacheByModel(); + } + + /** + * Handle the Schedule "restored" event. + */ + public function restored(Schedule $schedule): void + { + $this->scheduleCacheService->clearAllCacheByModel(); + } + + /** + * Handle the Schedule "force deleted" event. + */ + public function forceDeleted(Schedule $schedule): void + { + $this->scheduleCacheService->clearCache($schedule); + $this->scheduleCacheService->clearAllCacheByModel(); + } +} \ No newline at end of file diff --git a/app/Observers/SlideObserver.php b/app/Observers/SlideObserver.php new file mode 100644 index 0000000..256dea8 --- /dev/null +++ b/app/Observers/SlideObserver.php @@ -0,0 +1,77 @@ +cacheService = new SliderCacheService(); + } + /** + * Handle the MainSlider "created" event. + */ + public function created(Slide $slide): void + { + // Устанавливаем сортировку для новой записи + $slide->sort = 1; + $slide->save(); + + // Обновляем сортировку для всех остальных записей + $this->updateSortOrder($slide->id, $slide->slider_id); + + $this->cacheService->clearAllCacheByModel(); + } + + /** + * Handle the MainSlider "updated" event. + */ + public function updated(Slide $slide): void + { + $this->cacheService->clearAllCacheByModel(); + } + + /** + * Handle the MainSlider "deleted" event. + */ + public function deleted(Slide $slide): void + { + $this->cacheService->clearAllCacheByModel(); + } + + /** + * Handle the MainSlider "restored" event. + */ + public function restored(Slide $slide): void + { + // + } + + /** + * Handle the MainSlider "force deleted" event. + */ + public function forceDeleted(Slide $slide): void + { + // + } + + protected function updateSortOrder($id, $slider_id): void + { + // Получаем все записи, отсортированные по текущему значению sort + $slides = Slide::orderBy('sort', 'asc')->where([['slider_id', $slider_id], ['id', '!=', $id]])->get(); + + if ($slides->count() > 0) { + foreach ($slides as $index => $slide) { + $slide->sort = $index + 2; // Начинаем с 1 + $slide->save(); + } + } + } +} diff --git a/app/Observers/UserObserver.php b/app/Observers/UserObserver.php new file mode 100644 index 0000000..0f564a4 --- /dev/null +++ b/app/Observers/UserObserver.php @@ -0,0 +1,59 @@ +userCacheService = app(UserCacheService::class); + } + + /** + * Handle the User "created" event. + */ + public function created(User $user): void + { + $this->userCacheService->clearAllCacheByModel(); + } + + /** + * Handle the User "updated" event. + */ + public function updated(User $user): void + { + $this->userCacheService->clearCache($user); + $this->userCacheService->clearAllCacheByModel(); + } + + /** + * Handle the User "deleted" event. + */ + public function deleted(User $user): void + { + $this->userCacheService->clearCache($user); + $this->userCacheService->clearAllCacheByModel(); + } + + /** + * Handle the User "restored" event. + */ + public function restored(User $user): void + { + $this->userCacheService->clearAllCacheByModel(); + } + + /** + * Handle the User "force deleted" event. + */ + public function forceDeleted(User $user): void + { + $this->userCacheService->clearCache($user); + $this->userCacheService->clearAllCacheByModel(); + } +} \ No newline at end of file diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index 986a946..5e56104 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -2,17 +2,42 @@ namespace App\Providers; +use App\Models\AcademicJournal; +use App\Models\AdditionalEducation; +use App\Models\ContactWidget; +use App\Models\Department; +use App\Models\Division; +use App\Models\EducationalProgram; +use App\Models\Event; +use App\Models\Faculty; use App\Models\MainSection; use App\Models\MainSlider; use App\Models\Page; +use App\Models\PageReferenceList; use App\Models\Post; +use App\Models\Schedule; +use App\Models\Slide; use App\Models\SubSection; +use App\Models\User; +use App\Observers\AcademicJournalObserver; +use App\Observers\AdditionalEducationObserver; +use App\Observers\ContactWidgetObserver; +use App\Observers\DepartmentObserver; +use App\Observers\DivisionObserver; +use App\Observers\EducationalProgramObserver; +use App\Observers\EventObserver; +use App\Observers\FacultyObserver; use App\Observers\MainSectionObserver; use App\Observers\MainSliderObserver; use App\Observers\PageObserver; +use App\Observers\PageReferenceListObserver; use App\Observers\PostObserver; +use App\Observers\ScheduleObserver; +use App\Observers\SlideObserver; use App\Observers\SubSectionObserver; +use App\Observers\UserObserver; use App\Services\App\Cache\MainSliderCacheService; +use App\Services\App\Cache\SliderCacheService; use Carbon\Carbon; use Filament\Facades\Filament; use Filament\Support\Facades\FilamentView; @@ -47,7 +72,7 @@ class AppServiceProvider extends ServiceProvider $this->loadViewsFrom(__DIR__.'/path/to/views', 'checkpoint'); FilamentView::registerRenderHook(TablesRenderHook::TOOLBAR_REORDER_TRIGGER_AFTER, function () { - (new MainSliderCacheService())->clearAllCacheByModel(); + (new SliderCacheService())->clearAllCacheByModel(); }); } @@ -56,7 +81,18 @@ class AppServiceProvider extends ServiceProvider Post::observe(PostObserver::class); MainSection::observe(MainSectionObserver::class); SubSection::observe(SubSectionObserver::class); - MainSlider::observe(MainSliderObserver::class); + Slide::observe(SlideObserver::class); + AcademicJournal::observe(AcademicJournalObserver::class); + AdditionalEducation::observe(AdditionalEducationObserver::class); + Department::observe(DepartmentObserver::class); + Division::observe(DivisionObserver::class); + Event::observe(EventObserver::class); + Faculty::observe(FacultyObserver::class); + User::observe(UserObserver::class); + EducationalProgram::observe(EducationalProgramObserver::class); + Schedule::observe(ScheduleObserver::class); + ContactWidget::observe(ContactWidgetObserver::class); + PageReferenceList::observe(PageReferenceListObserver::class); } private static function setLocaleTime() : void { @@ -65,15 +101,17 @@ class AppServiceProvider extends ServiceProvider } - private static function registerFilamentNavigationGroups() + private static function registerFilamentNavigationGroups(): void { Filament::registerNavigationGroups([ + 'Виджеты', + 'Новости и мероприятия', 'Структура приложения', 'Структура института', 'Образование', 'Расписание и группы', - 'Новости и мероприятия', - 'Библиотека', + 'Наука', + 'Settings', ]); } } diff --git a/app/Services/App/Breadcrumb/BreadcrumbService.php b/app/Services/App/Breadcrumb/BreadcrumbService.php index c7fa523..e499abe 100644 --- a/app/Services/App/Breadcrumb/BreadcrumbService.php +++ b/app/Services/App/Breadcrumb/BreadcrumbService.php @@ -7,34 +7,71 @@ use App\Http\Resources\ClientBreadcrumbSection; use App\Http\Resources\ClientBreadcrumbSubSection; use App\Models\Page; use Illuminate\Support\Facades\Cache; +use Illuminate\Support\Facades\Route; class BreadcrumbService { - public function generateBreadcrumbs($routeName) : array|null + public function generateBreadcrumbs(): ?array { - $path = $this->generatePath($routeName); + $routeName = Route::currentRouteName(); - // Кешируем страницу - $page = Cache::remember('page_' . $path, now()->addHours(1), function () use ($path) { - return Page::where('path', '=', $path)->with('section.pages.section', 'section.mainSection')->first(); - }); - if (isset($page->section)) { - $breadcrumbs = [ - 'mainSection' => new ClientBreadcrumbSection($page->section->mainSection), - 'subSection' => new ClientBreadcrumbSubSection($page->section), - 'page' => new ClientBreadcrumbPage($page), - ]; - } else { - $breadcrumbs = null; + // Пытаемся найти index-версию маршрута + $indexRouteName = $this->getIndexRouteName($routeName); + + if ($indexRouteName === null) { + return null; } - return $breadcrumbs; + + // Используем index-версию, если она существует + $finalRouteName = Route::has($indexRouteName) ? $indexRouteName : $routeName; + + + + $path = $this->generatePath($finalRouteName); + + $page = Cache::remember('page_' . $path, now()->addHours(1), function () use ($path) { + return Page::where('path', $path) + ->with('section.pages.section', 'section.mainSection') + ->first(); + }); + + if (!$page?->section) { + return null; + } + + return [ + 'mainSection' => new ClientBreadcrumbSection($page->section->mainSection), + 'subSection' => new ClientBreadcrumbSubSection($page->section), + 'page' => new ClientBreadcrumbPage($page), + ]; } - private function generatePath($routeName) : string + private function generatePath(string $routeName): string { + if ($routeName === 'page.view') { + return request()->path(); + } $routeUrl = route($routeName); + return ltrim(parse_url($routeUrl, PHP_URL_PATH), '/'); } + + private function getIndexRouteName(string $routeName = null): string|null + { + if ($routeName === null) { + return null; + } + $parts = explode('.', $routeName); + + // Если в маршруте нет точек или он уже заканчивается на index + if (count($parts) <= 1 || end($parts) === 'index') { + return $routeName; + } + + // Заменяем последнюю часть на index + $parts[count($parts) - 1] = 'index'; + return implode('.', $parts); + } } \ No newline at end of file diff --git a/app/Services/App/Cache/AcademicJournalCacheService.php b/app/Services/App/Cache/AcademicJournalCacheService.php new file mode 100644 index 0000000..c6037bc --- /dev/null +++ b/app/Services/App/Cache/AcademicJournalCacheService.php @@ -0,0 +1,37 @@ +clearAllCacheByModel(); + } + } + + public function clearAllCacheByModel(): void + { + $this->clearCacheByPrefix(CacheKeys::ACADEMIC_JOURNAL_PREFIX->value.'*'); + $this->clearCacheByPrefix(CacheKeys::ACADEMIC_JOURNALS_PREFIX->value.'*'); + } + + public function getCachedData(string $key) + { + return Cache::get($key); + } + + public function cacheData(string $key, $data, int $ttl = null): void + { + Cache::put($key, $data, $ttl ?? self::DEFAULT_TTL); + } + + +} \ No newline at end of file diff --git a/app/Services/App/Cache/AdditionalEducationCacheService.php b/app/Services/App/Cache/AdditionalEducationCacheService.php new file mode 100644 index 0000000..00efabe --- /dev/null +++ b/app/Services/App/Cache/AdditionalEducationCacheService.php @@ -0,0 +1,35 @@ +clearAllCacheByModel(); + } + } + + public function clearAllCacheByModel(): void + { + $this->clearCacheByPrefix(CacheKeys::ADDITIONAL_EDUCATIONAL_PROGRAM_PREFIX->value.'*'); + $this->clearCacheByPrefix(CacheKeys::ADDITIONAL_EDUCATIONAL_PROGRAMS_PREFIX->value.'*'); + } + + public function getCachedData(string $key) + { + return Cache::get($key); + } + + public function cacheData(string $key, $data, int $ttl = null): void + { + Cache::put($key, $data, $ttl ?? self::DEFAULT_TTL); + } +} \ No newline at end of file diff --git a/app/Services/App/Cache/CategoryCacheService.php b/app/Services/App/Cache/CategoryCacheService.php index b7967a2..c3ae770 100644 --- a/app/Services/App/Cache/CategoryCacheService.php +++ b/app/Services/App/Cache/CategoryCacheService.php @@ -2,48 +2,30 @@ namespace App\Services\App\Cache; +use App\Enums\CacheKeys; use Illuminate\Support\Facades\Cache; class CategoryCacheService extends AbstractCacheService implements CacheInterface { - /** - * Очищает кеш, связанный с постом. - * - * @param mixed $entity Пост или связанная сущность - * @return void - */ + private const DEFAULT_TTL = 3600; + public function clearCache($entity): void { + $this->clearAllCacheByModel(); } public function clearAllCacheByModel(): void { - $this->clearCacheByPrefix('categories*'); - $this->clearCacheByPrefix('category_content_*'); + $this->clearCacheByPrefix(CacheKeys::CATEGORIES_PREFIX->value.'*'); } - - /** - * Получает кешированные данные по ключу. - * - * @param string $key Ключ кеша - * @return mixed - */ public function getCachedData(string $key) { return Cache::get($key); } - /** - * Кеширует данные по ключу. - * - * @param string $key Ключ кеша - * @param mixed $data Данные для кеширования - * @param int $ttl Время жизни кеша в секундах - * @return void - */ - public function cacheData(string $key, $data, int $ttl = 3600): void + public function cacheData(string $key, $data, int $ttl = null): void { - Cache::put($key, $data, $ttl); + Cache::put($key, $data, $ttl ?? self::DEFAULT_TTL); } } \ No newline at end of file diff --git a/app/Services/App/Cache/ContactWidgetCacheService.php b/app/Services/App/Cache/ContactWidgetCacheService.php new file mode 100644 index 0000000..422d17a --- /dev/null +++ b/app/Services/App/Cache/ContactWidgetCacheService.php @@ -0,0 +1,35 @@ +clearAllCacheByModel(); + } + } + + public function clearAllCacheByModel(): void + { + $this->clearCacheByPrefix(CacheKeys::CONTACT_WIDGET_PREFIX->value.'*'); + $this->clearCacheByPrefix(CacheKeys::CONTACT_WIDGETS_PREFIX->value.'*'); + } + + public function getCachedData(string $key) + { + return Cache::get($key); + } + + public function cacheData(string $key, $data, int $ttl = null): void + { + Cache::put($key, $data, $ttl ?? self::DEFAULT_TTL); + } +} \ No newline at end of file diff --git a/app/Services/App/Cache/DepartmentCacheService.php b/app/Services/App/Cache/DepartmentCacheService.php new file mode 100644 index 0000000..3bd641d --- /dev/null +++ b/app/Services/App/Cache/DepartmentCacheService.php @@ -0,0 +1,36 @@ +clearAllCacheByModel(); + } + } + + public function clearAllCacheByModel(): void + { + $this->clearCacheByPrefix(CacheKeys::DEPARTMENT_PREFIX->value.'*'); + $this->clearCacheByPrefix(CacheKeys::DEPARTMENTS_PREFIX->value.'*'); + } + + public function getCachedData(string $key) + { + return Cache::get($key); + } + + public function cacheData(string $key, $data, int $ttl = null): void + { + Cache::put($key, $data, $ttl ?? self::DEFAULT_TTL); + } + +} \ No newline at end of file diff --git a/app/Services/App/Cache/DivisionCacheService.php b/app/Services/App/Cache/DivisionCacheService.php new file mode 100644 index 0000000..3f57661 --- /dev/null +++ b/app/Services/App/Cache/DivisionCacheService.php @@ -0,0 +1,56 @@ +clearAllCacheByModel(); + } + } + + public function clearAllCacheByModel(): void + { + $this->clearCacheByPrefix(self::CACHE_PREFIX.'*'); + $this->clearAllDivisionsCache(); + } + + public function getCachedData(string $key) + { + return Cache::get($key); + } + + public function cacheData(string $key, $data, int $ttl = null): void + { + Cache::put($key, $data, $ttl ?? self::DEFAULT_TTL); + } + + public function getCacheKey(int $id): string + { + return self::CACHE_PREFIX . $id; + } + + public function getAllCacheKey(): string + { + return self::ALL_CACHE_KEY; + } + + private function forgetDivisionCache(int $id): void + { + Cache::forget($this->getCacheKey($id)); + } + + private function clearAllDivisionsCache(): void + { + Cache::forget(self::ALL_CACHE_KEY); + } +} \ No newline at end of file diff --git a/app/Services/App/Cache/EducationalProgramCacheService.php b/app/Services/App/Cache/EducationalProgramCacheService.php new file mode 100644 index 0000000..79590d8 --- /dev/null +++ b/app/Services/App/Cache/EducationalProgramCacheService.php @@ -0,0 +1,52 @@ +clearAllCacheByModel(); + + } + } + + public function clearAllCacheByModel(): void + { + $this->clearCacheByPrefix(CacheKeys::EDUCATION_PROGRAMS_PREFIX->value.'*'); + $this->clearCacheByPrefix(CacheKeys::EDUCATION_PROGRAM_PREFIX->value.'*'); + + } + + public function getCachedData(string $key) + { + return Cache::get($key); + } + + public function cacheData(string $key, $data, int $ttl = null): void + { + Cache::put($key, $data, $ttl ?? self::DEFAULT_TTL); + } + + public function getCacheKey(int $id): string + { + return CacheKeys::EDUCATION_PROGRAM_PREFIX->value . $id; + } + + private function forgetProgramCache(int $id): void + { + Cache::forget($this->getCacheKey($id)); + } + + private function clearAllProgramsCache(): void + { + Cache::forget(CacheKeys::EDUCATION_PROGRAMS_PREFIX->value . '*'); + } +} \ No newline at end of file diff --git a/app/Services/App/Cache/EventCacheService.php b/app/Services/App/Cache/EventCacheService.php new file mode 100644 index 0000000..319a106 --- /dev/null +++ b/app/Services/App/Cache/EventCacheService.php @@ -0,0 +1,42 @@ +clearAllCacheByModel(); + + } + } + + public function clearAllCacheByModel(): void + { + $this->clearCacheByPrefix(CacheKeys::EVENT_PREFIX->value.'*'); + $this->clearAllEventsCache(); + } + + public function getCachedData(string $key) + { + return Cache::get($key); + } + + public function cacheData(string $key, $data, int $ttl = null): void + { + Cache::put($key, $data, $ttl ?? self::DEFAULT_TTL); + } + + + private function clearAllEventsCache(): void + { + Cache::forget(CacheKeys::EVENTS_PREFIX->value.'*'); + } +} \ No newline at end of file diff --git a/app/Services/App/Cache/FacultyCacheService.php b/app/Services/App/Cache/FacultyCacheService.php new file mode 100644 index 0000000..bd0011b --- /dev/null +++ b/app/Services/App/Cache/FacultyCacheService.php @@ -0,0 +1,42 @@ +clearAllCacheByModel(); + + } + } + + public function clearAllCacheByModel(): void + { + $this->clearCacheByPrefix(CacheKeys::FACULTY_PREFIX->value.'*'); + $this->clearAllFacultiesCache(); + } + + public function getCachedData(string $key) + { + return Cache::get($key); + } + + public function cacheData(string $key, $data, int $ttl = null): void + { + Cache::put($key, $data, $ttl ?? self::DEFAULT_TTL); + } + + + private function clearAllFacultiesCache(): void + { + Cache::forget(CacheKeys::FACULTIES_PREFIX->value.'*'); + } +} \ No newline at end of file diff --git a/app/Services/App/Cache/MainSectionCacheService.php b/app/Services/App/Cache/MainSectionCacheService.php index e48fe8f..ec74375 100644 --- a/app/Services/App/Cache/MainSectionCacheService.php +++ b/app/Services/App/Cache/MainSectionCacheService.php @@ -2,43 +2,36 @@ namespace App\Services\App\Cache; +use App\Enums\CacheKeys; use Illuminate\Support\Facades\Cache; class MainSectionCacheService extends AbstractCacheService implements CacheInterface { - /** - * Очищает кеш, связанный с постом. - * - * @param mixed $entity Пост или связанная сущность - * @return void - */ + private const DEFAULT_TTL = 3600; + public function clearCache($entity): void { - $this->clearCacheByPrefix('posts_'); + $this->clearNavigationCache(); } + public function clearAllCacheByModel(): void + { + $this->clearNavigationCache(); + } - /** - * Получает кешированные данные по ключу. - * - * @param string $key Ключ кеша - * @return mixed - */ public function getCachedData(string $key) { return Cache::get($key); } - /** - * Кеширует данные по ключу. - * - * @param string $key Ключ кеша - * @param mixed $data Данные для кеширования - * @param int $ttl Время жизни кеша в секундах - * @return void - */ - public function cacheData(string $key, $data, int $ttl = 3600): void + public function cacheData(string $key, $data, int $ttl = null): void { - Cache::put($key, $data, $ttl); + Cache::put($key, $data, $ttl ?? self::DEFAULT_TTL); + } + + + private function clearNavigationCache(): void + { + Cache::forget(CacheKeys::NAVIGATION_PREFIX->value); } } \ No newline at end of file diff --git a/app/Services/App/Cache/MainSliderCacheService.php b/app/Services/App/Cache/MainSliderCacheService.php deleted file mode 100644 index 8e9087c..0000000 --- a/app/Services/App/Cache/MainSliderCacheService.php +++ /dev/null @@ -1,48 +0,0 @@ -clearCacheByPrefix('active_sliders*'); - } - - - /** - * Получает кешированные данные по ключу. - * - * @param string $key Ключ кеша - * @return mixed - */ - public function getCachedData(string $key) - { - return Cache::get($key); - } - - /** - * Кеширует данные по ключу. - * - * @param string $key Ключ кеша - * @param mixed $data Данные для кеширования - * @param int $ttl Время жизни кеша в секундах - * @return void - */ - public function cacheData(string $key, $data, int $ttl = 3600): void - { - Cache::put($key, $data, $ttl); - } -} \ No newline at end of file diff --git a/app/Services/App/Cache/PageCacheService.php b/app/Services/App/Cache/PageCacheService.php index d916b9d..b2c0936 100644 --- a/app/Services/App/Cache/PageCacheService.php +++ b/app/Services/App/Cache/PageCacheService.php @@ -2,62 +2,42 @@ namespace App\Services\App\Cache; +use App\Enums\CacheKeys; use App\Models\Page; use Illuminate\Support\Facades\Cache; -use Illuminate\Support\Facades\Redis; class PageCacheService extends AbstractCacheService implements CacheInterface { - /** - * Очищает кеш, связанный с постом. - * - * @param mixed $entity Пост или связанная сущность - * @return void - */ + private const DEFAULT_TTL = 3600; + public function clearCache($entity): void { - $cacheKeyByPath = md5($entity->path); - $cacheKeyById = md5($entity->id); - - - - Cache::forget('page_' . $cacheKeyByPath); - Cache::forget('page_' . $cacheKeyById); - - Cache::forget('navigation'); - + if ($entity instanceof Page) { + $this->clearAllCacheByModel(); + $this->clearNavigationCache(); + } } public function clearAllCacheByModel(): void { - $this->clearCacheByPrefix('page_*'); - $this->clearCacheByPrefix('page_data_*'); - Cache::forget('navigation'); - + $this->clearCacheByPrefix(CacheKeys::PAGE_PREFIX->value.'*'); + $this->clearCacheByPrefix(CacheKeys::PAGE_DATA_PREFIX->value.'*'); + $this->clearNavigationCache(); } - - /** - * Получает кешированные данные по ключу. - * - * @param string $key Ключ кеша - * @return mixed - */ public function getCachedData(string $key) { return Cache::get($key); } - /** - * Кеширует данные по ключу. - * - * @param string $key Ключ кеша - * @param mixed $data Данные для кеширования - * @param int $ttl Время жизни кеша в секундах - * @return void - */ - public function cacheData(string $key, $data, int $ttl = 3600): void + public function cacheData(string $key, $data, int $ttl = null): void { - Cache::put($key, $data, $ttl); + Cache::put($key, $data, $ttl ?? self::DEFAULT_TTL); + } + + + private function clearNavigationCache(): void + { + Cache::forget(CacheKeys::NAVIGATION_PREFIX->value); } } \ No newline at end of file diff --git a/app/Services/App/Cache/PageReferenceListCacheService.php b/app/Services/App/Cache/PageReferenceListCacheService.php new file mode 100644 index 0000000..b58a0cc --- /dev/null +++ b/app/Services/App/Cache/PageReferenceListCacheService.php @@ -0,0 +1,42 @@ +clearAllCacheByModel(); + + } + } + + public function clearAllCacheByModel(): void + { + $this->clearCacheByPrefix(CacheKeys::PAGE_REFERENCE_LIST_PREFIX->value.'*'); + $this->clearAllReferencesCache(); + } + + public function getCachedData(string $key) + { + return Cache::get($key); + } + + public function cacheData(string $key, $data, int $ttl = null): void + { + Cache::put($key, $data, $ttl ?? self::DEFAULT_TTL); + } + + + private function clearAllReferencesCache(): void + { + Cache::forget(CacheKeys::PAGE_REFERENCE_LISTS_PREFIX->value.'*'); + } +} \ No newline at end of file diff --git a/app/Services/App/Cache/PostCacheService.php b/app/Services/App/Cache/PostCacheService.php index abb2dbb..539906a 100644 --- a/app/Services/App/Cache/PostCacheService.php +++ b/app/Services/App/Cache/PostCacheService.php @@ -2,59 +2,56 @@ namespace App\Services\App\Cache; +use App\Enums\CacheKeys; use Illuminate\Support\Facades\Cache; -use Illuminate\Support\Facades\Redis; class PostCacheService extends AbstractCacheService implements CacheInterface { + private const DEFAULT_TTL = 3600; + /** - * Очищает кеш, связанный с постом. - * - * @param mixed $entity Пост или связанная сущность - * @return void + * Clear cache for specific post */ public function clearCache($entity): void { - $cacheKeyBySlug = md5($entity->slug); - $cacheKeyById = md5($entity->id); - - Cache::forget('post_' .$cacheKeyBySlug); - Cache::forget('post_' .$cacheKeyById); - - $this->clearCacheByPrefix('recent_posts*'); - - } - - public function clearAllCacheByModel(): void - { - $this->clearCacheByPrefix('post_*'); - $this->clearCacheByPrefix('posts_*'); - $this->clearCacheByPrefix('recent_posts*'); + $this->forgetPostCache($entity->slug, $entity->id); + $this->clearRecentPostsCache(); } /** - * Получает кешированные данные по ключу. - * - * @param string $key Ключ кеша - * @return mixed + * Clear all cache related to posts */ + public function clearAllCacheByModel(): void + { + $this->clearCacheByPrefix(CacheKeys::POST_PREFIX->value.'*'); + $this->clearCacheByPrefix(CacheKeys::POSTS_PREFIX->value.'*'); + $this->clearRecentPostsCache(); + } + public function getCachedData(string $key) { return Cache::get($key); } - /** - * Кеширует данные по ключу. - * - * @param string $key Ключ кеша - * @param mixed $data Данные для кеширования - * @param int $ttl Время жизни кеша в секундах - * @return void - */ - public function cacheData(string $key, $data, int $ttl = 3600): void + public function cacheData(string $key, $data, int $ttl = null): void { - Cache::put($key, $data, $ttl); + Cache::put($key, $data, $ttl ?? self::DEFAULT_TTL); } + /** + * Forget cache for specific post by slug and id + */ + private function forgetPostCache(string $slug, int $id): void + { + Cache::forget(CacheKeys::POST_PREFIX->value.md5($slug)); + Cache::forget(CacheKeys::POST_PREFIX->value.md5($id)); + } + /** + * Clear recent posts cache + */ + private function clearRecentPostsCache(): void + { + $this->clearCacheByPrefix(CacheKeys::RECENT_POSTS_PREFIX->value.'*'); + } } \ No newline at end of file diff --git a/app/Services/App/Cache/ScheduleCacheService.php b/app/Services/App/Cache/ScheduleCacheService.php new file mode 100644 index 0000000..10dc0ca --- /dev/null +++ b/app/Services/App/Cache/ScheduleCacheService.php @@ -0,0 +1,46 @@ +clearAllCacheByModel(); + } + } + + public function clearAllCacheByModel(): void + { + $this->clearCacheByPrefix(CacheKeys::SCHEDULE_PREFIX->value.'*'); + $this->clearAllSchedulesCache(); + } + + public function getCachedData(string $key) + { + return Cache::get($key); + } + + public function cacheData(string $key, $data, int $ttl = null): void + { + Cache::put($key, $data, $ttl ?? self::DEFAULT_TTL); + } + + + private function forgetScheduleCache(int $id): void + { + Cache::forget($this->getCacheKey($id)); + } + + private function clearAllSchedulesCache(): void + { + Cache::forget(CacheKeys::SCHEDULES_PREFIX->value); + } +} \ No newline at end of file diff --git a/app/Services/App/Cache/SliderCacheService.php b/app/Services/App/Cache/SliderCacheService.php new file mode 100644 index 0000000..6200a0a --- /dev/null +++ b/app/Services/App/Cache/SliderCacheService.php @@ -0,0 +1,36 @@ +clearAllCacheByModel(); + } + + public function clearAllCacheByModel(): void + { + $this->clearSliderCache(); + } + + public function getCachedData(string $key) + { + return Cache::get($key); + } + + public function cacheData(string $key, $data, int $ttl = null): void + { + Cache::put($key, $data, $ttl ?? self::DEFAULT_TTL); + } + + private function clearSliderCache(): void + { + $this->clearCacheByPrefix(CacheKeys::SLIDER_PREFIX->value.'*'); + } +} \ No newline at end of file diff --git a/app/Services/App/Cache/SubSectionCacheService.php b/app/Services/App/Cache/SubSectionCacheService.php index 9262e01..3a2fd28 100644 --- a/app/Services/App/Cache/SubSectionCacheService.php +++ b/app/Services/App/Cache/SubSectionCacheService.php @@ -2,42 +2,38 @@ namespace App\Services\App\Cache; +use App\Enums\CacheKeys; use Illuminate\Support\Facades\Cache; class SubSectionCacheService extends AbstractCacheService implements CacheInterface { - /** - * Очищает кеш, связанный с постом. - * - * @param mixed $entity Пост или связанная сущность - * @return void - */ + private const DEFAULT_TTL = 3600; + public function clearCache($entity): void { - $this->clearCacheByPrefix('posts_'); + $this->clearAllCacheByModel(); + } + + public function clearAllCacheByModel(): void + { + $this->clearNavigationCache(); } - /** - * Получает кешированные данные по ключу. - * - * @param string $key Ключ кеша - * @return mixed - */ public function getCachedData(string $key) { return Cache::get($key); } - /** - * Кеширует данные по ключу. - * - * @param string $key Ключ кеша - * @param mixed $data Данные для кеширования - * @param int $ttl Время жизни кеша в секундах - * @return void - */ - public function cacheData(string $key, $data, int $ttl = 3600): void + public function cacheData(string $key, $data, int $ttl = null): void { - Cache::put($key, $data, $ttl); + Cache::put($key, $data, $ttl ?? self::DEFAULT_TTL); + } + + + + + private function clearNavigationCache(): void + { + Cache::forget(CacheKeys::NAVIGATION_PREFIX->value); } } \ No newline at end of file diff --git a/app/Services/App/Cache/TagCacheService.php b/app/Services/App/Cache/TagCacheService.php index 4cdc694..0fc97c1 100644 --- a/app/Services/App/Cache/TagCacheService.php +++ b/app/Services/App/Cache/TagCacheService.php @@ -2,49 +2,47 @@ namespace App\Services\App\Cache; +use App\Enums\CacheKeys; use Illuminate\Support\Facades\Cache; class TagCacheService extends AbstractCacheService implements CacheInterface { - /** - * Очищает кеш, связанный с постом. - * - * @param mixed $entity Пост или связанная сущность - * @return void - */ + private const DEFAULT_TTL = 3600; + public function clearCache($entity): void { + $this->clearAllCacheByModel(); } public function clearAllCacheByModel(): void { - $this->clearCacheByPrefix('tag_ids*'); - $this->clearCacheByPrefix('tags*'); - $this->clearCacheByPrefix('tag_content_*'); + $this->clearTagIdsCache(); + $this->clearTagsCache(); + $this->clearTagContentCache(); } - - /** - * Получает кешированные данные по ключу. - * - * @param string $key Ключ кеша - * @return mixed - */ public function getCachedData(string $key) { return Cache::get($key); } - /** - * Кеширует данные по ключу. - * - * @param string $key Ключ кеша - * @param mixed $data Данные для кеширования - * @param int $ttl Время жизни кеша в секундах - * @return void - */ - public function cacheData(string $key, $data, int $ttl = 3600): void + public function cacheData(string $key, $data, int $ttl = null): void { - Cache::put($key, $data, $ttl); + Cache::put($key, $data, $ttl ?? self::DEFAULT_TTL); + } + + private function clearTagIdsCache(): void + { + $this->clearCacheByPrefix(CacheKeys::TAG_IDS_PREFIX->value.'*'); + } + + private function clearTagsCache(): void + { + $this->clearCacheByPrefix(CacheKeys::TAGS_PREFIX->value.'*'); + } + + private function clearTagContentCache(): void + { + $this->clearCacheByPrefix(CacheKeys::TAG_CONTENT_PREFIX->value.'*'); } } \ No newline at end of file diff --git a/app/Services/App/Cache/UserCacheService.php b/app/Services/App/Cache/UserCacheService.php new file mode 100644 index 0000000..ae725ae --- /dev/null +++ b/app/Services/App/Cache/UserCacheService.php @@ -0,0 +1,37 @@ +clearAllCacheByModel(); + } + } + + public function clearAllCacheByModel(): void + { + $this->clearCacheByPrefix(CacheKeys::USER_PREFIX->value.'*'); + } + + public function getCachedData(string $key) + { + return Cache::get($key); + } + + public function cacheData(string $key, $data, int $ttl = null): void + { + Cache::put($key, $data, $ttl ?? self::DEFAULT_TTL); + } + + + +} \ No newline at end of file diff --git a/app/Services/App/Seo/SeoDescriptionInterface.php b/app/Services/App/Seo/SeoDescriptionInterface.php new file mode 100644 index 0000000..3426d2b --- /dev/null +++ b/app/Services/App/Seo/SeoDescriptionInterface.php @@ -0,0 +1,8 @@ +seo?->toArray(); + } + public function getSeoForCurrentPage(): ?array + { + $path = $this->getCurrentPath(); + + $page = Cache::remember( + CacheKeys::PAGE_PREFIX->value . $path, + now()->addHours(1), + fn() => Page::where('path', $path)->first() + ); + + return $page->seo?->toArray(); // Предполагается, что у модели Page есть поле `seo` (JSON или массив) + } + + private function getCurrentPath(): string + { + if (Route::currentRouteName() === 'page.view') { + return request()->path(); + } + + $routeUrl = route(Route::currentRouteName()); + return ltrim(parse_url($routeUrl, PHP_URL_PATH), '/'); + } +} \ No newline at end of file diff --git a/app/Services/Filament/Domain/Posts/PostSliderService.php b/app/Services/Filament/Domain/Posts/PostSliderService.php index 81c0945..2f48438 100644 --- a/app/Services/Filament/Domain/Posts/PostSliderService.php +++ b/app/Services/Filament/Domain/Posts/PostSliderService.php @@ -5,6 +5,7 @@ namespace App\Services\Filament\Domain\Posts; use App\Dto\MainSliderDTO; use App\Models\MainSlider; use App\Models\Post; +use App\Models\Slide; use Illuminate\Database\Eloquent\Model; use Illuminate\Support\Facades\Log; @@ -23,7 +24,8 @@ class PostSliderService public function create(): void { try { - $this->post->mainSlider()->create([ + $post = Post::find($this->post->id); + $slide = new Slide([ 'title' => $this->dto->title, 'content' => $this->dto->content, 'image' => $this->dto->image, @@ -33,11 +35,13 @@ class PostSliderService 'is_active' => $this->dto->is_active, 'start_time' => $this->dto->start_time, 'end_time' => $this->dto->end_time, + 'slider_id' => $this->dto->slider_id, ]); + $post->slide()->save($slide); - Log::info('MainSlider created successfully', ['postTitle' => $this->post->title]); + Log::info('Slide created successfully', ['postTitle' => $this->post->title]); } catch (\Exception $e) { - Log::error('Failed to create MainSlider', [ + Log::error('Failed to create slide', [ 'postTitle' => $this->post->title, 'error' => $e->getMessage(), ]); @@ -53,31 +57,20 @@ class PostSliderService public function update(): void { try { - // Retrieve the MainSlider instance - $mainSlider = $this->post->mainSlider; + $post = Post::find($this->post->id); + $post->slide()->update([ + 'title' => $this->dto->title, + 'content' => $this->dto->content, + 'image' => $this->dto->image, + 'link' => $this->generatePostLink(), + 'settings' => $this->dto->settings, + 'color_theme' => $this->dto->color_theme, + 'is_active' => $this->dto->is_active, + 'start_time' => $this->dto->start_time, + 'end_time' => $this->dto->end_time, + 'slider_id' => $this->dto->slider_id, + ]); - - if ($mainSlider) { - - // Update properties and save to trigger model events - $mainSlider->fill([ - 'title' => $this->dto->title, - 'content' => $this->dto->content, - 'image' => $this->dto->image, - 'link' => $this->generatePostLink(), - 'settings' => $this->dto->settings, - 'color_theme' => $this->dto->color_theme, - 'is_active' => $this->dto->is_active, - 'start_time' => $this->dto->start_time, - 'end_time' => $this->dto->end_time, - ]); - - $mainSlider->save(); // This will trigger updating and updated observer events - - Log::info('MainSlider updated successfully', ['postTitle' => $this->post->title]); - } else { - Log::warning('MainSlider not found for update', ['postTitle' => $this->post->title]); - } } catch (\Exception $e) { Log::error('Failed to update MainSlider', [ 'postTitle' => $this->post->title, diff --git a/app/Services/Filament/Domain/Seo/SeoGeneratorService.php b/app/Services/Filament/Domain/Seo/SeoGeneratorService.php new file mode 100644 index 0000000..df64657 --- /dev/null +++ b/app/Services/Filament/Domain/Seo/SeoGeneratorService.php @@ -0,0 +1,95 @@ + $this->extractSeoTitle($data), + 'description' => $this->extractSeoDescription($data['content']), + 'image' => $this->extractSeoImage($data), + ]; + } + + /** + * Извлекает SEO-заголовок. + * + * @param array $data + * @return string + */ + private function extractSeoTitle(array $data): string + { + return $data['title']; + } + + /** + * Извлекает SEO-описание. + * + * @param array $content + * @return string + */ + private function extractSeoDescription(array $content): string + { + $rowData = $this->getBlockBySeoActiveState('paragraph', $content); + if ($rowData === null) { + $rowData = $this->getFirstBlockByName('paragraph', $content); + } + + $description = $rowData ? html_entity_decode(strip_tags($rowData['data']['content'])) : ''; + return Str::limit(htmlspecialchars($description, ENT_QUOTES, 'UTF-8'), 160); + } + + /** + * Извлекает SEO-изображение. + * + * @param array $data + * @return string|null + */ + private function extractSeoImage(array $data): ?string + { + return $data['preview'] ?? null; + } + + /** + * Находит первый блок по имени. + * + * @param string $name + * @param array $content + * @return array|null + */ + private function getFirstBlockByName(string $name, array $content): ?array + { + foreach ($content as $block) { + if ($block['type'] === $name) { + return $block; + } + } + return null; + } + + /** + * Находит блок по SEO-активности. + * + * @param string $name + * @param array $content + * @return array|null + */ + private function getBlockBySeoActiveState(string $name, array $content): ?array + { + foreach ($content as $block) { + if ($block['type'] === $name && ($block['data']['seo_active'] ?? false)) { + return $block; + } + } + return null; + } +} \ No newline at end of file diff --git a/app/Services/Filament/Services/BreadcrumbFinderService.php b/app/Services/Filament/Services/BreadcrumbFinderService.php new file mode 100644 index 0000000..582686e --- /dev/null +++ b/app/Services/Filament/Services/BreadcrumbFinderService.php @@ -0,0 +1,37 @@ +getBreadcrumb($html); + } + + private function getBreadcrumb(string $html): ?string + { + $document = new Document($html); + $breadcrumbs = $document->first('ol.breadcrumb')?->find('li') ?? []; + + foreach ($breadcrumbs as $index => $breadcrumb) { + if ($index === 2) { // Индексация с 0 → третий элемент = 2 + return $breadcrumb->text(); + } + } + + return null; + } + + +} \ No newline at end of file diff --git a/app/Services/Filament/Services/CategoryFinderService.php b/app/Services/Filament/Services/CategoryFinderService.php new file mode 100644 index 0000000..94e091d --- /dev/null +++ b/app/Services/Filament/Services/CategoryFinderService.php @@ -0,0 +1,36 @@ +first('ul.dropdown-menu'); + $links = $dropdownMenu->find('a'); + $categories = []; + + foreach ($links as $link) { + $category = trim($link->text()); + $categories[] = $category; + } + + return $categories; + } + + +} \ No newline at end of file diff --git a/app/Services/Filament/Services/StaticFileSearch.php b/app/Services/Filament/Services/StaticFileSearch.php new file mode 100644 index 0000000..58fdb0b --- /dev/null +++ b/app/Services/Filament/Services/StaticFileSearch.php @@ -0,0 +1,198 @@ + null + ]; + } + $page = request()->input('page', 1); + try { + $index = $this->getIndex(); + $results = []; + $normalizedQuery = $this->normalizeText(Str::lower($query)); + + foreach ($index as $filePath => $content) { + if (stripos($content['content'], $normalizedQuery) !== false) { + $relativePath = str_replace(public_path() . '/', '', $filePath); + $results[] = [ + 'file' => $relativePath, + 'content' => $content['title'], + 'category' => trim($content['category']), + ]; + } + } + return $this->paginateResults($results, $page); + } catch (\Exception $e) { + Log::error('Search error: ' . $e->getMessage()); + return [ + 'data' => [], + 'meta' => [ + 'current_page' => 1, + 'total' => 0, + 'per_page' => self::PER_PAGE, + 'last_page' => 1 + ] + ]; + } + } + + protected function paginateResults(array $results, int $page): array + { + $total = count($results); + $lastPage = max(1, ceil($total / self::PER_PAGE)); + $page = max(1, min($page, $lastPage)); + + $offset = ($page - 1) * self::PER_PAGE; + $paginatedResults = array_slice($results, $offset, self::PER_PAGE); + + return [ + 'data' => $paginatedResults, + 'meta' => [ + 'current_page' => $page, + 'total' => $total, + 'per_page' => self::PER_PAGE, + 'last_page' => $lastPage + ] + ]; + } + + protected function getIndex(): array + { + return Cache::remember(self::CACHE_KEY, self::CACHE_TTL, function() { + try { + $index = []; + $directory = public_path(self::FILES_DIR); + + if (!is_dir($directory)) { + Log::error("Directory not found: {$directory}"); + return []; + } + + $iterator = new RecursiveIteratorIterator( + new RecursiveDirectoryIterator($directory, RecursiveDirectoryIterator::SKIP_DOTS), + RecursiveIteratorIterator::SELF_FIRST + ); + + foreach ($iterator as $file) { + if ($file->isFile() && $this->isHtmlFile($file)) { + $content = file_get_contents($file->getPathname()); + if ($content !== false) { + $breadcrumb = app(BreadcrumbFinderService::class)->isSameCategory($content); + $text = $this->normalizeText(strip_tags($content)); + $index[$file->getPathname()] = [ + 'title' => $this->getFirstH1Content($content), + 'content' => $text, + 'category' => $breadcrumb ?? null, + ]; + } + } + } + + return $index; + } catch (\Exception $e) { + Log::error('Index creation error: ' . $e->getMessage()); + return []; + } + }); + } + + protected function isHtmlFile(\SplFileInfo $file): bool + { + $extension = strtolower($file->getExtension()); + return in_array($extension, ['html', 'htm']); + } + + protected function normalizeText(string $text): string + { + $text = html_entity_decode($text, ENT_QUOTES | ENT_HTML5, 'UTF-8'); + $text = preg_replace('/\s+/u', ' ', $text); + $text = trim($text); + return Str::lower($text); + } + + protected function findMatches(string $content, string $query): array + { + $matches = []; + $offset = 0; + $query = Str::lower($query); + $content = Str::lower($content); + $queryLength = mb_strlen($query, 'UTF-8'); + + while (($offset = mb_strpos($content, $query, $offset, 'UTF-8')) !== false) { + $start = max(0, $offset - 50); + $length = min(150, mb_strlen($content) - $start); + $excerpt = mb_substr($content, $start, $length, 'UTF-8'); + + $matches[] = str_replace( + $query, + '[[HIGHLIGHT]]'.$query.'[[/HIGHLIGHT]]', + $excerpt + ); + + $offset += $queryLength; + } + + return $matches; + } + + public function clearCache(): bool + { + try { + Cache::forget(self::CACHE_KEY); + return true; + } catch (\Exception $e) { + Log::error('Cache clear error: ' . $e->getMessage()); + return false; + } + } + + public function rebuildIndex(): array + { + $this->clearCache(); + return $this->getIndex(); + } + + public function getCacheStatus(): array + { + return [ + 'exists' => Cache::has(self::CACHE_KEY), + 'ttl' => Cache::get(self::CACHE_KEY.'_ttl', null), + 'driver' => config('cache.default'), + 'path' => public_path(self::FILES_DIR), + 'directory_exists' => is_dir(public_path(self::FILES_DIR)) + ]; + } + + public function getFirstH1Content(string $content): ?string + { + try { + if (preg_match('/]*>(.*?)<\/h1>/is', $content, $matches)) { + return $this->normalizeText($matches[1]); + } + + return null; + } catch (\Exception $e) { + Log::error('Failed to get H1: ' . $e->getMessage()); + return null; + } + } +} \ No newline at end of file diff --git a/app/Services/Filament/Traits/SeoGenerate.php b/app/Services/Filament/Traits/SeoGenerate.php new file mode 100644 index 0000000..0abf548 --- /dev/null +++ b/app/Services/Filament/Traits/SeoGenerate.php @@ -0,0 +1,34 @@ +seo()->create($this->generateSeo($record)); + } + + public function updateSeo($record): void + { + if ($record->seo()->exists()) { + $record->seo()->update($this->generateSeo($record)); + } else { + $this->createSeo($record); + } + } + + + private function generateSeo($record) { + return app(SeoGeneratorService::class)->generate([ + 'title' => $record->title, + 'content' => $record instanceof SeoDescriptionInterface + ? $record->getSeoDescription() + : $record->content, + 'preview' => $record->preview, + ]); + } +} \ No newline at end of file diff --git a/composer.json b/composer.json index aa9b20b..233b537 100644 --- a/composer.json +++ b/composer.json @@ -15,6 +15,7 @@ "filament/spatie-laravel-tags-plugin": "^3.2", "guava/filament-icon-picker": "^2.0", "guzzlehttp/guzzle": "^7.8", + "imangazaliev/didom": "^2.0", "inertiajs/inertia-laravel": "^1.3", "intervention/image": "^2.7", "joshembling/image-optimizer": "^1.4", diff --git a/composer.lock b/composer.lock index ec7261b..9bb7235 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "80caf6cd28399609a51515170b117048", + "content-hash": "8dfdeaeee9096f0841adf98508f78eea", "packages": [ { "name": "anourvalar/eloquent-serialize", @@ -2792,6 +2792,58 @@ ], "time": "2023-12-03T19:50:20+00:00" }, + { + "name": "imangazaliev/didom", + "version": "2.0.1", + "source": { + "type": "git", + "url": "https://github.com/Imangazaliev/DiDOM.git", + "reference": "50fa6595d14f22c0c984efed5c818485cf548136" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/Imangazaliev/DiDOM/zipball/50fa6595d14f22c0c984efed5c818485cf548136", + "reference": "50fa6595d14f22c0c984efed5c818485cf548136", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-iconv": "*", + "php": ">=7.2" + }, + "require-dev": { + "phpunit/phpunit": "^8.5" + }, + "type": "library", + "autoload": { + "psr-4": { + "DiDom\\": "src/DiDom/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Imangazaliev Muhammad", + "email": "imangazalievm@gmail.com" + } + ], + "description": "Simple and fast HTML parser", + "homepage": "https://github.com/Imangazaliev/DiDOM", + "keywords": [ + "didom", + "html", + "parser", + "xml" + ], + "support": { + "issues": "https://github.com/Imangazaliev/DiDOM/issues", + "source": "https://github.com/Imangazaliev/DiDOM/tree/2.0.1" + }, + "time": "2023-03-05T03:23:48+00:00" + }, { "name": "inertiajs/inertia-laravel", "version": "v1.3.0", diff --git a/database/migrations/2025_03_23_173349_add_morphs_to_slides_table.php b/database/migrations/2025_03_23_173349_add_morphs_to_slides_table.php new file mode 100644 index 0000000..f977da6 --- /dev/null +++ b/database/migrations/2025_03_23_173349_add_morphs_to_slides_table.php @@ -0,0 +1,29 @@ +unsignedBigInteger('slidable_id')->nullable(); // Поле для идентификатора + $table->string('slidable_type')->nullable(); // Поле для типа модели + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('slides', function (Blueprint $table) { + $table->dropColumn(['slidable_id', 'slidable_type']); + }); + } +}; diff --git a/database/migrations/2025_03_26_214117_add_settings_column_to_custom_forms_table.php b/database/migrations/2025_03_26_214117_add_settings_column_to_custom_forms_table.php new file mode 100644 index 0000000..0e3207a --- /dev/null +++ b/database/migrations/2025_03_26_214117_add_settings_column_to_custom_forms_table.php @@ -0,0 +1,28 @@ +text('settings')->nullable()->after('send_message'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('custom_forms', function (Blueprint $table) { + $table->dropColumn('settings'); + }); + } +}; diff --git a/public/logos/logo_urgpu.png b/public/logos/logo_urgpu.png new file mode 100644 index 0000000..56f7fbe Binary files /dev/null and b/public/logos/logo_urgpu.png differ diff --git a/resources/js/Navbars/DesktopNavBar.vue b/resources/js/Navbars/DesktopNavBar.vue index a0eef0c..31a8eab 100644 --- a/resources/js/Navbars/DesktopNavBar.vue +++ b/resources/js/Navbars/DesktopNavBar.vue @@ -60,7 +60,7 @@ - + - + Поиск + - + + + + + Поиск + + + + + @@ -103,6 +117,7 @@ import {Link} from "@inertiajs/vue3"; import BasicIcon from "@/componentss/ui/icons/BasicIcon.vue"; import {helpers} from "@/mixins/Helpers.js"; +import {mapActions} from "vuex"; export default { @@ -122,11 +137,7 @@ export default { Link, }, methods: { - removeCookieBvi() { - if (this.getCookie('bvi_panelActive') === 'true') { - this.deleteCookiesWithPrefix('bvi') - } - } + ...mapActions(['toggleBvi']) }, diff --git a/resources/js/Navbars/MainPageNavbar.vue b/resources/js/Navbars/MainPageNavbar.vue index f246bac..b524d8a 100644 --- a/resources/js/Navbars/MainPageNavbar.vue +++ b/resources/js/Navbars/MainPageNavbar.vue @@ -1,184 +1,157 @@ + \ No newline at end of file diff --git a/resources/js/Navbars/MobileNavbar.vue b/resources/js/Navbars/MobileNavbar.vue index 8544523..97cc127 100644 --- a/resources/js/Navbars/MobileNavbar.vue +++ b/resources/js/Navbars/MobileNavbar.vue @@ -59,7 +59,7 @@ class="flex items-center gap-x-3 py-2 px-2.5 text-sm rounded-lg hover:bg-gray-[#EFEFEF] focus:outline-none focus:bg-gray-100" :class="(IS_SAME_ROUTE(page.path) ? 'text-blue-600 bg-white border border-[#E4E4E7]' : 'text-gray-700')" > - + {{ page.title }} @@ -72,8 +72,8 @@
  • - Расписание diff --git a/resources/js/Pages/Client/AcademicJournals/Index.vue b/resources/js/Pages/Client/AcademicJournals/Index.vue index 29fed8c..15ed8b4 100644 --- a/resources/js/Pages/Client/AcademicJournals/Index.vue +++ b/resources/js/Pages/Client/AcademicJournals/Index.vue @@ -3,15 +3,16 @@ import {Head, Link} from "@inertiajs/vue3"; import MainPageNavBar from "@/Navbars/MainPageNavbar.vue"; import MetaTags from "@/componentss/shared/SEO/MetaTags.vue"; import BasicFooter from "@/footers/BasicFooter.vue"; +import AcademicJournalsListBreadcrumbs + from "@/componentss/features/academicJournals/components/AcademicJournalsListBreadcrumbs.vue.vue"; export default { name: "Index", components: { + AcademicJournalsListBreadcrumbs, BasicFooter, MetaTags, MainPageNavBar, - ClientFooterDown, - ClientScrollTimeline, Link, Head, }, @@ -44,8 +45,8 @@ export default {
    -
    -
    +
    +
    @@ -54,6 +55,7 @@ export default {
    +

    Научные периодические издания НТГСПИ

    diff --git a/resources/js/Pages/Client/AcademicJournals/Show.vue b/resources/js/Pages/Client/AcademicJournals/Show.vue index 64bd66b..b14055e 100644 --- a/resources/js/Pages/Client/AcademicJournals/Show.vue +++ b/resources/js/Pages/Client/AcademicJournals/Show.vue @@ -5,10 +5,15 @@ import Builder from "@/componentss/shared/builder/pageBuilder/Builder.vue"; import BasicFooter from "@/footers/BasicFooter.vue"; import MetaTags from "@/componentss/shared/SEO/MetaTags.vue"; import AcademicJournalsTitle from "@/componentss/features/academicJournals/components/AcademicJournalsTitle.vue"; +import BasicPagination from "@/componentss/shared/paginate/BasicPagination.vue"; +import AcademicJournalsItemBreadcrumbs + from "@/componentss/features/academicJournals/components/AcademicJournalsItemBreadcrumbs.vue"; export default { name: "Show", components: { + AcademicJournalsItemBreadcrumbs, + BasicPagination, MetaTags, BasicFooter, Builder, @@ -66,6 +71,7 @@ export default {
    +
    -
    +

    Найдено мероприятий: {{ events.data.length }}

    @@ -107,26 +107,24 @@ export default {
    diff --git a/resources/js/Pages/Client/Events/Show.vue b/resources/js/Pages/Client/Events/Show.vue index def1a9d..86d5b68 100644 --- a/resources/js/Pages/Client/Events/Show.vue +++ b/resources/js/Pages/Client/Events/Show.vue @@ -70,28 +70,32 @@ export default {
    -
    +
    -
    -

    - Онлайн -

    -

    - {{ event.data.category }} -

    -
    - Дата начала: {{ event.data.event_date_start }}, {{ event.data.event_time_start }} -
    -
    - Адрес: {{ event.data.address }} -
    -
    -
    +
    + + Онлайн + + + {{ event.data.category.title }} + +
    + Дата начала: {{ event.data.event_date_start }}, {{ event.data.event_time_start }} +
    +
    + Адрес: {{ event.data.address }} +
    +
    +
    -
    +
    diff --git a/resources/js/Pages/Client/Posts/Index.vue b/resources/js/Pages/Client/Posts/Index.vue index 5d4dd5b..20b0c1c 100644 --- a/resources/js/Pages/Client/Posts/Index.vue +++ b/resources/js/Pages/Client/Posts/Index.vue @@ -76,12 +76,12 @@ export default {
    -
    +

    Новости НТГСПИ

    Узнайте последние новости любимого вуза

    - +
    @@ -93,9 +93,9 @@ export default { -
    +

    Найдено новостей: {{ posts.meta.total }}

    -
    +
    @@ -103,8 +103,8 @@ export default {
    -
    -
    +
    +
    diff --git a/resources/js/Pages/Dashboard/CreateSchedule.vue b/resources/js/Pages/Dashboard/CreateSchedule.vue deleted file mode 100644 index ff978c6..0000000 --- a/resources/js/Pages/Dashboard/CreateSchedule.vue +++ /dev/null @@ -1,125 +0,0 @@ - - - - - \ No newline at end of file diff --git a/resources/js/Pages/Main.vue b/resources/js/Pages/Main.vue index e300dd9..1652d47 100644 --- a/resources/js/Pages/Main.vue +++ b/resources/js/Pages/Main.vue @@ -2,7 +2,7 @@ - +

    Последние новости

    diff --git a/resources/js/componentss/features/academicJournals/components/AcademicJournalsItemBreadcrumbs.vue b/resources/js/componentss/features/academicJournals/components/AcademicJournalsItemBreadcrumbs.vue new file mode 100644 index 0000000..11e7320 --- /dev/null +++ b/resources/js/componentss/features/academicJournals/components/AcademicJournalsItemBreadcrumbs.vue @@ -0,0 +1,209 @@ + + + + + + \ No newline at end of file diff --git a/resources/js/componentss/features/academicJournals/components/AcademicJournalsListBreadcrumbs.vue.vue b/resources/js/componentss/features/academicJournals/components/AcademicJournalsListBreadcrumbs.vue.vue new file mode 100644 index 0000000..ba41933 --- /dev/null +++ b/resources/js/componentss/features/academicJournals/components/AcademicJournalsListBreadcrumbs.vue.vue @@ -0,0 +1,180 @@ + + + + + + \ No newline at end of file diff --git a/resources/js/componentss/features/pages/components/PageBreadcrumbs.vue b/resources/js/componentss/features/pages/components/PageBreadcrumbs.vue index 1da575d..db19e91 100644 --- a/resources/js/componentss/features/pages/components/PageBreadcrumbs.vue +++ b/resources/js/componentss/features/pages/components/PageBreadcrumbs.vue @@ -9,7 +9,7 @@
  • -
  • +
  • -
  • +
  • - -
    - +
    +
    -
    -